Skip to content
hello tis
Go back

외부 API 호출 시 커넥션 끊김 대응기 — RST, FIN, 그리고 커넥션 풀의 한계

개요

외부 API 호출 시 500 응답이 총 48건 발생했다. Connection reset by peer 에러가 원인이었고 외부 API 앞단의 Cloudflare가 idle 상태인 커넥션을 선제적으로 끊고 있었다. 그래서 Connection Pool의 idle 커넥션을 주기적으로 정리하도록 설정을 추가했다.

외부 API 호출 500 응답 48건이 발생한 에러 로그 화면

클라이언트 입장에서는 첫 요청도 실패하고, 재시도도 실패한다. Cloudflare가 idle 연결을 먼저 끊기 전에 클라이언트 쪽에서 먼저 정리해야 Connection reset by peer가 사라진다.

@Bean
fun estoneidWebClient(): WebClient {
    val provider = ConnectionProvider.builder("풀이름")
        //...
        .maxIdleTime(Duration.ofSeconds(30))  // idle 연결 30초 후 정리
        .maxLifeTime(Duration.ofSeconds(300)) // 연결 최대 수명 5분
        .evictInBackground(Duration.ofSeconds(30)) // 백그라운드로 연결 정리
        .build()
	//...
}

각 설정의 역할은 다음과 같다.

딥다이브 1 : 요청에 대한 조건 분기 정리

요청이 들어왔을 때 풀에서 커넥션을 꺼내는 과정을 조건 분기로 표현하면 다음과 같다. 커넥션 풀은 소켓 상태를 능동적으로 감시하지 않는다. RST든 FIN이든 OS 커널의 TCP 스택이 수신하지만, 애플리케이션 레벨의 풀은 소켓을 읽지 않는 한 이 사실을 모른다. 그래서 maxIdleTime을 통과한 연결이라도, 상대방이 이미 끊었다면 실패하게 된다.

풀에서 커넥션을 꺼내는 과정의 조건 분기 흐름도

연결이 끊어진 상황에 대해서 절대적으로 대처하는 방법으로는 커넥션 풀을 사용하지 않는 방법도 있을 수 있다. ConnectionProvider.newConnection()을 활용하면 요청마다 새 TCP 연결을 열고 닫기 때문에, 풀에 죽은 연결이 남아있을 가능성 자체가 사라진다.

@Bean
fun estoneidWebClient(): WebClient {
    val httpClient = HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)

    return WebClient.builder()
        .clientConnector(ReactorClientHttpConnector(httpClient))
        .build()
}

반면, 매번 새 연결을 생성하므로 성능은 떨어진다. 정합성이 중요하고 요청량이 적은 경우에 적합한 방법이다.

딥다이브 2 : Connection reset by peer 와 Connection has been closed 의 차이

상대방이 연결을 끊는 방식에 따라 발생하는 에러가 다르다. 두 에러는 로그에서 비슷해 보이지만, 원인과 대응이 다르므로 구분해보려고 한다. 두 에러 모두 TCP 레벨(L4, Transport Layer) 에서 발생한다. 차이는 상대방이 연결을 끊는 방식에 있다.

Connection reset by peer — RST에 의한 강제 종료 RST는 4-way handshake 없이 즉시 연결을 끊는 것이다. Cloudflare 같은 프록시가 idle timeout을 초과한 연결을 강제로 정리할 때 RST 패킷을 보낸다. OS 커널은 RST를 수신하지만, 커넥션 풀은 소켓을 능동적으로 읽지 않기 때문에 이 사실을 모른다. 해당 연결을 꺼내 요청을 보내는 순간에야 IOException이 발생한다.

RST 패킷에 의한 강제 종료 시 커넥션 상태 다이어그램

Connection has been closed — FIN에 의한 정상 종료 FIN은 정상적인 종료 절차를 밟는 것이다. 서버가 FIN을 보내면 커널이 이를 수신하고 ACK를 응답하여 소켓을 CLOSE_WAIT 상태로 전환한다. 그러나 커넥션 풀은 소켓을 능동적으로 읽지 않기 때문에, FIN이 도착한 건 커널이 알고 있지만 애플리케이션은 모른다. 풀이 연결을 꺼내주는 시점까지 아무도 이 사실을 확인하지 않으며, 재사용하려는 순간에야 PrematureCloseException이 발생한다.

FIN 핸드셰이크 후 CLOSE_WAIT 상태로 전환되는 커넥션 상태 다이어그램

정리하면 다음과 같다.

항목Connection reset by peerConnection has been closed
발생 주체상대방(서버/프록시)이 RST 패킷 강제 전송연결이 정상 종료(FIN) 후 재사용 시도
TCP 레벨RST 플래그 (4-way handshake 없이 즉시 종료)FIN 핸드셰이크 후 CLOSE_WAIT 상태
원인Cloudflare/LB의 idle timeout 초과 후 강제 종료풀에서 꺼낸 연결이 이미 정상 종료(graceful close)됨
Reactor Netty 예외IOException: Connection reset by peerPrematureCloseException / ClosedChannelException
대응 방법클라이언트 idle timeout을 프록시보다 짧게 설정연결 유효성 검사(evict) 주기 조정

딥다이브 3 : evictInBackground 로 인한 race condition

evictInBackground(30s)maxIdleTime(30s)이 동일한 값으로 설정되어, 커넥션을 획득하는 acquire 스레드와 백그라운드 eviction 스레드가 같은 커넥션을 동시에 다루는 race condition이 발생했다.

