Node.js 비동기 에러 핸들링 - Promise 체이닝 vs try-catch

문제 상황

외부 API를 호출하는 Node.js 서버를 운영 중이었는데, 간헐적으로 서버가 다운되는 현상이 발생했다. 로그를 확인해보니 UnhandledPromiseRejectionWarning이 원인이었다.

app.get('/api/user/:id', (req, res) => {
  fetchUserFromDB(req.params.id)
    .then(user => {
      return callExternalAPI(user.token); // 여기서 에러 발생
    })
    .then(data => res.json(data));
});

마지막 .catch()가 없어서 Promise rejection이 처리되지 않았다.

Promise 체이닝 방식

가장 기본적인 해결법은 .catch() 추가다.

app.get('/api/user/:id', (req, res) => {
  fetchUserFromDB(req.params.id)
    .then(user => callExternalAPI(user.token))
    .then(data => res.json(data))
    .catch(err => {
      console.error(err);
      res.status(500).json({ error: 'Internal Server Error' });
    });
});

async/await + try-catch

Node 8부터 async/await를 본격적으로 사용할 수 있게 되면서, 더 직관적인 에러 핸들링이 가능해졌다.

app.get('/api/user/:id', async (req, res) => {
  try {
    const user = await fetchUserFromDB(req.params.id);
    const data = await callExternalAPI(user.token);
    res.json(data);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

글로벌 에러 핸들러

개별 라우트마다 try-catch를 작성하는 것도 번거롭고, 놓치는 경우가 생긴다. Express 미들웨어로 전역 핸들러를 만들었다.

const asyncHandler = fn => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

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

// 에러 핸들링 미들웨어
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal Server Error' });
});

결론

  • Promise는 반드시 .catch() 추가
  • async/await 사용 시 try-catch 필수
  • 래퍼 함수로 중복 제거 가능
  • process.on('unhandledRejection')로 최후의 안전장치 추가 고려

배포 후 일주일간 모니터링했는데 서버 크래시가 사라졌다.