서로 의존성이 없는 세 개의 API 요청을 순차적으로 await했더니 응답 시간이 3배가 됐습니다. 비동기 코드인데 왜 느려진 걸까요?

자바스크립트는 싱글 스레드이지만 논블로킹 I/O 모델로 동시에 수천 개의 연결을 처리할 수 있습니다. 핵심은 비동기 패턴을 올바르게 사용하는 것입니다.

왜 비동기가 필요한가

자바스크립트는 ** 싱글 스레드** 언어입니다. 콜 스택이 하나뿐이라 한 번에 하나의 작업만 실행할 수 있어요.

그런데 네트워크 요청이나 파일 읽기 같은 I/O 작업은 수백 밀리초에서 수 초까지 걸립니다. 이걸 동기로 처리하면? 그 시간 동안 브라우저 UI가 통째로 멈춰요. 버튼 클릭도 안 되고, 스크롤도 안 되고, 렌더링도 멈추는 최악의 UX가 됩니다.

그래서 자바스크립트 런타임(브라우저, Node.js)은 ** 논블로킹 I/O** 모델을 채택했습니다. 시간이 걸리는 작업을 런타임(Web API, libuv 등)에 위임하고, 콜 스택은 다음 코드를 바로 실행해요. 작업이 끝나면 콜백 함수를 태스크 큐에 넣어 이벤트 루프가 콜 스택이 비었을 때 꺼내 실행하는 구조입니다.

PLAINTEXT
[콜 스택] → 비동기 작업 발생 → [Web API / libuv에 위임]
                                      ↓ 완료
                               [태스크 큐에 콜백 등록]
                                      ↓ 콜 스택 비면
                               [이벤트 루프가 콜백 실행]

이 구조 덕분에 싱글 스레드로도 수천 개의 동시 연결을 처리할 수 있습니다. Node.js가 I/O 집약적인 서버에서 강한 이유가 바로 이거예요.


콜백 패턴

가장 원시적인 비동기 처리 방식입니다. 함수의 인자로 "끝나면 실행해줘"라는 함수를 넘기는 거예요.

JAVASCRIPT
function getUser(id, callback) {
  setTimeout(() => {
    callback(null, { id, name: '김개발' });
  }, 1000);
}

getUser(1, (err, user) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(user);
});

단순한 경우에는 괜찮습니다. 문제는 비동기 작업이 연쇄적으로 이어질 때 발생해요.

콜백 지옥 (Callback Hell)

JAVASCRIPT
getUser(1, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getOrderDetail(orders[0].id, (err, detail) => {
      if (err) return handleError(err);
      getProduct(detail.productId, (err, product) => {
        if (err) return handleError(err);
        console.log(product);
      });
    });
  });
});

들여쓰기가 깊어지는 게 본질적인 문제가 아닙니다. 진짜 문제는 이래요.

  • ** 에러 처리가 중복됩니다** — 매 콜백마다 if (err) 체크를 반복해야 해요.
  • ** 흐름 제어가 힘듭니다** — 분기, 반복, 병렬 실행 같은 제어 흐름을 콜백으로 표현하면 코드가 금세 난해해져요.
  • ** 에러가 삼켜집니다** — 콜백 안에서 던진 예외는 try/catch로 잡을 수 없어요. 비동기 콜백은 콜 스택이 이미 풀린 뒤에 실행되기 때문입니다.
JAVASCRIPT
try {
  setTimeout(() => {
    throw new Error('이 에러는 catch에서 안 잡힘');
  }, 0);
} catch (e) {
  // 여기 도달하지 않는다
}

Promise

ES2015(ES6)에서 도입된 Promise는 콜백의 문제를 구조적으로 해결합니다. "미래에 완료될 비동기 작업의 결과"를 객체로 표현하는 패턴이에요.

3가지 상태

상태설명
pending초기 상태. 아직 이행도 거부도 아닌 상태
fulfilled작업이 성공적으로 완료됨 (resolve 호출)
rejected작업이 실패함 (reject 호출)

