요약
Go 1.27의 HTTP 클라이언트는 읽지 않은 응답 본문을 알아서 비워 HTTP/1 연결을 다시 씁니다.
Go 팀의 니컬러스 후신과 데이미언 닐이 Go 블로그에 쓴 글입니다. 응답 본문을 끝까지 읽지 않고 닫으면 연결을 버리고 새로 맺던 오랜 문제를 표준 라이브러리 안에서 해결했다고 설명했습니다.
왜 중요한가
- 같은 서버에 HTTP/1 요청을 잇달아 보내는 프로그램은 코드를 고치지 않아도 빨라집니다.
- 개발자들이 직접 짜서 쓰던 본문 비우기 코드는 이제 지워도 됩니다.
핵심 내용
- HTTP/1로 100번 요청하는 예제가 Go 1.26의 약 710ms에서 Go 1.27의 약 150ms로 줄었습니다.
- net/http의 Transport가 읽지 않은 본문을 백그라운드에서 최대 50ms, 256KB까지 읽어 비웁니다.
- 연결을 새로 맺으면 TCP와 TLS(암호화 연결) 핸드셰이크로 왕복 시간이 여러 번 더 듭니다.
- 직접 비우면 큰 본문이나 느린 응답에 발목이 잡혀 상한과 시간 제한까지 챙겨야 했습니다.
- 응답에 Connection: close 헤더가 있으면 어차피 닫힐 연결이라 비우지 않습니다.
HN 반응
- 여러 프로젝트에 복사해 쓰던 비우기 함수를 지울 수 있다는 반응이 많았지만, 연결을 즉시 끊는 방법은 없느냐는 지적도 있었습니다.
- 아직도 HTTP/1을 쓰느냐는 물음에는 CloudFront의 원본 서버 연결이나 TLS 없는 내부망처럼 피하기 어려운 경우가 많다는 답이 이어졌습니다.
http/3는 언제쯤 표준 라이브러리에 들어올지 궁금하네요. Go가 http를 기본으로 간단하면서도 강력하게 지원한다는 점은, 탄탄한 기본 표준 라이브러리가 없었다면 지금만큼 멀리 가지 못했을 핵심 요인 중 하나였다고 생각합니다. 그래도 결국 폭발적으로 성장하긴 했겠지만 이만큼 빠르지는 않았을 거예요. 서드파티 패키지 하나 없이 Go에 기본으로 들어 있는 것만으로 웹 앱을 통째로 만드는 데에는 뭔가 특별한 맛이 있거든요. 제가 예전에 비슷하게 느꼈던 "언어"는 PHP 정도였고, Racket도 그럴 수 있겠네요. Racket으로 웹 개발을 할 때는 따로 뭔가를 애써 설정할 필요가 없었던 것 같으니까요.
Go 팀이 이 자동 연결 재사용을 구현하겠다고 마음먹은 게, 아마 drainAndCloseBody의 끔찍하지만 올바른 세 번째 버전을 실제로 마주치고 나서였다고 생각하면 좀 웃기네요.
drainAndCloseBody 제안 // 팀: 합리적이네요
drainAndCloseBodyWithTimer 구현 // 팀: 여기까진 문제없고요
그다음 drainAsyncAndCloseBodyWithTimer // 팀: 좀 길긴 한데 이것도 괜찮네요
구글의 누군가가 3번을 http/2 연결에서 씀 // 팀: 여기가 선을 긋는 지점인가요?
단순함을 추구하는 Go의 사명을 지키려 애쓰는 심정이 이해가 갑니다. 이 흔하면서도 까다로운 엣지 케이스를 생각하면요. 3번이 이치에는 맞지만, 세상에, 정말 못생기긴 했네요 ㅎㅎ.
제가 뭔가 놓친 걸까요, 아니면 이 내용이 net/http 자체에는 전혀 문서화돼 있지 않은 건가요? "Close"의 구현이 놀라운데, 어딘가에는 문서로 남겨 두어야 합니다.
Go로 HTTP 클라이언트를 만들어 보려는 사람들 사이에서 이게 자주 묻는 질문이라는 건 제가 보증할 수 있습니다. 그래서 찜찜하긴 해도 왜 이렇게 됐는지는 이해가 갑니다. 다만 제가 이해하지 못한 게 하나 있습니다. 이 연결을 일부러 확 닫아 버리고 싶을 때, 이건 충분히 합리적인 바람인데, 재사용할 생각도 없는 소켓을 쓸데없이 비우지 않으려면 어떻게 해야 합니까? Response 객체에 "Connection: close" 헤더를 위조해서 넣어야 합니까? 아니면 Body.Response의 구체 타입 아래 어딘가에 있는 소켓을 파내서 이 코드를 우회하기를 바라야 합니까? 그럴 수 있는 방법도 있어야 할 것 같은데요.
HTTP 라이브러리는 대체로 만들기 어렵습니다. 실무에서 HTTP를 제대로 다루려면 최소한 DNS와 TLS를 이해해야 하고, 많은 경우 L4 네트워킹까지 알아야 하기 때문입니다. 애플리케이션 개발자를 겨냥하면 추상화가 새는 결과가 나옵니다(어떤 HTTP 버전을 쓰는지도 모르는 상태에서 읽지 않은 H/1 본문을 비우는 동작 같은 것). 아니면 애플리케이션 개발자가 쓸 줄 모를 만큼 저수준인 라이브러리가 됩니다. 이렇게 나누려는 시도의 예로 Rust의 hyper와 reqwest를 보시면 됩니다.
예전에 HTTP 라이브러리를 직접 구현해 본 사람으로서 말씀드리면, 프로토콜 자체의 까다로움이 TLS나 L4의 엣지 케이스를 다 신경 쓰는 것에 못지않습니다.
TCP와 IP를 밑바닥부터 구현하는 쪽이 HTTP 라이브러리를 구현하는 것보다 쉽다고 주장하고 싶을 정도입니다.
https://pkg.go.dev/net/http#Client.Do
비슷한 문구가 다른 몇 곳에도 나옵니다. "asynchronously"로 검색해 보세요.
아, 다행이네요. 예상했던 자리는 아니지만 어쨌든 있긴 하군요.
Close에 직접 문서화돼 있지 않은 건 합리적이라고 봅니다. Close는 이 얘기를 하기엔 너무 상위 수준이에요. 예를 들어 HTTP/2에서는 요청의 수명 주기가 기반 소켓과 완전히 분리돼 있어서 Close를 해도 의도한 대로 동작하지 않습니다. 연결을 최대한 빨리 확 닫고 싶다면, 기반 계층을 알아야 해서 더 지저분하게 느껴지더라도 기반 연결을 직접 잡아서 강제로 닫는 게 오히려 논리적으로 보입니다. 제게 이 "Close"는 논리적으로 HTTP 요청 자체의 관점에서 하는 동작이고, 제가 헷갈리거나 잘못 이해한 게 아니라면 어차피 연결을 닫지도 않습니다(예를 들어 본문이 없고 연결이 keep-alive 상태면 기반 연결이 닫히지 않을 텐데, 맞죠?).
이제 json의 Decoder로 본문을 읽어도 연결이 재사용되지 않을 수 있습니다. 응답에 개행 문자가 들어 있으면요.
Decoder는 X바이트(512였나? 기억이 안 나네요)만 읽는데, 구조가 이 바이트 수(또는 그 배수)에 딱 맞게 들어가면 개행이 그대로 남습니다.
저는 디코딩 뒤에 항상 Decoder.More를 확인합니다(공백을 비워 줍니다).
편리하긴 하지만, Decoder는 그런 용도에는 권장되지 않습니다. 접미사 없이 JSON 문서를 최대 하나만 담은 리더가 아닐 때 엣지 케이스가 생기기 때문입니다.
표준 라이브러리 관리자들도 이게 "더 나은" 편한 방법이 없다는 문제임을 인식했고, encoding/json/v2에는 그 용도를 위한 UnmarshalRead가 있습니다: https://pkg.go.dev/encoding/json/v2#UnmarshalRead
읽지 않은 바이트를 최대 256K까지 비웁니다. 본문을 아예 읽지 않은 경우만 최적화하는 게 아니에요.
https://cs.opensource.google/go/go/+/refs/tags/go1.27.2:src/net/http/transport.go;l=2629-2634
코드를 보면 transfer-encoding:chunked로 스트리밍되는 응답은 처리하지 못합니다. 즉 상대편이 이렇게 하고 있다면
이 최적화는 아무 효과가 없습니다.
으악, 전혀 몰랐던 엣지 케이스네요. 두 패키지가 맞물려서 생기는 문제라 어디에 문서화돼 있는지 알기도 어렵겠어요.
HTTP 없이도 일어날 수 있는 일입니다. 저희는 10년 전에 runc에서 이 문제를 겪었습니다1. 일부 컨테이너 실행이 영문 모를 ECONNRESET으로 실패했는데, 더미 환경 변수를 하나 추가하면 다시 동작했습니다.
알고 보니 json.Encoder는 항상 개행을 덧붙이는데, (윗분 말씀대로) json.Decoder는 JSON이 2의 거듭제곱 바이트 경계에서 끝나면 그 개행을 읽으려 하지 않습니다(제 기억으로는 처음 몇 번은 버퍼가 두 배씩 커집니다). 파이프를 닫을 때 남아 있는 그 한 바이트 때문에 읽기와 쓰기가 터져 버립니다. 저희는 개행을 출력하지 않는 json.Marshal로 바꿨습니다(그 결과 인코딩된 형태를 스트리밍하지 못하게 된 건 아쉽지만요).
그 경험 이후로 지금도 encoding/json 얘기만 나오면 신경이 곤두섭니다.
드디어 xhttp.DrainResponseBody1를 은퇴시킬 수 있겠네요. 이제 알았는데 어차피 그렇게 잘 동작하던 것도 아니었더라고요.
저는 r60 이후로 프로젝트에서 프로젝트로, 회사에서 회사로 복사해 가며 쓰던 비슷한 헬퍼를 드디어 지울 수 있겠네요.
저도 프로젝트마다 코드를 복사하는 걸 그만두려고 그 패키지를 만든 겁니다.
복사 자체가 문제여서는 아니고요. 흔히 하는 말처럼 약간의 복사는 약간의 의존성보다 낫으니까요. 문제는 복사해 둔 코드에서 버그를 발견할 때마다(꼭 발견하게 되죠) 전부 찾아다니며 고치는 게 고역이었다는 겁니다.
응답 본문을 닫으면 HTTP 본문 다운로드를 통째로 건너뛸 수 있다는, 예전에는 믿을 수 있던 동작이 내부적으로 바뀌는 건 아쉽습니다. 하지만 이 트레이드오프는 마음에 듭니다. 다운로드 시간을 50ms로 제한하니, 취소된 HTTP 본문이 한꺼번에 몰려서 네트워크가 감당하지 못하는 일은 없고 약간의 트래픽을 조금 기다리는 정도로 끝나니까요.
글 맨 끝에 Connection: close일 때는 본문을 비우지 않는다고 나옵니다. 리버스 프록시에서도 이게 동작하나요? 저는 서버가 본문을 소비하지 않으면 nginx가 계속 IO 오류를 내고, 그게 클라이언트에는 5xx 코드로 올라가는 문제를 겪었습니다. Firefox에서도 XHR 상태 코드가 0으로 나오는 걸 본 것 같습니다. 본문을 소비하지 않고 일찍 닫는 건 Chrome과 curl에서는 기대한 대로 동작하는 것 같고요.
이게 이 효과를 보려면 http2를 꺼야 한다는 뜻인가요, 아니면 http2가 실제로는 쓰이고 있지 않다는 걸 더 보여 주는 증거일 뿐인가요?
글에서 설명하듯이 HTTP/2에서는 이런 게 필요 없습니다. 프로토콜 설계상 연결 본문을 읽어서 비우지 않아도 연결 공유의 이점을 얻을 수 있으니까요.
맞아요. 글에서는 공개 웹 서버에 요청을 보내 변경 효과를 보여 주지만, 이번 변경은 마이크로서비스 사이의 내부 통신에서 오버헤드와 지연을 줄이려는 목적이 가장 컸을 것 같습니다. 그런 통신은 암호화하지 않은 HTTP/1 트래픽인 경우가 아주 많거든요.
HTTP/2는 방식이 다릅니다. HTTP/2에서는 TCP 연결 하나에서 서로 다른 데이터를 멀티플렉싱하고 우선순위를 매길 수 있죠. 하지만 제 기억으로는 웹의 60~70%는 여전히 HTTP/1입니다.
2026년에도 아직 HTTP/1을 쓸 이유가 있나요?
주로 H/2를 쓰지 않는 서버나 클라이언트와 상호 운용해야 해서입니다. 다른 상황도 있는데, 예를 들어 CDN을 운영하면서 kernel TLS로 sendfile을 한다면 아래와 같은 일을 할 수 있습니다.
이건 H2/H3에서는 안 됩니다. 웹소켓도 실제로는 여전히 H/1에서만 쓸 수 있습니다(명세는 있지만 브라우저에서는 꺼져 있습니다). 또 하나는, HTTP를 큰 (양방향) 파이프 하나를 위한 서두로만 쓴다면 H/2의 추가 프레이밍과 흐름 제어는 성능에 짐이 될 뿐이라는 점입니다.
SSL이 필요 없다면 HTTP/2나 HTTP/3는 쓸 수 없습니다. 외부에 노출되는 서비스에는 말이 안 되지만, 로컬 네트워크에서는 괜찮거나 심지어 피할 수 없는 경우도 있습니다.
암호화 없이도 HTTP/2를 쓸 수 있습니다.
h2c라고 부르죠. 엄밀히는 표준을 따르는 건 아니고 브라우저도 지원하지 않지만, 트래픽이 localhost나 내부 사설 네트워크를 지나는 프록시와 백엔드 사이 통신에서는 많이 씁니다.믿기 어렵겠지만 Cloudfront는 오리진으로는 HTTP/1만 지원합니다. 다른 CDN 중에도 그런 곳이 있고요.