Devin.KR
로그인

자바스크립트 Promise 기초 - 콜백 지옥과 에러 전파 (JS 중급 12단원)

개발자 조회 1

이 단원에서 배우는 것

9~11단원까지는 전부 동기 코드였다. 함수를 부르면 결과가 그 자리에서 나왔다. 하지만 도서 대출 서비스에서 실제로 하는 일 — DB 조회, 서버 호출, 파일 읽기 — 은 하나도 그 자리에서 끝나지 않는다. 이번 단원은 "결과가 나중에 오는 일"을 다루는 방식이 콜백에서 Promise 로 옮겨 간 이유를 다룬다. 13단원의 async/await 는 여기서 배운 Promise 를 읽기 좋게 쓰는 문법이므로, 이 단원을 건너뛰면 다음 단원의 에러 처리가 이해되지 않는다.

  • 자바스크립트가 단일 스레드인데도 대기 중에 멈추지 않는 이유를 이벤트 루프로 설명한다.
  • 콜백 방식의 세 가지 구조적 결함(중첩·에러 처리·조합 불가)을 짚고, Promise 가 각각을 어떻게 없애는지 대응시킨다.
  • Promise 체인에서 에러가 어디로 흘러가는지 추적하고, 체인이 끊기는 지점을 찾아낸다.

왜 필요한가

대출 처리는 최소 세 단계다. 도서를 찾고, 재고를 확인하고, 대출 기록을 저장한다. 각 단계가 DB 를 다녀오므로 결과는 나중에 온다. 콜백으로 쓰면 이렇게 된다.

findBook('B001', (err, book) => {
  if (err) { handleError(err); return; }
  checkStock(book, (err2, ok) => {
    if (err2) { handleError(err2); return; }
    if (!ok) { handleError(new Error('재고 없음')); return; }
    saveLoan(book, 'M01', (err3, loan) => {
      if (err3) { handleError(err3); return; }
      sendNotice(loan, (err4) => {
        if (err4) { handleError(err4); return; }
        console.log('완료');
      });
    });
  });
});

흔히 "콜백 지옥"이라 부르지만, 들여쓰기는 사실 가장 사소한 문제다. 진짜 문제는 세 가지다.

  1. 에러 처리를 매 단계에서 손으로 반복해야 한다. if (err) return 을 한 군데라도 빠뜨리면 그 오류는 사라지고, 아무 일도 없었다는 듯 다음 줄이 실행된다.
  2. 콜백 안에서 던진 예외를 바깥에서 잡을 수 없다. try { findBook(...) } catch {} 로 감싸도 콜백은 이미 다른 시점에 실행되므로 try 블록은 끝난 뒤다.
  3. 조합할 수 없다. "세 도서를 동시에 조회하고 셋 다 끝나면 진행"을 콜백으로 쓰려면 완료 카운터를 직접 만들어야 한다.

Promise 는 "나중에 올 결과"를 값처럼 다룰 수 있는 객체로 만든다. 값이 되면 변수에 담고, 함수에서 반환하고, 배열에 넣어 한꺼번에 처리할 수 있다. 위 세 문제가 전부 여기서 풀린다.

문법과 예제

먼저: 대기 중에 무슨 일이 일어나는가

자바스크립트는 한 번에 한 줄만 실행한다. 그런데도 setTimeout 이 코드를 막지 않는 이유는 타이머·네트워크 같은 작업을 런타임(브라우저 엔진, Node)이 대신 맡고, 끝나면 대기열에 콜백을 넣어 두기 때문이다. 지금 실행 중인 코드가 전부 끝나야 대기열에서 하나를 꺼낸다.

console.log('1 동기');
setTimeout(() => console.log('4 매크로태스크'), 0);
Promise.resolve().then(() => console.log('3 마이크로태스크'));
console.log('2 동기');

// 1 동기
// 2 동기
// 3 마이크로태스크
// 4 매크로태스크

대기열이 두 개라는 점이 중요하다. Promise 의 then 콜백은 마이크로태스크 큐에 들어가고, setTimeout매크로태스크 큐에 들어간다. 동기 코드가 끝나면 마이크로태스크를 전부 비운 다음에야 매크로태스크를 하나 꺼낸다. 그래서 setTimeout(fn, 0)Promise.resolve().then(fn) 보다 항상 늦다. 이 순서는 13단원에서 await 가 어디서 멈추는지 설명할 때 다시 쓴다.