한 번 fulfilled나 rejected가 되면 다시 바뀌지 않습니다. 이걸 settled 상태라고 해요.

JAVASCRIPT
const promise = new Promise((resolve, reject) => {
  const data = fetchSomething();
  if (data) {
    resolve(data);   // → fulfilled
  } else {
    reject(new Error('데이터 없음')); // → rejected
  }
});

then / catch / finally 체이닝

Promise의 핵심은 체이닝입니다. then은 새로운 Promise를 반환하기 때문에 연속 호출이 가능해요.

JAVASCRIPT
getUser(1)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetail(orders[0].id))
  .then(detail => getProduct(detail.productId))
  .then(product => console.log(product))
  .catch(err => console.error('어디서든 발생한 에러:', err))
  .finally(() => console.log('무조건 실행'));

아까 콜백 지옥이었던 코드가 플랫하게 바뀌었습니다. catch 하나로 체인 전체의 에러를 잡을 수 있고, finally는 성공/실패와 무관하게 항상 실행돼요. 로딩 스피너를 끄는 로직 같은 걸 여기에 넣으면 됩니다.

** 주의할 점 **: then 안에서 Promise를 반환하지 않으면 체이닝이 끊깁니다.

JAVASCRIPT
// 잘못된 예 — getOrders의 결과가 다음 then으로 전달되지 않음
getUser(1)
  .then(user => {
    getOrders(user.id); // return 빠짐!
  })
  .then(orders => {
    console.log(orders); // undefined
  });

Promise 정적 메서드

Promise.all, allSettled, race, any는 어떻게 다른 걸까요? 핵심은 "실패 하나가 전체에 어떤 영향을 주느냐"입니다.

Promise.all

모든 Promise가 fulfilled되면 결과 배열을 반환합니다. ** 하나라도 rejected되면 즉시 rejected**돼요.

JAVASCRIPT
const [users, posts, comments] = await Promise.all([
  fetchUsers(),
  fetchPosts(),
  fetchComments(),
]);

세 요청을 동시에 보내고, 전부 성공해야만 다음으로 넘어갑니다. 하나라도 실패하면 나머지 결과를 버려요(취소하는 건 아니고, 결과를 무시합니다).

Promise.allSettled

모든 Promise가 settled(fulfilled 또는 rejected)될 때까지 기다립니다. ** 절대 rejected되지 않아요.**

JAVASCRIPT
const results = await Promise.allSettled([
  fetchUsers(),
  fetchPosts(),
  fetchComments(),
]);

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log(result.value);
  } else {
    console.error(result.reason);
  }
});

"일부 실패해도 나머지 결과는 써야 할 때" 사용합니다. 대시보드에서 여러 위젯 데이터를 동시에 로드하는 상황이 전형적인 예시예요.

Promise.race

가장 먼저 settled된 Promise의 결과를 반환합니다. 나머지는 무시해요.

JAVASCRIPT
const result = await Promise.race([
  fetch('/api/data'),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error('타임아웃')), 5000)
  ),
]);

타임아웃 구현에 자주 쓰입니다. 위 예시에서 fetch가 5초 안에 응답하지 않으면 타임아웃 에러가 발생해요.

Promise.any

가장 먼저 fulfilled 된 Promise의 결과를 반환합니다. 모두 rejected되면 AggregateError를 던져요.

JAVASCRIPT
const fastest = await Promise.any([
  fetch('https://cdn1.example.com/data'),
  fetch('https://cdn2.example.com/data'),
  fetch('https://cdn3.example.com/data'),
]);

여러 미러 서버 중 가장 빠른 응답을 사용할 때 유용합니다. race와 달리 rejected는 무시하고 fulfilled만 기다린다는 게 차이점이에요.

메서드성공 조건실패 조건
all전부 성공하나라도 실패
allSettled항상 성공없음
race첫 번째 settled첫 번째가 실패면 실패
any첫 번째 성공전부 실패

async / await

ES2017에서 도입된 async/await은 Promise를 기반으로 동작하지만, 동기 코드처럼 읽히게 만들어줍니다. 문법적 설탕(syntactic sugar)이라고 부르지만, 가독성 개선 효과는 설탕 이상이에요.

