golang

Golang: HTTP 클라이언트의 연결 관리(1)

주먹불끈 2023. 5. 14. 02:40

개요

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 - 하나의 호스트에 연결할 수 있는 개수를 설정하여 이처럼 과도한 연결이 발생하지 않도록 해보자.

반응형