TECH 으로 돌아가기
TECH HACKER NEWS 오늘 7분 읽기 29 READS

Go 1.27, HTTP/1 커넥션 재사용 자동화로 순차 요청이 빨라진다

Go 1.27, HTTP/1 커넥션 재사용 자동화로 순차 요청이 빨라진다
SOURCE IMAGE · HACKER NEWS

Go 1.27이 HTTP 클라이언트에 작지만 영향력 있는 변경을 담았다. 같은 엔드포인트로 많은 요청을 순차적으로 보내는 프로그램에서 체감 성능이 크게 달라질 수 있다. 공식 블로그가 제시한 벤치마크를 보면 그 폭이 분명하다. index.golang.org에 HTTP/1(GODEBUG=http2client=0)로 GET 요청을 100번 보내는 함수가 Go 1.26에서는 약 710밀리초가 걸렸지만, Go 1.27에서는 약 150밀리초로 줄었다. 거의 5배 차이다. 흥미로운 점은 이 개선이 새로운 기능이 아니라, 그동안 개발자들이 각자 손으로 처리해 오던 번거로운 작업을 표준 라이브러리가 대신 맡아준 결과라는 것이다.

왜 커넥션 재사용이 문제였나

Go의 net/http는 HTTP/1.0, 1.1, 2를 모두 지원하며 이들은 TCP 위에서, HTTPS라면 대개 TLS 위에서 동작한다. 새 커넥션을 열 때는 TCP 핸드셰이크와 TLS 핸드셰이크에 여러 번의 왕복(round trip)이 필요하고, 여기에 TLS 계산을 위한 CPU 시간까지 더해진다. 리전 간 통신처럼 단방향 지연이 50밀리초(왕복 100밀리초)에 이르는 환경에서는 커넥션을 세우는 데만 300밀리초가 들 수 있다. 정작 요청 자체는 응답이 작거나 대역폭이 넉넉하면 1 RTT 남짓이면 끝난다. 설정 비용이 요청 비용을 압도하는 셈이다. 그래서 클라이언트와 서버는 가능하면 기존 커넥션을 재사용하려 한다.

문제는 HTTP/1의 재사용 방식에 있다. HTTP/1에서는 하나의 커넥션 위로 여러 요청과 응답이 순서대로 오간다. 두 GET 요청을 같은 TCP 커넥션으로 보내면 응답은 하나의 바이트 스트림으로 연달아 도착한다. net/http는 상태 줄과 헤더만 읽어 Response를 채운 뒤 바로 반환하고, 본문은 호출자가 resp.Body를 읽을 때 비로소 소비된다. 그런데 응답이 서로 붙어 있기 때문에, 다음 응답의 시작 위치는 현재 응답 본문을 Content-Length나 Transfer-Encoding이 알려주는 끝까지 다 읽어야만 찾을 수 있다.

읽지 않은 본문이 커넥션을 버리게 만든다

Go 1.26까지는 본문을 읽지 않은 채 resp.Body.Close를 호출하면, net/http가 아직 소비되지 않은 본문이 남아 있음을 보고 해당 커넥션을 재사용할 수 없다고 판단한다. 결국 커넥션을 버리고 다음 요청마다 새 커넥션을 열어, 설정 비용을 반복해서 치른다. 앞선 벤치마크에서 본문을 읽지 않은 코드가 느렸던 이유가 바로 이것이다. 참고로 HTTP/2는 이 문제가 없다. 응답을 엄격히 순서대로 붙여 보내지 않고 프레임 스트림을 다중화하기 때문에, 한 스트림의 읽지 않은 본문이 다른 요청의 커넥션 공유를 막지 않는다.

이 미묘함은 겉보기에 멀쩡한 코드에서 조용한 성능 저하를 일으킨다. 클라이언트가 응답의 상태 코드만 필요로 하는 경우는 흔하다. 하지만 서버는 {"success": true, "message": "update processed"} 같은, 결국 쓰이지 않는 본문을 함께 보낼 수 있고 그러면 재사용이 실패한다. 본문을 읽는 코드도 안전하지 않다. 예를 들어 encoding/json.Decoder는 JSON 객체의 닫는 중괄호를 보는 순간 읽기를 멈춘다. 서버가 응답 끝에 개행 문자를 붙여 보냈다면 그 한 바이트가 읽히지 않은 채 남아, 멀쩡한 커넥션이 재사용 직전 단 1바이트를 남기고 버려질 수 있다.

수동 우회법의 한계와 Go 1.27의 선택

이 '읽지 않은 본문' 문제는 오래된 날카로운 모서리였고, 많은 Go 개발자가 저마다 우회법을 만들어 썼다. 가장 흔한 방식은 Close 전에 본문을 끝까지 읽어 비우는(drain) 것이다. 하지만 본문이 크면 나머지를 내려받아 버리는 것보다 새 TCP 커넥션을 여는 편이 빠를 수 있으므로, 비우는 바이트 수에 상한을 둬야 한다. 또 본문이 느리게 흘러들어올 수 있으니 소비에 쓰는 시간에도 상한이 필요하다. 그렇다고 Close에서 최대 50밀리초를 막혀 기다릴 수는 없으니, 본문을 백그라운드에서 비우며 다음 요청과 경쟁시키는 식으로 비동기 처리까지 해야 한다. 안전하게 본문을 비우는 일이 놀라울 만큼 까다롭고 번거로웠던 것이다. 게다가 이 관행이 퍼지면서 세부가 잊히기도 했다. HTTP/2에서는 불필요한데도 본문을 비우려 드는 오해가 대표적이다.

Go 1.27부터는 net/http.Transport가 이 모든 과정을 자동으로 수행한다. 읽지 않은 HTTP/1 응답 본문을 최대 50밀리초 또는 256KB 한도 안에서 비동기로 비워 커넥션을 되살린다. 추가 최적화도 들어갔다. 응답에 Connection: close 헤더가 있으면 어차피 커넥션을 닫을 것이므로 본문을 비우는 작업이 낭비임을 알고 건너뛴다. 다만 블로그는 이 동작이 향후 버전에서 다른 개선과 함께 바뀔 수 있다고 덧붙였다. 상한값이나 조건이 표준 라이브러리 내부 정책이라는 점은 실무에서 기억해둘 만하다.

실무자에게 주는 메시지는 간결하다. 이제는 본문이 더 이상 필요 없을 때 그냥 Close만 호출하는 것이 권장 관행이다. 그동안 코드베이스에 복제되어 온 수동 drain 보일러플레이트, 특히 HTTP/2 커넥션에까지 적용된 불필요한 우회 코드는 정리 대상이 된다. 다만 이 개선은 HTTP/1에 한정되고 Go 1.27 이상에서만 유효하므로, 구버전 런타임을 쓰거나 매우 큰 본문을 다루는 서비스라면 업그레이드 시점과 기존 우회 코드 제거를 함께 검토하는 편이 안전하다. 핵심은 복잡성을 라이브러리에 맡기고 애플리케이션 코드를 단순하게 유지하라는, Go다운 방향이다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://go.dev/blog/http-connection-reuse
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...