"promise 전, async/await 전, callback 이 있었어. Node 의 stdlib 전체가 그 둘레로 지어졌어. 모던 코드 읽으려면 뭘 대체한 건지 알아야 해."
원죄 (그리고 그 정당화)
초기 Node (2014 년 이전) 엔 언어에 Promise 지원 없었어. 파일 읽기 시작하려면 fs.readFile(path, callback) 호출하고, 바이트 도착하면 callback 이 fire. 관례는 error-first: callback(err, result). err 가 truthy 면 뭔가 잘못된 거; 아니면 result 가 네 데이터. 모든 Node API 가 몇 년 동안 이 모양 따랐어.
왜 굳이 callback? libuv 가 async I/O 를 "작업 시작, 끝나면 통지" 로 노출하니까. "끝나면 통지" 의 제일 단순한 JS 모양은 "내가 부를 함수 넘겨줘". Callback 은 libuv 의 현실을 감싸는 최소 viable wrapper 였어.
Pyramid of Doom
Callback 은 composition 이 나빠. 파일 읽고, 파싱하고, 결과 쓰고, webhook 통지? 중첩된 callback 네 개. 각자 에러 처리 추가, 각자 들여쓰기 추가. 악명 높은 패턴:
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, (err) => {
if (err) return done(err);
notifyWebhook((err) => done(err));
});
});
});
});
이걸 "callback hell" 또는 "pyramid of doom" 이라 불렀어. 작동은 했는데 읽으려면 다섯 단계 중첩을 통한 에러 path 추적해야 했어. JavaScript 커뮤니티가 몇 년을 우회책 발명에 썼어 — control-flow 라이브러리 (async.js, caolan/async), continuation-passing-style helper, custom monadic wrapper. 어느 것도 만족스럽지 않았어.
Error-First 가 여전히 어디든 있어
callback(err, ...rest) 써. 의존하는 라이브러리가 callback API 를 노출할 수 있어. 패턴을 즉시 알아보고 promise 로 변환할 수 있어야 해:import { promisify } from 'node:util';
const readFileAsync = promisify(fs.readFile);
const data = await readFileAsync('a.json', 'utf-8');util.promisify 는 어떤 error-first callback 함수든 promise 반환 함수로 감싸. 두 시대 사이의 다리야.Callback 이 여전히 맞는 곳
Callback 안 죽었어 — 한 특정 상황에 맞아: 한 번 이상 일어나는 이벤트. HTTP 서버의 request handler 는 모든 요청에 fire; 다음 요청을 "await" 하지 않고, callback 등록해. EventEmitter, stream listener, 파일 watcher — 다 callback 모양 써, semantics 가 "언제든 몇 번이든 나 불러" 라서.
Promise 는 단일 eventual value 표현. EventEmitter 는 이벤트 stream 표현. 옳은 semantics 엔 옳은 모양 써.