Node.js 교재 · 9편 · 비동기 진화사

비동기 패턴 — 콜백·Promise·async/await

같은 문제, 세 시대의 답. 셋 다 만나니까 셋 다 읽어야 한다.

콜백 중첩이 Promise 체인으로, 다시 async/await 으로 정리되는 컨셉 일러스트

8편에서 본 이벤트 루프의 결과 — Node 의 거의 모든 함수가 비동기다. 그 비동기 결과를 받는 방법이 시간이 지나며 진화했다. 콜백 → Promise → async/await. 신규 코드는 모두 async/await 이지만, 옛 라이브러리·예제·StackOverflow 답변엔 콜백·Promise 가 그대로 살아있다. 셋 다 읽을 줄 알아야 한다.

이번 편에서 세 패턴을 같은 예제로 비교하고, 변환 방법과 실전 함정까지 정리한다.

1. 1세대 — 콜백 (Callback)

Node 가 처음 정한 규약 — error-first callback. 함수의 마지막 인자는 콜백, 콜백의 첫 인자는 에러(없으면 null), 두 번째가 결과.

import fs from 'node:fs'; fs.readFile('a.txt', 'utf-8', (err, dataA) => { if (err) return console.error(err); fs.readFile('b.txt', 'utf-8', (err, dataB) => { if (err) return console.error(err); fs.writeFile('out.txt', dataA + dataB, (err) => { if (err) return console.error(err); console.log('합쳤어요'); }); }); });

세 작업이 순차로 일어나야 하니까 콜백이 콜백을 부르고 또 부른다. 이걸 "콜백 헬" 또는 "피라미드 오브 둠" 이라 부른다. 한두 단계는 괜찮지만 5단계 넘어가면 디버깅 불가. 이걸 풀기 위해 Promise 가 등장.

2. 2세대 — Promise

ES2015 의 답. 비동기 결과를 담는 객체. .then() 으로 결과를, .catch() 로 에러를 받는다.

import fs from 'node:fs/promises'; fs.readFile('a.txt', 'utf-8') .then(dataA => fs.readFile('b.txt', 'utf-8').then(dataB => dataA + dataB)) .then(merged => fs.writeFile('out.txt', merged)) .then(() => console.log('합쳤어요')) .catch(err => console.error(err));

이미 콜백보다 평평하다. 에러 처리도 마지막 .catch 한 군데. 그러나 .then 안에서 다시 비동기를 부르면 여전히 들여쓰기가 깊어지긴 한다.

Promise 의 3가지 상태 — pending (대기) → fulfilled (성공, 값 있음) 또는 rejected (실패, 에러 있음). 한 번 상태가 정해지면 변하지 않음. 같은 promise 에 .then 을 여러 번 붙여도 결과는 같다 (한 번만 실행됨).

3. 3세대 — async / await

ES2017 의 답. Promise 를 더 자연스럽게 쓰는 문법 설탕. await 키워드가 Promise 의 결과 값을 "기다려서" 변수에 넣어준다.

import fs from 'node:fs/promises'; try { const dataA = await fs.readFile('a.txt', 'utf-8'); const dataB = await fs.readFile('b.txt', 'utf-8'); await fs.writeFile('out.txt', dataA + dataB); console.log('합쳤어요'); } catch (err) { console.error(err); }

마치 동기 코드처럼 읽힌다. 에러 처리는 익숙한 try/catch. 이게 2026 표준. 신규 코드는 무조건 이 패턴.

Node 14.8 부터 모듈 최상위에서도 await 가능 (top-level await, ESM 한정). 옛 코드에선 async function main() { … } main() 으로 감싸야 했지만 이제 안 해도 됨.

4. 세 시대 한눈에 비교

구분콜백Promiseasync/await
등장Node 시작 (2009)ES2015ES2017
모양중첩 함수.then().then()동기처럼
에러 처리매 콜백 첫 인자.catch()try/catch
가독성3단계+ 곤란평평하지만 chain최상
병렬 처리수동 카운터Promise.allawait Promise.all
현재 권장옛 코드만반환 시 OK기본

5. 변환과 흔한 함정

옛 콜백 API 를 Promise 로 감싸기

레거시 라이브러리가 콜백 API 만 제공하면? Node 가 표준 변환기를 제공한다.

import { promisify } from 'node:util'; import fs from 'node:fs'; const readFile = promisify(fs.readFile); const data = await readFile('hello.txt', 'utf-8'); console.log(data);

promisify 는 마지막 인자가 콜백인 함수를 Promise 반환 함수로 자동 변환. 단 콜백이 (err, ...rest) 표준 형태여야 함.

흔한 함정

실수 1위 — forEach 안에서 awaitarr.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편.

© 2026 주나이테크(주) @JUNAITECH