Golang: HTTP 클라이언트의 연결 관리(1)
개요
Golang HTTP 클라이언트의 커넥션 풀(connection pool)에 대해서 명확히 정리하고 싶다는 욕심이 있었는데 좋은 블로그 포스팅을 찾아서 이참에 정리를 해본다. 내용의 대부분과 코드는 다음 링크에서 가져온 것이다.
링크: https://www.sobyte.net/post/2022-03/go-http-client-connection-control/
버즈빌 기술 블로그의 글도 정성이 가득하여 큰 도움이 되었다.
링크: https://tech.buzzvil.com/blog/http-connection-pool-in-go-explained/
개념 정리
서버와 클라이언트는 서로간의 원하는 작업을 위해 연결하고 일을 한다. 이를 다음과 같이 비유해본다.
전송 계층(Transport layer) - 전화 연결
서버와 클라이언트가 이야기를 나누려면 일단 전화를 걸어서 통화가 가능해져야 한다. 전송계층을 전화연결로 비유해본다. 전화가 연결되면 무엇이든 대화를 주고 받을 수 있게 된다.
전송 계층의 대표적 프로토콜이 TCP이다.
응용 계층(Application layer) - 전화 통화
본사와 해외지사간의 업무를 상상해보자. 일단 전화가 연결이 되면 이 전화 연결 위에서 실제 업무가 가능해진다. 영업부서가 해외 담당자에게 다음 달 업무계획을 요청하고 받거나, 개발부서가 해외 고객의 버그 리스트를 요청하여 받을 수 있는 것이다.
응용 계층의 대표적 프로토콜중 하나가 HTTP이며 1.1과 2.0은 TCP를 전송계층으로 사용하고, 3.0은 QUIC(Quick UDP Internet Connection)라 하여 UDP를 전송계층으로 사용한다.
상상해보기
- 글로벌 이슈가 발생하면 회사의 전 부서가 동시다발로 전화를 걸어서 수 십, 수 백개의 전화 연결이 동시에 일어날 수 있겠다.
- 본사와 해외지사의 집중 소통 시간이 있어서 2~3개의 전화 연결만 해두고 그 연결을 10여개의 부서가 돌아가면서 쓸 수도 있겠다.
- 전화 통화를 마치고 다음 부서를 위해 끊지 않고 두는데, 일정 시간 이상 사용하는 부서가 없으면 전화 비용 절약을 위해 전화를 끊을 수 있다.
이처럼 비유해본 일들이 실제 HTTP 통신에서 발생하며 세 번에 나누어 하나씩 챙겨보도록 하자.
Default HTTP 클라이언트
Golang의 net/http 패키지에서 기본 제공하는 HTTP 클라이언트를 사용하는게 가장 간편하지만 프로덕션에서는 세부 설정을 이해하고 적절하게 설정하는 것이 필요하다. 다음 코드와 같이 사용하는 것이 기본 HTTP 클라이언트를 사용하는 것이다.
resp, err := http.Get("<http://example.com/>")
...
resp, err := http.Post("<http://example.com/upload>", "image/jpeg", &buf)
...
실험을 위한 서버 코드
서버는 요청을 받으면 10초 뒤에 회신을 해준다.
깃헙 링크: https://github.com/bigwhite/experiments/blob/master/http-client/default-client/server.go
func Index(w http.ResponseWriter, r *http.Request) {
log.Println("receive a request from:", r.RemoteAddr, r.Header)
time.Sleep(10 * time.Second)
w.Write([]byte("ok"))
}
func main() {
var s = http.Server{
Addr: ":8080",
Handler: http.HandlerFunc(Index),
}
s.ListenAndServe()
}
실험을 위한 클라이언트 코드
클라이언트는 256개의 고루틴을 만들어 동시에 256개의 요청이 발생한다. 그런데 서버는 10초 뒤에 회신을 하게 되므로 순간적으로 256개의 TCP 연결(connection)이 발생하게 된다.
이를 위 전화의 비유에 빗대어 말해보자면 급박한 상황이 발생하여 256번의 전화 연결이 발생한 것이다.
깃헙 링크: https://github.com/bigwhite/experiments/blob/master/http-client/default-client/client.go
func main() {
var wg sync.WaitGroup
wg.Add(256)
for i := 0; i < 256; i++ {
go func() {
defer wg.Done()
resp, err := http.Get("<http://localhost:8080>")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
fmt.Println(string(body))
}()
}
wg.Wait()
}
결과 - panic
이 실험의 환경에서는 사용자가 열 수 있는 파일의 개수(ulimit -n)의 개수가 256개인데, 이를 넘어서는 파일(통신에서는 socket이라는 파일을 사용한다고 생각하면 된다)을 열려고 하여 에러가 발생한 것이다.
panic: Get "<http://localhost:8080>": dial tcp [::1]:8080: socket: too many open files
Golang TCP 계층의 기본값
연결을 유지하는, 커넥션 풀과 관련한 필드들은 다음과 같다(버즈빌 포스팅에서 가져옴). 여기에서 MaxConnsPerHost의 기본값이 무제한 인것을 알 수 있다. 무제한이기에 위와 같은 문제가 발생한 것이다.
MaxIdleConns: 유지 가능한 최대 유휴 커넥션 수, default: 100
MaxIdleConnsPerHost: 호스트마다 유지 가능한 최대 유휴 커넥션 수, default: 2
IdleConnTimeout: 유휴 커넥션 타임아웃, default: 90초
MaxConnsPerHost: 호스트마다 사용 가능한 최대 활성/유휴 커넥션 수, default: 0 (무제한)
다음 포스팅에서는 MaxConnsPerHost - 하나의 호스트에 연결할 수 있는 개수를 설정하여 이처럼 과도한 연결이 발생하지 않도록 해보자.