JAVASCRIPT
async function getUserProduct(userId) {
  const user = await getUser(userId);
  const orders = await getOrders(user.id);
  const detail = await getOrderDetail(orders[0].id);
  const product = await getProduct(detail.productId);
  return product;
}

async 함수는 항상 Promise를 반환합니다. await은 Promise가 settled될 때까지 함수 실행을 일시 중단하고, fulfilled되면 값을 꺼내줘요.

try / catch로 에러 처리

JAVASCRIPT
async function getUserProduct(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    return orders;
  } catch (err) {
    // getUser 또는 getOrders에서 발생한 에러를 여기서 잡음
    console.error('유저 정보 로드 실패:', err.message);
    throw err; // 필요하면 다시 던져서 호출자에게 전파
  }
}

콜백에서는 불가능했던 try/catch 에러 핸들링이 비동기 코드에서도 자연스럽게 동작합니다. 이게 async/await의 가장 큰 장점 중 하나예요.


병렬 실행 vs 순차 실행

이건 실수하기 정말 쉬운 부분입니다.

순차 실행 (느림)

JAVASCRIPT
async function loadData() {
  const users = await fetchUsers();      // 1초
  const posts = await fetchPosts();      // 1초
  const comments = await fetchComments(); // 1초
  // 총 3초
}

await이 이전 작업이 끝날 때까지 기다립니다. 세 요청 사이에 의존성이 없는데도 순차적으로 실행되니까 시간이 낭비돼요.

병렬 실행 (빠름)

JAVASCRIPT
async function loadData() {
  const [users, posts, comments] = await Promise.all([
    fetchUsers(),
    fetchPosts(),
    fetchComments(),
  ]);
  // 총 1초 (가장 느린 요청 기준)
}

Promise.all로 감싸면 세 요청이 동시에 출발합니다. 결과는 전부 끝났을 때 한꺼번에 받아요.

흔한 실수 — Promise를 먼저 만들어두기

JAVASCRIPT
async function loadData() {
  // Promise 객체를 먼저 생성 → 이 시점에 이미 요청이 시작됨
  const usersPromise = fetchUsers();
  const postsPromise = fetchPosts();

  // await은 결과를 기다릴 뿐
  const users = await usersPromise;
  const posts = await postsPromise;
}

이 패턴도 사실상 병렬입니다. Promise는 생성 시점에 즉시 실행을 시작하기 때문이에요. 하지만 Promise.all을 쓰는 게 의도가 더 명확하고, 에러 처리도 깔끔합니다.


에러 처리 전략

개별 에러 처리 vs 전체 에러 처리

JAVASCRIPT
// 전체를 한 번에 — 간단하지만 어디서 터졌는지 모름
async function loadPage() {
  try {
    const user = await getUser();
    const posts = await getPosts(user.id);
    render(user, posts);
  } catch (err) {
    showErrorPage(err);
  }
}

// 개별로 — 각 실패에 다른 대응 가능
async function loadPage() {
  let user;
  try {
    user = await getUser();
  } catch (err) {
    return showLoginPrompt();
  }

  let posts;
  try {
    posts = await getPosts(user.id);
  } catch (err) {
    posts = []; // 포스트 실패해도 페이지는 보여주자
  }

  render(user, posts);
}

전역 에러 핸들링 — unhandledrejection

처리되지 않은 Promise 거부를 전역에서 잡을 수 있습니다.

JAVASCRIPT
// 브라우저
window.addEventListener('unhandledrejection', event => {
  console.error('처리되지 않은 Promise 거부:', event.reason);
  event.preventDefault(); // 기본 에러 로그 방지
  // Sentry 같은 에러 트래킹 서비스로 전송
});

// Node.js
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection:', reason);
});

catch를 안 달아놓은 Promise가 rejected되면 이 이벤트가 발생합니다. 프로덕션에서는 이걸 에러 리포팅 서비스와 연결해두는 게 기본이에요.