Promise 만들기

Promise 는 세 상태 중 하나다. pending(대기), fulfilled(성공, 값 하나를 가짐), rejected(실패, 이유 하나를 가짐). 한 번 pending 을 벗어나면 다시 바뀌지 않는다.

const BOOK_DB = {
  B001: { id: 'B001', title: '자바스크립트 완벽 가이드', stock: 2 },
  B002: { id: 'B002', title: '리팩터링', stock: 0 }
};

function findBook(bookId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {                       // DB 왕복 흉내
      const book = BOOK_DB[bookId];
      if (!book) {
        reject(new Error(`도서 없음: ${bookId}`));
        return;                              // reject 뒤에도 코드는 계속 실행된다
      }
      resolve(book);
    }, 50);
  });
}

rejectresolve 를 부른다고 함수가 끝나지 않는다. return 을 붙이는 습관을 들인다. 두 번째 호출은 무시되므로 값이 틀어지진 않지만, 뒤 코드가 계속 돌면서 다른 부작용을 만든다.

new Promise 를 직접 쓰는 자리는 사실 많지 않다. 콜백 API 를 Promise 로 감쌀 때만 쓴다. 이미 Promise 를 반환하는 함수를 또 감싸는 것은 안티패턴이다.

then 으로 잇기 — 반환값이 다음 단계의 입력이 된다

function checkStock(book) {
  if (book.stock <= 0) return Promise.reject(new Error(`${book.title}: 재고 없음`));
  return book;                                  // 값을 그냥 반환해도 된다
}

function saveLoan(book, memberId) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ loanId: 'L2026-0001', bookId: book.id, memberId }), 50);
  });
}

findBook('B001')
  .then(checkStock)
  .then((book) => saveLoan(book, 'M01'))
  .then((loan) => console.log('대출 완료:', loan.loanId))
  .catch((err) => console.error('대출 실패:', err.message))
  .finally(() => console.log('처리 종료'));

// 대출 완료: L2026-0001
// 처리 종료

then 의 규칙은 딱 하나다. 콜백이 반환한 값이 다음 then 의 인자가 된다. 반환한 것이 Promise 라면 그것이 끝날 때까지 기다린 뒤 그 결과를 넘긴다. 그래서 위 코드는 평평한 한 줄기가 된다. 중첩이 사라진 이유가 이것이다.

checkStock 처럼 값을 그대로 반환해도 되고 Promise 를 반환해도 된다. 호출하는 쪽은 차이를 모른다. 이 성질 덕에 동기 함수와 비동기 함수를 같은 체인에 섞을 수 있다.

에러는 아래로 흐른다

체인 어디서 실패해도 가장 가까운 catch 하나가 받는다. then 콜백 안에서 throw 한 것도 똑같이 rejection 이 된다.

findBook('B002')            // 재고 0
  .then(checkStock)         // 여기서 reject
  .then((book) => saveLoan(book, 'M01'))   // 건너뛴다
  .then((loan) => console.log('대출 완료:', loan.loanId))  // 건너뛴다
  .catch((err) => console.error('대출 실패:', err.message));

// 대출 실패: 리팩터링: 재고 없음

catch 가 값을 반환하면 체인은 다시 정상으로 돌아온다. 대체 값을 넣어 복구할 때 쓴다.

findBook('B999')
  .catch(() => ({ id: 'B000', title: '대체 도서', stock: 1 }))   // 복구
  .then((book) => console.log('진행:', book.title));

// 진행: 대체 도서

catch 안에서 다시 throw 하면 아래로 계속 흐른다. "여기서 로그만 남기고 처리는 위로 넘긴다"가 이 형태다.

여러 개를 한꺼번에 — 콜백으로는 불가능하던 것

const ids = ['B001', 'B002', 'B999'];

// 하나라도 실패하면 즉시 실패
Promise.all(ids.map(findBook))
  .then((books) => console.log(books.length))
  .catch((err) => console.error('all 실패:', err.message));
// all 실패: 도서 없음: B999

