Spring WebClient로 외부 API를 호출할 때, 내부에서 HTTP 요청을 실제로 보내는 건 누구일까? 커넥션은 매번 새로 만들까, 재사용할까? 타임아웃 설정이 여러 곳에 있는데 뭐가 다를까?

이번 글에서는 WebClient 아래에서 동작하는 Reactor Netty HttpClient 를 직접 다뤄봅니다. ConnectionProvider로 커넥션 풀을 관리하는 방법, 각 타임아웃 설정이 어떤 구간을 커버하는지, 그리고 문제가 생겼을 때 Wire 로깅으로 실제 바이트를 들여다보는 방법까지 정리합니다.


WebClient와 Reactor Netty — 내부 동작 구조

Spring WebFlux에서 WebClient.create()를 호출하면, 내부적으로 Reactor Netty의 HttpClient 가 HTTP 요청을 처리합니다. spring-boot-starter-webflux 의존성에 reactor-netty-http가 포함되어 있어 별도 설정 없이 기본 엔진으로 동작합니다.

PLAINTEXT
WebClient → HttpClient (Reactor Netty) → ConnectionProvider → Netty Channel

이 구조에서 중요한 점은 WebClient는 HTTP 요청의 인터페이스 이고, 실제 네트워크 I/O는 Reactor Netty가 담당한다는 겁니다.

JAVA
// 기본 WebClient — 내부적으로 Reactor Netty HttpClient 사용
WebClient client = WebClient.create("https://api.example.com");

// Reactor Netty HttpClient를 직접 커스터마이징하고 싶을 때
HttpClient httpClient = HttpClient.create()
    .baseUrl("https://api.example.com");

WebClient client = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .build();

WebClient를 기본으로 생성하면 Reactor Netty의 글로벌 리소스(커넥션 풀, EventLoopGroup) 를 공유합니다. 대부분의 경우 이 기본 설정으로 충분하지만, 외부 API별로 커넥션 풀을 분리하거나 타임아웃을 세밀하게 조정하려면 HttpClient를 직접 구성해야 합니다.


HttpClient 구성 — 기본 설정

Reactor Netty의 HttpClient는 빌더 패턴으로 다양한 설정을 체이닝할 수 있습니다.

JAVA
import reactor.netty.http.client.HttpClient;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

HttpClient httpClient = HttpClient.create()
    // 기본 URL 설정
    .baseUrl("https://api.example.com")
    // 공통 헤더 설정
    .headers(h -> {
        h.add("Authorization", "Bearer token");
        h.add("Accept", "application/json");
    })
    // 응답 타임아웃 — 요청 후 첫 응답까지의 최대 대기 시간
    .responseTimeout(Duration.ofSeconds(5))
    // Netty 채널 옵션으로 TCP 연결 타임아웃 설정
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
    // 채널 파이프라인에 읽기/쓰기 타임아웃 핸들러 추가
    .doOnConnected(conn -> conn
        .addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
        .addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS))
    );

여기서 주목할 점은 doOnConnected()입니다. 이 콜백은 커넥션이 수립된 직후 실행되며, Netty의 ChannelPipeline에 직접 핸들러를 추가할 수 있습니다. Reactor Netty가 Netty 위의 추상화 계층이지만, 필요하면 Netty 레벨까지 내려갈 수 있다는 뜻입니다.

HttpClient는 ** 불변(immutable)**이다. baseUrl()이나 headers() 같은 메서드를 호출하면 기존 인스턴스를 수정하는 게 아니라 새로운 인스턴스를 반환한다. 그래서 기본 HttpClient를 만들어두고 요청별로 추가 설정을 체이닝하는 패턴이 안전하다.


ConnectionProvider — 커넥션 풀의 핵심

기본 동작

HttpClient.create()를 호출하면 기본적으로 ** 커넥션 풀링이 활성화 **됩니다. 내부적으로 ConnectionProvider.create("reactor.netty.http.client")가 사용되며, 리모트 주소(host:port)별로 커넥션을 관리합니다.

