Published on

Spring Boot 3.2 HTTP/2 RST_STREAM 502 원인·해결

Authors

서버를 Spring Boot 3.2로 올리고 HTTP/2를 켠 뒤, 특정 구간에서만 간헐적으로 502 Bad Gateway가 튀면서 클라이언트나 프록시 로그에 RST_STREAM이 보이면 원인 추적이 생각보다 까다롭습니다. HTTP/1.1에서는 잘 되던 요청이 HTTP/2에서만 깨지기도 하고, 애플리케이션 로그에는 아무 것도 남지 않는 경우도 흔합니다.

이 글은 RST_STREAM이 무엇인지부터, 어디에서 리셋이 발생했는지(클라이언트·프록시·백엔드) 경계면을 나누어 진단하는 방법, 그리고 Spring Boot 3.2(내장 Tomcat/Jetty/Netty)와 대표 프록시(Nginx/ALB/Envoy) 조합에서 자주 발생하는 패턴과 해결책을 정리합니다.

HTTP/2에서 RST_STREAM과 502의 관계

HTTP/2는 하나의 TCP 연결 위에 여러 스트림을 멀티플렉싱합니다. 특정 요청/응답 단위는 스트림으로 흐르고, 스트림을 중단할 때는 RST_STREAM 프레임이 사용됩니다.

중요한 포인트는 다음입니다.

  • RST_STREAM은 “연결이 끊겼다”가 아니라 “해당 스트림을 중단했다”에 가깝습니다.
  • 중단 주체는 클라이언트일 수도, 중간 프록시일 수도, 백엔드 서버일 수도 있습니다.
  • 프록시는 백엔드에서 스트림이 비정상 종료되거나 업스트림 응답을 완성하지 못하면, 클라이언트에게 502(또는 503)로 변환해 내보내는 경우가 많습니다.

즉, 화면에 보이는 502는 “프록시가 업스트림을 정상 응답으로 만들지 못했다”는 결과이고, RST_STREAM은 그 과정에서 스트림이 리셋되었음을 나타내는 단서입니다.

가장 흔한 원인 지도(우선순위)

현장에서 빈도가 높은 순서로 정리하면 보통 아래 흐름입니다.

  1. 프록시의 업스트림 타임아웃/버퍼링/최대 요청 시간으로 스트림 중단
  2. 백엔드(Spring Boot)에서 응답을 끝까지 쓰지 못함
    • 예: 클라이언트가 먼저 끊어 Broken pipe가 나거나, 서버가 예외로 스트림을 종료
  3. HTTP/2 설정 불일치
    • 예: 프록시는 HTTP/2, 업스트림은 HTTP/1.1인데 keep-alive/헤더/청크 처리 문제
  4. TLS/ALPN 협상 문제 또는 중간 장비의 HTTP/2 취급 이슈
  5. 대용량 응답/스트리밍(SSE, 다운로드)에서 flow-control, buffer, window 문제

이 중 1번과 2번이 대부분이고, “Spring Boot 3.2로 올린 뒤”라는 변화가 있었다면 Tomcat/Jetty 버전 변화, 기본 타임아웃, HTTP/2 구현체 변경 영향이 겹치는 경우가 많습니다.

1단계: 리셋 주체를 먼저 가르기

진단을 빠르게 하려면 “누가 RST_STREAM을 보냈는지”를 먼저 분리해야 합니다.

A. 프록시(예: Nginx/ALB/Envoy) 로그에서 단서 찾기

  • Nginx: upstream prematurely closed connection while reading response header from upstream, client prematurely closed connection
  • Envoy: upstream_reset_before_response_started, upstream_remote_reset, stream reset 카운터
  • ALB: 액세스 로그의 target_status_code, elb_status_code, error_reason(가능한 경우)

프록시가 502를 내보냈다면, 프록시 로그에 업스트림 관련 메시지가 남는 경우가 많습니다.

B. 백엔드(Spring Boot)에서 “클라이언트가 먼저 끊음” 흔적 확인

대표적으로 다음이 보입니다.

  • java.io.IOException: Broken pipe
  • org.apache.catalina.connector.ClientAbortException
  • Connection reset by peer

