Node.js 비동기 에러 핸들링 - 놓치기 쉬운 Promise rejection

문제 상황

프로덕션 서버에서 간헐적으로 API 요청이 응답 없이 끊기는 현상이 발생했다. 로그를 확인해보니 특정 Promise가 reject되었을 때 에러를 catch하지 않아 unhandledRejection이 발생하고 있었다.

app.get('/api/users/:id', async (req, res) => {
  const user = await getUserFromDB(req.params.id); // 여기서 에러 발생 가능
  res.json(user);
});

문제는 getUserFromDB가 실패했을 때 에러가 Express 미들웨어로 전달되지 않는다는 것이었다.

해결 방법

1. try-catch로 감싸기

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await getUserFromDB(req.params.id);
    res.json(user);
  } catch (error) {
    next(error);
  }
});

2. asyncHandler 유틸리티 작성

매번 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 getUserFromDB(req.params.id);
  res.json(user);
}));

3. 전역 unhandledRejection 리스너 추가

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // 프로덕션에서는 로깅 후 프로세스 종료 고려
});

교훈

Express는 기본적으로 async/await 에러를 자동으로 처리하지 않는다. Node.js 10부터는 unhandledRejection이 발생하면 경고를 출력하고, 향후 버전에서는 프로세스를 종료할 예정이라고 한다. 모든 async 라우트 핸들러에 에러 처리를 추가했고, PM2로 프로세스가 죽었을 때 자동 재시작되도록 설정했다.