Reactor Netty 심화 — HttpClient & 커넥션 풀
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가 포함되어 있어 별도 설정 없이 기본 엔진으로 동작합니다.
WebClient → HttpClient (Reactor Netty) → ConnectionProvider → Netty Channel
이 구조에서 중요한 점은 WebClient는 HTTP 요청의 인터페이스 이고, 실제 네트워크 I/O는 Reactor Netty가 담당한다는 겁니다.
// 기본 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는 빌더 패턴으로 다양한 설정을 체이닝할 수 있습니다.
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)별로 커넥션을 관리합니다.
// 기본 커넥션 풀 — 풀링 활성화 (ConnectionProvider.create() 사용)
HttpClient pooledClient = HttpClient.create();
// 커넥션 풀 비활성화 — 매 요청마다 새 커넥션 생성
HttpClient newConnectionClient = HttpClient.newConnection();
기본 풀의 maxConnections는 ** 프로세서 수 × 2(최소 16)**입니다. 4코어 서버라면 16개가 기본값이 됩니다.
커스텀 ConnectionProvider 구성
프로덕션 환경에서는 기본 설정을 그대로 사용하기보다, 호출 대상의 특성에 맞게 커넥션 풀을 직접 구성하는 것이 좋습니다.
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) | 리모트 주소별 동시 최대 커넥션 수 |
pendingAcquireMaxCount | maxConnections × 2 | 풀이 가득 찼을 때 대기할 수 있는 요청 수 |
pendingAcquireTimeout | 45초 | 대기 큐에서 커넥션을 기다리는 최대 시간 |
maxLifeTime | 무제한 | 커넥션의 절대 수명 |
maxIdleTime | 무제한 | 유휴 커넥션의 최대 대기 시간 |
evictInBackground | 비활성 | 백그라운드에서 만료된 커넥션을 정리하는 주기 |
프로덕션에서
maxIdleTime을 설정하지 않으면, 서버 쪽에서 이미 끊은 커넥션을 클라이언트가 재사용하려다 에러가 발생할 수 있다. 로드 밸런서의 유휴 타임아웃(보통 60초)보다 짧게 설정하는 것이 안전하다.
커넥션 풀 동작 — 생성, 재사용, 반환
커넥션 풀의 동작 흐름을 단계별로 정리하면 이렇습니다.
1. 커넥션 획득 (Acquire)
요청 발생 → 풀에서 유휴 커넥션 검색
├─ 유휴 커넥션 있음 → 유효성 검사 후 재사용
├─ 유휴 커넥션 없음 + maxConnections 미도달 → 새 커넥션 생성
└─ 유휴 커넥션 없음 + maxConnections 도달 → 대기 큐에 추가
├─ pendingAcquireTimeout 내에 커넥션 반환됨 → 재사용
└─ pendingAcquireTimeout 초과 → PoolAcquireTimeoutException
2. 커넥션 반환 (Release)
요청이 완료되면 커넥션은 풀로 반환됩니다. 이때 커넥션의 상태를 확인하고, 문제가 있으면 폐기합니다.
요청 완료 → 커넥션 반환
├─ 커넥션 정상 + maxLifeTime 미초과 → 풀에 반환 (유휴 상태)
├─ 커넥션 정상 + maxLifeTime 초과 → 폐기 후 풀에서 제거
└─ 커넥션 비정상 (RST, 에러 등) → 폐기
3. Eviction (만료 커넥션 정리)
evictInBackground()를 설정하면, 별도 타이머가 주기적으로 풀을 스캔하면서 maxIdleTime이나 maxLifeTime을 초과한 커넥션을 정리합니다.
// 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 와 연동하여 커넥션 풀 메트릭을 노출할 수 있습니다.
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를 직접 구현할 수 있습니다.
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에서 타임아웃을 설정할 수 있는 곳이 여러 군데입니다. 각각이 어떤 구간을 담당하는지 혼동하기 쉬우니 정리해보겠습니다.
[DNS 조회] → [TCP 연결] → [TLS 핸드셰이크] → [요청 전송] → [응답 대기] → [응답 수신]
├─────────┤ ├──────────┤ ├─────────┤ ├─────────┤
connectTimeout writeTimeout responseTimeout readTimeout
1. connectTimeout — TCP 연결 수립
// Netty 채널 옵션으로 설정
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000); // 3초
TCP 3-way 핸드셰이크가 완료되기까지의 최대 대기 시간입니다. 서버가 응답하지 않거나 네트워크 경로에 문제가 있을 때 이 타임아웃이 발동합니다. 기본값은 30초 입니다.
2. responseTimeout — 응답 대기
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(5));
요청을 보낸 후 서버로부터 첫 번째 응답 바이트가 도착하기까지 의 최대 대기 시간입니다. 서버가 요청을 받았지만 처리가 오래 걸릴 때 이 타임아웃이 발동합니다.
3. readTimeout / writeTimeout — 소켓 레벨
HttpClient httpClient = HttpClient.create()
.doOnConnected(conn -> conn
// 마지막으로 데이터를 읽은 후 10초 동안 데이터가 없으면 타임아웃
.addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
// 마지막으로 데이터를 쓴 후 10초 동안 쓰기가 완료되지 않으면 타임아웃
.addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS))
);
이 타임아웃은 Netty의 IdleStateHandler 기반입니다. 대용량 응답을 청크로 받을 때, 청크 사이의 간격이 너무 길면 ReadTimeoutHandler가 발동합니다.
전체를 합치면
// 프로덕션 권장 설정 예시
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 특성에 맞게,readTimeout은responseTimeout보다 길게 설정하는 것이 일반적이다. 대용량 파일 다운로드 같은 경우에는readTimeout을 넉넉하게 잡아야 한다.
Wire 로깅 — 실제 바이트 확인하기
API 호출이 실패하거나 예상과 다른 응답이 올 때, HTTP 레벨 로그만으로는 원인을 찾기 어려울 수 있습니다. Wire 로깅을 활성화하면 네트워크를 통해 실제로 주고받는 바이트 를 확인할 수 있습니다.
설정 방법
application.yml에 다음을 추가합니다.
logging:
level:
# Reactor Netty HTTP 클라이언트 전체 로깅
reactor.netty.http.client: DEBUG
또는 HttpClient에서 직접 Wire 로깅을 활성화할 수도 있습니다.
HttpClient httpClient = HttpClient.create()
.wiretap("reactor.netty.http.client.HttpClient",
LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL);
AdvancedByteBufFormat에는 세 가지 옵션이 있습니다.
HEX_DUMP— 16진수 + ASCII 덤프 (기본값)TEXTUAL— 텍스트로 디코딩해서 출력 (JSON API 디버깅에 유용)SIMPLE— 이벤트만 출력, 데이터 내용은 생략
출력 예시 (TEXTUAL)
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 호출까지 영향받기 때문입니다.
// 결제 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에서는 @PreDestroy나 DisposableBean을 활용할 수 있습니다.
@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 로깅 **: 디버깅 시에만 일시적으로 활성화하고, 민감 데이터 노출에 주의한다