JAVA
// 기본 커넥션 풀 — 풀링 활성화 (ConnectionProvider.create() 사용)
HttpClient pooledClient = HttpClient.create();

// 커넥션 풀 비활성화 — 매 요청마다 새 커넥션 생성
HttpClient newConnectionClient = HttpClient.newConnection();

기본 풀의 maxConnections는 ** 프로세서 수 × 2(최소 16)**입니다. 4코어 서버라면 16개가 기본값이 됩니다.

커스텀 ConnectionProvider 구성

프로덕션 환경에서는 기본 설정을 그대로 사용하기보다, 호출 대상의 특성에 맞게 커넥션 풀을 직접 구성하는 것이 좋습니다.

JAVA
import reactor.netty.resources.ConnectionProvider;
import java.time.Duration;

ConnectionProvider provider = ConnectionProvider.builder("custom-pool")
    // 리모트 주소별 최대 커넥션 수
    .maxConnections(50)
    // 커넥션을 기다리는 대기 큐의 최대 크기
    // 기본값: maxConnections × 2
    .pendingAcquireMaxCount(100)
    // 대기 큐에서 커넥션을 기다리는 최대 시간
    .pendingAcquireTimeout(Duration.ofSeconds(3))
    // 커넥션의 최대 유지 시간 — 이 시간이 지나면 풀에서 제거
    .maxLifeTime(Duration.ofMinutes(5))
    // 커넥션의 최대 유휴 시간 — 사용하지 않은 채 이 시간이 지나면 제거
    .maxIdleTime(Duration.ofSeconds(30))
    // 풀에서 커넥션을 꺼낸 후 사용 가능한지 확인 여부
    .evictInBackground(Duration.ofSeconds(30))
    .build();

HttpClient httpClient = HttpClient.create(provider);

각 설정의 의미를 하나씩 보겠습니다.

설정기본값설명
maxConnections프로세서 × 2 (최소 16)리모트 주소별 동시 최대 커넥션 수
pendingAcquireMaxCountmaxConnections × 2풀이 가득 찼을 때 대기할 수 있는 요청 수
pendingAcquireTimeout45초대기 큐에서 커넥션을 기다리는 최대 시간
maxLifeTime무제한커넥션의 절대 수명
maxIdleTime무제한유휴 커넥션의 최대 대기 시간
evictInBackground비활성백그라운드에서 만료된 커넥션을 정리하는 주기

프로덕션에서 maxIdleTime을 설정하지 않으면, 서버 쪽에서 이미 끊은 커넥션을 클라이언트가 재사용하려다 에러가 발생할 수 있다. 로드 밸런서의 유휴 타임아웃(보통 60초)보다 짧게 설정하는 것이 안전하다.


커넥션 풀 동작 — 생성, 재사용, 반환

커넥션 풀의 동작 흐름을 단계별로 정리하면 이렇습니다.

1. 커넥션 획득 (Acquire)

PLAINTEXT
요청 발생 → 풀에서 유휴 커넥션 검색
  ├─ 유휴 커넥션 있음 → 유효성 검사 후 재사용
  ├─ 유휴 커넥션 없음 + maxConnections 미도달 → 새 커넥션 생성
  └─ 유휴 커넥션 없음 + maxConnections 도달 → 대기 큐에 추가
       ├─ pendingAcquireTimeout 내에 커넥션 반환됨 → 재사용
       └─ pendingAcquireTimeout 초과 → PoolAcquireTimeoutException

2. 커넥션 반환 (Release)

요청이 완료되면 커넥션은 풀로 반환됩니다. 이때 커넥션의 상태를 확인하고, 문제가 있으면 폐기합니다.

PLAINTEXT
요청 완료 → 커넥션 반환
  ├─ 커넥션 정상 + maxLifeTime 미초과 → 풀에 반환 (유휴 상태)
  ├─ 커넥션 정상 + maxLifeTime 초과 → 폐기 후 풀에서 제거
  └─ 커넥션 비정상 (RST, 에러 등) → 폐기

3. Eviction (만료 커넥션 정리)

