운영 중인 백엔드에서 다운스트림 호출 중 간헐적으로
IOException: Connection reset by peer 에러가 발생하고 있었다.
“가끔 끊긴다” 정도로 넘기기에는 영향 도메인이 너무 민감해서,
근본 원인을 짚고 가는 쪽으로 방향을 잡았다.
WebClient 사용WebClient
↓ (keep-alive)
GKE LB / SNAT / istio
↓
다운스트림 서비스
WebClient는 초기에 별도 튜닝 없이 WebClient.create() 기본값으로 사용하고 있었다.
즉, 커넥션 풀 옵션이 사실상 디폴트 였고, idle timeout / life time / eviction 정책을
명시적으로 잡아두지 않은 상태였다.
증상이 “idle 후 첫 호출에서 자주 발생”한다는 점이 결정적인 단서였다.
가능한 원인을 다음과 같이 후보로 두고 좁혀갔다.
| 가설 | 검증 | 판단 |
|---|---|---|
| 다운스트림 서버 자체 장애 | 다운스트림 로그·메트릭·헬스 모두 정상 | 제외 |
| 일시적 네트워크 단절 | 단절이 아니라 재현 패턴이 명확 (idle 후 첫 호출) | 제외 |
| 클라이언트 풀의 dead connection 재사용 | 패턴, 타이밍, 에러 종류(RST) 모두 일치 | 유력 |
핵심은 WebClient(Reactor Netty) 풀이 보관하던 keep-alive 커넥션이 이미 죽어 있었다는 점 이었다.
Connection reset by peer즉, “클라이언트 풀의 idle/life time이 서버·네트워크 장비의 idle timeout보다 길면 반드시 터진다” 는
전형적인 미스매치였다.
해결책 후보는 다음 세 가지였다.
| 대안 | 장점 | 단점 | 판단 |
|---|---|---|---|
| A. 재시도 필터(3회 backoff) 도입 | 적용이 빠름 | 비-멱등 요청까지 재시도되어 결제·계약 도메인에서 위험. RST는 줄지만 근본 원인은 그대로 | 제외 (시도 후 롤백) |
| B. 풀 옵션만 재튜닝 | 핵심 원인을 직접 차단 | 풀 파라미터 튜닝 비용 | 부족 |
| C. 풀 튜닝 + 프로파일 분리 + WebClientFactory 일원화 | 근본 원인 차단 + 운영 표준화 가능 | 작업 범위가 넓음 | 선택 |
처음에는 A안을 짧게 시도했지만, 멱등성 보장이 없는 도메인에서 자동 재시도는 그 자체가 사고 유발 요인 이라 판단해 롤백했다.
이후 다운스트림별 성격을 반영하고 WebClient 생성을 한 곳으로 모을 수 있는 C안으로 정리했다.
maxLifeTime 60s → 5m (불필요한 재연결 비용 누적 방지)
pendingAcquireTimeout 60s → 2s (큐잉으로 숨기지 말고 fast-fail)
evictInBackground 120s → 30s (죽은 커넥션을 더 자주 정리)
maxIdleTime 20s → 5s (서버 idle timeout보다 먼저 클라가 닫음)
핵심 원칙은 “서버·LB가 닫기 전에 클라가 먼저 닫는다” 이다.
다운스트림 성격이 다른데 같은 풀 설정을 쓰는 건 부적절해 두 프로파일로 분리.
standard: maxConn 50, connectTimeout 1s, responseTimeout 3s — 같은 클러스터 다운스트림external: maxConn 50, connectTimeout 2s, responseTimeout 10s — 외부 HTTPS, 레거시모든 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();
}
Connection reset by peer 발생 건수Connection reset by peer 가 더 이상 발생하지 않음pendingAcquireTimeout 을 짧게 잡은 덕분에 풀 고갈이 60초 큐잉에 숨지 않고 즉시 알람으로 노출 → 용량 이슈 가시화정량 지표를 외부로 공개하기 어려운 부분은 운영 관점으로 요약하면 다음과 같다.