본문으로 건너뛰기
Advertisement

콜백과 콜백 지옥

비동기 프로그래밍의 시작은 콜백 함수였습니다. JavaScript의 비동기 역사를 살펴보고, 콜백의 장점과 한계를 이해해봅시다.


비동기 패턴의 역사

JavaScript의 비동기 처리는 다음과 같이 진화해 왔습니다:

1995~2010  → 콜백 함수(Callback)
2012~2015 → Promise
2017~ → async/await
현재 → 세 가지 방식 모두 공존

동기 vs 비동기

// 동기(Synchronous): 순서대로, 완료될 때까지 대기
console.log('1');
const result = heavyComputation(); // 완료될 때까지 블로킹!
console.log('2'); // heavyComputation 완료 후 실행

// 비동기(Asynchronous): 완료를 기다리지 않음
console.log('1');
setTimeout(() => console.log('비동기 완료'), 1000);
console.log('2'); // setTimeout 완료를 기다리지 않고 즉시 실행

// 출력:
// 1
// 2
// 비동기 완료 (1초 후)

콜백 함수 기본 패턴

콜백은 다른 함수에게 전달되는 함수입니다. 작업이 완료된 후 호출됩니다.

// 기본 콜백 패턴
function fetchData(url, callback) {
// 비동기 작업 시뮬레이션
setTimeout(() => {
const data = { id: 1, name: 'Alice', url };
callback(null, data); // 성공: 첫 번째 인수는 null
}, 1000);
}

fetchData('https://api.example.com/users/1', function(error, data) {
if (error) {
console.error('에러:', error);
return;
}
console.log('데이터:', data);
});

// 화살표 함수로
fetchData('https://api.example.com/users/1', (error, data) => {
if (error) return console.error(error);
console.log(data);
});

Node.js 에러 우선 콜백 패턴 (Error-First Callback)

Node.js에서 표준화된 패턴입니다. 첫 번째 인수는 항상 에러, 두 번째부터 결과입니다.

const fs = require('node:fs');

// Node.js fs 모듈의 에러 우선 콜백
fs.readFile('/path/to/file.txt', 'utf-8', (err, data) => {
if (err) {
// 에러 처리
if (err.code === 'ENOENT') {
console.error('파일을 찾을 수 없습니다');
} else {
console.error('읽기 오류:', err.message);
}
return;
}

// 성공 처리
console.log('파일 내용:', data);
});

// 직접 에러 우선 콜백 구현
function divide(a, b, callback) {
if (b === 0) {
callback(new Error('0으로 나눌 수 없습니다'));
return;
}
callback(null, a / b);
}

divide(10, 2, (err, result) => {
if (err) return console.error(err.message);
console.log('결과:', result); // 5
});

divide(10, 0, (err, result) => {
if (err) return console.error(err.message); // 0으로 나눌 수 없습니다
console.log('결과:', result);
});

콜백 지옥(Callback Hell)

여러 비동기 작업을 순차적으로 처리할 때 콜백이 중첩되면 코드가 피라미드 형태가 됩니다.

// 콜백 지옥 예시: 사용자 → 게시글 → 댓글을 순서대로 가져오기
const fs = require('node:fs');

// 나쁜 예: 깊은 중첩
fs.readFile('user.json', 'utf-8', (err, userData) => {
if (err) {
console.error('사용자 읽기 실패:', err);
return;
}

const user = JSON.parse(userData);
fs.readFile(`posts_${user.id}.json`, 'utf-8', (err, postsData) => {
if (err) {
console.error('게시글 읽기 실패:', err);
return;
}

const posts = JSON.parse(postsData);
fs.readFile(`comments_${posts[0].id}.json`, 'utf-8', (err, commentsData) => {
if (err) {
console.error('댓글 읽기 실패:', err);
return;
}

const comments = JSON.parse(commentsData);
fs.writeFile('output.json', JSON.stringify({
user,
posts,
comments
}), (err) => {
if (err) {
console.error('저장 실패:', err);
return;
}

console.log('완료!');
// 여기서도 또 다른 작업이 필요하다면...?
// 더 깊은 중첩이 필요함!
});
});
});
});

// 위 코드의 문제점:
// 1. 가독성이 매우 낮음 (들여쓰기가 계속 깊어짐)
// 2. 에러 처리가 반복적이고 누락 가능성이 높음
// 3. 코드 재사용이 어려움
// 4. 디버깅이 어려움

콜백 지옥의 실제 사례: API 체이닝

// 실제 HTTP 요청 시뮬레이션
function httpGet(url, callback) {
setTimeout(() => {
const responses = {
'/api/user/1': { id: 1, name: 'Alice', roleId: 2 },
'/api/role/2': { id: 2, name: 'Admin', permissionId: 5 },
'/api/permission/5': { id: 5, actions: ['read', 'write', 'delete'] },
};

if (responses[url]) {
callback(null, responses[url]);
} else {
callback(new Error(`404: ${url}`));
}
}, Math.random() * 500);
}

// 콜백 지옥 버전
httpGet('/api/user/1', (err, user) => {
if (err) return handleError(err);

httpGet(`/api/role/${user.roleId}`, (err, role) => {
if (err) return handleError(err);

httpGet(`/api/permission/${role.permissionId}`, (err, permission) => {
if (err) return handleError(err);

console.log(`${user.name}의 권한: ${permission.actions.join(', ')}`);
// Alice의 권한: read, write, delete
});
});
});

function handleError(err) {
console.error('에러 발생:', err.message);
}

콜백 지옥의 문제점 심화 분석

1. 에러 처리의 어려움