// 전부 끝날 때까지 기다리고, 성공/실패를 각각 보고
Promise.allSettled(ids.map(findBook)).then((results) => {
  results.forEach((r, i) => {
    if (r.status === 'fulfilled') console.log('OK ', ids[i], r.value.title);
    else                          console.log('NG ', ids[i], r.reason.message);
  });
});
// OK  B001 자바스크립트 완벽 가이드
// OK  B002 리팩터링
// NG  B999 도서 없음: B999

네 가지 조합 함수가 있다. all(전부 성공해야 성공), allSettled(결과를 모두 수집, 절대 reject 하지 않음), race(가장 먼저 끝난 것 — 성공이든 실패든), any(가장 먼저 성공한 것). 실무 비중은 allallSettled 가 압도적이다. 판단 기준은 "하나가 실패하면 전체가 의미 없는가"다. 대출 화면에서 도서 정보와 회원 정보를 동시에 가져오는 경우는 all, 알림 세 건을 각각 보내는 경우는 allSettled 다.

실무에서 자주 틀리는 것

1. then 콜백에서 return 을 빠뜨려 체인이 끊긴다

findBook('B001')
  .then((book) => { saveLoan(book, 'M01'); })    // ← return 없음
  .then((loan) => console.log(loan.loanId));

// TypeError: Cannot read properties of undefined (reading 'loanId')

중괄호 블록에서 return 을 안 쓰면 반환값은 undefined 다. 그러면 다음 thensaveLoan 이 끝나기를 기다리지 않고 undefined 를 받는다. 게다가 saveLoan 이 실패해도 체인의 catch 가 잡지 못한다. 그 Promise 는 체인에서 떨어져 나가 처리되지 않은 rejection 이 된다.

대책: then 콜백은 가능하면 중괄호 없는 화살표 함수로 쓴다. 그러면 반환이 강제된다.

.then((book) => saveLoan(book, 'M01'))   // 중괄호가 없으니 빠뜨릴 수 없다

2. Promise 생성자 안에서 비동기로 throw 한다

function findBookBad(bookId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const book = BOOK_DB[bookId];
      if (!book) throw new Error('도서 없음');   // ← 아무도 못 잡는다
      resolve(book);
    }, 50);
  });
}

findBookBad('B999').catch((e) => console.log('잡힘?', e.message));
// catch 가 실행되지 않는다. 프로세스 전체가 uncaught exception 으로 죽는다.

new Promise 의 실행 함수는 동기적으로 실행되는 부분에서 던진 예외만 자동으로 rejection 으로 바꿔 준다. setTimeout 콜백은 이미 다른 실행 흐름이라 해당되지 않는다. 콜백 API 를 감쌀 때는 예외를 던지지 말고 반드시 reject() 를 호출한다.

덧붙여, Node.js 15 부터 처리되지 않은 rejection 은 경고가 아니라 프로세스 종료다. 개발 중에 "왜 서버가 조용히 죽지" 싶으면 이 경우를 의심한다.

3. 이미 Promise 를 반환하는 함수를 다시 new Promise 로 감싼다

// 나쁨
function loadBook(id) {
  return new Promise((resolve, reject) => {
    findBook(id).then((book) => resolve(book)).catch((e) => reject(e));
  });
}

// 좋음
function loadBook(id) {
  return findBook(id);
}

Promise 생성자 안티패턴이라 부른다. 코드가 길어질 뿐 아니라, 위 형태에서 then 콜백 안에서 실수로 예외가 나면 바깥 Promise 는 영원히 pending 으로 남는다. 어느 catch 도 실행되지 않고 프로그램이 그 자리에서 멈춘다. 디버깅이 매우 어려운 종류의 버그다. new Promise 는 콜백 API 나 이벤트를 감쌀 때만 쓴다.

4. then 의 두 번째 인자와 catch 를 같은 것으로 안다

// (A) then 의 두 번째 인자
findBook('B001').then(
  (book) => { throw new Error('렌더링 실패'); },
  (err)  => console.log('A 에서 잡힘:', err.message)
);
// 아무것도 출력되지 않는다. 처리되지 않은 rejection 이 된다.

// (B) catch
findBook('B001')
  .then((book) => { throw new Error('렌더링 실패'); })
  .catch((err) => console.log('B 에서 잡힘:', err.message));
// B 에서 잡힘: 렌더링 실패

then(onOk, onErr)onErr같은 thenonOk 에서 난 오류를 잡지 못한다. 앞 단계의 실패만 받는다. catch 는 다음 단계라서 앞 then 의 오류까지 받는다. 특별한 이유가 없으면 catch 를 쓴다.

