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

빌드노트

본문은 왔는데 HTTP/1.1 응답이 끝나지 않는다

HTTP/1.1 응답에서 본문이 보여도 전송이 끝나지 않을 때 확인할 종료 경계를 다룬다. 같은 hello를 길이 없이 연결 유지, 정확한 Content-Length, 연결 종료, 완전하거나 마지막 청크가 빠진 chunked로 보내 curl의 수신 본문과 완료·시간 초과를 비교했다.

HTTP/1.1 서버가 hello를 보냈고 curl 화면에도 그 글자가 보이는데, 명령은 끝나지 않는다. 응답을 보내는 코드에서 본문의 끝을 무엇으로 알리는지 확인해야 한다. 본문을 전송한 것과 클라이언트가 응답 완료를 판단한 것은 별개다.

일반적인 GET의 200 응답에서 Content-Length도 Transfer-Encoding도 없다면, 클라이언트는 서버의 연결 종료로 본문 끝을 판단한다. 서버가 연결을 계속 열어 두면 더 올 데이터가 없는지 알 수 없다. Content-Type이나 본문 속 JSON의 닫는 괄호는 이 경계를 대신하지 않는다. 이 규칙은 RFC 9112의 메시지 길이 판정 (새 탭에서 열림)에 따른다.

기다리는 주소에서 헤더와 종료 코드를 함께 본다

Windows의 PowerShell 7과 curl.exe가 있다면, 이미 실행 중인 자기 서버의 읽기 전용 GET 주소로 확인할 수 있다. 아래의 8080과 경로를 실제 대상에 맞춘다. --noproxy '*'는 이 로컬 서버에 직접 접속하기 위한 옵션이다.

다음 명령은 응답 헤더와 본문을 표시하고, 전송이 끝나거나 제한 시간에 도달한 뒤 curl의 종료 코드를 출력한다.

$url = 'http://127.0.0.1:8080/'
curl.exe -q --http1.1 --noproxy '*' --max-time 1 -sS -i $url
"`nexit=$LASTEXITCODE"

--max-time 1의 1초는 짧은 예제를 확인하기 위한 값이다. 자기 응답의 정상 처리 시간에 맞춰 정한다. curl의 전송 시간 제한 (새 탭에서 열림)은 대기를 중단할 뿐, 응답의 길이 정보를 보충하지 않는다.

새 로컬 서버가 길이 헤더 없이 hello를 보내고 연결을 유지하게 한 뒤, 위 명령을 임의 포트에 실행했다. 표준 출력은 다음과 같았다.

HTTP/1.1 200 OK
Content-Type: text/plain
Connection: keep-alive

hello
exit=28

별도 오류 출력에는 5 bytes received라는 문구가 있었다. 본문 5바이트를 받았지만 완료하지 못한 것이다. 28은 curl의 시간 초과 종료 코드 (새 탭에서 열림)다. HTTP 200은 이미 받았으므로 상태 코드만으로 전송 성공을 판단하면 이 차이를 놓친다.

시간 초과만으로 길이 누락을 단정하지는 않는다. Content-Length가 있으면 선언한 만큼 실제로 보냈는지 확인한다. Transfer-Encoding: chunked가 있으면 마지막 청크까지 보냈는지 본다. 둘 다 없는데 유한한 응답을 보낸 서버가 연결도 닫지 않는 경우가 여기서 확인할 조건이다. 본문이 아직 오지 않은 처리 지연이나, 계속 열려 있어야 하는 스트림과는 응답 계약부터 구별한다.

같은 본문을 보내고 끝나는 방법만 바꿨다

Windows 11, Python 3.12.10의 소켓 서버와 curl 8.21.0에서 압축 없는 HTTP/1.1 GET을 비교했다. 서버는 모두 같은 hello를 보냈고 클라이언트의 제한은 1초였다. 실제 수신 결과는 다음과 같았다.

  • 길이 정보 없이 연결 유지: hello 5바이트 수신, 종료 코드 28.
  • Content-Length: 5를 넣고 연결 유지: 같은 5바이트 수신, 종료 코드 0.
  • 길이 정보 없이 Connection: close를 보내고 연결 종료: 같은 5바이트 수신, 종료 코드 0.