PooledConnectionProvider의 내부 구조 PooledConnectionProvider는 내부적으로 channelPools라는 Map<SocketAddress, ConnectionPool>을 관리한다. 이때 키는 DNS 해석 전의 hostname:port(unresolved SocketAddress) 기준이다. 같은 hostname에 대한 요청은 하나의 풀을 공유하며, DNS가 다른 IP로 변경되어도 풀 키는 동일하다. 커넥션을 획득할 때(acquire)와 백그라운드 eviction이 각각 다음과 같이 동작한다.

문제는 acquire 스레드가 커넥션을 획득한 직후, eviction 스레드가 같은 커넥션을 폐기하는 시나리오다.

acquire 스레드와 eviction 스레드가 같은 커넥션을 동시에 다루는 race condition 시퀀스 다이어그램

acquire 시점에는 모든 검증을 통과했지만, 요청을 보내기 직전에 eviction 스레드가 해당 연결을 폐기해버린다.

해결 방안 비교

방안eviction 주기 늘리기eviction 제거
설명eviction 스레드의 실행 빈도를 줄여 acquire와 겹칠 확률을 낮춤백그라운드 eviction 스레드를 비활성화
효과race condition 빈도 감소race condition 제거
단점race condition 제거 못함acquire 시점에 커넥션 교체 부하 + stale connection 발생 가능성

eviction 주기를 늘리는 방안은 30s → 120s로 변경하면 경쟁 빈도가 1/4로 줄어들겠지만, 여전히 타이밍이 겹칠 가능성이 남는다.

eviction 스레드를 제거하면 race condition은 사라지지만, 부작용이 있다. acquire 시점에 커넥션을 교체하는 부하 : acquire 시점에야 idle 체크 후 버리고 새 연결을 생성하므로 약간의 latency가 추가된다.

딥다이브 4: 게이트웨이 커넥션 풀은 왜 ELASTIC인가

부하테스트하며 Grafana에서 커넥션 풀 상태를 확인했다. 두 가지 의문이 생겼다.

Grafana에서 확인한 커넥션 풀 상태 대시보드

왜 pending connections가 0인가?

pending connections는 풀에 여유가 없어서 커넥션 획득을 대기하는 요청 수다. 이 값이 항상 0인 이유는 현재 커넥션 풀 타입이 ELASTIC(기본값)이기 때문이다. ELASTIC은 max-connectionsInteger.MAX_VALUE로 사실상 무제한이라, 풀이 부족해서 대기하는 상황 자체가 발생하지 않는다.

타입max-connections 기본값동작
ELASTIC (기본)Integer.MAX_VALUE필요할 때마다 커넥션 생성, 제한 없음
FIXEDJVM이 인식하는 CPU 코어 수 × 2최대 개수 제한, 초과 시 대기

각 타입별 우려사항은 다음과 같다.

ELASTICFIXED
우려사항downstream 장애 시 커넥션이 무한히 생성될 수 있다특정 downstream이 느려지면 커넥션을 독점하여 다른 downstream까지 병목이 전파된다
예시support:8091 장애 → 커넥션 수만 개 생성 → 메모리 고갈support:8091 응답 느림 → 500개 중 400개 점유 → ea-api:443은 100개만 사용 가능
완화 방법FIXED로 전환하거나 max-connections 상한 설정forRemoteHost()로 remote address별 개별 제한 설정

현재 환경에서는 ELASTIC 기본값을 유지하되, 메트릭 모니터링으로 커넥션 증가 추이를 관찰하고 있다.

evictInBackground 없이 커넥션은 어떻게 정리되는가?

딥다이브 3에서 race condition 방지를 위해 evictInBackground를 제거했다. 그런데 메트릭을 보면 idle 커넥션이 무한히 쌓이지 않고 정리되고 있었다. 백그라운드 eviction이 없어도 acquire 시점에 idle time 체크하면서 활성 커넥션을 획득하기 위해 루프를 돌기 때문이다.

한 번의 acquire에서 여러 개의 idle 초과 커넥션을 연쇄적으로 폐기한다. 그래프에서 idle 커넥션이 줄어든 이유는 실제 사용자 트래픽(support:8091로 프록시되는 요청)이 들어오면서 acquire 시점에 idle 시간과 max lifetime에 의해 커넥션이 정리된 것으로 예상된다.

idle connection 수 만큼을 루프를 도는 만큼 레이턴시가 발생할 수 있음을 유의해야 한다.

모니터링 결과

모니터링했을 때, idle connection이 70 → 19개 까지 정리하면서 응답시간이 10ms → 18ms 상승하는 현상도 확인됐다. 커넥션 정리가 연쇄적으로 발생하면서 응답시간이 상승한 것으로 확인된다.

idle connection 정리에 따른 응답시간 상승을 보여주는 모니터링 그래프

이 때, 초록색 점선은 reqeust 비율이며, 유지되고 있다. 따라서 request 비율이 상승해서 발생하는 지연 현상은 아니라고 판단했다.

마무리하며

최근 외부 API 앞단에 Cloudflare를 도입하는 서비스가 늘어나고 있다. Cloudflare뿐 아니라 AWS ALB, GCP Cloud Load Balancing 등 프록시를 거치는 구조가 일반적이 되면서, 외부 통신 시 커넥션이 끊어지는 상황은 더 이상 예외가 아니라 기본적으로 대비해야 할 문제가 됐다. 이번 경험을 통해 알게 된 점을 정리한다.


Share this post:

Previous Post
스토리지 서비스로 배운 속도와 안정성 트레이드오프
Next Post
온프레미스 GPU 시스템으로 교체 목표하기 - 1편