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 생태계가 빠르게 변하는 만큼 새로운 패턴을 적극 활용할 필요가 있다.