왜 지금 에러 처리 구조를 정리해야 하는가

서비스에서 가장 자주 발생하는 장애는 기능 버그보다 실패 처리의 일관성 부족에서 발생합니다. 하나의 라우트에서 throw로 무작위 에러를 흘려보내면, 운영에서는 "뭐가 문제인지"를 알기 어렵고, 대응 시간도 길어집니다. 특히 Node.js API에서는 async/await가 보편화되면서 비동기 실패가 흩어지기 쉬워, 기준 없이 처리하면 동일한 오류가 3분기마다 다른 형태로 응답됩니다.

목표는 단순합니다. "실패를 감지하는 기준"과 "응답을 표준화하는 방식"을 통일해 재현성과 대응 속도를 높이는 것입니다.

에러를 세 종류로 먼저 나누면 구조가 쉬워진다

  • 클라이언트 입력 오류: 유효성/권한/형식 오류. 즉시 4xx로 종료하고 사용자 메시지를 명확히 전달.
  • 외부 의존성 오류: DB, API, 메시지큐, 캐시와 같은 외부 원인. 대부분 재시도/폴백 전략이 필요.
  • 프로그래밍 오류: null 접근, 타입 불일치, 예상하지 못한 상태 전이. 즉시 에러 추적 로그와 함께 500 처리.

이 분류가 되면, 각 층에서 해야 할 일이 달라집니다. 입력 오류는 API 스펙을 정확히 드러내고, 외부 의존성 오류는 retry budget을 두고 실패율만 모니터링하며, 프로그래밍 오류는 즉시 경보를 울려 코드 수정으로 되돌립니다.

코드에서 실전적으로 쓰기 위한 최소 구조

아래 예시는 운영 친화적인 에러 기본 형태입니다. 메시지에는 사용자가 봐야 하는 부분과 내부 추적 ID를 분리해 둡니다.

class AppError extends Error {
  constructor(status, code, message, details = {}) {
    super(message);
    this.name = 'AppError';
    this.status = status;
    this.code = code;
    this.details = details;
  }
}

const BAD_REQ = (details) => new AppError(400, 'INVALID_INPUT', '요청 값이 올바르지 않습니다.', details);
const DEP_FAIL = () => new AppError(503, 'DEPENDENCY_ERROR', '외부 서비스 처리 실패');
const SYS_FAIL = () => new AppError(500, 'SERVER_ERROR', '예상치 못한 오류가 발생했습니다.');

예외를 잡는 공통 헬퍼를 둬야 async/await에서 try/catch 반복을 줄일 수 있습니다.

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

app.get('/api/payments/:id', wrapAsync(async (req, res) => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id)) throw BAD_REQ({ field: 'id' });
  const data = await paymentService.get(id);
  if (!data) throw SYS_FAIL();
  res.json({ ok: true, data });
}));

병렬 처리에서의 부분 실패 대응

병렬 호출을 Promise.all로만 쓰면 하나라도 실패하면 전체가 끝나는 구조라 처리 로직이 약합니다. 배너/통계 같이 일부 데이터만 부족해도 표시 가능한 화면이라면 Promise.allSettled를 사용해 실패를 격리합니다.

const [user, summary, tips] = await Promise.allSettled([
  getUser(userId),
  getSummary(userId),
  getTips(userId)
]);

const result = {
  user: user.status === 'fulfilled' ? user.value : null,
  summary: summary.status === 'fulfilled' ? summary.value : null,
  tips: tips.status === 'fulfilled' ? tips.value : null,
  warnings: []
};

if (summary.status === 'rejected') {
  result.warnings.push('요약 데이터 조회 실패, 캐시 갱신 후 재시도 예정');
}

핵심은 API가 실패했을 때도 전체 응답이 완전히 무효화되지 않도록 비즈니스 관점에서 타협 범위를 정해두는 것입니다.

마지막 체크리스트

  • 공통 에러 타입을 한 파일에 모아 상태코드, 코드, 상세 원인 문자열을 고정했는가?
  • 모든 라우트 핸들러가 wrapAsync로 감싸져 있는가?
  • 예상 가능한 외부 장애에 대한 retry + timeout + circuit breaker 정책이 있는가?
  • 운영 로그에 상관없을 수 있는 사용자 응답을 넣는 대신 correlationId로 추적 가능한가?
  • 부분 실패 허용 API는 Promise.allSettled로 경고 메시지 정책을 문서화했는가?

에러 처리는 한 번에 완성되지 않습니다. 다만 분류 기준을 고정하고 코드 패턴을 통일하면 디버깅 시간은 급격히 줄고, 장애 복구 절차는 안정화됩니다. 팀 단위로 위 체크리스트를 주 단위 리포트에 붙여 두면, "오늘은 어디서 깨졌는가"보다 "다음에 또 안 깨지려면"을 더 빨리 판단할 수 있습니다.