- Published on
Go net/http2 stream error 원인·해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Go 서비스에서 HTTP/2를 쓰다 보면 로그에 아래와 비슷한 메시지가 간헐적으로 등장합니다.
http2: stream error: stream ID ...; ...stream error: INTERNAL_ERROR/CANCEL/PROTOCOL_ERROR/FLOW_CONTROL_ERRORRST_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가지 범주로 정리됩니다.
- 클라이언트 취소/타임아웃(
CANCEL) - 응답 바디 미소비/미닫힘으로 인한 커넥션 재사용 문제
- 서버·프록시 타임아웃 불일치
- TLS/ALPN 협상/다운그레이드 문제
- 헤더/트레일러/프레이밍 규약 위반(
PROTOCOL_ERROR) - 플로우 컨트롤/동시성 한계(
FLOW_CONTROL_ERROR,ENHANCE_YOUR_CALM) - 프록시/LB가 발생시키는 RST_STREAM(버퍼·크기 제한·업스트림 오류·재시작)
실무에서는 (a) 타임아웃 정렬, (b) 바디 정리 철저, (c) 동시성 제한, (d) 프록시 로그와 Go http2debug를 함께 보기만 해도 해결 확률이 크게 올라갑니다. 특히 Kubernetes/Ingress 경유 트래픽이라면 RST_STREAM은 애플리케이션 문제가 아닐 수 있으니, 프록시/업스트림/재시작 이벤트까지 한 화면에서 묶어 관찰하는 것이 핵심입니다.