자바스크립트 Promise 기초 - 콜백 지옥과 에러 전파 (JS 중급 12단원)
이 단원에서 배우는 것
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('완료');
});
});
});
});
흔히 "콜백 지옥"이라 부르지만, 들여쓰기는 사실 가장 사소한 문제다. 진짜 문제는 세 가지다.
- 에러 처리를 매 단계에서 손으로 반복해야 한다.
if (err) return을 한 군데라도 빠뜨리면 그 오류는 사라지고, 아무 일도 없었다는 듯 다음 줄이 실행된다. - 콜백 안에서 던진 예외를 바깥에서 잡을 수 없다.
try { findBook(...) } catch {}로 감싸도 콜백은 이미 다른 시점에 실행되므로try블록은 끝난 뒤다. - 조합할 수 없다. "세 도서를 동시에 조회하고 셋 다 끝나면 진행"을 콜백으로 쓰려면 완료 카운터를 직접 만들어야 한다.
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);
});
}
reject 나 resolve 를 부른다고 함수가 끝나지 않는다. 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(가장 먼저 성공한 것). 실무 비중은 all 과 allSettled 가 압도적이다. 판단 기준은 "하나가 실패하면 전체가 의미 없는가"다. 대출 화면에서 도서 정보와 회원 정보를 동시에 가져오는 경우는 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 다. 그러면 다음 then 은 saveLoan 이 끝나기를 기다리지 않고 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 은 같은 then 의 onOk 에서 난 오류를 잡지 못한다. 앞 단계의 실패만 받는다. 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 로 다시 다룬다.
스스로 확인하기
- 다음 코드의 출력 순서를 쓰라.
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'); - 아래 콜백 스타일 함수를 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); } - 다음 코드는 회원과 도서를 모두 조회한 뒤 대출을 저장하려는 의도인데, 두 군데가 틀렸다. 찾아서 고쳐라.
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)); }); }
정답
A F C E D B. 동기 코드(A,F)가 먼저 전부 실행된다. 그다음 마이크로태스크 큐를 처리하는데, 등록 순서대로C와E가 먼저 들어가 있다.D는C가 끝난 뒤에야 큐에 들어가므로E다음이다.setTimeout의B는 마이크로태스크가 전부 빈 뒤라 마지막이다.
Node 표준 라이브러리 함수라면 직접 감싸는 대신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)); // 회원 없음: M99util.promisify나node:fs/promises같은 Promise 판을 먼저 찾는다.- 틀린 곳은 (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));catch를borrow안에 두지 않은 것도 의도적이다. 어떻게 처리할지는 호출하는 쪽이 정한다.