Published on

Go net/http2 unexpected EOF·RST_STREAM 해결

Authors

서버나 외부 API를 호출하는 Go 서비스에서 간헐적으로 net/http2: unexpected EOF 또는 stream error: stream ID ...; RST_STREAM 같은 오류가 튀어나오면, 대부분은 “애플리케이션 버그”라기보다 중간 경로(프록시/로드밸런서), 타임아웃 불일치, 커넥션 재사용 정책의 조합에서 발생합니다. 특히 HTTP/2는 하나의 TCP 커넥션 위에 여러 스트림을 멀티플렉싱하기 때문에, 커넥션 레벨에서 무언가가 끊기면 여러 요청이 동시에 영향을 받으며 로그가 더 혼란스러워집니다.

이 글에서는 Go net/http 클라이언트 기준으로 unexpected EOF·RST_STREAM재현 가능한 형태로 진단하고, 실전에서 효과가 컸던 해결 패턴을 정리합니다.

관련해서 인프라 레이어에서 502/리셋이 섞여 보인다면 함께 읽을 만합니다.

증상: 에러 메시지가 의미하는 것

net/http2: unexpected EOF

  • HTTP/2 프레이밍을 읽는 도중, 상대가 커넥션을 조용히 닫아버려 더 이상 바이트를 읽을 수 없을 때 자주 발생합니다.
  • 원인은 다양합니다.
    • L7/L4 로드밸런서가 유휴 커넥션을 정리
    • 프록시가 최대 커넥션 수/스트림 수 제한을 넘겨 강제 종료
    • 서버가 크래시/재시작
    • TLS 중간장비가 세션을 끊음

RST_STREAM

  • HTTP/2에서 스트림 단위로 “이 스트림은 더 이상 처리하지 않겠다”는 신호입니다.
  • 대표 상황
    • 서버가 요청 바디를 다 읽기 전에 취소(예: CANCEL)
    • 서버/프록시가 타임아웃으로 스트림 종료
    • ENHANCE_YOUR_CALM(과도한 요청), REFUSED_STREAM(리밋), INTERNAL_ERROR 등 다양한 코드로 나타날 수 있음

핵심은 둘 다 “클라이언트가 뭘 잘못했다”로 단정하기 어렵고, 네트워크 경로 전체를 함께 봐야 한다는 점입니다.

가장 흔한 원인 7가지

1) 로드밸런서/프록시의 유휴 커넥션 타임아웃과 Go 커넥션 재사용

Go http.Transport는 기본적으로 커넥션을 풀에 보관하고 재사용합니다. 문제는 중간 장비(ALB, Nginx, Envoy 등)가 유휴 커넥션을 먼저 끊어버렸는데, 클라이언트는 그 커넥션이 살아있다고 생각하고 다음 요청을 태우면서 unexpected EOF가 터지는 패턴입니다.

해결 포인트

  • IdleConnTimeout을 중간 장비보다 짧게 설정
  • 또는 일정 기간마다 커넥션을 새로 만들도록 유도
package main

import (
	"net"
	"net/http"
	"time"
)

func newClient() *http.Client {
	dialer := &net.Dialer{
		Timeout:   5 * time.Second,
		KeepAlive: 30 * time.Second,
	}

	tr := &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: dialer.DialContext,
		ForceAttemptHTTP2: true,

		MaxIdleConns:        200,
		MaxIdleConnsPerHost: 50,
		IdleConnTimeout:     30 * time.Second, // 예: ALB/Nginx 유휴 타임아웃보다 짧게

		TLSHandshakeTimeout:   5 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
		ResponseHeaderTimeout: 10 * time.Second,
	}

	return &http.Client{
		Transport: tr,
		Timeout:   20 * time.Second, // 전체 요청 상한
	}
}

func main() {
	_ = newClient()
}

운영 환경에서 ALB idle timeout, Nginx keepalive_timeout, Envoy idle_timeout 같은 값과 정렬시키는 것이 중요합니다.

2) 서버/프록시가 HTTP/2를 “완전 지원”하지 않는 경우

겉으로는 HTTP/2가 붙는 것처럼 보여도, 중간 프록시가 업스트림과 다운스트림에서 프로토콜을 다르게 처리하거나(예: 클라이언트는 HTTP/2, 백엔드는 HTTP/1.1), 특정 상황에서 스트림을 리셋하는 제품/설정이 있습니다.

진단

  • curl로 HTTP/2 강제 및 상세 로그 확인

아래처럼 부등호가 들어갈 수 있는 표현은 MDX에서 문제될 수 있으니, 커맨드 자체만 사용합니다.

curl -v --http2 https://api.example.com/health
curl -v --http2-prior-knowledge http://service.example.com/health
  • 프록시 계층이 있다면 “클라이언트-프록시”와 “프록시-업스트림” 각각에서 HTTP/2 여부를 확인

해결

  • 문제 구간에서 HTTP/2를 끄고 HTTP/1.1로 고정(임시 우회로 유용)
  • 또는 프록시/ALB의 HTTP/2 설정을 재검토