두 번째 인자가 유용한 경우도 있다. "이 단계의 실패만 별도로 처리하고, 그 처리 과정에서 난 오류는 뒤 catch 로 보낸다"가 정확히 필요할 때다. 드물다.

5. 반복문 안에서 순차적으로 대기시킨다

// 도서 5권을 조회하는데 한 건에 50ms 라면 250ms 가 걸린다
let chain = Promise.resolve([]);
for (const id of ids) {
  chain = chain.then((acc) => findBook(id).then((b) => [...acc, b]));
}

// 서로 무관한 조회라면 동시에 보낸다 — 50ms
Promise.all(ids.map(findBook)).then((books) => console.log(books.length));

순차 처리가 필요한 경우(앞 결과가 다음 요청의 입력인 경우, 또는 서버 부하를 제한해야 하는 경우)도 분명 있다. 다만 기본값은 병렬이고 순차는 이유가 있을 때 고르는 것이다. 이 판단은 13단원에서 await 로 다시 다룬다.

스스로 확인하기

  1. 다음 코드의 출력 순서를 쓰라.
    console.log('A');
    setTimeout(() => console.log('B'), 0);
    Promise.resolve()
      .then(() => console.log('C'))
      .then(() => console.log('D'));
    Promise.resolve().then(() => console.log('E'));
    console.log('F');
  2. 아래 콜백 스타일 함수를 Promise 를 반환하도록 바꿔라. 규칙은 Node 관례를 따른다(callback(err, value)).
    function loadMember(memberId, callback) {
      setTimeout(() => {
        const db = { M01: { id: 'M01', name: '김대출', suspended: false } };
        const member = db[memberId];
        if (!member) callback(new Error(`회원 없음: ${memberId}`));
        else callback(null, member);
      }, 30);
    }
  3. 다음 코드는 회원과 도서를 모두 조회한 뒤 대출을 저장하려는 의도인데, 두 군데가 틀렸다. 찾아서 고쳐라.
    function borrow(memberId, bookId) {
      return new Promise((resolve, reject) => {
        Promise.all([loadMemberP(memberId), findBook(bookId)])
          .then(([member, book]) => {
            if (member.suspended) throw new Error('대출 정지 회원');
            saveLoan(book, member.id);
          })
          .then((loan) => resolve(loan));
      });
    }

정답

  1. A F C E D B. 동기 코드(A, F)가 먼저 전부 실행된다. 그다음 마이크로태스크 큐를 처리하는데, 등록 순서대로 CE 가 먼저 들어가 있다. DC 가 끝난 뒤에야 큐에 들어가므로 E 다음이다. setTimeoutB 는 마이크로태스크가 전부 빈 뒤라 마지막이다.
  2. function loadMemberP(memberId) {
      return new Promise((resolve, reject) => {
        loadMember(memberId, (err, member) => {
          if (err) { reject(err); return; }
          resolve(member);
        });
      });
    }
    
    loadMemberP('M01').then((m) => console.log(m.name));   // 김대출
    loadMemberP('M99').catch((e) => console.log(e.message)); // 회원 없음: M99
    Node 표준 라이브러리 함수라면 직접 감싸는 대신 util.promisifynode:fs/promises 같은 Promise 판을 먼저 찾는다.
  3. 틀린 곳은 (1) saveLoan 앞에 return 이 없어 체인이 끊기고, (2) catch 가 없어 reject 가 절대 호출되지 않는다. member.suspended 에서 던진 오류가 어디에도 도달하지 않고 바깥 Promise 는 영원히 pending 이다. 근본적으로는 Promise 생성자 안티패턴이므로 감싸는 것 자체를 없애는 게 맞다.
    function borrow(memberId, bookId) {
      return Promise.all([loadMemberP(memberId), findBook(bookId)])
        .then(([member, book]) => {
          if (member.suspended) throw new Error('대출 정지 회원');
          return saveLoan(book, member.id);
        });
    }
    
    borrow('M01', 'B001')
      .then((loan) => console.log(loan.loanId))
      .catch((e) => console.error(e.message));
    catchborrow 안에 두지 않은 것도 의도적이다. 어떻게 처리할지는 호출하는 쪽이 정한다.