BFF에서 발생한 Connection reset by peer 해결기

seonwoo_jung·2026년 5월 23일

1. 문제 상황

운영 중인 백엔드에서 다운스트림 호출 중 간헐적으로
IOException: Connection reset by peer 에러가 발생하고 있었다.

  • 평소에는 멀쩡하다가, 트래픽이 잠잠해진 뒤 첫 호출에서 자주 발생
  • 다운스트림 서버 측 로그·메트릭은 정상 (서버는 끊은 적이 없다고 함)
  • 결제·계약같이 비-멱등 도메인 호출에서도 같은 에러가 발생 → 단순 리트라이로 덮을 수 없는 상황
  • 알람은 계속 울리는데 실제 사용자 트랜잭션이 깨졌는지 매번 수동으로 확인해야 했음 → 운영 비용 증가

“가끔 끊긴다” 정도로 넘기기에는 영향 도메인이 너무 민감해서,
근본 원인을 짚고 가는 쪽으로 방향을 잡았다.

2. 기존 구조

  • 백엔드는 Spring WebFlux 기반, 다운스트림 호출은 Reactor Netty WebClient 사용
  • 다운스트림 종류
    • 같은 GKE 클러스터 내부의 도메인 서비스 (계약 / 결제 / 일반 / 인증 / 상품)
    • 외부 HTTPS 및 레거시 GraphQL
  • 호출 경로 중간에 GKE LoadBalancer / SNAT / istio 사이드카 등이 위치
WebClient
   ↓ (keep-alive)
GKE LB / SNAT / istio
   ↓
다운스트림 서비스

WebClient는 초기에 별도 튜닝 없이 WebClient.create() 기본값으로 사용하고 있었다.
즉, 커넥션 풀 옵션이 사실상 디폴트 였고, idle timeout / life time / eviction 정책을
명시적으로 잡아두지 않은 상태였다.

3. 원인 분석 / 위험 요소

증상이 “idle 후 첫 호출에서 자주 발생”한다는 점이 결정적인 단서였다.

가능한 원인을 다음과 같이 후보로 두고 좁혀갔다.

가설검증판단
다운스트림 서버 자체 장애다운스트림 로그·메트릭·헬스 모두 정상제외
일시적 네트워크 단절단절이 아니라 재현 패턴이 명확 (idle 후 첫 호출)제외
클라이언트 풀의 dead connection 재사용패턴, 타이밍, 에러 종류(RST) 모두 일치유력

핵심은 WebClient(Reactor Netty) 풀이 보관하던 keep-alive 커넥션이 이미 죽어 있었다는 점 이었다.

  • 다운스트림 또는 중간 경로(GKE LB / SNAT / istio)가 idle timeout으로 먼저 FIN/RST 를 보냄
  • 클라이언트 풀은 그 사실을 모른 채 dead connection을 재사용
  • → 첫 write 시 RST 수신 → Connection reset by peer

즉, “클라이언트 풀의 idle/life time이 서버·네트워크 장비의 idle timeout보다 길면 반드시 터진다”
전형적인 미스매치였다.

4. 해결 방향 검토

해결책 후보는 다음 세 가지였다.

대안장점단점판단
A. 재시도 필터(3회 backoff) 도입적용이 빠름비-멱등 요청까지 재시도되어 결제·계약 도메인에서 위험. RST는 줄지만 근본 원인은 그대로제외 (시도 후 롤백)
B. 풀 옵션만 재튜닝핵심 원인을 직접 차단풀 파라미터 튜닝 비용부족
C. 풀 튜닝 + 프로파일 분리 + WebClientFactory 일원화근본 원인 차단 + 운영 표준화 가능작업 범위가 넓음선택

처음에는 A안을 짧게 시도했지만, 멱등성 보장이 없는 도메인에서 자동 재시도는 그 자체가 사고 유발 요인 이라 판단해 롤백했다.
이후 다운스트림별 성격을 반영하고 WebClient 생성을 한 곳으로 모을 수 있는 C안으로 정리했다.

5. 최종 설계 / 구현

A. 커넥션 풀 재튜닝

maxLifeTime           60s  → 5m   (불필요한 재연결 비용 누적 방지)
pendingAcquireTimeout 60s  → 2s   (큐잉으로 숨기지 말고 fast-fail)
evictInBackground    120s  → 30s  (죽은 커넥션을 더 자주 정리)
maxIdleTime           20s  → 5s   (서버 idle timeout보다 먼저 클라가 닫음)

핵심 원칙은 “서버·LB가 닫기 전에 클라가 먼저 닫는다” 이다.

B. 프로파일 분리 (intra-cluster vs external)

다운스트림 성격이 다른데 같은 풀 설정을 쓰는 건 부적절해 두 프로파일로 분리.

  • standard: maxConn 50, connectTimeout 1s, responseTimeout 3s — 같은 클러스터 다운스트림
  • external: maxConn 50, connectTimeout 2s, responseTimeout 10s — 외부 HTTPS, 레거시

C. WebClientFactory로 일원화

모든 WebClient Bean이 동일한 풀 / 타임아웃 / 로깅 셋업을 공유하도록 팩토리에서 생성한다.

public WebClient create(String baseUrl, WebClientTuning tuning) {
    ConnectionProvider connectionProvider = ConnectionProvider.builder("pool-" + tuning.name())
        .maxConnections(tuning.maxConnections())
        .maxIdleTime(Duration.ofSeconds(5))
        .maxLifeTime(Duration.ofMinutes(5))
        .pendingAcquireTimeout(Duration.ofSeconds(2))
        .evictInBackground(Duration.ofSeconds(30))
        .build();

    HttpClient httpClient = HttpClient.create(connectionProvider)
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, tuning.connectTimeoutMs())
        .option(ChannelOption.SO_KEEPALIVE, true)
        .responseTimeout(Duration.ofSeconds(tuning.responseTimeoutSec()))
        .doOnConnected(conn -> conn
            .addHandlerLast(new ReadTimeoutHandler(tuning.responseTimeoutSec()))
            .addHandlerLast(new WriteTimeoutHandler(tuning.responseTimeoutSec())));

    return WebClient.builder()
        .baseUrl(baseUrl)
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
        .filter(TraceIdPropagationFilter.propagateTraceId())
        .filter(WebClientLoggingFilter.loggingFilter())
        .build();
}

6. 검증

검증 시나리오

  • 재현 시나리오: 호출 후 일정 시간(서버 idle timeout 이상) 대기 → 다시 호출 → RST 발생 여부 확인
  • 부하 테스트: 운영 트래픽과 유사한 패턴으로 burst → idle → burst 반복

확인한 지표

  • Connection reset by peer 발생 건수
  • 다운스트림 평균 응답 시간 / p95
  • 풀 상태(active / idle / pending) 추이

7. 결과

  • 재현 시나리오에서 Connection reset by peer 가 더 이상 발생하지 않음
  • 운영 환경의 RST 알람이 사실상 사라짐
  • pendingAcquireTimeout 을 짧게 잡은 덕분에 풀 고갈이 60초 큐잉에 숨지 않고 즉시 알람으로 노출 → 용량 이슈 가시화
  • WebClient 생성이 팩토리로 통일되면서 풀 옵션·트레이스 헤더·로깅이 다운스트림 전체에서 동일하게 동작

정량 지표를 외부로 공개하기 어려운 부분은 운영 관점으로 요약하면 다음과 같다.

  • 비-멱등 도메인에 대한 무분별한 자동 재시도를 도입하지 않고도 RST를 해소

0개의 댓글