Go 클라이언트에서 HTTP/2를 피하고 싶다면, 가장 단순한 방법은 ForceAttemptHTTP2false로 두고(또는 기본값 사용), 서버가 HTTP/2를 광고해도 선택되지 않게 환경을 조정하는 것입니다. 다만 TLS ALPN 협상 등으로 완전히 차단하려면 구성에 따라 추가 조치가 필요합니다.

3) 타임아웃 불일치: 클라이언트는 기다리는데 중간 장비가 먼저 끊음

HTTP/2 스트림은 “요청 하나”가 길어질 때 중간 장비의 read_timeout/stream_idle_timeout에 걸려 RST_STREAM이 날아오기 쉽습니다.

체크리스트

  • Go http.Client.Timeout (전체 상한)
  • Transport.ResponseHeaderTimeout (헤더 대기 상한)
  • 서버 핸들러의 타임아웃(예: 서버 ReadTimeout, WriteTimeout)
  • Ingress/프록시의 업스트림 타임아웃

해결 패턴

  • 클라이언트 타임아웃은 가장 바깥(사용자 경험) 기준, 프록시/서버 타임아웃은 그보다 조금 더 짧거나 길게 “의도적으로” 계층화
  • 긴 작업은 동기 호출 대신 비동기 작업 큐/폴링 패턴 고려

4) 요청 바디 재시도 불가로 인한 EOF/RST 혼합

네트워크가 흔들릴 때 재시도를 넣으면 좋아 보이지만, POST/PUT처럼 바디가 있는 요청은 바디가 한 번 읽히면 재전송이 어려워 실패가 꼬일 수 있습니다. HTTP/2에서 스트림이 리셋되면, 클라이언트는 “보냈다/안 보냈다” 경계가 애매해집니다.

해결

  • Go 1.13+에서는 http.Request.GetBody를 설정하면 재시도를 안전하게 구성할 수 있습니다.
package main

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"time"
)

func newJSONRequest(ctx context.Context, url string, body []byte) (*http.Request, error) {
	r, err := http.NewRequestWithContext(ctx, http.MethodPost, url, io.NopCloser(bytes.NewReader(body)))
	if err != nil {
		return nil, err
	}
	r.Header.Set("Content-Type", "application/json")

	// 재시도 시 바디를 다시 만들 수 있게 제공
	r.GetBody = func() (io.ReadCloser, error) {
		return io.NopCloser(bytes.NewReader(body)), nil
	}
	return r, nil
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	_, _ = newJSONRequest(ctx, "https://api.example.com/v1", []byte(`{"ping":"pong"}`))
}

그리고 재시도는 무조건이 아니라, 재시도 가능한 에러만 선별하는 것이 핵심입니다.

5) 과도한 동시성으로 인한 스트림/커넥션 제한 초과

HTTP/2는 커넥션 하나에 많은 요청을 얹을 수 있지만, 서버는 MAX_CONCURRENT_STREAMS 같은 제한을 둡니다. 제한을 넘기면 REFUSED_STREAM 또는 내부적으로 RST_STREAM이 발생할 수 있습니다.

해결

  • 클라이언트 동시성 제한(세마포어)
  • Transport.MaxConnsPerHost로 호스트별 커넥션 상한을 설정해 “커넥션 하나에 몰빵”을 완화
tr := &http.Transport{
	ForceAttemptHTTP2: true,
	MaxConnsPerHost:   50,
	MaxIdleConnsPerHost: 50,
	IdleConnTimeout:   30 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
_ = client

동시성은 애플리케이션 레벨에서도 제한하는 편이 안정적입니다.

6) 서버가 응답을 쓰는 도중 클라이언트 컨텍스트가 취소됨

클라이언트가 타임아웃으로 취소하면 서버는 context canceled를 겪고, HTTP/2에서는 스트림이 CANCEL로 리셋되는 형태가 흔합니다. 이때 서버 로그만 보면 “왜 갑자기 끊겼지”가 됩니다.

해결

  • 클라이언트 타임아웃을 실제 처리 시간에 맞게 조정
  • 서버는 Request.Context() 취소를 정상 흐름으로 취급하고, 불필요한 에러 로그를 줄임

7) 노드 시간 드리프트, TLS/인증 계열 이슈가 간헐적 끊김으로 보이는 경우

unexpected EOF로만 보이지만 실제로는 TLS 재협상/인증서 검증/토큰 검증 실패가 원인인 경우도 있습니다. 특히 컨테이너/노드 시간 드리프트는 TLS와 STS 호출 실패를 연쇄적으로 만들 수 있습니다.

진단을 빠르게 만드는 로깅/관측 팁

1) 요청 단위로 반드시 남길 것

  • method, url host, path, status
  • request_id(헤더로 전파)
  • duration_ms
  • 에러 발생 시 net.Error 여부, timeout 여부

2) Go에서 에러 분류

func isTimeoutErr(err error) bool {
	if err == nil {
		return false
	}
	if ne, ok := err.(interface{ Timeout() bool }); ok {
		return ne.Timeout()
	}
	return false
}

