ERR_HTTP_HEADERS_SENT는 왜 가끔만 터지나: 중복 응답 추적기

댓글 등록 API가 이상하게 굴었습니다. 평소에는 멀쩡한데, 하루에 몇 번씩 Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client가 찍히며 프로세스가 흔들렸습니다. 재현이 안 되니 더 답답했습니다. 같은 요청을 열 번 보내면 열 번 다 성공하는데, 로그에는 분명히 남아 있었습니다.

가끔만 터진다는 점이 오히려 힌트였습니다. 코드 자체가 늘 틀렸다면 매번 터졌을 테니까요. 특정 조건에서만 지나가는 경로가 있고, 그 경로에서 응답을 두 번 보내고 있다는 뜻이었습니다.

이 글은 그 조건을 어떻게 좁혔는지, 왜 return 하나가 빠지면 이런 일이 생기는지, 그리고 응답 지점을 한 곳으로 모아 구조적으로 막은 과정을 정리한 기록입니다.


에러 메시지가 실제로 말하는 것

HTTP 응답은 헤더가 먼저 나가고 그다음 본문이 나갑니다. 한번 헤더를 내보내면 그 응답은 이미 클라이언트로 흘러가기 시작한 상태라, 상태 코드나 헤더를 뒤늦게 바꿀 방법이 없습니다.

그래서 이 에러는 문법이 틀렸다는 신호가 아닙니다. 이미 끝난 응답에 대고 한 번 더 응답하려 했다는 신호입니다. 원인을 찾을 때 “어디가 잘못됐나”가 아니라 “어느 요청에서 응답 함수가 두 번 불렸나”를 물어야 하는 이유가 여기 있습니다.

참고로 이 에러는 첫 번째 응답을 막지 않습니다. 클라이언트는 정상 응답을 이미 받았고, 서버에서만 예외가 납니다. 사용자 화면은 멀쩡한데 로그만 시끄러운 상황이 만들어지는 것도 이 때문입니다.


범인을 찾은 방법: 응답 함수를 세어 본다

스택 트레이스가 가리키는 줄은 두 번째 호출 지점일 뿐이라, 첫 번째가 어디서 나갔는지는 알려 주지 않습니다. 그래서 요청 하나가 응답을 몇 번 보내는지 직접 세는 미들웨어를 잠깐 넣었습니다.

// 임시 진단용 : 응답이 두 번 이상 나가는 요청을 잡아냅니다
app.use((req, res, next) => {
  const originalJson = res.json.bind(res);
  let count = 0;
  res.json = (payload) => {
    count += 1;
    if (count > 1) {
      console.warn('[DUP RESPONSE]', req.method, req.originalUrl);
      console.warn(new Error('second response').stack);
    }
    return originalJson(payload);
  };
  next();
});

두 번째 호출 시점에 일부러 예외 객체를 만들어 스택을 찍는 것이 요령입니다. 이렇게 하면 실제로 중복 응답을 낸 코드 위치가 그대로 드러납니다. 배포본에는 남기지 않고 원인을 찾을 때만 잠깐 씁니다.

몇 시간 뒤 로그가 걸렸습니다. 문제의 경로는 차단된 회원이 댓글을 달려고 했을 때였습니다. 하루에 몇 번뿐인 경로라 평소 테스트에서는 걸리지 않았던 것입니다.


원인: 응답은 함수를 끝내지 않는다

문제의 코드는 이랬습니다.

router.post('/comments', async (req, res) => {
  const { postId, memberId, body } = req.body;
  const member = await Member.findByPk(memberId);

  if (!member) {
    res.status(404).json({ message: '회원을 찾을 수 없습니다.' });
  }
  if (member.status === 'BLOCKED') {
    res.status(403).json({ message: '댓글 작성이 제한된 계정입니다.' });
  }

  const saved = await Comment.create({ postId, memberId, body });
  res.status(201).json(saved);   // 위에서 이미 응답했어도 여기까지 옵니다
});

여기가 초보와 경력자를 가리지 않고 걸리는 지점입니다. res.json()응답을 보내는 함수일 뿐, 함수 실행을 끝내는 문장이 아닙니다. 차단된 회원이면 403을 보낸 뒤에도 코드는 아래로 계속 흘러가고, 댓글을 실제로 저장한 다음 201을 한 번 더 보내려다 예외가 납니다.

더 나쁜 건 에러만 나는 게 아니라는 점입니다. 차단된 회원의 댓글이 실제로 저장됩니다. 클라이언트는 403을 받아 거절당했다고 알지만, 데이터베이스에는 행이 하나 늘어 있습니다. 로그만 시끄러운 문제로 보고 넘겼다면 데이터가 조용히 어긋나고 있었을 것입니다.


콜백과 프로미스가 섞이면 더 안 보인다

위 예제는 그나마 눈에 띕니다. 비동기가 끼면 훨씬 찾기 어려워집니다.

router.post('/comments', (req, res) => {
  Member.findByPk(req.body.memberId).then((member) => {
    if (!member) res.status(404).json({ message: '없는 회원입니다.' });

    Comment.create(req.body).then((saved) => {
      res.status(201).json(saved);
    });
  });
});

