메일 하나가 서버를 죽인 이유 - err.status: 'error'의 함정

dasong·2026년 2월 17일

Postmortem

목록 보기
1/3
post-thumbnail

이 글은 2022년 12월에 발생한 이슈를 기록한 글입니다.

발단

우리 서비스에서는 멤버가 그룹에 소속될 때, 해당 멤버에게 소속이 완료되었다는 메일을 전송한다.

하지만 어느 날, 메일 발송에 실패했다는 오류가 발생했다.

메일이 발송되지 않은 건 대수롭지 않게 넘어갈 수도 있었다. 애초에 메일 발송이 실패해도 소속 승인은 정상적으로 이뤄지도록 설계되어 있었고, 실제로 소속도 잘 처리됐으니까.

그런데 서버가 crash나면서 재시작되고 있었다. 그게 이상했다.


증상 분석

CloudWatch 로그에서 스택 트레이스를 발견했다.

RangeError [ERR_HTTP_INVALID_STATUS_CODE]: Invalid status code: error
    at ServerResponse.writeHead (_http_server.js:255:11)
    ...

Invalid status code: error — statusCode 값이 숫자가 아니라 문자열 'error' 였다.

하지만 코드 어디에도 res.statusCode = 'error'를 직접 쓰는 부분이 없었다. 도대체 어디서 온 걸까?


범인 추적

멤버 소속 API의 마지막 로직은 mandrill-api 라이브러리로 소속 완료 메일을 보내는 것이었다. mandrill의 이메일 발송 히스토리에도 기록이 없었으니, 메일을 보내다가 오류가 발생했을 가능성이 높았다.

라이브러리 내부를 뜯어보다

mandrill-apisendTemplate 메소드는 내부적으로 call 메소드를 호출한다. call 메소드는 Mailchimp API에 HTTP 요청을 보내는데, 두 가지 상황에서 오류를 발생시킨다:

  1. API 요청 중 네트워크 오류 발생
  2. 응답 파싱 후 statusCode가 200이 아닌 경우

이때 sendTemplate에 전달했던 onerror 콜백을 호출하며 에러 오브젝트를 넘긴다.

// mandrill-api 내부에서 생성하는 에러 오브젝트
{
  status: 'error',
  name: 'GeneralError',
  message: e
}

에러가 전파되는 경로

우리 코드의 onerror 콜백은 이랬다:

(e) => {
  reject(e);
}

별도 처리 없이 그대로 reject. 이 에러 오브젝트는 Express의 에러 핸들러로 전달된다.

// app.js 에러 핸들러
if (err.status) {
  res.statusCode = err.status; // 💥 'error' (string) 가 들어옴
}

err.status가 존재하니 조건을 통과하고, 그대로 statusCode에 할당된다. Node.js는 statusCode가 반드시 숫자여야 하기 때문에, 응답을 보내는 시점에 ERR_HTTP_INVALID_STATUS_CODE를 던지며 프로세스가 죽는다.


원인 요약

sendTemplate 실패
    → onerror 콜백: reject({ status: 'error', ... })
        → Express 에러 핸들러: res.statusCode = err.status
            → Node.js: statusCode는 number여야 함 → 서버 크래시

세 곳의 실수가 겹쳤다:

  1. 라이브러리: status 필드에 문자열 'error'를 사용 (HTTP status code와 필드명 충돌)
  2. 우리 코드 (onerror): 에러 오브젝트를 검증 없이 그대로 reject
  3. 우리 코드 (에러 핸들러): err.status가 유효한 숫자인지 확인하지 않음

해결 방안

에러 핸들러에서 err.status를 사용하기 전에 유효한 HTTP 상태 코드인지 검증한다.

// 수정 전
if (err.status) {
  res.statusCode = err.status;
}

// 수정 후
const status = parseInt(err.status, 10);
if (status >= 100 && status < 600) {
  res.statusCode = status;
} else {
  res.statusCode = 500;
}

또는 onerror 콜백에서 라이브러리 에러를 우리 도메인의 에러로 변환하는 방법도 있다. 이 방법이 더 근본적인 해결책이다.

(e) => {
  const error = new Error('메일 발송 실패');
  error.status = 500;
  reject(error);
}

교훈

외부 라이브러리의 에러 오브젝트를 그대로 신뢰하지 말 것.

라이브러리마다 에러 오브젝트의 구조가 다르다. status, code, statusCode 등 필드명도 의미도 제각각이다. 특히 HTTP 응답에 직접 사용되는 값은 반드시 타입과 범위를 검증해야 한다.

또한 이번 케이스처럼 에러가 메일 발송이라는 부가 작업에서 발생해 핵심 응답을 망가뜨리는 패턴은 흔하다. 중요도가 낮은 부가 작업은 try-catch로 격리해 메인 플로우에 영향을 주지 않도록 설계하는 것이 좋다.

profile
소비자에서 생산자로

0개의 댓글