빌드노트
Node HTTP timeout 이벤트 뒤에도 응답이 들어온다
Node core HTTP의 request.setTimeout()은 소켓 비활동을 알리는 이벤트다. 지연 응답과 명시적 destroy()를 실제 비교하고, timeout 뒤의 응답·오류·종료 및 주기적인 데이터 수신을 구별한다.
Node의 http.request()에서 req.setTimeout(200)을 설정했는데, timeout 로그 뒤에 HTTP 200 응답이 들어올 수 있다. 이 설정은 소켓이 일정 시간 활동하지 않았다는 이벤트를 보낼 뿐, 요청을 취소하지 않는다. 그 시점에 기다리기를 끝내려면 timeout 처리 안에서 req.destroy()를 호출해야 한다. Node v22.23.2의 요청 timeout 계약 (새 탭에서 열림)
timeout 로그와 응답 완료를 같이 본다
먼저 요청을 만드는 곳에서 timeout 콜백이 로그만 남기는지, 실제로 destroy()도 호출하는지 확인한다. 콜백에서 Promise를 reject하는 코드만 있어도 그 자체로 HTTP 요청이 닫히지는 않는다.
다음 코드는 기존 요청의 timeout 이후에도 응답이 도착하는지 확인하는 진단용이다. Node core http.request()가 반환한 req를 대상으로, req.end()를 호출하기 전에 붙인다. 기존 setTimeout() 설정은 이 블록으로 교체한다. 여기서는 응답 본문을 버리므로, 본문을 사용하는 코드에는 기존 처리기를 유지하고 필요한 로그만 추가한다.
req.setTimeout(200, () => {
console.log('timeout', 'destroyed=' + req.destroyed);
});
req.on('error', err => console.log('error', err.code, err.message));
req.on('close', () => console.log('request close'));
req.on('response', res => {
console.log('response', res.statusCode);
res.on('end', () => console.log('response end', 'complete=' + res.complete));
res.on('error', err => console.log('response error', err.code));
res.resume();
});
Windows의 Node v22.23.2에서 새 loopback 서버를 열고, 요청을 받은 뒤 800ms 후에 응답하도록 했다. 연결을 재사용하지 않는 GET에 위 코드를 적용한 실제 출력이다.
timeout destroyed=false
response 200
response end complete=true
request close
별도로 시각을 기록한 같은 조건의 실험에서는 요청 timeout이 시작 후 210.0ms에, 응답은 806.2ms에 발생했다. 응답 본문의 end까지 도착했고 요청 오류는 없었다. timeout이 발생했을 때 destroyed=false였다는 상태와, 이후 실제 응답 완료가 함께 확인됐다.
close 로그만으로 성공을 판단하지는 않는다. 아래의 강제 종료에서도 close는 발생한다. 응답 성공 여부는 상태 코드와 본문 완료를 보고, 종료 원인은 error도 함께 확인한다. 요청의 req.end()는 요청 전송을 마치는 호출이므로 응답을 다 받았다는 뜻으로 쓰면 안 된다. 요청 전송 종료와 응답 이벤트 순서 (새 탭에서 열림)
기다리기를 끝내려면 콜백에서 요청을 닫는다
소켓이 200ms 동안 활동하지 않으면 이 요청을 끝낸다는 정책이라면, 위의 setTimeout() 블록을 다음으로 바꾼다. 앞서 붙인 error 처리기는 유지한다.
req.setTimeout(200, () => {
const error = new Error('socket idle timeout');
error.code = 'LOCAL_IDLE_TIMEOUT';
req.destroy(error);
});
LOCAL_IDLE_TIMEOUT은 이 예제에서 지정한 코드다. Node가 timeout 이벤트에 자동으로 붙이는 오류 코드가 아니다. destroy(error)는 요청과 소켓을 파괴하며 전달한 오류를 error 이벤트로 알린다. 이 오류를 호출자에게 실패로 전달하는 처리도 애플리케이션에 있어야 한다. request.destroy(error) (새 탭에서 열림)
서버 지연을 그대로 둔 비교에서는 timeout이 213.0ms, 요청 오류가 213.7ms, 요청의 close가 214.2ms에 관찰됐다. HTTP 응답 이벤트는 없었다. 위 진단 코드에서 timeout 로그를 남기는 문장도 빠졌으므로 출력은 다음과 같았다.
error LOCAL_IDLE_TIMEOUT socket idle timeout
request close
숫자는 이 실행의 관찰값이며 200ms에 정확히 실행된다는 보장은 아니다. 응답을 받기 전에 끊은 조건의 결과이기도 하다. 이미 헤더나 본문 일부를 받은 뒤 끊으면 응답 객체의 오류도 확인해야 하므로, 요청의 error만 보고 모든 수신 실패를 처리했다고 판단하지 않는다.
200ms는 요청 전체 시간과 다르다
req.setTimeout()은 연결된 소켓에 timeout을 설정한다. 기준은 요청 시작부터 흐른 총시간이 아니라 소켓의 비활동 시간이다. request.setTimeout() (새 탭에서 열림), socket.setTimeout() (새 탭에서 열림)
이를 구별하려고 서버가 곧바로 헤더와 첫 데이터를 보내고, 이후 100ms 간격으로 작은 본문 조각을 보내게 했다. 같은 200ms 설정에서 timeout은 한 번도 발생하지 않았고 응답은 610.4ms에 완료됐다. 클라이언트 소켓의 데이터 수신 로그도 이어졌다. 전체 시간이 200ms를 넘었다는 사실만으로 이 timeout 설정이 고장 났다고 볼 수 없다.
따라서 원하는 제한을 먼저 정해야 한다. 소켓의 침묵을 제한하려면 setTimeout()과 명시적인 종료 처리를 연결한다. 요청 생성부터 응답 본문 완료까지 총시간을 제한하려면 그 구간을 덮는 별도의 deadline과 요청 종료 처리가 필요하다. 이 글의 200ms 설정을 DNS 조회·연결 대기까지 포함한 전체 기한으로 사용하지 않는다.
실험은 새 IPv4 loopback 서버, 연결을 재사용하지 않는 HTTP GET, 빠른 응답·지연 응답·timeout 시 종료·주기적인 본문 수신의 네 조건에 한정했다. 외부 네트워크, TLS, 공유 Agent의 소켓 재사용, 본문 수신 도중의 강제 종료는 실행하지 않았다. 클라이언트의 destroy()로 서버의 DB 변경이나 다른 작업까지 취소됐다고 판단할 근거도 없다. 실제로 이 실험의 서버 타이머는 클라이언트가 끊어진 뒤에도 실행됐으며, 서버 쪽 작업의 중단 여부는 따로 확인해야 한다.