fetch API

XMLHttpRequest를 대체하는 모던 HTTP 클라이언트입니다. Promise 기반이라 async/await과 궁합이 좋아요.

기본 사용법

JAVASCRIPT
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);

  // fetch는 네트워크 에러만 reject한다
  // 404, 500 같은 HTTP 에러는 reject되지 않음!
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return response.json(); // 이것도 Promise를 반환
}

**핵심 포인트 **: fetch는 네트워크 에러(서버 연결 불가 등)만 reject합니다. 404나 500 응답은 정상 응답으로 취급되므로 response.ok를 직접 체크해야 해요. axios 같은 라이브러리는 이걸 자동으로 해준다는 차이가 있습니다.

AbortController — 요청 취소

JAVASCRIPT
const controller = new AbortController();

fetch('/api/data', { signal: controller.signal })
  .then(res => res.json())
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('요청이 취소됨');
    }
  });

// 필요한 시점에 취소
controller.abort();

React에서 컴포넌트 언마운트 시 진행 중인 요청을 정리(cleanup)할 때 필수적입니다.

JAVASCRIPT
useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err);
      }
    });

  return () => controller.abort(); // cleanup
}, []);

타임아웃 구현

AbortSignal.timeout()을 쓰면 깔끔합니다. (비교적 최신 API)

JAVASCRIPT
// 모던 방식 (AbortSignal.timeout)
const response = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000), // 5초 타임아웃
});

// 호환성을 고려한 방식
async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return response;
  } finally {
    clearTimeout(id);
  }
}

모듈 시스템 — CommonJS vs ESM

비동기 개념과 직접 연관되는 건 동적 import()인데, 그 전에 두 모듈 시스템의 차이를 짚고 넘어갈게요.

CommonJS (CJS)

JAVASCRIPT
// 내보내기
module.exports = { getUser, getOrders };
// 또는
exports.getUser = getUser;

// 가져오기
const { getUser, getOrders } = require('./user');
  • Node.js의 기본 모듈 시스템입니다.
  • require는 ** 동기적으로** 동작합니다. 파일을 읽고 파싱하고 실행까지 끝나야 다음 줄로 넘어가요.
  • 런타임에 조건부 로드가 가능합니다 (if (조건) require(...))

ESM (ES Modules)

JAVASCRIPT
// 내보내기
export function getUser(id) { /* ... */ }
export default class UserService { /* ... */ }

// 가져오기
import { getUser } from './user.js';
import UserService from './user.js';
  • ES2015 표준 모듈 시스템입니다.
  • import/export정적 선언 이에요. 파일 최상단에만 쓸 수 있고, 빌드 타임에 의존성 그래프를 분석할 수 있어서 트리 셰이킹(tree shaking)이 가능합니다.
  • 브라우저에서 <script type="module">로 직접 사용 가능해요.

동적 import()

JAVASCRIPT
// 조건부 모듈 로드
const module = await import('./heavy-module.js');
module.doSomething();

// React에서 lazy loading
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

import()는 Promise를 반환합니다. 코드 스플리팅(code splitting)의 핵심이며, 초기 번들 크기를 줄이는 데 쓰여요. React의 React.lazy도 내부적으로 이걸 사용합니다.

구분CommonJSESM
문법require / module.exportsimport / export
로딩동기비동기 (동적 import())
분석런타임정적 분석 가능
트리 셰이킹불가가능
브라우저직접 불가 (번들러 필요)네이티브 지원

마이크로태스크 vs 매크로태스크

이벤트 루프는 태스크를 두 종류의 큐에서 가져옵니다.

  • 매크로태스크(macrotask): setTimeout, setInterval, setImmediate, I/O 콜백
  • ** 마이크로태스크(microtask)**: Promise.then/catch/finally, queueMicrotask, MutationObserver

** 실행 순서 : 콜 스택이 비면, 마이크로태스크 큐를 ** 전부 비울 때까지 실행합니다. 그 후 매크로태스크를 ** 하나** 실행해요. 그리고 다시 마이크로태스크 큐를 전부 비웁니다. 이걸 반복해요.