// 에러를 놓치기 쉬운 패턴
asyncOp1((err, result1) => {
// err 체크를 잊어버렸다!
asyncOp2(result1, (err, result2) => {
if (err) throw err; // 비동기 컨텍스트에서 throw는 잡히지 않음!
console.log(result2);
});
});

// 비동기 에러는 try/catch로 잡을 수 없음
try {
setTimeout(() => {
throw new Error('비동기 에러'); // try/catch로 잡히지 않음!
}, 100);
} catch (e) {
console.error('이것은 실행되지 않음');
}

2. 순서 보장의 어려움

// 여러 비동기 작업의 완료를 모두 기다리기 어려움
let results = [];
let completed = 0;

function checkDone() {
if (completed === 3) {
console.log('모두 완료:', results);
}
}

fetchData('/api/a', (err, data) => {
results[0] = data;
completed++;
checkDone();
});

fetchData('/api/b', (err, data) => {
results[1] = data;
completed++;
checkDone();
});

fetchData('/api/c', (err, data) => {
results[2] = data;
completed++;
checkDone();
});

// 이 코드는 취약하고 에러 처리도 없음
// Promise.all이 이 문제를 해결함

3. 재사용성 부족

// 콜백 기반 코드는 재사용하기 어려움
function processUserData(userId, callback) {
getUser(userId, (err, user) => {
if (err) return callback(err);

getOrders(user.id, (err, orders) => {
if (err) return callback(err);

// 특정 로직이 함수 내부에 묶여있어 재사용 불가
const totalAmount = orders.reduce((sum, order) => sum + order.amount, 0);
callback(null, { user, orders, totalAmount });
});
});
}

콜백 지옥 완화 기법

Promise가 등장하기 전, 개발자들은 다음 방법으로 콜백 지옥을 완화했습니다.

1. 이름 있는 함수로 분리

// 콜백을 명명된 함수로 분리
function handleUser(err, user) {
if (err) return console.error(err);
fs.readFile(`posts_${user.id}.json`, 'utf-8', handlePosts.bind(null, user));
}

function handlePosts(user, err, postsData) {
if (err) return console.error(err);
const posts = JSON.parse(postsData);
console.log(`${user.name}의 게시글:`, posts);
}

fs.readFile('user.json', 'utf-8', handleUser);
// 들여쓰기는 줄었지만 로직 흐름 파악이 어려움

2. async.js 라이브러리 (역사적 해결책)

// async.js를 사용한 콜백 지옥 완화
const async = require('async');

async.waterfall([
(callback) => fs.readFile('user.json', 'utf-8', callback),
(userData, callback) => {
const user = JSON.parse(userData);
fs.readFile(`posts_${user.id}.json`, 'utf-8', (err, data) => {
callback(err, user, data);
});
},
(user, postsData, callback) => {
const posts = JSON.parse(postsData);
callback(null, { user, posts });
}
], (err, result) => {
if (err) return console.error(err);
console.log(result);
});

콜백 지옥 탈출 미리보기

현대 JavaScript는 Promise와 async/await으로 이 문제를 해결했습니다.

// 콜백 버전 (나쁨)
getUser(1, (err, user) => {
if (err) return handleError(err);
getPosts(user.id, (err, posts) => {
if (err) return handleError(err);
getComments(posts[0].id, (err, comments) => {
if (err) return handleError(err);
console.log(user, posts, comments);
});
});
});

// Promise 버전 (좋음)
getUser(1)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => console.log(comments))
.catch(handleError);

// async/await 버전 (최고)
async function loadData() {
try {
const user = await getUser(1);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
console.log(user, posts, comments);
} catch (err) {
handleError(err);
}
}

콜백을 Promise로 변환

레거시 콜백 기반 코드를 Promise로 변환하는 방법입니다.

// 수동 변환
function readFilePromise(path, encoding) {
return new Promise((resolve, reject) => {
fs.readFile(path, encoding, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}

// Node.js util.promisify 활용
const { promisify } = require('node:util');
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);

// 이제 async/await로 사용 가능
async function processFile() {
const data = await readFile('input.txt', 'utf-8');
await writeFile('output.txt', data.toUpperCase());
console.log('완료');
}

// Node.js 최신 버전: fs.promises
const fsPromises = fs.promises;

async function modernFileOp() {
const data = await fsPromises.readFile('file.txt', 'utf-8');
console.log(data);
}

고수 팁

1. 콜백이 여전히 유용한 경우

// 이벤트 리스너: 콜백이 자연스러운 패턴
button.addEventListener('click', (event) => {
console.log('클릭됨', event.target);
});

// 스트림: 콜백/이벤트 기반이 효율적
const stream = fs.createReadStream('large-file.txt');
stream.on('data', (chunk) => process(chunk));
stream.on('end', () => console.log('완료'));
stream.on('error', (err) => console.error(err));

// Array 메서드: 동기 콜백
const doubled = [1, 2, 3].map(x => x * 2);

2. 콜백을 받는 함수 설계 시 주의사항

// 동기/비동기 혼용 금지 (Zalgo 문제)
function badFunction(value, callback) {
if (cache[value]) {
callback(null, cache[value]); // 동기!
return;
}

fetchFromAPI(value, callback); // 비동기!
}

// 항상 비동기로 통일
function goodFunction(value, callback) {
if (cache[value]) {
// 항상 비동기로
queueMicrotask(() => callback(null, cache[value]));
return;
}

fetchFromAPI(value, callback);
}

3. 콜백 패턴의 타입스크립트 타이핑

// 에러 우선 콜백 타입 정의
type NodeCallback<T> = (error: Error | null, result?: T) => void;

function fetchUser(id: number, callback: NodeCallback<User>): void {
// 구현...
}
Advertisement