이게 보이면 “백엔드가 문제라기보다” 프록시/클라이언트가 먼저 스트림을 끊었을 가능성이 큽니다. 즉, 타임아웃이나 최대 요청 시간을 의심해야 합니다.

C. 패킷 캡처가 가능하면 nghttp2/tcpdump로 교차 확인

운영에서 캡처가 어렵다면, 최소한 재현 환경에서 다음처럼 확인합니다.

# 클라이언트에서 HTTP/2로 요청하고 verbose로 프레임/헤더를 확인
curl --http2 -v https://api.example.com/health

# HTTP/2 디버깅에 강한 도구(설치 필요)
nghttp -nv https://api.example.com/health

프록시 앞단에서 RST_STREAM이 보이는데 백엔드까지는 정상이라면, 중간 프록시가 리셋을 주도했을 가능성이 높습니다.

2단계: Spring Boot 3.2 서버 스택별 체크 포인트

Spring Boot 3.2는 Jakarta EE 10 기반이며 내장 서버와 라이브러리 버전이 바뀝니다. HTTP/2는 서버 구현체에 따라 “지원 방식”과 “성숙도”가 다릅니다.

Tomcat(내장)에서 HTTP/2

Tomcat HTTP/2는 TLS 위에서 ALPN을 통해 활성화되는 구성이 일반적입니다.

점검 1) 커넥터/스레드/타임아웃

HTTP/2는 한 연결에 스트림이 많이 붙기 때문에, 예전처럼 커넥션 수만 보고 튜닝하면 병목이 생길 수 있습니다.

  • server.tomcat.threads.max
  • server.tomcat.accept-count
  • server.connection-timeout

예시 설정:

server:
  http2:
    enabled: true
  connection-timeout: 5s
  tomcat:
    threads:
      max: 300
    accept-count: 100

프록시가 업스트림 타임아웃으로 끊는다면, 백엔드 처리 시간이 길어져 타임아웃에 걸리는 경우가 많습니다. 특히 DB 풀 고갈, 외부 API 지연이 원인이면 응답이 늦어지고 프록시가 먼저 스트림을 정리합니다.

점검 2) 큰 응답/스트리밍에서 flush와 예외 처리

SSE나 대용량 스트리밍에서 “응답을 쓰다가 중간에 예외 발생” 또는 “클라이언트가 끊었는데 계속 write”가 발생하면 리셋과 502가 연쇄될 수 있습니다.

SSE 예시(클라이언트 중단 시 정리):

