이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • HTTP client connection pool은 왜 DB connection pool만큼 중요한가?
  • 외부 API 장애가 났을 때 connect timeout, read timeout, pool acquire timeout을 어떻게 구분하는가?
  • pool 크기와 timeout, retry를 어떻게 잡아야 장애를 증폭시키지 않는가?

개요

백엔드 애플리케이션은 DB뿐 아니라 다른 HTTP 서비스도 호출한다. 결제 API, 지도 API, 인증 서버, 사내 사용자 API, 파일 변환 서버, 알림 서버가 모두 HTTP client의 대상이 될 수 있다. 이때 매번 새 TCP/TLS 연결을 만들면 지연과 CPU 비용이 커진다. 그래서 HTTP client는 보통 connection pool을 두고 연결을 재사용한다.

문제는 pool이 성능 최적화 장치인 동시에 장애 증폭 지점이라는 점이다. 외부 API가 느려지면 연결이 오래 점유되고, 새 요청은 pool에서 연결을 빌리지 못해 대기한다. 여기에 retry가 붙으면 같은 pool을 더 오래 붙잡아 전체 요청이 밀릴 수 있다.

왜 백엔드 개발자가 알아야 하는가

HTTP client pool 장애는 겉으로 보면 “외부 API가 느림”처럼 보이지만, 실제 사용자 영향은 내 애플리케이션에서 발생한다.

  • 요청 thread가 외부 API 응답을 기다리며 묶인다.
  • pool에서 연결을 못 빌려 대기하다가 timeout이 난다.
  • retry가 같은 외부 API를 다시 호출해 pool 점유 시간을 늘린다.
  • 하나의 전역 HTTP client pool을 여러 외부 API가 공유해 장애가 전파된다.
  • downstream은 이미 복구됐는데 stale connection이나 idle timeout mismatch 때문에 간헐적 reset이 남는다.

DB pool 고갈은 많이 의심하지만 HTTP client pool 고갈은 늦게 발견되는 경우가 많다. 전문 백엔드 개발자라면 outbound HTTP 호출도 DB 호출만큼 명시적으로 설계해야 한다.

요청 흐름에서의 위치

HTTP client pool은 사용자의 inbound 요청이 애플리케이션 코드에 도착한 뒤, 애플리케이션이 다시 외부 API나 내부 서비스로 나가는 outbound 구간에 있다.

Client
→ LB / Proxy
→ Application
→ HTTP Client Pool
→ DNS / TCP / TLS
→ External API / Internal Service

사용자 입장에서는 하나의 API 요청이지만, 서버 내부에서는 두 번째 네트워크 요청이 발생한다. 이 후단 호출이 느려지면 사용자 요청의 time_starttransfer가 길어지고, 서버 thread와 connection pool이 함께 묶인다.

원리

HTTP client pool은 대체로 다음 상태를 가진다.

상태의미장애 신호
active현재 요청에 사용 중인 연결계속 max에 붙어 있으면 downstream 지연이나 과도한 동시성 의심
idle재사용 대기 중인 연결너무 많으면 자원 낭비, 너무 적으면 매번 새 연결 생성
pending acquire연결을 빌리려고 기다리는 요청pool 고갈의 핵심 신호
max connectionspool이 만들 수 있는 최대 연결 수너무 작으면 대기, 너무 크면 downstream 압박
max idle timeidle 연결 유지 시간LB/proxy idle timeout과 어긋나면 stale connection 가능
max life time연결 최대 수명장시간 연결의 누적 문제를 줄이는 장치

pool이 작으면 caller에서 대기열이 생긴다. pool이 너무 크면 downstream 서비스가 감당할 수 없는 동시 연결을 밀어 넣는다. 따라서 pool 크기는 “우리 서버가 만들 수 있는 연결 수”가 아니라 “downstream이 안정적으로 처리할 수 있는 동시성”을 기준으로 정해야 한다.

Timeout을 분리해서 보기

HTTP client 호출에는 여러 timeout이 있다.

