Published on

Go net/http2 stream error 원인·해결 7가지

Authors

서론

Go 서비스에서 HTTP/2를 쓰다 보면 로그에 아래와 비슷한 메시지가 간헐적으로 등장합니다.

  • http2: stream error: stream ID ...; ...
  • stream error: INTERNAL_ERROR / CANCEL / PROTOCOL_ERROR / FLOW_CONTROL_ERROR
  • RST_STREAM 동반(상대가 스트림을 리셋)

이 에러는 “HTTP/2 연결 자체가 죽었다”라기보다 특정 스트림(요청/응답 단위)이 중간에 리셋되거나 규약 위반/리소스 부족으로 종료되었다는 의미인 경우가 많습니다. 특히 L7 프록시(Envoy/NGINX), 클라우드 LB, gRPC, 장시간 스트리밍 응답에서 자주 나타납니다.

아래에서는 Go net/http + x/net/http2 관점에서 원인 7가지를 실무적으로 분해하고, 각 케이스별 재현 포인트, 확인 방법, 해결책을 제시합니다. (환경이 Kubernetes라면 네트워크 계층에서 RST가 섞여 보일 수 있으니, 유사 증상은 Kubernetes gRPC UNAVAILABLE·RST_STREAM 원인과 Envoy·NGINX 대응도 함께 참고하면 좋습니다.)


1) 클라이언트 컨텍스트 취소/타임아웃으로 인한 CANCEL

증상

  • 서버 로그: stream error: CANCEL 또는 context canceled
  • 클라이언트가 타임아웃으로 먼저 끊고, 서버는 쓰기 중 RST_STREAM을 맞음

흔한 원인

  • http.Client{Timeout: ...}이 너무 짧음
  • 요청 컨텍스트에 context.WithTimeout을 걸어두고 장시간 처리
  • 프론트/게이트웨이의 타임아웃이 더 짧아 중간에서 끊음

해결

  • 클라이언트/프록시/서버 타임아웃을 계층별로 정렬(가장 바깥이 가장 짧지 않게)
  • 서버는 r.Context().Done()을 주기적으로 확인해 작업을 중단
// 클라이언트 타임아웃을 "전체 요청"이 아니라 단계별로 관리
tr := &http.Transport{
    ForceAttemptHTTP2: true,
}
client := &http.Client{Transport: tr}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/slow", nil)
resp, err := client.Do(req)
if err != nil {
    // context deadline exceeded가 먼저 발생하면 서버는 CANCEL/RST_STREAM을 볼 수 있음
    log.Printf("do error: %v", err)
    return
}
defer resp.Body.Close()

2) 응답 바디 미소비/미닫힘으로 인한 커넥션 재사용 실패 → 후속 스트림 에러

증상

  • 간헐적 stream error + 동시 요청이 늘수록 악화
  • 커넥션 풀이 꼬이면서 후속 요청에서 이상 증상

흔한 원인

  • resp.Body.Close() 누락
  • 바디를 끝까지 읽지 않고 반환(특히 에러 처리 분기에서)

해결

  • 항상 defer resp.Body.Close()
  • 필요 시 io.Copy(io.Discard, resp.Body)로 끝까지 소모 후 닫기
resp, err := client.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

if resp.StatusCode >= 400 {
    // 바디를 읽지 않고 바로 리턴하면 커넥션 재사용이 깨질 수 있음
    io.Copy(io.Discard, resp.Body)
    return fmt.Errorf("bad status: %s", resp.Status)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
    return err
}
_ = body

> 팁: HTTP/1.1에서도 중요하지만, HTTP/2에서는 단일 TCP 연결에 다수 스트림이 얹히므로 “한 스트림의 정리 실패”가 체감상 더 넓게 번질 수 있습니다.


3) 서버/프록시의 Idle timeout·Read timeout·Write timeout 불일치

증상

  • 일정 시간 이후 장시간 요청(다운로드/업로드/스트리밍)에서 stream error: INTERNAL_ERROR 또는 CANCEL
  • 프록시가 먼저 연결/스트림을 끊는 형태

