Node.js 비동기 에러 핸들링 - Promise rejection 추적하기

문제 상황

프로덕션 서버에서 간헐적으로 500 에러가 발생했지만 로그에는 아무것도 남지 않았다. 재현도 어려워서 원인 파악에 애를 먹었다.

원인 파악

Node.js 8부터 unhandled promise rejection에 대한 경고가 추가되었다. 프로세스 레벨 이벤트 리스너를 추가해서 추적을 시작했다.

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // 에러 로깅 서비스로 전송
  logger.error({
    type: 'unhandledRejection',
    reason: reason,
    stack: reason.stack
  });
});

문제는 데이터베이스 쿼리를 수행하는 미들웨어에서 .catch() 없이 Promise를 반환하는 부분이었다.

// 문제가 있던 코드
app.get('/api/users/:id', (req, res) => {
  getUserById(req.params.id)
    .then(user => res.json(user));
  // 에러 처리 없음!
});

해결 방법

  1. Express 라우트 핸들러에서 Promise를 사용할 때는 반드시 에러 핸들링을 추가했다.
app.get('/api/users/:id', (req, res, next) => {
  getUserById(req.params.id)
    .then(user => res.json(user))
    .catch(next); // Express 에러 핸들러로 전달
});
  1. async/await를 사용하는 경우 try-catch 래퍼를 만들어 적용했다.
const asyncHandler = fn => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await getUserById(req.params.id);
  res.json(user);
}));

결과

프로덕션 배포 후 unhandled rejection 로그가 더 이상 발생하지 않았다. ESLint에 no-floating-promises 규칙을 추가해서 사전에 방지할 수 있도록 했다.

Node.js 공식 문서에서는 향후 버전에서 unhandled rejection 발생 시 프로세스를 종료시킬 계획이라고 하니, 지금부터 철저히 관리해야겠다.