timeout걸리는 위치실패 의미
pool acquire timeoutpool에서 연결을 빌릴 때사용 가능한 연결이 없어 대기하다 실패
connect timeoutTCP 연결을 맺을 때대상까지 연결이 안 되거나 방화벽/라우팅/리스너 문제
TLS handshake timeoutTLS 협상 중인증서, SNI, cipher, backend TLS 문제
write timeout요청 body를 보낼 때느린 네트워크, 큰 업로드, 상대 수신 지연
read/response timeout요청 후 응답을 기다릴 때downstream 처리 지연, DB/API 지연, upstream overload

read timeout이 늘어난다고 항상 네트워크가 느린 것은 아니다. 상대 서버가 요청을 받고 DB나 외부 API를 기다리는 중일 수도 있다. 반대로 pool acquire timeout은 외부 API에 요청도 보내기 전에 내 애플리케이션 내부에서 대기하다 실패한 것이다.

실무에서 자주 만나는 문제

  • 전역 HTTP client 하나가 모든 외부 API를 공유한다.
    • 결제 API 장애가 알림 API, 지도 API 호출까지 막을 수 있다.
    • 중요 dependency별로 pool과 timeout을 분리한다.
  • pool acquire timeout이 없어서 요청이 무한히 대기한다.
    • 장애 시 thread가 천천히 고갈되고 p99 latency가 먼저 무너진다.
    • pending acquire metric을 반드시 본다.
  • retry가 pool 고갈을 악화시킨다.
    • 실패한 요청이 pool을 오래 점유한 뒤 다시 같은 pool을 사용한다.
    • retry budget, backoff, circuit breaker가 필요하다.
  • LB idle timeout보다 client max idle time이 길다.
    • client는 연결이 살아 있다고 믿지만 중간 LB는 이미 닫았을 수 있다.
    • 간헐적 Connection reset by peer, Broken pipe가 발생할 수 있다.

디버깅 방법

먼저 외부 API 장애인지, 내 HTTP client pool 장애인지 분리한다.

사용자 API latency 증가
→ app trace에서 external API span 증가 확인
→ HTTP client pool active/idle/pending 확인
→ pool acquire timeout인지 connect/read timeout인지 확인
→ retry count와 circuit breaker state 확인
→ downstream 상태와 quota 확인

운영 중에는 다음 지표가 특히 중요하다.

  • 외부 API별 호출 수, error rate, p95/p99 latency
  • HTTP client pool active connection
  • HTTP client pool idle connection
  • pending acquire count 또는 acquire duration
  • timeout 종류별 error count
  • retry attempt count
  • circuit breaker open/half-open/closed state

thread dump도 도움이 된다. 많은 request thread가 같은 HTTP client 호출 stack에서 대기한다면 downstream 지연이나 pool 대기를 의심한다.

코드로 확인하기

Spring WebClient와 Reactor Netty를 쓴다면 dependency별 pool을 명시적으로 분리할 수 있다.

@Bean
WebClient paymentWebClient(WebClient.Builder builder) {
    ConnectionProvider provider = ConnectionProvider.builder("payment-pool")
            .maxConnections(50)
            .pendingAcquireTimeout(Duration.ofMillis(500))
            .maxIdleTime(Duration.ofSeconds(20))
            .maxLifeTime(Duration.ofMinutes(5))
            .build();
 
    HttpClient httpClient = HttpClient.create(provider)
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000)
            .responseTimeout(Duration.ofSeconds(3))
            .doOnConnected(conn -> conn
                    .addHandlerLast(new ReadTimeoutHandler(3))
                    .addHandlerLast(new WriteTimeoutHandler(3)));
 
    return builder
            .baseUrl("https://payment.example.com")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .defaultHeader("User-Agent", "order-service")
            .build();
}

이 예시에서 봐야 할 포인트는 값 자체가 아니라 구분이다.

  • payment-pool처럼 dependency별 pool 이름을 둔다.
  • maxConnections는 downstream이 감당할 수 있는 동시성 기준으로 잡는다.
  • pendingAcquireTimeout은 pool 대기를 무한정 허용하지 않기 위해 둔다.
  • CONNECT_TIMEOUT_MILLIS는 연결 수립 한계다.
  • responseTimeoutReadTimeoutHandler는 응답 대기 한계다.

부하 테스트 때는 외부 API를 일부러 느리게 만들어 본다.