evictInBackground()를 설정하면, 별도 타이머가 주기적으로 풀을 스캔하면서 maxIdleTime이나 maxLifeTime을 초과한 커넥션을 정리합니다.

JAVA
// 30초마다 만료된 커넥션 정리
ConnectionProvider provider = ConnectionProvider.builder("eviction-pool")
    .maxConnections(50)
    .maxIdleTime(Duration.ofSeconds(20))
    .maxLifeTime(Duration.ofMinutes(5))
    .evictInBackground(Duration.ofSeconds(30))
    .build();

evictInBackground를 설정하지 않으면 만료 검사는 ** 커넥션을 획득할 때만** 수행됩니다. 트래픽이 적은 시간대에는 만료된 커넥션이 풀에 남아 있다가, 다음 요청 시 stale 커넥션을 사용하려다 에러가 발생할 수 있습니다.


커넥션 풀 메트릭 — 모니터링

프로덕션에서 커넥션 풀은 반드시 모니터링해야 합니다. Reactor Netty는 Micrometer 와 연동하여 커넥션 풀 메트릭을 노출할 수 있습니다.

JAVA
import io.micrometer.core.instrument.MeterRegistry;

ConnectionProvider provider = ConnectionProvider.builder("monitored-pool")
    .maxConnections(50)
    .metrics(true)  // Micrometer 메트릭 활성화
    .build();

metrics(true)를 설정하면 다음 메트릭이 자동으로 등록됩니다.

메트릭 이름설명
reactor.netty.connection.provider.total.connections전체 커넥션 수 (활성 + 유휴)
reactor.netty.connection.provider.active.connections현재 사용 중인 커넥션 수
reactor.netty.connection.provider.idle.connections유휴 커넥션 수
reactor.netty.connection.provider.pending.connections대기 큐에서 커넥션을 기다리는 요청 수
reactor.netty.connection.provider.max.connections최대 커넥션 수 설정값

커스텀 MeterRegistrar

더 세밀한 메트릭 수집이 필요하다면 ConnectionProvider.MeterRegistrar를 직접 구현할 수 있습니다.

JAVA
ConnectionProvider provider = ConnectionProvider.builder("custom-metrics-pool")
    .maxConnections(50)
    .metrics(true, () -> (poolName, id, remoteAddress, metrics) -> {
        // metrics 객체에서 커넥션 풀 상태를 읽어 커스텀 처리
        // 예: 활성 커넥션 비율이 80%를 넘으면 알람
        double activeRatio = (double) metrics.acquiredSize() / metrics.maxConnections();
        if (activeRatio > 0.8) {
            log.warn("커넥션 풀 {} 사용률 {}%", poolName, (int)(activeRatio * 100));
        }
    })
    .build();

Grafana 대시보드를 구성할 때, 특히 주의 깊게 봐야 하는 메트릭은 pending.connections 입니다. 이 값이 지속적으로 0보다 크다면 커넥션 풀이 부족하다는 신호입니다.


타임아웃 설정 계층 — 각각 어떤 구간을 커버하는가

Reactor Netty에서 타임아웃을 설정할 수 있는 곳이 여러 군데입니다. 각각이 어떤 구간을 담당하는지 혼동하기 쉬우니 정리해보겠습니다.

PLAINTEXT
[DNS 조회] → [TCP 연결] → [TLS 핸드셰이크] → [요청 전송] → [응답 대기] → [응답 수신]
              ├─────────┤                     ├──────────┤  ├─────────┤  ├─────────┤
              connectTimeout                  writeTimeout  responseTimeout readTimeout

1. connectTimeout — TCP 연결 수립

JAVA
// Netty 채널 옵션으로 설정
HttpClient httpClient = HttpClient.create()
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000); // 3초

TCP 3-way 핸드셰이크가 완료되기까지의 최대 대기 시간입니다. 서버가 응답하지 않거나 네트워크 경로에 문제가 있을 때 이 타임아웃이 발동합니다. 기본값은 30초 입니다.

2. responseTimeout — 응답 대기

JAVA
HttpClient httpClient = HttpClient.create()
    .responseTimeout(Duration.ofSeconds(5));

