Ray Book
비동기 JavaScript

콜백과 콜백 지옥

비동기 프로그래밍의 시작점인 콜백 패턴, 그리고 왜 더 나은 방법이 필요했는지를 살펴봅니다

javascriptcallbackasyncerror-handling

콜백이란

콜백 (callback) 은 다른 함수에 인자로 전달되어, 특정 시점에 호출되는 함수입니다. JavaScript에서 비동기를 처리하는 가장 오래된 패턴입니다.

// 동기 — 결과를 바로 반환
const data = readFileSync("file.txt");
console.log(data);

// 비동기 — 결과를 콜백으로 전달
readFile("file.txt", (err, data) => {
  console.log(data);
});

이전 글에서 배운 이벤트 루프의 관점에서 보면, 콜백은 비동기 작업이 완료된 후 태스크 큐에 들어가 콜스택이 비었을 때 실행됩니다.

콜백 패턴

이벤트 리스너

button.addEventListener("click", () => {
  console.log("clicked");
});

가장 흔한 콜백 — 이벤트가 발생할 때 호출됩니다.

Node.js 에러 우선 콜백

fs.readFile("file.txt", "utf-8", (err, data) => {
  if (err) {
    console.error("읽기 실패:", err);
    return;
  }
  console.log(data);
});

Node.js의 규약: 콜백의 첫 번째 인자는 에러, 두 번째부터 결과. 에러가 없으면 null이 전달됩니다.

타이머

setTimeout(() => {
  console.log("1초 후");
}, 1000);

setInterval(() => {
  console.log("매 초마다");
}, 1000);

콜백 지옥

순차적으로 실행해야 하는 비동기 작업이 여러 개일 때 문제가 시작됩니다:

getUser(userId, (err, user) => {
  if (err) return handleError(err);

  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);

    getOrderDetails(orders[0].id, (err, details) => {
      if (err) return handleError(err);

      getShippingInfo(details.trackingId, (err, shipping) => {
        if (err) return handleError(err);

        displayResult(shipping);
      });
    });
  });
});

이것이 콜백 지옥 (Callback Hell) 또는 죽음의 피라미드 (Pyramid of Doom) 입니다. 문제는 들여쓰기만이 아닙니다:

1. 에러 처리의 반복

매 단계마다 if (err) 체크를 반복해야 합니다. 하나라도 빼먹으면 에러가 조용히 무시됩니다.

getUser(userId, (err, user) => {
  // 여기서 err 체크를 깜빡하면?
  getOrders(user.id, (err, orders) => {
    // user가 undefined일 수 있음 → TypeError
  });
});

2. 제어 역전

콜백을 다른 함수에 넘기면, 그 함수가 콜백을 언제, 몇 번 호출할지 제어 합니다. 호출하는 쪽을 신뢰해야 합니다.

thirdPartyAPI.doSomething(data, (result) => {
  // 이 콜백이 호출될까? 몇 번 호출될까?
  // 동기적으로 호출될까, 비동기적으로 호출될까?
  // 에러가 발생하면 어떻게 전달될까?
  processResult(result);
});

3. 흐름 추적의 어려움

코드가 위에서 아래로 읽히지 않고, 안으로 안으로 들어갑니다. 실행 순서를 머릿속으로 추적하기 어렵습니다.

4. 병렬 처리의 복잡성

두 비동기 작업을 병렬로 실행하고 둘 다 끝나면 결과를 합치려면:

let userResult, ordersResult;
let completed = 0;

getUser(userId, (err, user) => {
  if (err) return handleError(err);
  userResult = user;
  completed++;
  if (completed === 2) combine(userResult, ordersResult);
});

getOrders(userId, (err, orders) => {
  if (err) return handleError(err);
  ordersResult = orders;
  completed++;
  if (completed === 2) combine(userResult, ordersResult);
});

카운터 변수, 완료 체크, 결과 저장... 두 개만으로도 이 정도입니다.

콜백 지옥 완화 시도

함수 분리

function handleShipping(err, shipping) {
  if (err) return handleError(err);
  displayResult(shipping);
}

function handleDetails(err, details) {
  if (err) return handleError(err);
  getShippingInfo(details.trackingId, handleShipping);
}

function handleOrders(err, orders) {
  if (err) return handleError(err);
  getOrderDetails(orders[0].id, handleDetails);
}

function handleUser(err, user) {
  if (err) return handleError(err);
  getOrders(user.id, handleOrders);
}

getUser(userId, handleUser);

들여쓰기는 해결되지만, 코드가 분산되어 흐름을 따라가려면 함수 사이를 점프해야 합니다. 근본적인 해결은 아닙니다.

async 라이브러리

// async.js 라이브러리 (2010년대)
async.waterfall([
  (cb) => getUser(userId, cb),
  (user, cb) => getOrders(user.id, cb),
  (orders, cb) => getOrderDetails(orders[0].id, cb),
  (details, cb) => getShippingInfo(details.trackingId, cb),
], (err, shipping) => {
  if (err) return handleError(err);
  displayResult(shipping);
});

라이브러리로 구조화할 수 있지만, 여전히 콜백 기반이고 언어 수준의 해결이 아닙니다.

콜백이 여전히 쓰이는 곳

콜백이 나쁜 것은 아닙니다. 단순한 경우에는 여전히 가장 직관적입니다:

  • 이벤트 리스너addEventListener
  • 배열 메서드map, filter, forEach
  • 단일 비동기 작업 — 중첩이 없는 경우
  • 스트림 — Node.js 스트림의 on('data', callback)

문제는 순차적 비동기 작업이 중첩 될 때 발생합니다.

다음 단계

콜백의 한계를 해결하기 위해 ES2015에서 Promise 가 도입되었습니다. 다음 글에서는 Promise가 콜백의 문제를 어떻게 해결하는지 — 체이닝, 에러 전파, 병렬 실행을 살펴보겠습니다.