본문 바로가기
빌드노트 검색

빌드노트

Node에서 본문을 쓴 뒤 setHeader를 호출하면 ERR_HTTP_HEADERS_SENT가 난다

Node HTTP 서버의 첫 write 전후에 headersSent를 비교하고, 늦은 setHeader 오류와 실제 클라이언트 응답을 확인했다. 상태·헤더 결정을 본문 쓰기 앞으로 옮기는 수정과, 늦은 statusCode 대입이 예외 없이도 실제 응답을 바꾸지 못한 결과를 설명한다.

Node의 HTTP 응답에서 res.write('hello')를 호출한 뒤 res.setHeader()로 헤더를 추가하면 ERR_HTTP_HEADERS_SENT가 날 수 있다. res.end()를 아직 호출하지 않았어도 그렇다. 첫 본문 쓰기가 헤더를 함께 확정하기 때문에, 상태 코드와 헤더를 결정하는 코드는 첫 write() 앞으로 옮겨야 한다.

이 글의 대상은 node:http 서버의 http.ServerResponse다. Node v22.23.2의 write 문서 (새 탭에서 열림)는 명시적인 writeHead() 호출이 없어도 첫 write()가 보관 중인 헤더와 첫 본문 조각을 보낸다고 설명한다. 화면에 본문이 보일 때까지 기다려야 헤더가 확정되는 것은 아니다.

end 전인데도 헤더가 이미 나갔다

자기 서버에서는 오류가 난 setHeader()보다 앞서 응답을 쓰는 곳을 찾는다. 아래는 http.createServer((req, res) => { ... })의 응답 처리 부분에 넣을 수 있는 작은 재현이다. 기존 응답 코드와 함께 실행하지 말고, 로컬 진단용 처리 부분을 이 코드로 바꿔 확인한다. res는 이 콜백의 두 번째 인자다. Node가 설치돼 있어야 하며 node --version으로 버전을 확인할 수 있다.

이 코드는 첫 write() 전후의 헤더 전송 여부와 응답 종료 여부를 출력하고, 뒤늦은 헤더 설정에서 잡힌 오류를 보여 준다.

res.statusCode = 201;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
console.log('before:', res.headersSent, res.writableEnded);
res.write('hello');
console.log('after write:', res.headersSent, res.writableEnded);
try {
  res.setHeader('X-Stage', 'ready');
} catch (err) {
  console.log(err.code, err.message);
}
res.end();
console.log('after end:', res.headersSent, res.writableEnded);

Windows의 Node.js v22.23.2에서 새 서버를 127.0.0.1의 임의 포트에 열고 합성 GET 요청을 보냈다. 서버 출력은 다음과 같았다.

before: false false
after write: true false
ERR_HTTP_HEADERS_SENT Cannot set headers after they are sent to the client
after end: true true

after write: true false에서 두 값은 서로 다른 경계를 나타낸다. headersSent는 이미 헤더를 보냈다는 뜻이고, writableEnded는 end()를 호출했는지를 나타낸다. 응답 본문을 계속 쓸 수 있는 동안에도 초기 헤더 변경은 이미 불가능할 수 있다. headersSent 문서 (새 탭에서 열림), writableEnded 문서 (새 탭에서 열림)

같은 요청의 클라이언트에서는 상태 코드 201, Content-Type: text/plain; charset=utf-8, 본문 hello를 받았다. X-Stage는 없었다. 서버에 오류가 기록됐다고 이미 보낸 상태 코드가 자동으로 실패 상태로 바뀌지는 않았다. 여기서는 오류를 잡았으므로 뒤의 end()까지 실행해 기존 응답을 마칠 수 있었다. 이 end()는 관찰을 마무리하기 위한 것이며, 헤더 오류를 고친 코드가 아니다.

헤더 설정을 첫 write 앞으로 옮긴다

앞의 재현에서 늦게 호출하던 setHeader('X-Stage', 'ready')를 첫 본문 쓰기 앞으로 옮긴다. 이 코드는 같은 상태 코드와 본문을 유지하면서, 헤더 설정 순서만 바로잡는다.

res.statusCode = 201;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('X-Stage', 'ready');
console.log('before:', res.headersSent, res.writableEnded);
res.write('hello');
console.log('after write:', res.headersSent, res.writableEnded);
res.end();
console.log('after end:', res.headersSent, res.writableEnded);

