비동기 패턴 — 콜백·Promise·async/await
같은 문제, 세 시대의 답. 셋 다 만나니까 셋 다 읽어야 한다.
8편에서 본 이벤트 루프의 결과 — Node 의 거의 모든 함수가 비동기다. 그 비동기 결과를 받는 방법이 시간이 지나며 진화했다. 콜백 → Promise → async/await. 신규 코드는 모두 async/await 이지만, 옛 라이브러리·예제·StackOverflow 답변엔 콜백·Promise 가 그대로 살아있다. 셋 다 읽을 줄 알아야 한다.
이번 편에서 세 패턴을 같은 예제로 비교하고, 변환 방법과 실전 함정까지 정리한다.
1. 1세대 — 콜백 (Callback)
Node 가 처음 정한 규약 — error-first callback. 함수의 마지막 인자는 콜백, 콜백의 첫 인자는 에러(없으면 null), 두 번째가 결과.
세 작업이 순차로 일어나야 하니까 콜백이 콜백을 부르고 또 부른다. 이걸 "콜백 헬" 또는 "피라미드 오브 둠" 이라 부른다. 한두 단계는 괜찮지만 5단계 넘어가면 디버깅 불가. 이걸 풀기 위해 Promise 가 등장.
2. 2세대 — Promise
ES2015 의 답. 비동기 결과를 담는 객체. .then() 으로 결과를, .catch() 로 에러를 받는다.
이미 콜백보다 평평하다. 에러 처리도 마지막 .catch 한 군데. 그러나 .then 안에서 다시 비동기를 부르면 여전히 들여쓰기가 깊어지긴 한다.
.then 을 여러 번 붙여도 결과는 같다 (한 번만 실행됨).
3. 3세대 — async / await
ES2017 의 답. Promise 를 더 자연스럽게 쓰는 문법 설탕. await 키워드가 Promise 의 결과 값을 "기다려서" 변수에 넣어준다.
마치 동기 코드처럼 읽힌다. 에러 처리는 익숙한 try/catch. 이게 2026 표준. 신규 코드는 무조건 이 패턴.
Node 14.8 부터 모듈 최상위에서도 await 가능 (top-level await, ESM 한정). 옛 코드에선 async function main() { … } main() 으로 감싸야 했지만 이제 안 해도 됨.
4. 세 시대 한눈에 비교
| 구분 | 콜백 | Promise | async/await |
|---|---|---|---|
| 등장 | Node 시작 (2009) | ES2015 | ES2017 |
| 모양 | 중첩 함수 | .then().then() | 동기처럼 |
| 에러 처리 | 매 콜백 첫 인자 | .catch() | try/catch |
| 가독성 | 3단계+ 곤란 | 평평하지만 chain | 최상 |
| 병렬 처리 | 수동 카운터 | Promise.all | await Promise.all |
| 현재 권장 | 옛 코드만 | 반환 시 OK | 기본 |
5. 변환과 흔한 함정
옛 콜백 API 를 Promise 로 감싸기
레거시 라이브러리가 콜백 API 만 제공하면? Node 가 표준 변환기를 제공한다.
promisify 는 마지막 인자가 콜백인 함수를 Promise 반환 함수로 자동 변환. 단 콜백이 (err, ...rest) 표준 형태여야 함.
흔한 함정
arr.forEach(async x => await fetchUser(x)) 는 동작 안 함. forEach 가 콜백의 Promise 를 무시. 대신 for (const x of arr) { await fetchUser(x) } 또는 await Promise.all(arr.map(fetchUser)).
다른 함정 4가지:
await빼먹기 — Promise 객체가 그대로 값으로 들어가Promise <pending>출력. TypeScript 라면 컴파일러가 잡아준다.- 병렬 가능한데 순차로 — 독립적인 두 fetch 는
Promise.all로 묶어야 두 배 빠름. - uncaught rejection — Promise 의
.catch빼먹으면 프로세스가 죽을 수 있다 (Node 22 부터 강제). 항상try/catch또는.catch. - callback 안에서 throw — 비동기 콜백 안의
throw는 호출자가 못 잡는다. 콜백 API 라면 에러를callback(err)로 넘겨야.
요약 — 9편 좌표
여기까지 정리. Node 비동기는 세 시대 — 콜백 → Promise → async/await. 신규 코드는 무조건 async/await + try/catch. 콜백만 있는 옛 API 는 promisify 로 감싸기. forEach + await 함정, await 빼먹기, 독립적인데 순차 호출 — 흔한 실수 3가지 외워두면 된다. 다음 편에선 대용량 데이터를 메모리 걱정 없이 처리하는 Stream 을 본다.
다음 편 예고 — Stream
큰 파일·네트워크 데이터를 청크 단위로 흘리는 법. 10편.