콜백이란
콜백 (callback) 은 다른 함수에 인자로 전달되어, 특정 시점에 호출되는 함수입니다. JavaScript에서 비동기를 처리하는 가장 오래된 패턴입니다.
// 동기 — 결과를 바로 반환
const data = readFileSync("file.txt");
console.log(data);
// 비동기 — 결과를 콜백으로 전달
readFile("file.txt", (err, data) => {
console.log(data);
});이전 글에서 배운 이벤트 루프의 관점에서 보면, 콜백은 비동기 작업이 완료된 후 태스크 큐에 들어가 콜스택이 비었을 때 실행됩니다.
콜백 패턴
이벤트 리스너
button.addEventListener("click", () => {
console.log("clicked");
});가장 흔한 콜백 — 이벤트가 발생할 때 호출됩니다.
Node.js 에러 우선 콜백
fs.readFile("file.txt", "utf-8", (err, data) => {
if (err) {
console.error("읽기 실패:", err);
return;
}
console.log(data);
});Node.js의 규약: 콜백의 첫 번째 인자는 에러, 두 번째부터 결과. 에러가 없으면 null이 전달됩니다.
타이머
setTimeout(() => {
console.log("1초 후");
}, 1000);
setInterval(() => {
console.log("매 초마다");
}, 1000);콜백 지옥
순차적으로 실행해야 하는 비동기 작업이 여러 개일 때 문제가 시작됩니다:
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
getShippingInfo(details.trackingId, (err, shipping) => {
if (err) return handleError(err);
displayResult(shipping);
});
});
});
});이것이 콜백 지옥 (Callback Hell) 또는 죽음의 피라미드 (Pyramid of Doom) 입니다. 문제는 들여쓰기만이 아닙니다:
1. 에러 처리의 반복
매 단계마다 if (err) 체크를 반복해야 합니다. 하나라도 빼먹으면 에러가 조용히 무시됩니다.
getUser(userId, (err, user) => {
// 여기서 err 체크를 깜빡하면?
getOrders(user.id, (err, orders) => {
// user가 undefined일 수 있음 → TypeError
});
});2. 제어 역전
콜백을 다른 함수에 넘기면, 그 함수가 콜백을 언제, 몇 번 호출할지 제어 합니다. 호출하는 쪽을 신뢰해야 합니다.
thirdPartyAPI.doSomething(data, (result) => {
// 이 콜백이 호출될까? 몇 번 호출될까?
// 동기적으로 호출될까, 비동기적으로 호출될까?
// 에러가 발생하면 어떻게 전달될까?
processResult(result);
});3. 흐름 추적의 어려움
코드가 위에서 아래로 읽히지 않고, 안으로 안으로 들어갑니다. 실행 순서를 머릿속으로 추적하기 어렵습니다.
4. 병렬 처리의 복잡성
두 비동기 작업을 병렬로 실행하고 둘 다 끝나면 결과를 합치려면:
let userResult, ordersResult;
let completed = 0;
getUser(userId, (err, user) => {
if (err) return handleError(err);
userResult = user;
completed++;
if (completed === 2) combine(userResult, ordersResult);
});
getOrders(userId, (err, orders) => {
if (err) return handleError(err);
ordersResult = orders;
completed++;
if (completed === 2) combine(userResult, ordersResult);
});카운터 변수, 완료 체크, 결과 저장... 두 개만으로도 이 정도입니다.
콜백 지옥 완화 시도
함수 분리
function handleShipping(err, shipping) {
if (err) return handleError(err);
displayResult(shipping);
}
function handleDetails(err, details) {
if (err) return handleError(err);
getShippingInfo(details.trackingId, handleShipping);
}
function handleOrders(err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, handleDetails);
}
function handleUser(err, user) {
if (err) return handleError(err);
getOrders(user.id, handleOrders);
}
getUser(userId, handleUser);들여쓰기는 해결되지만, 코드가 분산되어 흐름을 따라가려면 함수 사이를 점프해야 합니다. 근본적인 해결은 아닙니다.
async 라이브러리
// async.js 라이브러리 (2010년대)
async.waterfall([
(cb) => getUser(userId, cb),
(user, cb) => getOrders(user.id, cb),
(orders, cb) => getOrderDetails(orders[0].id, cb),
(details, cb) => getShippingInfo(details.trackingId, cb),
], (err, shipping) => {
if (err) return handleError(err);
displayResult(shipping);
});라이브러리로 구조화할 수 있지만, 여전히 콜백 기반이고 언어 수준의 해결이 아닙니다.
콜백이 여전히 쓰이는 곳
콜백이 나쁜 것은 아닙니다. 단순한 경우에는 여전히 가장 직관적입니다:
- 이벤트 리스너 —
addEventListener - 배열 메서드 —
map,filter,forEach - 단일 비동기 작업 — 중첩이 없는 경우
- 스트림 — Node.js 스트림의
on('data', callback)
문제는 순차적 비동기 작업이 중첩 될 때 발생합니다.
다음 단계
콜백의 한계를 해결하기 위해 ES2015에서 Promise 가 도입되었습니다. 다음 글에서는 Promise가 콜백의 문제를 어떻게 해결하는지 — 체이닝, 에러 전파, 병렬 실행을 살펴보겠습니다.