@GetMapping(value = "/sse", produces = "text/event-stream")
public SseEmitter sse() {
    SseEmitter emitter = new SseEmitter(0L);

    emitter.onCompletion(() -> {
        // 리소스 정리
    });
    emitter.onTimeout(() -> {
        emitter.complete();
    });
    emitter.onError(ex -> {
        emitter.completeWithError(ex);
    });

    Executors.newSingleThreadExecutor().submit(() -> {
        try {
            for (int i = 0; i < 1000; i++) {
                emitter.send(SseEmitter.event().name("tick").data(i));
                Thread.sleep(1000);
            }
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });

    return emitter;
}

여기서 중요한 건 “에러를 삼키지 말고 종료를 명확히” 하는 것입니다. 그렇지 않으면 프록시가 보기엔 응답이 비정상 종료로 보여 502로 번역될 수 있습니다.

Jetty/Netty(WebFlux) 조합

WebFlux(Netty)나 Jetty는 HTTP/2 지원이 비교적 적극적이지만, 다음 패턴에서 RST_STREAM이 눈에 띄게 나타납니다.

  • 백프레셔 미준수로 인한 write 실패
  • 응답 바디를 publish하다가 cancel
  • 프록시가 idle timeout으로 스트림을 끊어 cancel 전파

WebFlux에서 “클라이언트 취소”를 정상 흐름으로 처리하는지 확인해야 합니다.

3단계: 프록시/로드밸런서 설정에서 실제로 많이 터지는 지점

502가 보인다면 대개 프록시가 관여합니다. HTTP/2에서는 특히 “요청은 살아있는데 스트림만 정리”하는 형태가 많아 로그가 애매해집니다.

Nginx를 쓴다면: 업스트림은 HTTP/1.1로 두는 것이 안전한 경우가 많음

Nginx는 클라이언트와는 HTTP/2로 통신하되, 업스트림은 HTTP/1.1 keep-alive로 두는 구성이 흔합니다. 업스트림까지 HTTP/2로 밀어붙이면(특히 gRPC가 아닌 일반 HTTP) 예상치 못한 조합 이슈가 생길 수 있습니다.

예시:

server {
  listen 443 ssl http2;

  location / {
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    proxy_connect_timeout 5s;
    proxy_read_timeout 60s;
    proxy_send_timeout 60s;

    proxy_pass http://spring_upstream;
  }
}

핵심은 proxy_read_timeout입니다. 백엔드가 61초 걸리면 Nginx는 업스트림을 끊고, 클라이언트 입장에서는 RST_STREAM과 함께 502로 보일 수 있습니다.

AWS ALB를 쓴다면: idle timeout과 타깃 응답 시간

ALB는 클라이언트와 HTTP/2를 지원하지만, 타깃 그룹과의 통신은 보통 HTTP/1.1입니다. 여기서 자주 문제되는 건:

  • ALB idle timeout(기본 60초)
  • 애플리케이션의 긴 처리(특히 스트리밍이 아닌데 응답이 늦음)

처리 시간이 길다면:

  • ALB idle timeout을 늘리거나
  • 백엔드에서 중간 응답을 flush할 수 있는 구조로 바꾸거나(가능한 경우)
  • 비동기 처리로 즉시 202를 주고 폴링/콜백으로 전환

을 검토해야 합니다.

Envoy/Ingress(Nginx Ingress 포함): upstream timeouts와 buffer

Kubernetes Ingress 계열에서는 annotation으로 타임아웃이 짧게 잡혀 있는 경우가 많습니다. 특히 “HTTP/2를 켠 뒤”에는 연결 수가 줄어드는 대신 스트림 수가 늘어, 단일 연결에서 지연이 쌓이며 타임아웃이 더 잘 드러납니다.

관련해서 클러스터 레벨 이슈가 의심되면 다음 글도 함께 보면 좋습니다.

4단계: 재현을 위한 최소 테스트와 관측 포인트

재현 1) 의도적으로 느린 엔드포인트 만들기

@GetMapping("/slow")
public String slow(@RequestParam(defaultValue = "70000") long ms) throws Exception {
    Thread.sleep(ms);
    return "ok";
}

이 상태에서 프록시 타임아웃이 60초라면, ms=70000에서 거의 확실히 502를 재현할 수 있습니다.

재현 2) HTTP/2로만 호출해 비교

# HTTP/2 강제
curl --http2 -v https://api.example.com/slow?ms=70000

# HTTP/1.1로 강제
curl --http1.1 -v https://api.example.com/slow?ms=70000

HTTP/1.1에서는 다른 에러 양상(예: 단순 timeout)인데 HTTP/2에서만 RST_STREAM이 두드러지면, 프록시의 HTTP/2 처리/변환 구간을 집중적으로 봐야 합니다.

관측: Micrometer로 지연과 실패율을 먼저 수치화

Spring Boot Actuator를 켜고 http.server.requestsmax, percentile을 보면 “일부 요청이 프록시 타임아웃을 넘는지”를 빠르게 확인할 수 있습니다.

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.95,0.99

해결책 체크리스트(현장 적용 순서)

1) 타임아웃 정렬: 프록시와 백엔드의 시간 제한을 계층적으로 맞추기

가장 흔한 정답은 “타임아웃이 서로 엇갈려서 중간에서 끊긴다”입니다.

권장 원칙:

  • 클라이언트 타임아웃 &gt; 프록시 타임아웃 &gt; 백엔드 타임아웃
  • 또는 백엔드가 확실히 더 짧게 실패하고(예: 504/503), 프록시는 그보다 길게 기다리기

여기서 부등호는 반드시 엔티티로 표기해야 하므로 &gt;를 사용했습니다.

2) HTTP/2는 “클라이언트 구간만” 적용해도 충분한 경우가 많음

  • 외부 클라이언트 &lt;-&gt; 프록시: HTTP/2
  • 프록시 &lt;-&gt; Spring Boot: HTTP/1.1 keep-alive

