Node.js 비동기 에러 핸들링과 Promise 체이닝

문제 상황

프로덕션 환경에서 간헐적으로 서버가 다운되는 이슈가 발생했다. 로그를 확인해보니 UnhandledPromiseRejectionWarning이 원인이었다.

app.get('/api/users/:id', (req, res) => {
  db.query('SELECT * FROM users WHERE id = ?', [req.params.id])
    .then(user => {
      res.json(user);
    });
});

Promise rejection이 catch되지 않으면 Node.js가 경고를 출력하고, 향후 버전에서는 프로세스가 종료될 예정이라고 한다.

해결 방법

1. 개별 라우트에 catch 추가

app.get('/api/users/:id', (req, res) => {
  db.query('SELECT * FROM users WHERE id = ?', [req.params.id])
    .then(user => {
      res.json(user);
    })
    .catch(err => {
      console.error(err);
      res.status(500).json({ error: 'Internal Server Error' });
    });
});

2. 에러 핸들링 래퍼 함수

매번 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 db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
  res.json(user);
}));

3. 글로벌 에러 핸들러

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production' 
      ? 'Internal Server Error' 
      : err.message
  });
});

결론

Node.js 8에서 async/await를 본격적으로 사용하면서 에러 핸들링 패턴을 확립하는 것이 중요했다. 래퍼 함수 패턴은 코드 중복을 줄이고 일관된 에러 처리를 가능하게 했다. 향후 프로젝트에서는 이 패턴을 기본으로 적용할 예정이다.