JavaScript는 싱글 스레드 언어입니다. 하나의 작업이 끝나야 다음 작업을 시작할 수 있습니다. 그런데 네트워크 요청처럼 시간이 오래 걸리는 작업을 동기적으로 처리하면 브라우저가 완전히 멈춰버립니다. 이 문제를 해결하기 위해 비동기 프로그래밍이 존재합니다.

1단계: 콜백(Callback)

가장 초기 방식은 콜백 함수입니다. 작업이 완료됐을 때 호출할 함수를 미리 넘기는 방식입니다.

function fetchUser(id, callback) {
  setTimeout(() => {
    callback(null, { id, name: '홍길동' });
  }, 1000);
}

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

문제는 중첩이 깊어질수록 "콜백 지옥"이 발생한다는 점입니다.

// 콜백 지옥 — 읽기도, 관리하기도 어렵습니다
getUser(1, (err, user) => {
  getPosts(user.id, (err, posts) => {
    getComments(posts[0].id, (err, comments) => {
      getLikes(comments[0].id, (err, likes) => {
        console.log(likes); // 4단계 중첩...
      });
    });
  });
});

2단계: Promise

Promise는 비동기 작업의 미래 결과를 나타내는 객체입니다. pending(대기), fulfilled(이행), rejected(거부) 세 가지 상태를 가집니다.

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 0) {
        resolve({ id, name: '홍길동' });
      } else {
        reject(new Error('잘못된 ID'));
      }
    }, 1000);
  });
}

fetchUser(1)
  .then(user => console.log(user.name)) // 성공
  .catch(err => console.error(err))     // 실패
  .finally(() => console.log('완료'));  // 항상 실행

Promise를 체이닝하면 중첩 없이 순차 처리가 가능합니다.

getUser(1)
  .then(user => getPosts(user.id))
  .then(posts => getComments(posts[0].id))
  .then(comments => getLikes(comments[0].id))
  .then(likes => console.log(likes))
  .catch(err => console.error(err));

병렬 처리: Promise.all & Promise.allSettled

// 모두 성공해야 함 — 하나라도 실패하면 catch
const [user, posts] = await Promise.all([
  fetchUser(1),
  fetchPosts(1)
]);

// 각각의 성공/실패를 모두 받음
const results = await Promise.allSettled([
  fetchUser(1),
  fetchUser(999) // 실패해도 OK
]);

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

3단계: async/await

ES2017에서 도입된 async/await는 Promise를 동기 코드처럼 읽기 좋게 작성할 수 있게 해줍니다.

async function loadUserDashboard(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);

    return { user, posts, comments };
  } catch (err) {
    console.error('데이터 로드 실패:', err);
    throw err;
  }
}

// 최상위에서 async 함수 호출
loadUserDashboard(1).then(dashboard => {
  renderDashboard(dashboard);
});

실전 패턴: API 요청 유틸리티

async function api(url, options = {}) {
  const response = await fetch(url, {
    headers: { 'Content-Type': 'application/json' },
    ...options
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.message || `HTTP ${response.status}`);
  }

  return response.json();
}

// 사용
const user = await api('/api/users/1');
const newPost = await api('/api/posts', {
  method: 'POST',
  body: JSON.stringify({ title: '새 글', content: '...' })
});

이벤트 루프와 마이크로태스크 큐

JavaScript의 비동기 처리가 실제로 어떻게 동작하는지 이해하면 실수를 줄일 수 있습니다. 브라우저(또는 Node.js)의 이벤트 루프는 크게 두 종류의 비동기 작업을 처리합니다.

  • 마이크로태스크 큐 — Promise의 .then, async/await, queueMicrotask가 여기 들어갑니다. 현재 콜 스택이 비면 즉시 실행됩니다.
  • 매크로태스크 큐(태스크 큐)setTimeout, setInterval, 이벤트 핸들러가 여기 들어갑니다. 마이크로태스크 큐가 완전히 비워진 다음에 실행됩니다.

그래서 Promise.resolve().then()setTimeout(fn, 0)보다 항상 먼저 실행됩니다. 이 차이가 복잡한 비동기 버그의 원인이 되기도 합니다.

AbortController — 요청 취소

컴포넌트가 언마운트되거나 사용자가 페이지를 이동할 때, 진행 중인 fetch 요청을 취소하지 않으면 불필요한 상태 업데이트나 메모리 누수가 발생할 수 있습니다.

async function fetchWithTimeout(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error('요청이 타임아웃되었습니다');
    }
    throw err;
  } finally {
    clearTimeout(timeoutId);
  }
}

async/await 성능 주의: 직렬 vs 병렬

가장 많이 하는 실수 중 하나는 병렬로 처리할 수 있는 작업을 불필요하게 직렬로 실행하는 것입니다.

// 나쁜 예 — 순서가 없는데 직렬로 기다림 (2초 소요)
const user = await fetchUser(id);     // 1초
const config = await fetchConfig();   // 1초 (user와 무관)