흔한 원인

  • Go 서버 ReadTimeout/WriteTimeout이 너무 짧음
  • Ingress/Envoy/ALB의 idle timeout이 더 짧음
  • 큰 응답에서 서버가 천천히 flush하는데 중간 장비가 idle로 판단

해결

  • 타임아웃을 “요청 특성”에 맞게 설계
    • API: 짧게
    • 파일/스트리밍: 길게 혹은 별도 엔드포인트/별도 LB 설정
  • 서버에서 스트리밍 시 주기적 flush(단, 과도한 flush는 역효과)
srv := &http.Server{
    Addr:         ":8443",
    ReadTimeout:  15 * time.Second,
    WriteTimeout: 0, // 스트리밍/대용량 응답은 무한 또는 충분히 크게(정책적으로 결정)
    IdleTimeout:  60 * time.Second,
    Handler:      mux,
}
log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))

4) TLS/ALPN 협상 문제(HTTP/2 미협상, 중간장비 다운그레이드)

증상

  • 어떤 환경에서는 잘 되는데 특정 프록시/클라이언트 조합에서만 stream error 또는 핸드셰이크 이후 이상
  • ALPN이 h2가 아닌 http/1.1로 내려가거나, 중간 장비가 HTTP/2를 부분 지원

확인

  • GODEBUG=http2debug=2,tls13=1로 클라이언트/서버에서 협상 로그 확인
  • 프록시(예: NGINX Ingress) 설정에서 http2/grpc/proxy_http_version 확인

해결

  • 클라이언트: ForceAttemptHTTP2: true 및 TLS 설정 점검
  • 서버: TLS 설정에서 ALPN이 h2를 제공하는지 확인(Go는 기본적으로 지원)
// 클라이언트 Transport에서 HTTP/2 시도 강제
tr := &http.Transport{
    ForceAttemptHTTP2: true,
    TLSClientConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,
    },
}
client := &http.Client{Transport: tr}

5) 헤더/트레일러/프레이밍 규약 위반 → PROTOCOL_ERROR

증상

  • stream error: PROTOCOL_ERROR
  • 특정 요청에서만 100% 재현(특정 헤더 조합)

흔한 원인

  • 금지된/잘못된 헤더 전송(HTTP/2에서 Connection, Keep-Alive, Transfer-Encoding 등)
  • 헤더 키 대소문자/중복/값 형식 문제(대개 프록시가 변형)
  • 트레일러 사용 시 Trailer 헤더 선언 누락, 혹은 잘못된 순서

해결

  • 애플리케이션에서 hop-by-hop 헤더를 직접 만지지 않기
  • 프록시가 헤더를 주입/변경하는지 확인
// 잘못된 예: HTTP/2에서 hop-by-hop 헤더를 넣으면 문제를 유발할 수 있음
req.Header.Set("Connection", "keep-alive") // 지양

// 올바른 방향: 표준 헤더만 사용하고, 프록시 레벨에서 필요 설정
req.Header.Set("User-Agent", "my-client/1.0")

6) 플로우 컨트롤/동시성 한계로 인한 FLOW_CONTROL_ERROR 또는 ENHANCE_YOUR_CALM

증상

  • 대량 동시 요청/대용량 업로드에서 간헐적 stream reset
  • FLOW_CONTROL_ERROR 또는 ENHANCE_YOUR_CALM(상대가 과도한 트래픽으로 판단)

흔한 원인

  • 단일 커넥션에 너무 많은 동시 스트림
  • 서버/프록시의 MAX_CONCURRENT_STREAMS 제한
  • 클라이언트가 무제한 고루틴으로 쏘며 backpressure 부재

해결

  • 클라이언트에 동시성 제한(세마포어)
  • 필요 시 호스트별 커넥션 수 조절(HTTP/2는 기본적으로 한 커넥션에 몰림)
// 동시성 제한으로 HTTP/2 스트림 폭주 방지
sem := make(chan struct{}, 50) // 최대 50개 동시