요청을 보낸 후 서버로부터 첫 번째 응답 바이트가 도착하기까지 의 최대 대기 시간입니다. 서버가 요청을 받았지만 처리가 오래 걸릴 때 이 타임아웃이 발동합니다.

3. readTimeout / writeTimeout — 소켓 레벨

JAVA
HttpClient httpClient = HttpClient.create()
    .doOnConnected(conn -> conn
        // 마지막으로 데이터를 읽은 후 10초 동안 데이터가 없으면 타임아웃
        .addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
        // 마지막으로 데이터를 쓴 후 10초 동안 쓰기가 완료되지 않으면 타임아웃
        .addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS))
    );

이 타임아웃은 Netty의 IdleStateHandler 기반입니다. 대용량 응답을 청크로 받을 때, 청크 사이의 간격이 너무 길면 ReadTimeoutHandler가 발동합니다.

전체를 합치면

JAVA
// 프로덕션 권장 설정 예시
ConnectionProvider provider = ConnectionProvider.builder("api-pool")
    .maxConnections(50)
    .pendingAcquireTimeout(Duration.ofSeconds(3))
    .maxIdleTime(Duration.ofSeconds(20))
    .maxLifeTime(Duration.ofMinutes(5))
    .evictInBackground(Duration.ofSeconds(30))
    .metrics(true)
    .build();

HttpClient httpClient = HttpClient.create(provider)
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
    .responseTimeout(Duration.ofSeconds(5))
    .doOnConnected(conn -> conn
        .addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
        .addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS))
    );

WebClient webClient = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .build();

connectTimeout은 짧게(2~3초), responseTimeout은 API 특성에 맞게, readTimeoutresponseTimeout보다 길게 설정하는 것이 일반적이다. 대용량 파일 다운로드 같은 경우에는 readTimeout을 넉넉하게 잡아야 한다.


Wire 로깅 — 실제 바이트 확인하기

API 호출이 실패하거나 예상과 다른 응답이 올 때, HTTP 레벨 로그만으로는 원인을 찾기 어려울 수 있습니다. Wire 로깅을 활성화하면 네트워크를 통해 실제로 주고받는 바이트 를 확인할 수 있습니다.

설정 방법

application.yml에 다음을 추가합니다.

YAML
logging:
  level:
    # Reactor Netty HTTP 클라이언트 전체 로깅
    reactor.netty.http.client: DEBUG

또는 HttpClient에서 직접 Wire 로깅을 활성화할 수도 있습니다.

JAVA
HttpClient httpClient = HttpClient.create()
    .wiretap("reactor.netty.http.client.HttpClient",
             LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL);

AdvancedByteBufFormat에는 세 가지 옵션이 있습니다.

  • HEX_DUMP — 16진수 + ASCII 덤프 (기본값)
  • TEXTUAL — 텍스트로 디코딩해서 출력 (JSON API 디버깅에 유용)
  • SIMPLE — 이벤트만 출력, 데이터 내용은 생략

출력 예시 (TEXTUAL)

PLAINTEXT
DEBUG r.n.http.client.HttpClient - [7d42a1c0] REGISTERED
DEBUG r.n.http.client.HttpClient - [7d42a1c0] CONNECT: api.example.com/93.184.216.34:443
DEBUG r.n.http.client.HttpClient - [7d42a1c0] ACTIVE
DEBUG r.n.http.client.HttpClient - [7d42a1c0] WRITE: GET /users/1 HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer token

DEBUG r.n.http.client.HttpClient - [7d42a1c0] READ: HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 83

{"id":1,"name":"John","email":"john@example.com"}
DEBUG r.n.http.client.HttpClient - [7d42a1c0] INACTIVE
DEBUG r.n.http.client.HttpClient - [7d42a1c0] UNREGISTERED

로그에서 [7d42a1c0]Channel ID 입니다. 동시에 여러 요청이 발생해도 Channel ID로 특정 요청의 전체 흐름을 추적할 수 있습니다.

