Golang: HTTP 클라이언트의 연결 관리(2)
개요
지난 포스팅에서는 MaxConnsPerHost의 기본 설정이 무제한이라 에러가 발생한 경우를 보았다. 기본 설정을 보완하여 문제를 해결해보자.
기본 설정을 참고로 다시 보아두자.
MaxConnsPerHost는 하나의 호스트(서버)에 연결할 수 있는 개수의 설정이다.
MaxIdleConns: 유지 가능한 최대 유휴 커넥션 수, default: 100
MaxIdleConnsPerHost: 호스트마다 유지 가능한 최대 유휴 커넥션 수, default: 2
IdleConnTimeout: 유휴 커넥션 타임아웃, default: 90초
MaxConnsPerHost: 호스트마다 사용 가능한 최대 활성/유휴 커넥션 수, default: 0 (무제한)
호스트(서버)당 최대 연결개수의 제한
서버코드
서버는 요청을 받으면 10초 뒤에 회신을 한다.
func Index(w http.ResponseWriter, r *http.Request) {
fmt.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()
}
클라이언트 코드
원문의 코드를 이해가 쉽도록 순서를 약간 바꾸어 둔다.
클라이언트 전송 계층 설정
하나의 호스트에 요청을 위한 연결을 최대 5개까지 하도록 설정하였다. 전화로 비유하자면 해외 지사로 걸 수 있는 전화기를 최대 5대로 제한한 것이라 하겠다.
tr := &http.Transport{
MaxConnsPerHost: 5,
}
client := http.Client{
Transport: tr,
}
전화 통화 - 256번의 전화 통화 시도
기존에 제한이 없을때에는 패닉이 발생하였는데 동일한 조건의 요청에 대해 어떤 결과가 발생할까?
wg.Add(256)
for i := 0; i < 256; i++ {
go func(i int) {
defer wg.Done()
resp, err := client.Get("<http://localhost:8080>")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
fmt.Printf("g-%d: %s\\n", i, string(body))
}(i)
}
wg.Wait()
서버 로그 분석
최대 연결은 5개인데 최대 연결 개수인 5개를 사용한 다음의 요청은 어떻게 될까? 바로 사용할 연결이 없기에 요청들은 연결 대기 큐로 들어가게 된다. 이와 관련한 설정도 있으나 여기에서는 생략한다.
하나의 연결을 사용한 통화가 끝나고 나면 연결은 바로 닫히지 않고 큐에 요청이 있으면 그 요청이 사용하게 된다. 로그에 63673~63677 연결이 계속해서 사용되는 이유이다.
$go run server.go
receive a request from: [::1]:63677 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63675 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63676 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63673 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63674 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63673 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63675 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63674 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63676 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63677 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63677 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63674 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63676 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63675 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:63673 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
... ...
호스트(서버)당 최대 연결 대기 개수의 제한
서버 코드
서버는 요청을 받으면 바로 회신을 한다(지난 포스팅의 서버는 10초뒤에 회신을 하였었다).
func Index(w http.ResponseWriter, r *http.Request) {
fmt.Println("receive a request from:", r.RemoteAddr, r.Header)
w.Write([]byte("ok"))
}
func main() {
var s = http.Server{
Addr: ":8080",
Handler: http.HandlerFunc(Index),
}
s.ListenAndServe()
}
클라이언트 코드
원문의 코드를 이해가 쉽도록 순서를 약간 바꾸어 둔다.
클라이언트 전송 계층 설정
하나의 호스트에 최대 5개의 연결을 할 수 있고, 3개까지는 연결을 바로 끊지 않도록 설정을 하였다. 전화로 비유를 하자면 본사에서 하나의 해외 지사로 최대 5대의 전화기로 연결을 할 수 있고, 그 중 3대는 바로 끊지 않고 대기를 하는 것이다. 여기서는 IdleConnTimeout 설정을 하지 않았으니 기본값인 90초 뒤에도 전화기를 쓰는 사람이 없으면 전화를 끊게 된다.
tr := &http.Transport{
MaxConnsPerHost: 5,
MaxIdleConnsPerHost: 3,
}
client := http.Client{
Transport: tr,
}
1차 전화 통화(비유)
전화 통화로 비유를 하였는데 하나의 해외지사로 5대의 전화기로 전화를 걸고, 10초를 기다리도록 하였다. 통화는 바로 끝나는 데 그 중 3대의 전화기는 수화기를 끊지 않고 대기를 한다. 타임아웃은 90초이므로 10초가 지나도 3대의 전화기는 아직 연결중이다.
비유에서 TCP로 돌아오면 커넥션 풀에 최대 개수인 3개의 연결이 유지되고 있는 것이다.
wg.Add(5)
for i := 0; i < 5; i++ {
go func(i int) {
defer wg.Done()
resp, err := client.Get("<http://localhost:8080>")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
fmt.Printf("g-%d: %s\\n", i, string(body))
}(i)
}
wg.Wait()
time.Sleep(10 * time.Second)
2차 전화 통화(비유)
커넥션 풀에 3개의 연결이 유지되는 상황에서 이번에는 5개의 고루틴에서 1초단위로 100번의 요청이 발생하도록 하였다. 다시 말하자면, 5개의 요청이 거의 동시에 발생하며 이러한 요청이 1초 단위로 100번 발생한다.
wg.Add(5)
for i := 0; i < 5; i++ {
go func(i int) {
defer wg.Done()
for i := 0; i < 100; i++ {
resp, err := client.Get("<http://localhost:8080>")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
fmt.Printf("g-%d: %s\\n", i+10, string(body))
time.Sleep(time.Second)
}
}(i)
}
wg.Wait()
서버 로그 분석
1, 2차 전화 통화로 비유를 한 요청에 대한 서버 로그를 보자.
1차 전화 통화시에는 커넥션 풀에 연결이 하나도 없고, 동시다발로 5개의 전화 통화가 이루어져야 하니 5개의 연결이 이루어진 것을 알 수 있다. 클라이언트 측의 포트가 56242~56246 5개 인 것으로 이를 알 수 있다.
2차 전화 통화시에는 커넥션 풀에 3개의 연결이 유지되고 있다. 몇 개가 되었든 커넥션 풀에 유지되는 연결이 있으면 이후의 전화 통화 요청은 이 연결을 이용한다. 로그를 보면 커넥션 풀에 56242, 56243, 56244 3개의 연결이 남아 있고, 이를 계속하여 사용하는 것을 알 수 있다.
$go run server.go
// 1차 전화 통화 - 56242~56246 5개의 연결이 생성
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56246 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56242 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56243 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56245 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
// 2차 전화 통화 - 56242, 56243, 56244 커넥션 풀의 3개의 연결이 반복 사용됨
receive a request from: [::1]:56242 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56243 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56242 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56243 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56242 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56243 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56242 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56243 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56243 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56242 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56243 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56244 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
receive a request from: [::1]:56243 map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
... ...
정리
MaxIdleConnsPerHost를 설정하지 않고 MaxConnsPerHost만을 설정하였다면 계속하여 5개의 연결이 생성되었을 것이다. 연결은 비용이 든다. 전화도 전화를 걸면 신호가 가고, 전화를 받고 서로를 확인하는데 시간이 걸린다. 커넥션 풀이 없었다면 매 5개의 연결마다 이 비용이 발생하였을 것이다.
다음에는 커넥션 풀에 유지하는 연결의 타임아웃 설정을 알아보자.