for _, url := range urls {
    sem <- struct{}{}
    go func(u string) {
        defer func() { <-sem }()

        req, _ := http.NewRequest(http.MethodGet, u, nil)
        resp, err := client.Do(req)
        if err != nil {
            log.Printf("req error: %v", err)
            return
        }
        io.Copy(io.Discard, resp.Body)
        resp.Body.Close()
    }(url)
}

// 모두 끝날 때까지 대기(간단히)
for i := 0; i < cap(sem); i++ {
    sem <- struct{}{}
}

7) 중간 프록시/로드밸런서가 RST_STREAM을 발생시키는 케이스(버퍼링, 최대 바디, 업스트림 오류)

증상

  • 애플리케이션은 정상인데도 프록시 구간에서 스트림이 리셋
  • 대용량 요청/응답, 업로드, gRPC 스트리밍에서 두드러짐

흔한 원인

  • Ingress/Envoy/NGINX의 max_body_size, 버퍼 제한, 업스트림 연결 실패
  • 업스트림이 죽어 재시도/드레인 중 RST
  • Kubernetes에서 Pod 재시작/스케일링 중 커넥션 드롭

해결

  • 프록시 로그에서 RST_STREAM/upstream reset 이유 확인
  • 업로드/다운로드 크기 제한 및 버퍼 파라미터 조정
  • Pod 재시작/헬스체크/드레인(terminationGracePeriod, preStop) 점검

Kubernetes에서 애플리케이션이 자주 재시작된다면 네트워크 증상으로만 보지 말고, 먼저 크래시 원인을 잡는 것이 우선입니다. 빠른 진단은 Kubernetes CrashLoopBackOff 원인별 10분 진단을 참고하세요.


공통 진단 체크리스트(Go에서 바로 써먹기)

1) 디버그 로그 켜기

Go의 HTTP/2는 GODEBUG로 힌트를 많이 줍니다.

GODEBUG=http2debug=2,tls13=1 ./your-server
# 또는 클라이언트 실행 시 동일하게 적용

2) 에러를 “문자열”로만 보지 말고 unwrap

net/http는 에러가 여러 겹으로 감싸져 나옵니다.

resp, err := client.Do(req)
if err != nil {
    // url.Error, net.OpError, http2.StreamError 등으로 감싸질 수 있음
    log.Printf("err=%T %v", err, err)
    var uerr *url.Error
    if errors.As(err, &uerr) {
        log.Printf("url err: %T %v", uerr.Err, uerr.Err)
    }
}

3) 재현 조건을 좁히기

  • 특정 엔드포인트만? (대용량/장시간?)
  • 특정 클라이언트/리전/LB 경로만?
  • 동시성 증가에 비례?
  • 응답을 읽지 않는 코드 경로 존재?

결론

Go의 net/http2에서 보이는 stream error는 대부분 다음 7가지 범주로 정리됩니다.

  1. 클라이언트 취소/타임아웃(CANCEL)
  2. 응답 바디 미소비/미닫힘으로 인한 커넥션 재사용 문제
  3. 서버·프록시 타임아웃 불일치
  4. TLS/ALPN 협상/다운그레이드 문제
  5. 헤더/트레일러/프레이밍 규약 위반(PROTOCOL_ERROR)
  6. 플로우 컨트롤/동시성 한계(FLOW_CONTROL_ERROR, ENHANCE_YOUR_CALM)
  7. 프록시/LB가 발생시키는 RST_STREAM(버퍼·크기 제한·업스트림 오류·재시작)

실무에서는 (a) 타임아웃 정렬, (b) 바디 정리 철저, (c) 동시성 제한, (d) 프록시 로그와 Go http2debug를 함께 보기만 해도 해결 확률이 크게 올라갑니다. 특히 Kubernetes/Ingress 경유 트래픽이라면 RST_STREAM은 애플리케이션 문제가 아닐 수 있으니, 프록시/업스트림/재시작 이벤트까지 한 화면에서 묶어 관찰하는 것이 핵심입니다.