Node.js Express 프레임워크로 API 서버를 구축하여 테스트하다가 콘솔창에 Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client 라는 붉은색 에러 메시지를 마주친 적이 있을 것이다. 클라이언트에는 정상적으로 응답이 전달된 것처럼 보이는데, 서버 측에서는 미처리 예외가 발생하며 프로세스가 불안정해지는 고질적인 문제다. 다수의 개발자들이 조건문 뒤에 임시방편으로 코드를 계속 이어 붙이다가 이런 실수를 범한다. 이번 글에서는 이 에러가 발생하는 근본적인 원인부터 비동기 콜백에서 자주 발생하는 패턴, 그리고 코드 레벨에서 이를 명확하게 차단하는 해결법을 정리한다.
HTTP 프로토콜은 단일 요청(Request)에 대해 하나의 응답(Response)만을 반환하도록 설계되어 있다. 클라이언트가 서버에 요청을 보내면, 서버는 상태 코드(Status Code)와 헤더(Headers) 정보를 먼저 구성하여 전송한 후 본문(Body) 데이터를 흘려보낸다.
Express에서 res.send(), res.json(), res.redirect(), res.render() 같은 메서드를 호출하는 순간 HTTP 헤더가 클라이언트로 전송되고 응답 스트림이 닫힌다. 하지만 코드 실행 흐름이 여기서 멈추지 않고 뒤이어 또 다른 응답 메서드나 res.setHeader()를 호출하면 Node.js 런타임은 이미 전송이 끝난 스트림에 다시 헤더를 쓰려고 시도한다. 이때 발생하는 에러가 바로 ERR_HTTP_HEADERS_SENT다.
분기 처리 시 res.json()을 호출했지만 함수 실행을 종료하는 return 문을 빼먹는 경우다. 조건식 안의 응답이 나간 후에도 아래쪽 코드가 계속 실행되면서 두 번째 응답을 전송하려고 시도한다.
Array.prototype.forEach() 내부에서 조건에 부합할 때 res.json()을 호출하는 실수다. forEach는 루프를 도중에 break하거나 return으로 멈출 수 없기 때문에, 조건이 맞아 응답을 보낸 후에도 다음 요소를 순회하며 응답을 재요청하게 된다.
인증 미들웨어 등에서 에러가 발생했을 때 res.status(401).json()으로 응답을 보낸 후, 아래에서 next()를 호출하여 다음 라우터 핸들러까지 실행되게 만드는 패턴이다.
다음은 실무 라우터 코드에서 흔히 발생하는 잘못된 작성 방식과 이를 바로잡은 올바른 코드의 비교다.
✗ 잘못된 코드(return 누락으로 인한 중복 응답 호출)
app.get('/api/users/:id', async (req, res) => {
try {
const user = await findUserById(req.params.id);
if (!user) {
// 404 응답을 전송하지만 return이 없어 함수 실행이 계속됨
res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
// 위에서 404 응답을 보냈음에도 불고하고 아래 코드가 실행되어 200 응답을 또 보냄
res.json({ success: true, data: user });
} catch (error) {
res.status(500).json({ error: '서버 내부 오류' });
}
});✓ 올바른 코드(return 구문 추가 및 비동기 제어권 명확화)
app.get('/api/users/:id', async (req, res) => {
try {
const user = await findUserById(req.params.id);
if (!user) {
// return을 명시하여 응답 전송 후 즉시 함수 실행 종료
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
// 조건에 맞지 않을 때만 정상적으로 한 번만 실행됨
return res.json({ success: true, data: user });
} catch (error) {
return res.status(500).json({ error: '서버 내부 오류' });
}
});[실행 결과 / 서버 로그]
잘못된 코드를 실행할 경우 서버 터미널에 다음과 같은 예외 스택 트레이스가 출력된다.
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
at new NodeError (node:internal/errors:371:5)
at ServerResponse.setHeader (node:_http_outgoing:573:11)
at ServerResponse.header (node_modules/express/lib/response.js:771:10)
at ServerResponse.send (node_modules/express/lib/response.js:170:12)
at ServerResponse.json (node_modules/express/lib/response.js:267:15)
at /app/routes/user.js:12:9
Express 라우터에서 자주 사용되는 응답 관련 메서드의 동작 특징을 아래 표로 정리했다.
| 메서드 | 주요 역할 | 응답 스트림 종료 | 흔한 실수 및 주의점 |
|---|---|---|---|
| res.send() | Text, HTML, Buffer 등 일반 데이터 전송 | 예 | 전송 후 return 없이 추가 로직을 작성함 |
| res.json() | JSON 데이터 전송 (Content-Type 자동 설정) | 예 | 조건문(if) 내에서 return res.json() 형태로 쓰지 않음 |
| res.end() | 데이터 없이 HTTP 응답 즉시 종료 | 예 | 세션 종료 후 res.setHeader() 호출 시도 |
| next() | 다음 미들웨어로 제어권 넘김 | 아니오 | res.json() 호출 후 next()를 연속으로 실행함 |
✗ 잘못된 패턴: 미들웨어 검증 실패 시 응답 처리와 next()를 동시에 작성하여 후속 라우터가 호출되게 만듦.
✓ 올바른 패턴: 클라이언트에 응답을 직접 반환할 때는 return res.json(...)을 사용하고, 후속 처리가 필요할 때만 return next()를 단독 호출함.
Node.js ERR_HTTP_HEADERS_SENT 에러는 HTTP 프로토콜의 단일 응답 원칙을 위반했을 때 발생하는 지극히 당연한 결과다. 명시적인 return 문 작성과 비동기 흐름 제어라는 작은 습관이 모여서 서버의 안정성과 디버깅 효율을 높이는 큰 효과를 만든다는 점을 잊지 말자. 이 글의 실전 예제 섹션을 참고해 현재 프로젝트의 Express 라우터 코드를 점검하고 return 구문을 보강하면, 응답 중복 전송으로 인한 무작위 크래시를 완벽히 예방할 수 있을 것이다.