이 구성이 운영 안정성 측면에서 유리한 경우가 많습니다. 특히 Nginx나 ALB를 이미 쓰고 있다면 업스트림까지 HTTP/2로 갈 이유가 크지 않습니다(특별히 gRPC가 아니라면).

3) 스트리밍/SSE/다운로드는 프록시 버퍼링과 idle timeout을 별도로 설계

  • 버퍼링을 끄거나
  • 주기적으로 데이터를 보내 idle을 피하거나
  • 별도 도메인/경로로 timeout을 다르게 적용

등이 필요합니다.

4) 서버 리소스 병목 제거: 스레드, 커넥션, DB 풀

HTTP/2는 연결 수가 줄어드는 대신 동시 스트림이 늘어납니다. 결과적으로:

  • 요청 처리 스레드 부족
  • DB 커넥션 풀 고갈
  • 외부 API 호출 지연 누적

이 프록시 타임아웃을 유발합니다.

Kubernetes에서 리소스 병목이나 스케일링 문제가 의심되면 다음 글도 같이 참고할 만합니다.

5) “애플리케이션 로그가 조용한 502”를 대비한 상관관계 ID

502는 프록시에서 만들어지는 응답이라, 백엔드 로그만 보면 흔적이 없을 수 있습니다. 요청 ID를 프록시에서 주입하고 Spring에서 그대로 로깅하면 추적이 쉬워집니다.

Spring MVC 필터 예시:

@Component
public class RequestIdFilter extends OncePerRequestFilter {
    private static final String HEADER = "X-Request-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String rid = request.getHeader(HEADER);
        if (rid == null || rid.isBlank()) {
            rid = java.util.UUID.randomUUID().toString();
        }
        org.slf4j.MDC.put("rid", rid);
        response.setHeader(HEADER, rid);
        try {
            filterChain.doFilter(request, response);
        } finally {
            org.slf4j.MDC.remove("rid");
        }
    }
}

그리고 로깅 패턴에 %X{rid}를 포함하면, 프록시 액세스 로그의 X-Request-Id와 백엔드 로그를 한 줄로 묶을 수 있습니다.

케이스별 빠른 결론

케이스 A: 느린 요청에서만 502 + RST_STREAM

  • 원인: 프록시 read timeout 또는 ALB idle timeout
  • 해결: 프록시 타임아웃 상향, 백엔드 성능 개선, 비동기화(202), 스트리밍이면 주기적 flush/버퍼링 조정

케이스 B: 대용량 응답/다운로드에서 간헐적 502

  • 원인: 프록시 버퍼/최대 바디/flow-control 관련 설정, 클라이언트 중단
  • 해결: 버퍼링 설정, 전송 방식 조정, 서버에서 클라이언트 cancel 처리

케이스 C: 특정 클라이언트(특정 SDK, 모바일)에서만 발생

  • 원인: 클라이언트의 HTTP/2 구현(중간 취소, window 업데이트 문제), 네트워크 불안정
  • 해결: 클라이언트에서 HTTP/1.1 강제 옵션 제공, 프록시에서 HTTP/2 최소 버전/암호군 조정, 재시도 정책 설계

마무리

Spring Boot 3.2에서 HTTP/2를 켠 뒤 RST_STREAM502가 보인다면, 대부분은 “HTTP/2 자체 버그”라기보다 프록시-백엔드 경계의 타임아웃/버퍼링/스트리밍 처리가 원인입니다.

가장 효과적인 접근은 (1) 누가 스트림을 리셋했는지 먼저 가르고, (2) 타임아웃을 계층적으로 정렬하고, (3) HTTP/2 적용 범위를 클라이언트 구간으로 제한하는 보수적 구성을 먼저 적용한 뒤, (4) 스트리밍 엔드포인트만 별도 정책으로 다루는 것입니다.

운영 환경이 EKS라면 네트워크/TLS 계층 이슈가 함께 섞일 수 있으니, 필요 시 EKS TLS handshake timeout 해결 - IRSA·VPC·CoreDNS도 같이 점검해 보세요.