JAVASCRIPT
console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// 출력 순서: 1 → 4 → 3 → 2

왜 이렇게 되는지 단계별로 보면요:

  1. console.log('1') — 동기, 바로 실행. ** 출력: 1**
  2. setTimeout — 콜백을 매크로태스크 큐에 등록
  3. Promise.resolve().then(...) — 콜백을 마이크로태스크 큐에 등록
  4. console.log('4') — 동기, 바로 실행. ** 출력: 4**
  5. 콜 스택 비었음 → 마이크로태스크 큐 확인 → console.log('3') 실행. ** 출력: 3**
  6. 마이크로태스크 큐 비었음 → 매크로태스크 큐에서 하나 꺼냄 → console.log('2') 실행. ** 출력: 2**
JAVASCRIPT
// 더 복잡한 예시
setTimeout(() => console.log('A'), 0);

Promise.resolve()
  .then(() => {
    console.log('B');
    return Promise.resolve();
  })
  .then(() => console.log('C'));

Promise.resolve().then(() => console.log('D'));

console.log('E');

// 출력: E → B → D → C → A

이 예시에서 B가 D보다 먼저인 이유는 같은 마이크로태스크 큐에서 먼저 등록된 순서대로 실행되기 때문이고, C가 D보다 나중인 이유는 B의 then이 새로운 Promise를 반환하면서 C의 콜백이 한 턴 뒤에 등록되기 때문입니다.

Top-level await

ES2022부터 모듈의 최상위 스코프에서 await을 쓸 수 있습니다. 예전에는 async 함수 안에서만 가능했어요.

JAVASCRIPT
// config.js (ESM)
const response = await fetch('/api/config');
export const config = await response.json();

// main.js
import { config } from './config.js';
// config.js의 await이 끝나야 이 줄이 실행됨
console.log(config);

주의할 점이 있습니다. Top-level await은 해당 모듈을 import하는 모든 모듈의 실행을 블로킹해요. 남용하면 앱 초기 로딩이 느려질 수 있으니 설정 파일이나 초기화 코드 정도에만 제한적으로 쓰는 게 좋습니다.

for await...of

비동기 이터러블을 순회할 때 사용합니다. 스트리밍 데이터나 페이지네이션 처리에 유용해요.

JAVASCRIPT
async function* fetchPages(url) {
  let nextUrl = url;
  while (nextUrl) {
    const response = await fetch(nextUrl);
    const data = await response.json();
    nextUrl = data.nextPage;
    yield data.items;
  }
}

// 사용
for await (const items of fetchPages('/api/posts?page=1')) {
  items.forEach(item => console.log(item.title));
}

async function*은 비동기 제너레이터로, yield할 때마다 호출자가 값을 받아 처리할 수 있어요. for await...of는 각 yield된 Promise가 resolve될 때까지 기다렸다가 다음 반복으로 넘어갑니다.

Node.js에서 스트림을 읽을 때도 자주 써요.

JAVASCRIPT
import { createReadStream } from 'fs';

const stream = createReadStream('big-file.txt', { encoding: 'utf8' });

for await (const chunk of stream) {
  process.stdout.write(chunk);
}

파생 개념

이 글에서 다룬 비동기 패턴은 여러 주제와 연결됩니다.

  • 이벤트 루프 — 마이크로태스크/매크로태스크의 실행 주체입니다. 콜 스택, 태스크 큐, 렌더링 사이클까지 깊이 들어가면 별도의 글이 필요해요.
  • React 상태 관리useEffect의 cleanup에서 AbortController로 요청을 취소하거나, SuspenseReact.lazy로 코드 스플리팅을 구현할 때 비동기 패턴이 기반이 됩니다.
  • Node.js — 이벤트 루프 기반의 논블로킹 I/O 모델 자체가 비동기 패턴 위에 서있어요. 스트림, Worker Threads, fs.promises API 등 비동기 처리가 서버사이드에서도 핵심입니다.
댓글 로딩 중...