그리고 메시지에 RST_STREAM 또는 unexpected EOF가 포함되는지로 1차 분류를 하되, 최종 원인은 인프라 로그와 함께 교차 확인해야 합니다.

3) 패킷/프록시 로그로 “누가 먼저 끊었는지” 확인

  • 클라이언트 측: tcpdump 또는 eBPF 기반 네트워크 트레이싱
  • 프록시 측: Envoy/Nginx access log에 upstream reset 사유
  • 로드밸런서: ALB/NLB 타겟 리셋/502 지표

특히 ALB에서 target reset이 보이면 애플리케이션보다 먼저 인프라 설정을 의심하는 것이 비용이 적습니다.

실전 해결 레시피: 안전한 HTTP 클라이언트 템플릿

아래는 “운영에서 덜 터지는” 쪽으로 타임아웃과 커넥션 정책을 명시한 예시입니다.

package httpx

import (
	"crypto/tls"
	"net"
	"net/http"
	"time"
)

type ClientConfig struct {
	RequestTimeout       time.Duration
	DialTimeout          time.Duration
	TLSHandshakeTimeout  time.Duration
	ResponseHeaderTimeout time.Duration
	IdleConnTimeout      time.Duration
	MaxIdleConns         int
	MaxIdleConnsPerHost  int
	MaxConnsPerHost      int
}

func NewClient(cfg ClientConfig) *http.Client {
	if cfg.RequestTimeout == 0 {
		cfg.RequestTimeout = 20 * time.Second
	}
	if cfg.DialTimeout == 0 {
		cfg.DialTimeout = 5 * time.Second
	}
	if cfg.TLSHandshakeTimeout == 0 {
		cfg.TLSHandshakeTimeout = 5 * time.Second
	}
	if cfg.ResponseHeaderTimeout == 0 {
		cfg.ResponseHeaderTimeout = 10 * time.Second
	}
	if cfg.IdleConnTimeout == 0 {
		cfg.IdleConnTimeout = 30 * time.Second
	}
	if cfg.MaxIdleConns == 0 {
		cfg.MaxIdleConns = 200
	}
	if cfg.MaxIdleConnsPerHost == 0 {
		cfg.MaxIdleConnsPerHost = 50
	}
	if cfg.MaxConnsPerHost == 0 {
		cfg.MaxConnsPerHost = 100
	}

	dialer := &net.Dialer{Timeout: cfg.DialTimeout, KeepAlive: 30 * time.Second}

	tr := &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: dialer.DialContext,
		ForceAttemptHTTP2: true,

		MaxIdleConns:        cfg.MaxIdleConns,
		MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
		MaxConnsPerHost:     cfg.MaxConnsPerHost,
		IdleConnTimeout:     cfg.IdleConnTimeout,

		TLSHandshakeTimeout:   cfg.TLSHandshakeTimeout,
		ResponseHeaderTimeout: cfg.ResponseHeaderTimeout,
		ExpectContinueTimeout: 1 * time.Second,

		TLSClientConfig: &tls.Config{
			MinVersion: tls.VersionTLS12,
		},
	}

	return &http.Client{Transport: tr, Timeout: cfg.RequestTimeout}
}

이 템플릿을 기반으로, 환경별(ALB, 사내 프록시, 외부 API)로 IdleConnTimeout과 각종 타임아웃을 조정하면 unexpected EOF의 빈도가 눈에 띄게 줄어드는 경우가 많습니다.

재시도는 “무조건”이 아니라 “조건부”로

unexpected EOFRST_STREAM은 일시적일 수 있지만, 무한 재시도는 더 큰 장애를 만듭니다. 다음을 권장합니다.

  • 멱등 요청(GET, 일부 PUT)만 기본 재시도
  • POSTIdempotency-Key 같은 서버 지원이 있을 때만 적극 재시도
  • 지수 백오프와 지터 적용
  • 최대 재시도 횟수 제한

간단한 백오프 예시입니다.

func backoff(attempt int) time.Duration {
	base := 100 * time.Millisecond
	max := 2 * time.Second
	d := base * time.Duration(1<<attempt)
	if d > max {
		d = max
	}
	return d
}

최종 점검표

  • 중간 장비의 유휴 커넥션 타임아웃과 Go IdleConnTimeout을 정렬했는가
  • 프록시/ALB가 HTTP/2를 안정적으로 처리하는가(필요 시 특정 구간에서 HTTP/1.1로 우회)
  • 클라이언트/프록시/서버 타임아웃이 계층적으로 설계되었는가
  • 동시성/스트림 제한을 넘기지 않는가
  • 재시도는 멱등성과 바디 재생성(GetBody)을 고려했는가
  • 노드 시간, TLS, 인증 실패가 네트워크 오류로 위장되지 않는가

net/http2: unexpected EOFRST_STREAM은 원인이 하나로 고정되지 않습니다. 하지만 위 체크리스트대로 “커넥션 재사용과 타임아웃 정렬”부터 잡으면, 대부분의 간헐 오류는 빠르게 안정화됩니다.