연결을 닫는 조건에서는 본문 전송 뒤 약 0.2초를 두고 서버의 송신 방향을 종료했다. 나머지 조건에서 서버는 먼저 닫지 않고 클라이언트의 연결 종료를 기다렸다. 정확한 길이를 선언한 응답은 서버가 먼저 닫지 않아도 완료됐고, 길이가 없는 응답은 5바이트를 받고도 시간 초과로 끝났다.

이 차이는 본문 내용이나 처리 속도보다 응답을 끝내는 경계의 유무로 설명된다. HTTP/1.1은 기본적으로 연결을 유지하지만, 그 연결에서 메시지를 구별할 길이는 별도로 필요하다. Connection: keep-alive 자체는 본문이 어디서 끝나는지 알려 주지 않는다. RFC 9112의 지속 연결 조건 (새 탭에서 열림)

응답을 보내는 쪽에서 끝을 맞춘다

짧은 본문을 미리 완성할 수 있다면 최종 전송 바이트 수에 맞는 Content-Length를 보낸다. 위 실험에서는 그 값이 5였다. 프레임워크의 응답 완료 API가 길이와 전송 방식을 관리한다면 그 API로 응답을 끝내고, 클라이언트가 실제로 받은 헤더를 다시 확인한다. 값을 계산한 뒤 다른 내용을 덧붙여 보내면 선언과 전송이 다시 어긋난다.

전체 길이를 미리 모르는 HTTP/1.1 응답은 올바른 chunked 전송으로 끝을 알릴 수 있다. Transfer-Encoding: chunked 헤더와 함께, 청크 크기·데이터·마지막 청크를 실제로 보내야 한다. 이 방식에서는 Content-Length를 함께 보내지 않는다. 청크 형식 (새 탭에서 열림), 길이 헤더의 제약 (새 탭에서 열림)

같은 hello를 한 청크로 보낸 실제 본문 영역은 이스케이프 표기로 5\r\nhello\r\n0\r\n\r\n이었다. 여기서 \r\n은 문자 네 개가 아니라 CRLF 줄바꿈 바이트를 뜻한다. 크기는 16진수이며, 이 예제의 마지막 0 청크 뒤에는 트레일러 없이 빈 줄이 온다.

이 완전한 형식은 hello로 디코딩되고 종료 코드 0을 반환했다. 마지막 0\r\n\r\n만 빼고 연결을 유지하자, 여전히 hello 5바이트를 받았지만 종료 코드는 28이었다. 내용 청크를 다 보냈어도 전송 종료 표시가 빠지면 기다릴 수 있다.

기본 curl 출력은 청크 표시를 제거한다. 같은 $url에서 전송 형식을 살펴보려면 다음처럼 --raw를 추가한다. 이 명령도 로컬 서버에 실제 실행했다.

curl.exe -q --http1.1 --noproxy '*' --max-time 1 -sS -i --raw $url
"`nexit=$LASTEXITCODE"

완전한 청크 응답에서는 헤더 뒤에 5, hello, 0과 마지막 빈 줄이 나타났고 정상 종료했다. 마지막 청크를 뺀 응답은 5, hello까지만 나타난 뒤 시간 초과였다. 화면에서 빈 줄이 모호하면 서버가 내보낸 CRLF까지 확인해야 한다. curl 8.21.0의 raw 옵션 (새 탭에서 열림)은 전송 인코딩 등을 디코딩하지 않고 표시하는 옵션이다.

연결 종료로 끝내는 응답을 선택한다면 Connection: close와 함께 실제 전송 종료도 수행해야 한다. 이 방식은 그 연결을 다음 요청에 재사용할 수 없고, 연결이 끊긴 사실만으로 의도한 내용 전체가 왔는지도 확실히 구별하기 어렵다. 가능한 경우에는 길이나 청크로 경계를 주는 쪽을 택한다. RFC 9112의 close-delimited 응답 설명 (새 탭에서 열림)

여기서 직접 확인한 범위는 새 IPv4 loopback 서버의 단일 요청과 curl이다. HEAD·204·304처럼 본문이 없는 응답에는 앞의 GET 200 판정을 그대로 적용하지 않는다. HTTP/2·HTTP/3, TLS, 프록시를 거친 변환, 브라우저별 완료 이벤트, 동일 연결에서 다음 요청을 보내는 동작은 실험하지 않았다. 실제 서비스에서는 클라이언트에 도착한 HTTP 버전과 응답 경계를 기준으로 다시 확인해야 한다.

← 전체 글 보기