net/http 클라이언트
이 챕터에서 다루는 것
http.Get(url) 한 줄이면 HTTP 요청이 된다. 그리고 그 한 줄은 프로덕션에서
쓰면 안 되는 코드다.
이 챕터는 왜 그런지, 대신 무엇을 써야 하는지를 다룬다. 타임아웃, 본문 처리, 커넥션 재사용, 재시도까지 갖춘 클라이언트를 직접 만든다.
:::note 인터넷에 나가지 않는다
이 챕터의 모든 예제는 net/http/httptest가 같은 프로세스 안에 띄우는
루프백 서버를 상대한다. 포트는 :0으로 OS가 고른다.
느린 응답도, 실패도, 재시도도 전부 핸들러가 통제하므로 결과가 결정적이다.
이 방식은 8-3에서 이미 썼다. 거기서는 "테스트 도구"였고, 여기서는 개발 중에 클라이언트를 실제로 굴려 보는 방법이다. :::
문제 — http.Get은 영원히 기다린다
resp, err := http.Get("https://api.example.com/items")
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
네 줄에 문제가 네 개 있다.
- 타임아웃이 없다.
http.DefaultClient의Timeout은 0이고, 0은 "무제한"이다. 상대 서버가 연결만 받아 놓고 응답하지 않으면 이 고루틴은 영원히 멈춰 있다. 요청마다 고루틴을 만드는 서버라면 그대로 고루틴 누수다. - 상태 코드를 안 본다.
err는 전송이 실패했을 때만 nil이 아니다. 404도 500도 성공이다.resp.StatusCode를 직접 봐야 한다. - 본문 크기에 상한이 없다. 9-1에서 본 그대로다.
- 전역 상태를 쓴다.
http.DefaultClient와http.DefaultTransport는 프로그램 전체가 공유한다. 어느 라이브러리가 설정을 바꾸면 전부 영향을 받는다.
Client와 Transport의 역할 분담
계층이 둘이다.
http.Client— 리다이렉트 추적, 쿠키, 전체 타임아웃 같은 정책.http.RoundTripper(보통*http.Transport) — 커넥션 풀, TLS, 프록시, keep-alive 같은 전송.
Client는 가볍고, Transport가 상태(커넥션 풀)를 들고 있다.
그래서 Client는 만들어 두고 재사용해야 한다. 요청마다 새로 만들면
커넥션 풀이 매번 버려지고, 매번 TCP 핸드셰이크와 TLS 협상을 다시 한다.
go doc net/http.Client가 직접 그렇게 말한다.
The Client.Transport typically has internal state (cached TCP connections), so Clients should be reused instead of created as needed.
기본 Transport에서 반드시 손봐야 하는 값이 하나 있다.
$ go doc net/http.DefaultMaxIdleConnsPerHost
const DefaultMaxIdleConnsPerHost = 2
호스트당 유휴 커넥션이 2개다. 같은 API를 동시에 50번 두드리면 48개는 매번 새 커넥션을 열고, 응답을 받자마자 버린다. TIME_WAIT 소켓이 쌓인다.
// newTransport는 기본 설정이 위험한 항목만 손본 Transport를 만든다.
// http.DefaultTransport는 커넥션 풀 크기가 호스트당 2로 작다.
func newTransport() *http.Transport {
return &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100, // 기본값 2는 같은 호스트를 두드릴 때 너무 작다
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
}
타임아웃의 네 계층
타임아웃을 "하나만 걸면 된다"고 생각하면 진단이 안 되는 장애가 생긴다. 서로 다른 단계에 서로 다른 상한이 있다.
| 계층 | 무엇을 잰다 | 어디에 설정 |
|---|---|---|
Dialer.Timeout | TCP 연결 수립 | Transport.DialContext |
TLSHandshakeTimeout | TLS 협상 | Transport |
ResponseHeaderTimeout | 요청 전송 후 첫 응답 헤더까지 | Transport |
| 전체 | 연결 + 리다이렉트 + 본문 읽기 전부 | Client.Timeout 또는 요청 컨텍스트 |
전체 상한은 Client.Timeout이 아니라 컨텍스트로 거는 편이 낫다.
Client.Timeout은 그 클라이언트를 쓰는 모든 요청에 같은 값이 걸리고,
호출자가 더 짧은 데드라인을 갖고 있어도 그것을 존중하지 못한다.
// once는 한 번의 요청과 응답 처리다.
func (c *Client) once(ctx context.Context, method, rawURL string, body []byte, out any) error {
// 요청 단위 타임아웃. http.Client.Timeout을 쓰면 클라이언트 전체에 걸리고
// 호출자가 준 ctx의 더 짧은 데드라인을 존중하지 못한다.
ctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
context.WithTimeout은 더 짧은 쪽이 이긴다. 호출자가 50ms짜리 컨텍스트를
넘기면 우리가 건 2초는 의미가 없다. 실제로 그렇게 동작한다.
GET /slow (ctx 50ms) → DeadlineExceeded인가: true
호출자 ctx가 클라이언트 Timeout(2s)보다 짧으면 짧은 쪽이 이긴다
테스트에서는 응답하지 않는 핸들러로 이 경로를 만든다. time.Sleep이 아니라
채널로 붙잡아 두는 이유는, 테스트가 끝날 때 핸들러가 확실히 풀려나게
하기 위해서다.
// 서버가 응답하지 않으면 Timeout이 걸린다.
func TestPerRequestTimeout(t *testing.T) {
release := make(chan struct{})
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
})
c, _ := newClient(t, h, fetch.Config{Timeout: 100 * time.Millisecond})
t.Cleanup(func() { close(release) })
err := c.JSON(t.Context(), http.MethodGet, "/slow", nil, &item{})
if err == nil {
t.Fatal("타임아웃을 기대했다")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("err = %v, want DeadlineExceeded", err)
}
}
핸들러가 r.Context().Done()도 보고 있다는 점이 중요하다.
클라이언트가 포기하면 서버 쪽 요청 컨텍스트도 취소된다.
7-7에서 배운 전파가 네트워크를 건너서도
동작한다.
Body — 닫고, 끝까지 읽는다
이것이 이 챕터에서 가장 자주 틀리는 부분이다. 규칙이 둘이다.
resp.Body는 무슨 일이 있어도 닫는다. 안 닫으면 커넥션이 풀로 돌아오지 않는다.- 남은 바이트를 다 읽어야 커넥션이 재사용된다. 중간에 닫으면 Go는 그 커넥션을 재사용할 수 없다고 판단하고 버린다.
두 번째가 덜 알려져 있다. 상태 코드만 보고 return하는 코드가 사실은
커넥션을 계속 버리고 있는 경우가 많다.
resp, err := c.httpc.Do(req)
if err != nil {
return fmt.Errorf("fetch: %s %s: %w", method, rawURL, err)
}
// Body는 무슨 일이 있어도 닫는다. 닫지 않으면 커넥션이 풀로 돌아오지 않는다.
defer func() {
// 남은 바이트를 버려야 커넥션이 재사용된다. 다만 무한정 읽지는 않는다.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, c.cfg.MaxBodyBytes))
_ = resp.Body.Close()
}()
io.LimitReader로 감싼 이유가 있다. "끝까지 읽어라"를 무조건 따르면
악의적인 서버가 무한 스트림을 보내 클라이언트를 붙잡아 둘 수 있다.
상한까지만 비우고 닫는다.
이게 실제로 효과가 있는지는 서버가 본 RemoteAddr로 확인할 수 있다.
// 커넥션이 재사용되는지 확인한다. 같은 클라이언트로 여러 번 부르면
// 서버가 보는 RemoteAddr이 같아야 한다.
func TestConnectionReuse(t *testing.T) {
addrs := make(map[string]int)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addrs[r.RemoteAddr]++
if _, err := w.Write([]byte(`{"id":1}`)); err != nil {
t.Errorf("Write 에러 = %v", err)
}
})
c, _ := newClient(t, h, fetch.Config{})
for range 5 {
if err := c.JSON(t.Context(), http.MethodGet, "/x", nil, &item{}); err != nil {
t.Fatalf("JSON() 에러 = %v", err)
}
}
if len(addrs) != 1 {
t.Errorf("커넥션 %d개를 썼다, want 1개 — 본문을 끝까지 읽고 닫는지 확인하라", len(addrs))
}
}
다섯 번 요청해서 커넥션 하나. defer 안의 io.Copy를 지우고 이 테스트를
돌려 보면 숫자가 올라간다.
:::warning defer resp.Body.Close()를 err 검사 앞에 두지 않는다
resp, err := client.Do(req)
defer resp.Body.Close() // err != nil이면 resp가 nil이라 패닉
if err != nil {
return err
}
전송이 실패하면 resp는 nil이다. 에러를 먼저 확인하고 그다음에 defer를 건다.
VERSIONS.md에 적힌 Go 1.25의 nil 포인터 검사 수정 덕분에 이제는 이런 코드가
제자리에서 확실히 패닉한다 — 예전에는 엉뚱한 곳에서 터졌다.
:::
상태 코드는 직접 본다
err == nil은 "HTTP 대화가 성사됐다"는 뜻일 뿐이다.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
snippet, _ := io.ReadAll(io.LimitReader(limited, 256))
return &StatusError{
StatusCode: resp.StatusCode,
Method: method,
URL: rawURL,
Snippet: string(bytes.TrimSpace(snippet)),
}
}
에러에 본문 앞부분 256바이트를 같이 담는다. "500 Internal Server Error"만 로그에 남으면 디버깅할 게 없지만, 서버가 준 설명이 함께 있으면 다르다.
// StatusError는 2xx가 아닌 응답이다. 본문 앞부분을 함께 담아 디버깅을 돕는다.
type StatusError struct {
StatusCode int
Method string
URL string
Snippet string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("fetch: %s %s → %d %s: %s",
e.Method, e.URL, e.StatusCode, http.StatusText(e.StatusCode), e.Snippet)
}
GET /items/999 → fetch: GET {server}/items/999 → 404 Not Found: {"error":"없는 항목"}
StatusCodeOf = 404, 재시도했는가: 아니오 (4xx)
:::note {server}가 무엇인가
httptest가 고르는 포트는 실행할 때마다 다르다. 데모 프로그램은 출력에
포트가 섞이지 않도록 서버 주소를 {server}로 치환해서 찍는다.
// redact는 실행마다 달라지는 루프백 주소를 고정 문자열로 바꾼다.
func redact(s, base string) string {
return strings.ReplaceAll(s, base, "{server}")
}
:::
호출하는 쪽이 상태 코드로 분기할 수 있게 접근자를 하나 둔다.
// StatusCodeOf는 err에서 HTTP 상태 코드를 꺼낸다. 없으면 0.
func StatusCodeOf(err error) int {
if se, ok := errors.AsType[*StatusError](err); ok {
return se.StatusCode
}
return 0
}
재시도 — 무엇을 다시 보내는가
아무거나 재시도하면 안 된다. 404를 백 번 보내도 404다. 그리고 POST를 재시도하면 주문이 두 번 들어갈 수 있다.
// retryable은 다시 시도해 볼 가치가 있는 실패인지 판단한다.
func retryable(err error) bool {
if se, ok := errors.AsType[*StatusError](err); ok {
switch se.StatusCode {
case http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
}
return false // 4xx는 몇 번을 보내도 같다
}
// 전송 계층 실패는 일시적일 수 있다.
var ne net.Error
if errors.As(err, &ne) {
return true
}
return errors.Is(err, io.ErrUnexpectedEOF)
}
net.Error는 인터페이스라 errors.AsType을 쓸 수 없다.
VERSIONS.md에 적힌 제약 그대로다 — errors.AsType[E error]는 대상이
error를 구현해야 하고, 여기서는 인터페이스 타입 자체를 찾는 것이라
errors.As가 필요하다.
재시도 루프는 이렇게 생겼다.
var lastErr error
for attempt := 0; attempt <= c.cfg.MaxRetries; attempt++ {
if c.attempt != nil {
c.attempt(attempt)
}
if attempt > 0 {
wait := c.backoff(attempt, lastErr)
if err := c.cfg.Sleep(ctx, wait); err != nil {
return errors.Join(fmt.Errorf("fetch: 재시도 대기 중단: %w", err), lastErr)
}
}
err := c.once(ctx, method, target.String(), body, out)
if err == nil {
return nil
}
lastErr = err
// 컨텍스트가 끝났으면 재시도해 봐야 소용없다.
if ctx.Err() != nil {
return errors.Join(err, ctx.Err())
}
if !retryable(err) {
return err
}
}
return fmt.Errorf("fetch: %d회 시도 후 실패: %w", c.cfg.MaxRetries+1, lastErr)
세 가지가 들어 있다.
- 대기도 컨텍스트를 존중한다.
time.Sleep은 취소를 모른다. - 컨텍스트가 끝났으면 즉시 포기한다. 데드라인이 지난 뒤에 재시도하는 건 낭비다.
- 재시도 불가능한 에러는 바로 반환한다.
대기 함수는 이렇게 생겼다.
// sleepCtx는 컨텍스트를 존중하는 대기다. time.Sleep은 취소를 모른다.
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
body를 []byte로 들고 있다가 시도마다 bytes.NewReader로 새로 감싸는 것도
의도된 설계다. io.Reader를 그대로 들고 있으면 두 번째 시도에서 이미
소진되어 빈 본문이 나간다. 재시도하는 클라이언트에서 흔한 버그다.
백오프
실패할 때마다 대기를 두 배로 늘린다. 서버가 Retry-After로 알려 주면
그쪽을 따른다.
// backoff는 다음 대기 시간을 계산한다. 서버가 Retry-After를 줬으면 그것을 따른다.
func (c *Client) backoff(attempt int, lastErr error) time.Duration {
d := c.cfg.RetryBase << (attempt - 1)
if se, ok := errors.AsType[*StatusError](lastErr); ok && se.StatusCode == http.StatusTooManyRequests {
if hinted, ok := retryAfter(se.Snippet); ok {
return hinted
}
}
return d
}
:::warning 실무에서는 지터를 넣는다 이 예제의 백오프는 결정적이다 — 테스트에서 대기 시간을 정확히 단언하려고 일부러 그렇게 했다. 실제 서비스에서는 여기에 무작위 지터를 더해야 한다. 안 그러면 장애 복구 시점에 모든 클라이언트가 동시에 재시도해서 서버를 다시 넘어뜨린다. 지터를 테스트 가능하게 주입하는 방법은 8-3에서 다뤘다. :::
재시도를 결정적으로 테스트하기
대기를 실제로 하면 테스트가 느려지고, 시간을 단언하면 플래키해진다.
Sleep을 주입해서 대기 시간을 기록만 하고 실제로 자지는 않게 한다.
// 5xx는 재시도하고, 성공하면 거기서 멈춘다.
func TestRetriesUntilSuccess(t *testing.T) {
var calls atomic.Int32
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if err := json.NewEncoder(w).Encode(item{ID: 3, Name: "성공"}); err != nil {
t.Errorf("Encode 에러 = %v", err)
}
})
var waits []time.Duration
c, _ := newClient(t, h, fetch.Config{
MaxRetries: 5,
RetryBase: 100 * time.Millisecond,
Sleep: func(ctx context.Context, d time.Duration) error {
waits = append(waits, d)
return ctx.Err()
},
})
var got item
if err := c.JSON(t.Context(), http.MethodGet, "/x", nil, &got); err != nil {
t.Fatalf("JSON() 에러 = %v", err)
}
if calls.Load() != 3 {
t.Errorf("서버 호출 = %d회, want 3회", calls.Load())
}
if got.Name != "성공" {
t.Errorf("결과 = %+v", got)
}
// 백오프는 두 배씩 늘어난다.
want := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond}
if fmt.Sprint(waits) != fmt.Sprint(want) {
t.Errorf("대기 = %v, want %v", waits, want)
}
}
핸들러가 "처음 두 번은 503, 세 번째는 성공"이라는 일정을 통제한다.
실제 서버로는 절대 재현할 수 없는 상황이고, httptest니까 가능하다.
4xx가 재시도되지 않는 것도 서버 호출 횟수로 확인한다.
// 4xx는 재시도하지 않는다.
func TestNoRetryOn4xx(t *testing.T) {
var calls atomic.Int32
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadRequest)
})
c, _ := newClient(t, h, fetch.Config{MaxRetries: 3})
if err := c.JSON(t.Context(), http.MethodGet, "/x", nil, nil); err == nil {
t.Fatal("400은 에러여야 한다")
}
if got := calls.Load(); got != 1 {
t.Errorf("서버 호출 = %d회, want 1회", got)
}
}
데모를 실행하면 세 번째 시도에서 성공하는 것이 보인다.
GET /flaky → {ID:1 Name:드디어} (시도 번호 [0 1 2])
7-9의 빚 — Fetcher를 바꿔도 수집기는 그대로다
7-9의 마지막 연습문제가
"시뮬레이션 fetcher를 진짜 net/http 구현으로 바꿔도 수집기는 그대로여야 한다"였다.
여기서 갚는다.
수집기가 요구하는 것은 메서드 하나짜리 인터페이스다.
// Fetcher는 수집기가 요구하는 전부다. 구현이 HTTP인지 가짜인지 모른다.
type Fetcher interface {
Fetch(ctx context.Context, url string) (Page, error)
}
수집기 본문은 순전히 동시성 코드다. HTTP를 한 글자도 모른다.
// Collect는 워커 풀로 URL들을 병렬 수집한다.
// 이 함수는 Fetcher 구현이 무엇으로 바뀌든 한 글자도 바뀌지 않는다.
func Collect(ctx context.Context, f Fetcher, urls []string, workers int) []Result {
if workers <= 0 {
workers = 1
}
results := make([]Result, len(urls))
idx := make(chan int)
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for i := range idx {
page, err := f.Fetch(ctx, urls[i])
results[i] = Result{Page: page, Err: err}
}
})
}
for i := range urls {
select {
case idx <- i:
case <-ctx.Done():
// 남은 슬롯은 컨텍스트 에러로 채운다.
for j := i; j < len(urls); j++ {
results[j] = Result{Page: Page{URL: urls[j]}, Err: ctx.Err()}
}
close(idx)
wg.Wait()
return results
}
}
close(idx)
wg.Wait()
return results
}
7-8에서 정한 것들이 그대로 있다.
wg.Go, 인덱스 슬롯으로 입력 순서 보존, 컨텍스트 취소 처리.
여러 워커가 results의 서로 다른 인덱스에만 쓰기 때문에 락이 필요 없다.
진짜 구현은 이렇다.
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (Page, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Page{URL: url}, fmt.Errorf("crawl: 요청 생성: %w", err)
}
resp, err := f.Client.Do(req)
if err != nil {
return Page{URL: url}, fmt.Errorf("crawl: %s: %w", url, err)
}
defer func() {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, f.MaxBytes))
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return Page{URL: url}, fmt.Errorf("crawl: %s: 상태 %d", url, resp.StatusCode)
}
n, err := io.Copy(io.Discard, io.LimitReader(resp.Body, f.MaxBytes))
if err != nil {
return Page{URL: url}, fmt.Errorf("crawl: %s: 본문 읽기: %w", url, err)
}
return Page{URL: url, Bytes: int(n)}, nil
}
그리고 같은 테스트를 두 구현에 모두 돌린다.
cases := []struct {
name string
fetcher crawl.Fetcher
urls []string
}{
{"가짜", fakeFetcher{sizes: fakeSizes}, []string{"/a", "/b", "/c"}},
{"진짜 HTTP", crawl.NewHTTPFetcher(2 * time.Second), realURLs},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := crawl.Collect(t.Context(), tc.fetcher, tc.urls, 3)
if len(got) != 3 {
t.Fatalf("결과 %d개, want 3개", len(got))
}
want := []int{10, 20, 30}
for i, r := range got {
if r.Err != nil {
t.Fatalf("결과 %d 에러 = %v", i, r.Err)
}
if r.Page.Bytes != want[i] {
t.Errorf("결과 %d 바이트 = %d, want %d", i, r.Page.Bytes, want[i])
}
if r.Page.URL != tc.urls[i] {
t.Errorf("결과 %d URL = %q, want %q — 입력 순서가 유지되어야 한다",
i, r.Page.URL, tc.urls[i])
}
}
})
}
crawl.Collect(진짜 HTTP): 3개 URL, 합계 384바이트
Collect의 본문은 7-9의 시뮬레이션 버전과 한 글자도 다르지 않다
이것이 4-5에서 말한
"작은 인터페이스"의 실제 값어치다. 동시성 로직과 I/O 로직이 분리되어 있으니,
동시성은 가짜로 테스트하고 I/O는 httptest로 테스트한다.
흔한 실수
1. http.Get / http.DefaultClient를 쓴다
타임아웃이 무제한이고 전역 상태다.
2. Client를 요청마다 만든다
커넥션 풀이 매번 버려진다. 만들어 두고 재사용한다.
3. err == nil이면 성공이라고 생각한다
404도 500도 err == nil이다. resp.StatusCode를 본다.
4. resp.Body를 안 닫거나, 닫기 전에 다 안 읽는다
전자는 커넥션 누수, 후자는 커넥션 재사용 실패다.
5. defer resp.Body.Close()를 에러 검사 앞에 둔다
resp가 nil이라 패닉한다.
6. MaxIdleConnsPerHost를 기본값으로 둔다
호스트당 2다. 같은 API를 많이 두드리면 커넥션을 계속 새로 연다.
7. 요청 본문을 io.Reader로 들고 재시도한다
두 번째 시도에서 빈 본문이 나간다. []byte로 들고 매번 새로 감싼다.
8. 모든 실패를 재시도한다
4xx는 결과가 같고, 멱등하지 않은 POST는 부작용이 두 번 일어난다.
9. 재시도 대기에 time.Sleep을 쓴다
취소를 모른다. select로 ctx.Done()과 함께 기다린다.
10. 응답 본문에 상한을 안 건다
io.ReadAll(resp.Body)는 상대가 보내는 만큼 먹는다.
정리
http.Get과http.DefaultClient는 타임아웃이 무제한이고 전역 상태다. 자기Client를 만들고 재사용한다.Client는 정책,Transport는 전송. 상태(커넥션 풀)를 들고 있는 쪽은Transport다.DefaultMaxIdleConnsPerHost는 2라서 대개 너무 작다.- 타임아웃은 계층이다: dial, TLS 핸드셰이크, 응답 헤더, 그리고 전체. 전체 상한은 컨텍스트로 걸어야 호출자의 더 짧은 데드라인을 존중한다.
err == nil은 성공이 아니다. 상태 코드를 직접 보고, 에러에 본문 앞부분을 담아 둔다.- Body는 닫고, 상한까지 비운다. 안 비우면 커넥션이 재사용되지 않는다. 다섯 번 요청에 커넥션 하나가 나오는지로 검증할 수 있다.
- 재시도는 5xx·429·전송 실패에만. 대기는 컨텍스트를 존중하고,
요청 본문은
[]byte로 들고 매번 새로 감싼다. 실무에서는 지터를 넣는다. httptest.NewServer가 개발용 상대 서버다. 느린 응답, 실패 일정, 커넥션 관찰까지 전부 통제된 상태로 만들 수 있고 인터넷이 필요 없다.Fetcher인터페이스 하나로 가짜와 진짜가 교체된다. 7-9의 수집기는 구현이 바뀌어도 그대로다.
연습문제
-
once의defer에서io.Copy(io.Discard, ...)줄을 지우고TestConnectionReuse를 돌려 보자. 커넥션이 몇 개가 되는가? 그다음 핸들러가 응답 본문을 아주 짧게(1바이트) 쓰도록 바꾸면 어떻게 되는가? Go가 "다 읽었다"고 판단하는 기준이 무엇일지 추측해 보자. -
fetch.Client에 멱등성 보호를 넣어 보자.POST와PATCH는 기본적으로 재시도하지 않되, 호출자가 명시적으로 허용하면 재시도하게 만든다. API를 어떻게 설계해야 "실수로 켜기 어렵게" 되는가? -
retryable이 지금은net.Error면 무조건 재시도한다.net.Error에는Timeout() bool메서드가 있다. 타임아웃과 그 밖의 전송 실패를 구분해서, 어느 쪽을 재시도해야 하는지 판단해 보자.httptest서버를 닫아 버리는 방식으로 "연결 거부" 상황을 만들 수 있는가?