콜백 안에서 return 없이 응답하면, 그 return은 어차피 콜백 함수만 빠져나올 뿐이라 착각하기 쉽습니다. 하지만 여기서는 return이 있어야 안쪽 Comment.create 호출로 넘어가지 않습니다. 중첩이 깊어질수록 어느 return이 어디까지 끊는지 눈으로 따라가기가 힘들어집니다.

비동기 흐름에서 제어가 예상과 다르게 새어 나가는 문제는 언어를 가리지 않습니다. 자바 쪽에서 같은 성격의 함정을 정리한 글은 @Async 잘못 쓰면 트랜잭션·예외가 새어나간다입니다.


수정 1 — return으로 흐름을 끊는다

가장 급하게 적용한 수정입니다. 응답하는 모든 자리 앞에 return을 붙였습니다.

if (!member) {
  return res.status(404).json({ message: '회원을 찾을 수 없습니다.' });
}
if (member.status === 'BLOCKED') {
  return res.status(403).json({ message: '댓글 작성이 제한된 계정입니다.' });
}

한 줄이면 끝나는 수정이고, 이것만으로도 에러와 잘못된 저장이 함께 사라집니다. 팀에서는 아예 응답 호출 앞에는 예외 없이 return을 붙인다를 규칙으로 정했습니다. 마지막 응답처럼 굳이 필요 없는 자리에도 붙입니다. 판단할 거리를 남겨 두지 않는 편이 실수가 적기 때문입니다.


수정 2 — 응답을 한 곳에서만 내보낸다

규칙은 사람이 지키는 것이라 언젠가 또 빠집니다. 그래서 구조를 바꿨습니다. 실패는 응답하지 않고 예외를 던지게 하고, 응답은 에러 핸들러 한 곳에서만 내보내는 방식입니다.

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

router.post('/comments', asyncHandler(async (req, res) => {
  const { postId, memberId, body } = req.body;
  const member = await Member.findByPk(memberId);

  if (!member) throw new HttpError(404, '회원을 찾을 수 없습니다.');
  if (member.status === 'BLOCKED') throw new HttpError(403, '댓글 작성이 제한된 계정입니다.');

  const saved = await Comment.create({ postId, memberId, body });
  res.status(201).json(saved);
}));

// 응답을 내보내는 유일한 자리
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);
  const status = err.status || 500;
  res.status(status).json({ message: err.message });
});

throw는 그 자리에서 함수를 확실히 끝냅니다. return을 붙였는지 매번 확인할 필요가 없어집니다. 에러 핸들러 첫 줄의 res.headersSent 검사도 중요합니다. 이미 응답이 나간 뒤에 뒤늦게 예외가 올라온 경우, 여기서 또 응답하면 같은 에러를 에러 핸들러가 다시 만들어 냅니다.

asyncHandler는 비동기 함수에서 난 예외를 에러 핸들러로 넘겨 주는 얇은 감싸개입니다. 이게 없으면 async 함수 안의 예외가 조용히 사라져 요청이 응답 없이 매달립니다.

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

놓치기 쉬운 중복 응답 패턴

return 누락 말고도 응답이 두 번 나가는 경로가 몇 가지 더 있습니다. 겪었거나 리뷰에서 잡은 것들을 모았습니다.

패턴왜 두 번 나가나대응
if 안에서 return 없이 응답아래 코드가 계속 실행됨응답 앞에 return 고정
미들웨어에서 응답하고 next() 호출다음 핸들러가 또 응답응답했으면 next를 부르지 않음
타임아웃 처리와 정상 응답 경합느린 응답이 뒤늦게 도착res.headersSent로 가드
try 안에서 응답 후 catch에서 또 응답응답 이후 난 예외가 catch로 감응답은 try 밖으로 빼기
이벤트 콜백이 여러 번 실행같은 핸들러가 재호출됨once로 한 번만 구독
중복 응답이 생기는 다섯 가지 경로와 각각의 대응.

네 번째 패턴은 특히 사악합니다. 응답을 보낸 뒤 같은 try 블록 안에서 다른 예외가 나면, 그 예외를 잡은 catch가 친절하게 500 응답을 한 번 더 보냅니다. 응답은 되도록 try 바깥이나 흐름의 마지막에 두는 편이 안전합니다.


정리

  • 메시지를 오해하지 않습니다. 문법 오류가 아니라 응답을 두 번 보냈다는 신호입니다.
  • 가끔만 터지면 분기를 의심합니다. 드물게 지나가는 경로에 중복 응답이 숨어 있습니다.
  • 응답 횟수를 세어 첫 번째 지점을 찾습니다. 스택 트레이스만으로는 부족합니다.
  • 에러보다 데이터 오염이 더 큰 문제입니다. 거절해 놓고 저장까지 해 버릴 수 있습니다.
  • 실패는 throw, 응답은 한 곳에서. 규칙 대신 구조로 막는 편이 오래갑니다.

마지막 항목과 관련해 하나 덧붙이면, 이번처럼 거절과 저장이 함께 일어나는 문제는 트랜잭션 경계를 어떻게 잡느냐와도 맞닿아 있습니다. 검증을 통과한 뒤에만 저장이 확정되게 하는 방법은 데이터 변경과 트랜잭션 — INSERT·UPDATE·DELETE의 안전장치에 정리해 두었습니다. 같은 뿌리에서 나온 문제를 앞의 req.body가 undefined로 찍히던 날 편과 함께 보면 흐름이 이어집니다.

댓글 남기기