Node.js 비동기 처리 패턴 정리 - Callback에서 Promise까지
문제 상황
2년 전 작성된 API 서버 코드를 유지보수하던 중, 중첩된 콜백으로 인해 가독성이 심각하게 떨어지는 부분을 발견했다. Node.js 8부터는 async/await도 정식 지원되지만, 프로젝트 전반에 걸쳐 callback 패턴이 혼재되어 있어 점진적인 마이그레이션이 필요했다.
Callback 패턴의 한계
db.query('SELECT * FROM users WHERE id = ?', [userId], (err, user) => {
if (err) return handleError(err);
db.query('SELECT * FROM orders WHERE user_id = ?', [user.id], (err, orders) => {
if (err) return handleError(err);
payment.process(orders, (err, result) => {
if (err) return handleError(err);
// 더 깊어지는 중첩...
});
});
});
에러 핸들링도 매번 반복되고, 로직의 흐름을 파악하기 어려웠다.
Promise로 전환
util.promisify를 활용해 기존 callback 기반 함수를 Promise로 래핑했다.
const util = require('util');
const queryAsync = util.promisify(db.query);
queryAsync('SELECT * FROM users WHERE id = ?', [userId])
.then(user => queryAsync('SELECT * FROM orders WHERE user_id = ?', [user.id]))
.then(orders => payment.process(orders))
.then(result => {
// 처리 완료
})
.catch(err => handleError(err));
에러 핸들링이 한 곳으로 모이고, 체이닝으로 순차 처리가 명확해졌다.
async/await 적용
Node.js 8부터 정식 지원되는 async/await으로 더 직관적으로 개선했다.
async function processUserOrder(userId) {
try {
const user = await queryAsync('SELECT * FROM users WHERE id = ?', [userId]);
const orders = await queryAsync('SELECT * FROM orders WHERE user_id = ?', [user.id]);
const result = await payment.process(orders);
return result;
} catch (err) {
handleError(err);
}
}
동기 코드처럼 읽히면서도 비동기로 동작한다.
마이그레이션 전략
전체 코드를 한번에 바꾸는 대신, 새로 작성하는 코드는 async/await을 사용하고, 기존 callback 함수는 wrapper를 만들어 점진적으로 전환하는 방식을 택했다. 테스트 커버리지가 있는 부분부터 우선 적용했다.
정리
Callback 패턴이 완전히 나쁜 것은 아니지만, 복잡한 비동기 로직에서는 Promise와 async/await이 훨씬 유지보수하기 좋다. Node.js 생태계가 빠르게 변하는 만큼 새로운 패턴을 적극 활용할 필요가 있다.