"프로미스와 async/await가 자리 잡기 전, Node의 비동기 API는 콜백을 중심으로 자랐어. 지금도 그 흔적이 표준 라이브러리와 이벤트 API 곳곳에 남아 있어."
오류 우선 콜백이 생긴 이유
초기 Node에는 언어 차원의 프로미스가 없었어. 파일 읽기를 시작한 뒤 결과를 돌려받으려면 fs.readFile(path, callback)처럼 완료 시 실행할 함수를 함께 넘겼지. Node가 정착시킨 관례는 오류 우선 콜백이야. callback(err, result)에서 err가 있으면 실패이고, 없으면 나머지 인수가 결과야.
이 모양은 libuv의 비동기 I/O와 잘 맞았어. 작업을 시작한 호출 스택은 먼저 끝나고, I/O가 완료되면 런타임이 등록해 둔 함수를 호출해. 콜백은 “완료되면 알려 줘”라는 계약을 JavaScript 함수 하나로 표현한 셈이야.
중첩보다 더 까다로운 계약
여러 비동기 작업을 순서대로 잇기 시작하면 콜백이 안쪽으로 깊어져. 단계마다 오류 분기와 들여쓰기가 늘어나서 성공 경로와 실패 경로를 한눈에 보기 어려워지지.
fs.readFile('a.json', (err, raw) => {
if (err) return done(err);
parse(raw, (err, data) => {
if (err) return done(err);
transform(data, (err, out) => {
if (err) return done(err);
fs.writeFile('b.json', out, done);
});
});
});
이른바 “콜백 지옥”은 모양만의 문제가 아니야. 콜백은 성공이나 실패 중 정확히 한 번 호출되어야 해. 두 번 부르면 후속 작업이 중복되고, 한 번도 부르지 않으면 흐름이 멈춰. 어떤 구현은 즉시 콜백을 부르고 어떤 구현은 나중에 부르는 현상도 있었는데, 실행 순서를 예측하기 어려운 이 문제를 흔히 Zalgo라고 불렀어. 프로미스는 완료 상태를 한 번만 정하고 후속 처리를 항상 비동기적으로 이어서 이런 계약을 더 분명하게 만들었지.
오래된 API를 프로미스로 잇기
util.promisify는 표준 오류 우선 콜백 API를 프로미스 세계로 이어 주는 다리야.import fs from 'node:fs';
import { promisify } from 'node:util';
const readFile = promisify(fs.readFile);
const text = await readFile('a.json', 'utf8');다만 모든 콜백 함수를 무조건 감쌀 수 있는 건 아니야. 성공 값이 여러 개이거나 this에 의존하거나 오류 우선 규약을 따르지 않는 함수라면 전용 래퍼가 필요해. 표준 라이브러리가 node:fs/promises처럼 프로미스 API를 직접 제공한다면 그쪽을 먼저 고르면 돼.콜백이 지금도 알맞은 자리
콜백은 사라진 문법이 아니야. HTTP 요청, EventEmitter 이벤트, 스트림 알림처럼 여러 번 일어나는 사건을 구독할 때는 여전히 자연스러워. createServer(handler)의 처리 함수는 요청이 올 때마다 호출되고, emitter.on(name, listener)의 리스너도 같은 이벤트가 발생할 때마다 실행돼.
프로미스 하나는 한 번 끝나는 작업을 표현해. 반복되는 값에는 이벤트 리스너나 비동기 이터레이터 같은 다른 모양이 더 잘 맞아. 중요한 건 새 문법을 무조건 고르는 게 아니라, 한 번의 완료인지 이어지는 사건인지 구분하는 거야.
Pippa의 고백
net.createServer, fs.watch, process.on을 나란히 놓고 보니 공통점이 선명했어. 모두 결과 하나를 돌려주는 함수가 아니라 사건이 생길 때마다 실행할 관심사를 등록하는 API였지. 오래됐다는 이유만으로 나쁜 모양은 아니야. 표현하려는 시간의 모양과 맞지 않을 때 문제가 되는 거야.