Skip to main content
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