# 외부 API mock이 2초 지연되는 상황에서 caller의 p95, pending acquire, retry count를 본다.
wrk -t4 -c100 -d60s http://localhost:8080/orders

정상적인 설계라면 외부 API 장애가 전체 API thread를 끝없이 붙잡지 않고, timeout과 circuit breaker를 통해 빠르게 실패하거나 fallback으로 전환해야 한다.

주니어가 자주 하는 오해

  • HTTP client pool은 라이브러리가 알아서 잘 관리한다고 생각한다.
    • 기본값은 서비스 traffic, downstream 용량, timeout 정책을 모른다.
  • pool size를 늘리면 장애가 해결된다고 생각한다.
    • downstream이 느린 상태에서 pool만 키우면 더 많은 동시 요청을 밀어 넣어 장애가 커질 수 있다.
  • read timeout만 설정하면 충분하다고 생각한다.
    • pool acquire timeout과 connect timeout이 없으면 요청이 외부 API에 도달하기 전에도 오래 대기할 수 있다.
  • 모든 외부 API를 같은 client로 호출한다.
    • 장애 격리와 관측을 위해 dependency별 client와 pool을 분리하는 편이 낫다.

시니어의 설계 판단 기준

  • dependency별로 timeout, pool size, retry, circuit breaker, fallback을 따로 둔다.
  • pool 크기는 caller 서버 수까지 포함해 downstream 전체 동시 연결 수로 계산한다.
  • retry를 넣기 전에 idempotency와 retry budget을 확인한다.
  • pool pending acquire가 증가하면 scale out보다 downstream 지연, retry, timeout, pool 분리를 먼저 본다.
  • 장애 시 사용자에게 줄 응답을 미리 정한다. 외부 결제 장애와 추천 API 장애는 같은 실패가 아니다.

인프라 협업 포인트

  • 외부 API 호출이 특정 egress IP나 NAT gateway를 통해 나간다면 NAT port와 egress quota를 함께 본다.
  • 사내 내부 API라면 service discovery, internal DNS, mTLS, firewall rule을 확인한다.
  • proxy를 경유한다면 proxy connect timeout, upstream keepalive, idle timeout도 앱 설정과 맞춘다.
  • 인프라 팀에는 “외부 API가 느립니다”보다 pool active/pending, timeout 종류, 대상 host, 발생 시각을 전달한다.

실전 팁

  • DB pool dashboard가 있듯이 HTTP client pool dashboard도 만든다.
  • 외부 API별로 connect timeout, read timeout, pool acquire timeout, maxConnections, retry 값을 문서화한다.
  • 장애가 잦은 외부 API는 bulkhead를 둬서 중요 API의 pool과 thread를 보호한다.
  • 사용자 요청 안에서 오래 걸리는 외부 API를 동기 호출해야 하는지, 비동기 job으로 분리할 수 있는지 검토한다.
  • 모바일 앱에서 같은 요청을 재시도할 수 있으므로 서버의 idempotency 설계와 함께 본다.

위험 신호!

  • 외부 API별 timeout 값이 코드 어디에도 명시되어 있지 않다.
  • 모든 HTTP 호출이 하나의 전역 WebClient/RestClient를 공유한다.
  • pool pending metric이 없어서 고갈 여부를 알 수 없다.
  • retry 횟수는 있는데 retry budget과 idempotency 기준이 없다.
  • 외부 API 장애 때 request thread가 대량으로 WAITING 상태가 된다.

확인 질문

확인 질문

  • pool acquire timeout과 connect timeout은 어떻게 다른가?
    • pool acquire timeout은 내 애플리케이션의 pool에서 연결을 빌리지 못한 것이고, connect timeout은 대상 host와 TCP 연결을 맺지 못한 것이다.
  • HTTP client pool을 dependency별로 분리하는 이유는 무엇인가?
    • 한 외부 API 장애가 다른 외부 API 호출까지 막는 것을 줄이고, timeout과 pool 크기를 dependency 특성에 맞추기 위해서다.
  • retry가 pool 고갈을 악화시킬 수 있는 이유는 무엇인가?
    • 실패한 요청이 연결과 thread를 점유한 뒤 다시 같은 pool을 사용하므로 대기열과 downstream 부하를 동시에 키울 수 있기 때문이다.

참고 문서