주의할 점

  • Wire 로깅은 모든 바이트를 문자열로 변환 하므로 성능에 큰 영향을 줍니다
  • 인증 토큰, 개인정보 등 민감한 데이터 가 그대로 로그에 남습니다
  • 프로덕션에서는 반드시 비활성화하고, 문제 재현 시에만 일시적으로 켜야 합니다
  • 대용량 응답(파일 다운로드 등)에 Wire 로깅을 켜면 로그 파일이 급격히 커질 수 있습니다

외부 API별 커넥션 풀 분리

하나의 애플리케이션에서 여러 외부 API를 호출한다면, API별로 커넥션 풀을 분리하는 것이 좋습니다. 하나의 API가 느려져서 커넥션을 오래 점유하면, 다른 API 호출까지 영향받기 때문입니다.

JAVA
// 결제 API — 응답이 느리므로 커넥션을 넉넉하게
ConnectionProvider paymentProvider = ConnectionProvider.builder("payment-api")
    .maxConnections(30)
    .pendingAcquireTimeout(Duration.ofSeconds(5))
    .maxIdleTime(Duration.ofSeconds(30))
    .metrics(true)
    .build();

// 알림 API — 빠른 응답, 적은 커넥션으로 충분
ConnectionProvider notificationProvider = ConnectionProvider.builder("notification-api")
    .maxConnections(10)
    .pendingAcquireTimeout(Duration.ofSeconds(2))
    .maxIdleTime(Duration.ofSeconds(15))
    .metrics(true)
    .build();

// 각각의 WebClient 생성
WebClient paymentClient = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create(paymentProvider)
            .responseTimeout(Duration.ofSeconds(10))
    ))
    .baseUrl("https://payment.example.com")
    .build();

WebClient notificationClient = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create(notificationProvider)
            .responseTimeout(Duration.ofSeconds(3))
    ))
    .baseUrl("https://notification.example.com")
    .build();

커넥션 풀을 분리할 때 ConnectionProvider.builder()의 ** 이름(name)**을 다르게 주는 것이 중요하다. 이 이름이 메트릭의 태그로 사용되어 Grafana에서 풀별로 구분해서 모니터링할 수 있다.


ConnectionProvider 종료 — 리소스 정리

커스텀 ConnectionProvider를 생성했다면, 애플리케이션 종료 시 반드시 정리해야 합니다. Spring Boot에서는 @PreDestroyDisposableBean을 활용할 수 있습니다.

JAVA
@Configuration
public class WebClientConfig {

    private final ConnectionProvider provider;

    public WebClientConfig() {
        this.provider = ConnectionProvider.builder("api-pool")
            .maxConnections(50)
            .build();
    }

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(
                HttpClient.create(provider)))
            .build();
    }

    @PreDestroy
    public void destroy() {
        // 모든 커넥션을 닫고 리소스 정리
        provider.disposeLater()
            .block(Duration.ofSeconds(5));
    }
}

disposeLater()Mono<Void>를 반환하므로, 종료 시점에 block()으로 완료를 기다립니다. dispose()를 호출하면 즉시 모든 커넥션이 강제 종료되므로, 진행 중인 요청이 있다면 disposeLater()가 더 안전합니다.


정리

Reactor Netty HttpClient는 Spring WebClient 아래에서 실제 네트워크 I/O를 처리하는 엔진입니다. 기본 설정으로도 동작하지만, 프로덕션 환경에서는 커넥션 풀과 타임아웃을 명시적으로 구성하는 것이 안전합니다.

  • ** 커넥션 풀 분리 **: 외부 API별로 ConnectionProvider를 분리하면 장애 전파를 막을 수 있다
  • maxIdleTime: 로드 밸런서의 유휴 타임아웃보다 짧게 설정해야 stale 커넥션 에러를 방지한다
  • evictInBackground: 트래픽이 적은 시간대에도 만료 커넥션이 정리되도록 설정한다
  • metrics(true): pending.connections가 지속적으로 0보다 크면 풀 크기를 늘려야 한다
  • **Wire 로깅 **: 디버깅 시에만 일시적으로 활성화하고, 민감 데이터 노출에 주의한다
댓글 로딩 중...