빌드노트
http.request를 만들었는데 응답 콜백이 오지 않을 때
Node http.request의 생성, 헤더 전송, 본문 종료를 구분한다. loopback 실험으로 write·flushHeaders·end와 http.get의 차이를 확인하고, 서버의 응답 정책과 Content-Length 때문에 응답 유무만으로 end 호출을 판단할 수 없는 이유를 살펴본다.
Node의 http.request(options, callback)에 콜백을 달았는데 아무 응답도 오지 않는다면, 요청을 만든 곳에서 마지막 req.end()까지 실행되는지 먼저 확인한다. 특히 POST 본문을 req.write()로 쓰고 응답을 기다리는 코드가 대상이다. 본문을 쓴 것과 요청 전송을 마친 것은 다르다. 본문 끝을 기다린 뒤 응답하는 서버라면 양쪽이 서로 기다리게 된다.
여기서 콜백은 요청을 시작시키는 함수가 아니다. 응답 헤더를 받았을 때 실행되는 response 이벤트 처리기다. 따라서 그 콜백 안에만 req.end()를 두면, 정작 응답을 받기 위해 필요한 요청 종료가 뒤로 밀릴 수 있다. Node v22.23.2의 http.request 설명 (새 탭에서 열림)
마지막 write 다음에 end가 있는지 본다
기존 코드에서 req는 http.request()가 반환한 요청 객체이고, body는 마지막으로 보낼 본문 조각이라고 하자. 더 쓸 데이터가 없는 일반적인 POST라면, 마지막 req.write(body) 부분을 다음처럼 바꿔 종료 호출 전후를 확인한다. 기존 응답 처리기와 error 처리기는 유지한다.
req.write(body);
console.log('after write:', req.headersSent, req.writableEnded);
req.end();
console.log('after end:', req.headersSent, req.writableEnded);
Windows의 Node.js v22.23.2에서 본문을 hello로 두고 실행한 출력은 다음과 같았다. 이 비교의 POST에는 Content-Length를 직접 지정하지 않았다.
after write: true false
after end: true true
첫 줄의 true false는 헤더를 내보낸 상태에서도 end()는 아직 호출하지 않았다는 뜻이다. writableEnded는 end() 호출 여부를 보여 주며, 서버 수신 완료나 응답 성공까지 보장하지 않는다. 데이터가 전부 내보내졌는지도 이 값 하나로 판단할 수 없다. request.writableEnded (새 탭에서 열림)
이 코드는 마지막 본문 쓰기를 끝낸 자리에 넣는다. 스트림에서 데이터를 계속 보내는 요청이라면 중간에 end()를 추가하지 말고, 데이터를 보내는 쪽의 정상 완료 경로가 요청을 언제 종료하는지 확인해야 한다. 한 번에 보낼 작은 본문은 req.end(body)로 쓰기와 종료를 함께 처리할 수도 있다. request.end (새 탭에서 열림)
헤더가 도착해도 본문 끝은 오지 않을 수 있다
차이를 확인하려고 127.0.0.1에 새 HTTP/1.1 서버를 열었다. 서버는 요청 본문을 읽고, 서버 쪽 요청 객체의 end 이벤트가 발생한 뒤에만 200과 본문 ok를 응답하도록 했다. 각 조건은 새 연결로 실행했고, POST에는 Content-Length를 지정하지 않았다. 다음은 각 요청에 300ms의 관찰 구간을 둔 결과에서 필요한 값만 정리한 것이다.
- GET 요청 객체만 생성: TCP 연결은 맺어졌지만 서버의 HTTP
request이벤트는 없었다. 클라이언트의headersSent와writableEnded는 모두false였고 응답도 없었다. 소켓 연결과 HTTP 요청 수신은 같은 관찰이 아니었다. - POST에서
flushHeaders()만 호출: 서버의request이벤트가 발생했다. 본문 데이터와 본문end는 없었고,writableEnded=false인 채 응답을 기다렸다. - POST에서
write('hello')만 호출: 서버는 요청과hello를 받았지만 본문end는 받지 못했다.writableEnded=false였고 응답은 없었다. 뒤에flushHeaders()를 추가한 별도 조건도 같았다. - POST에서
write('hello')다음end()호출: 서버의 본문end가 발생했고, 클라이언트는writableEnded=true와 응답200, 응답 본문ok의 끝까지 확인했다.
이 POST들은 Transfer-Encoding: chunked로 전송됐다. write()는 본문 조각을 보내고, end()는 chunked 본문을 마치는 표시까지 보낸다. flushHeaders()는 헤더를 먼저 내보내는 호출이므로 본문 종료를 대신하지 않는다. request.write (새 탭에서 열림), request.flushHeaders (새 탭에서 열림)
별도 조건에서는 write('hello') 후 첫 관찰 구간 동안 응답이 없던 같은 요청에 end()만 추가로 호출했다. 다음 관찰 구간에는 서버의 본문 end와 클라이언트의 응답 200이 모두 나타났다. 이 조건에서는 더 기다리는 것보다 빠져 있던 종료 호출을 실행하는 것이 해결이었다.
응답을 받았다는 사실로 end 호출을 추정하지 않는다
앞의 무응답 결과에는 서버가 본문 끝까지 기다린다는 조건이 있다. 같은 POST에서 서버를 요청 헤더 수신 직후 응답하도록 바꾸자, flushHeaders()만으로도 200과 응답 본문 끝을 받았다. 이때 클라이언트는 여전히 writableEnded=false였고 서버의 요청 본문 end도 없었다.
본문 길이도 결과를 바꿨다. 서버는 다시 본문 끝을 기다리게 두고, 요청에 Content-Length: 5를 지정한 뒤 write('hello')만 실행했다. 이번에는 서버가 본문 끝을 인식하고 200을 보냈다. 그래도 클라이언트의 writableEnded는 false였고 요청의 finish 이벤트는 없었다. 이 실행에서 서버가 길이를 채워 받은 것과 클라이언트가 전송 스트림을 종료한 것은 서로 다른 상태였다.
따라서 “응답이 왔으니 end()도 호출됐을 것”이라고 판단하지 않는다. 반대로 “end()가 없으면 모든 서버가 반드시 응답하지 않는다”라고 일반화할 수도 없다. 정상적으로 전송을 마치는 코드는 응답 도착 여부와 관계없이 end()에 도달해야 한다. Content-Length를 추가해 무응답만 없애는 방식으로 빠진 종료 호출을 덮지 않는다.
writableEnded=true인데도 응답 콜백이 없다면, 다음으로는 요청의 error와 서버의 수신 로그를 확인한다. 요청의 finish는 마지막 데이터가 운영체제로 전달된 경계이며 서버 수신 확인은 아니다. 서버에서 본문 end까지 관찰됐다면, 이후 응답을 만드는 경로도 조사할 수 있다. 요청 finish 이벤트 (새 탭에서 열림)
본문 없는 GET도 http.request라면 종료한다
기존 http.request()가 본문 없는 GET을 만든다면 마지막에 req.end()를 호출한다. 같은 loopback 서버에서 GET 요청을 생성만 했을 때는 HTTP 요청 이벤트가 없었지만, end()를 호출한 조건에서는 서버의 본문 end와 응답 200까지 도착했다.
http.get()으로 만든 GET은 구분해야 한다. 이 편의 함수는 내부에서 req.end()를 호출한다. 실제 비교에서도 직접 end()를 호출하지 않고 서버의 본문 end, 클라이언트의 writableEnded=true, 응답 200을 확인했다. method: 'GET'을 지정한 http.request()가 같은 자동 종료를 해 준다고 생각하면 안 된다. http.get (새 탭에서 열림)
직접 검증한 범위는 Node v22.23.2의 로컬 HTTP/1.1, 연결을 재사용하지 않는 요청, 작은 문자열 본문이다. 외부 네트워크, TLS, 프록시, 파일 업로드와 프레임워크의 요청 래퍼는 실행하지 않았다. 응답 콜백이 오지 않는 기존 코드에서는 먼저 마지막 쓰기 이후의 end() 도달 여부를 확인하고, 이미 호출했다면 서버 수신과 응답 처리 쪽으로 확인 범위를 옮긴다.