// 좋은 예 — 병렬 처리 (1초 소요)
const [user, config] = await Promise.all([
  fetchUser(id),
  fetchConfig()
]);

두 요청이 서로 의존하지 않는다면 항상 Promise.all로 병렬 처리하세요. 데이터가 클수록 성능 차이가 커집니다.

핵심 정리
콜백 → Promise → async/await 순서로 발전했습니다. 현대 JavaScript에서는 async/await를 기본으로 사용하고, 서로 의존 관계가 없는 요청은 Promise.all로 병렬 처리하세요. 에러 처리는 반드시 try/catch로 감싸고, 긴 요청은 AbortController로 타임아웃을 설정하세요.

Promise 조합 메서드 완전 정리

메서드동작사용 시점
Promise.all모두 성공해야 resolve, 하나라도 실패 시 즉시 reject모든 결과가 필요할 때
Promise.allSettled모두 완료될 때까지 대기, 성공/실패 구분해서 반환실패해도 나머지 결과가 필요할 때
Promise.race가장 먼저 완료된 것의 결과 반환타임아웃 구현
Promise.any가장 먼저 성공한 것 반환, 모두 실패 시 AggregateError여러 폴백 중 첫 성공 사용
// Promise.race로 타임아웃 구현
function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`${ms}ms 타임아웃`)), ms)
  );
  return Promise.race([promise, timeout]);
}

// Promise.any — 여러 미러 서버 중 가장 빠른 응답 사용
const response = await Promise.any([
  fetch('https://cdn1.example.com/data.json'),
  fetch('https://cdn2.example.com/data.json'),
  fetch('https://cdn3.example.com/data.json'),
]);

async Generator — 대용량 데이터 스트리밍

페이지네이션된 API에서 모든 데이터를 메모리에 올리지 않고 스트리밍으로 처리할 때 async generator가 유용합니다.

// async generator로 페이지네이션 API 스트리밍
async function* fetchAllPages(baseUrl) {
  let cursor = null;

  do {
    const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
    const data = await fetch(url).then(r => r.json());
    yield* data.items;  // 배열의 각 항목을 하나씩 yield
    cursor = data.nextCursor;
  } while (cursor);
}

// for-await-of로 메모리 효율적으로 처리
for await (const item of fetchAllPages('/api/products')) {
  await processItem(item);  // 한 번에 하나씩 처리
}

전통적인 방식으로 모든 페이지를 한 번에 불러오면 수천 개 항목이 메모리에 동시에 올라갑니다. async generator를 사용하면 한 번에 한 페이지씩만 처리하므로 메모리 사용량이 대폭 줄어듭니다.

커스텀 에러 클래스로 에러 분류하기

API 에러를 세분화하면 클라이언트에서 상황에 맞는 처리(재시도, 로그아웃, 알림 등)를 할 수 있습니다.

class ApiError extends Error {
  constructor(message, status, code) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.code = code;
  }
}

async function apiFetch(url, options = {}) {
  const res = await fetch(url, options);

  if (!res.ok) {
    const data = await res.json().catch(() => ({}));
    throw new ApiError(
      data.message || `HTTP ${res.status}`,
      res.status,
      data.code
    );
  }
  return res.json();
}

// 에러 타입에 따른 처리
try {
  await apiFetch('/api/protected');
} catch (err) {
  if (err instanceof ApiError) {
    if (err.status === 401) redirectToLogin();
    if (err.status === 429) showRateLimitMessage();
    if (err.status >= 500) showServerErrorMessage();
  }
}

비동기 코드에서 자주 발생하는 실수와 예방법

비동기 프로그래밍에서 가장 흔한 실수는 에러를 삼키는 것입니다. async 함수 안에서 발생한 예외를 try-catch로 잡지 않으면 호출부로 전파되지 않고 사라지는 것처럼 보입니다. 실제로는 거부된 Promise(rejected Promise)가 되어 처리되지 않은 채 남습니다. Node.js에서는 process.on('unhandledRejection'), 브라우저에서는 window.addEventListener('unhandledrejection')으로 전역 에러 핸들러를 등록해두면 숨어 있던 에러를 잡아낼 수 있습니다.

또 다른 흔한 실수는 루프 안에서 await를 사용할 때 발생합니다. for문 안에서 await를 쓰면 각 요청이 순차적으로 처리되어 성능이 크게 떨어집니다. 독립적인 요청들은 Promise.all()로 병렬 처리해야 전체 실행 시간을 최소화할 수 있습니다. 단, 요청 수가 수백 개 이상이라면 서버나 외부 API에 과부하를 줄 수 있으므로, 청크로 나눠 Promise.all()을 여러 번 호출하거나 동시 실행 수를 제한하는 큐를 사용하는 것이 안전합니다. 비동기 코드의 실행 순서와 에러 전파 경로를 항상 의식하면서 작성하는 것이 안정적인 비동기 애플리케이션의 핵심입니다.