이 순서로도 before: false false, after write: true false, after end: true true가 나왔다. 차이는 오류가 없고 클라이언트가 X-Stage: ready를 받았다는 점이다. 상태 코드 201, Content-Type과 본문 hello는 문제 조건과 같았다. setHeader()는 이 조건에서 헤더를 준비했고 첫 write()가 그것을 내보냈다. setHeader 문서 (새 탭에서 열림)

자기 코드에서 setHeader() 직전의 headersSent가 true라면, 그 줄에 조건문을 추가해 오류만 숨기기 전에 응답을 먼저 확정한 호출을 찾는다. 본문을 쓰는 공통 함수나 스트림 연결도 살펴봐야 한다. 아직 본문을 쓰지 않았다면 명시적인 writeHead()나 flushHeaders() 호출이 있었는지 확인한다. 두 API도 헤더를 확정하는 경계가 될 수 있다. 이 두 호출 경로는 문서로 확인했으며 이번 비교 실험에는 넣지 않았다. writeHead 문서 (새 탭에서 열림), flushHeaders 문서 (새 탭에서 열림)

statusCode 대입은 오류 없이도 늦을 수 있다

헤더 설정을 고치다가 상태 코드 변경도 같은 방식으로 실패할 것이라 가정하면 놓치는 부분이 있다. 다음은 앞의 예제에서 헤더 설정을 빼고, 201 대입만 본문 쓰기 뒤로 옮긴 별도 대조다. 이 코드는 늦은 대입이 예외를 내는지와 서버 객체에 어떤 값이 남는지를 확인한다.

console.log('before:', res.headersSent, res.statusCode);
res.write('hello');
console.log('after write:', res.headersSent, res.statusCode);
res.statusCode = 201;
console.log('after assignment:', res.headersSent, res.statusCode);
res.end();

같은 Node 버전에서 실행한 서버 출력은 다음과 같았다.

before: false 200
after write: true 200
after assignment: true 201

대입은 예외를 던지지 않았고 서버 객체의 값도 201로 보였다. 하지만 클라이언트가 실제로 받은 상태 코드는 200이었으며 본문은 여전히 hello였다. 첫 write() 때 기본 상태 코드가 이미 나간 뒤였기 때문이다. 공식 statusCode 설명 (새 탭에서 열림)도 암묵적 헤더를 내보낼 때 사용할 상태 코드라고 설명한다. 위의 늦은 대입 후 객체 값과 클라이언트 값의 차이는 이번에 직접 관찰한 결과다. 따라서 예외가 없다는 사실이나 대입 뒤 로그만으로 수정 성공을 판단하면 안 된다.

성공·실패 여부를 조회한 뒤 응답을 결정하는 코드라면 그 조회와 분기를 첫 본문 쓰기 전에 끝낸다. 예를 들어 실패할 수 있는 작업 앞에서 안내용 write()를 먼저 실행하면, 뒤에서 실패를 발견해도 그 응답의 상태 코드나 Content-Type을 교체할 수 없다. 스트리밍이 꼭 필요하다면 시작 시 상태와 헤더를 먼저 정하고, 전송 도중 실패를 어떻게 알릴지는 그 스트림의 형식과 처리 정책으로 정해야 한다.

headersSent는 그 결정을 되돌리는 스위치가 아니다. true인 뒤에 오류를 잡고 새 writeHead(500)이나 다른 JSON 응답을 덧쓰는 방식으로 복구하지 않는다. 아직 응답 전이라면 실패 응답을 한 번 보낼 수 있지만, 이미 전송을 시작했다면 해당 스트림을 마칠지 중단할지 별도로 처리해야 한다. 이 글의 재현은 부분 본문이 유효한 합성 문자열이어서 기존 응답을 끝낸 경우다. 실제 작업 실패 후 부분 본문을 정상 완료처럼 끝내라는 뜻은 아니다.

직접 검증한 범위는 Node v22.23.2의 기본 HTTP/1.1 서버와 직접 연결한 로컬 클라이언트다. Express 등 프레임워크의 오류 처리, 프록시의 버퍼링, HTTP/2, 전송 도중 연결이 끊긴 경우는 검증하지 않았다. 실제 코드에서는 실패하는 헤더 설정 바로 앞의 headersSent, 그보다 먼저 응답을 쓴 호출, 클라이언트가 받은 상태·헤더를 함께 대조한다.

← 전체 글 보기