Express 미들웨어 에러 핸들링 제대로 하기

문제 상황

재택근무로 전환되면서 API 서버 모니터링이 더 중요해졌다. 그런데 Sentry에 찍혀야 할 에러들이 누락되는 경우가 있었다.

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

위 코드에서 User.findById가 실패하면 UnhandledPromiseRejection이 발생한다. Express의 기본 에러 핸들러가 이를 잡지 못한다.

해결 방법

1. async wrapper 유틸리티

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 User.findById(req.params.id);
  res.json(user);
}));

2. 중앙 에러 핸들러

app.use((err, req, res, next) => {
  console.error(err.stack);
  
  // Sentry 전송
  Sentry.captureException(err);
  
  // 환경별 응답 분기
  const isDev = process.env.NODE_ENV === 'development';
  res.status(err.status || 500).json({
    error: isDev ? err.message : 'Internal Server Error',
    ...(isDev && { stack: err.stack })
  });
});

3. 커스텀 에러 클래스

class ApiError extends Error {
  constructor(message, status = 500) {
    super(message);
    this.status = status;
  }
}

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new ApiError('User not found', 404);
  }
  res.json(user);
}));

결과

  • 누락되던 에러들이 Sentry에 제대로 기록됨
  • 에러 응답 형식이 통일됨
  • 디버깅 시간 단축

express-async-errors 같은 라이브러리도 있지만, 직접 구현한 wrapper가 더 명시적이고 통제 가능해서 이 방식을 선택했다.

Express 미들웨어 에러 핸들링 제대로 하기