외부 API를 호출할 때 "타임아웃 5초"만 설정해 두면 충분할까요? 트래픽이 몰리는 순간, 커넥션 풀이 고갈되고 재시도가 폭풍처럼 밀려오면 어떤 일이 벌어질까요?

WebClient는 Spring WebFlux가 제공하는 논블로킹 HTTP 클라이언트입니다. 단순히 요청을 보내고 응답을 받는 것 이상으로, 커넥션 풀 관리, 다양한 타임아웃 전략, 재시도 패턴, 에러 처리 같은 실전 설정이 필요합니다. 이 글에서는 프로덕션 환경에서 WebClient를 안정적으로 운용하기 위한 핵심 설정들을 하나씩 정리해 봅니다.


WebClient가 RestTemplate을 대체한 이유

Spring 5부터 RestTemplate은 유지보수 모드에 들어갔고, 공식 문서에서는 WebClient 사용을 권장합니다. Spring 6에서는 블로킹 환경을 위한 RestClient도 추가되었지만, 리액티브 스택에서는 여전히 WebClient가 표준입니다.

  • RestTemplate — 스레드 하나가 응답을 받을 때까지 블로킹. 동시 요청이 많아지면 스레드 풀이 금방 고갈됩니다.
  • WebClient — Reactor Netty 기반 논블로킹. 적은 스레드로도 많은 동시 요청을 처리할 수 있습니다.
  • RestClient — Spring 6에서 추가된 동기 방식 클라이언트. 블로킹 환경에서 WebClient 대신 쓸 수 있습니다.

WebClient는 블로킹 환경에서도 .block()으로 사용할 수 있지만, 가능하면 리액티브 체인 안에서 Mono/Flux를 그대로 다루는 편이 낫습니다. 블로킹 환경이라면 RestClient를 쓰는 게 더 자연스럽습니다.


커넥션 풀 설정 — ConnectionProvider

WebClient의 기본 HTTP 클라이언트는 Reactor Netty 이고, 커넥션 풀은 ConnectionProvider가 관리합니다. 기본 설정은 대부분의 경우 충분하지만, 대량 트래픽 환경에서는 직접 튜닝해야 합니다.

JAVA
// 커넥션 풀 설정
ConnectionProvider provider = ConnectionProvider.builder("custom-pool")
        .maxConnections(200)                    // 전체 최대 커넥션 수
        .maxIdleTime(Duration.ofSeconds(20))    // 유휴 커넥션 유지 시간
        .maxLifeTime(Duration.ofMinutes(5))     // 커넥션 최대 수명
        .pendingAcquireTimeout(Duration.ofSeconds(10))  // 풀에서 커넥션을 기다리는 최대 시간
        .pendingAcquireMaxCount(500)            // 대기열 최대 크기
        .evictInBackground(Duration.ofSeconds(30))      // 백그라운드 커넥션 정리 주기
        .metrics(true)                          // Micrometer 메트릭 활성화
        .build();

HttpClient httpClient = HttpClient.create(provider);

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

주요 설정값의 의미를 짚어 보겠습니다.

설정기본값설명
maxConnections500 (Reactor Netty 기본)풀 전체 최대 커넥션 수
maxIdleTime무제한유휴 커넥션 유지 시간. 너무 길면 끊긴 커넥션을 들고 있게 됩니다
maxLifeTime무제한커넥션 최대 수명. DNS 변경에 대응하려면 설정하는 게 좋습니다
pendingAcquireTimeout45초풀이 가득 찼을 때 대기하는 시간

커넥션 풀 고갈은 장애의 시작입니다. pendingAcquireTimeout이 지나면 PoolAcquireTimeoutException이 발생하는데, 이 에러가 보이기 시작하면 풀 사이즈를 늘리기보다 왜 커넥션이 반환되지 않는지 를 먼저 확인해야 합니다.


타임아웃 전략 — 세 가지 계층

WebClient에서 설정할 수 있는 타임아웃은 크게 세 가지 계층으로 나뉩니다.

JAVA
// 1. TCP 연결 타임아웃 — 서버와 TCP 핸드셰이크가 완료되기까지
HttpClient httpClient = HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000);

// 2. 응답 타임아웃 — 요청 전송 후 첫 응답을 받기까지
httpClient = httpClient.responseTimeout(Duration.ofSeconds(5));

// 3. 읽기/쓰기 타임아웃 — 데이터 전송 중 각 청크 사이의 간격
httpClient = httpClient.doOnConnected(conn ->
        conn.addHandlerLast(new ReadTimeoutHandler(10))   // 10초 읽기 타임아웃
            .addHandlerLast(new WriteTimeoutHandler(10))   // 10초 쓰기 타임아웃
);

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

각 타임아웃의 작동 시점이 다릅니다.

  • connectTimeout — TCP 연결 자체가 맺어지기까지. 서버가 다운되었거나 방화벽이 막고 있을 때 빠르게 실패합니다.
  • responseTimeout — 요청을 보낸 뒤 응답 헤더가 도착하기까지. 서버가 처리 중이지만 너무 오래 걸릴 때 끊습니다.
  • readTimeout — 응답 바디 수신 중 다음 데이터가 오기까지. 대용량 응답에서 중간에 멈추는 상황을 감지합니다.

타임아웃을 너무 짧게 잡으면 정상 요청도 실패하고, 너무 길게 잡으면 장애가 전파됩니다. 보통 connectTimeout은 13초, responseTimeout은 310초 정도를 기준으로 서비스 특성에 맞게 조정합니다.

특정 요청에만 다른 타임아웃을 적용하고 싶다면 요청 단위로 설정할 수도 있습니다.

JAVA
// 요청 단위 타임아웃 오버라이드
webClient.get()
        .uri("/slow-api")
        .httpRequest(req -> {
            HttpClientRequest reactorReq = req.getNativeRequest();
            reactorReq.responseTimeout(Duration.ofSeconds(30)); // 이 요청만 30초
        })
        .retrieve()
        .bodyToMono(String.class);

재시도 전략 — Retry.backoff

네트워크는 불안정합니다. 일시적 장애에 대비해 재시도 로직을 넣되, ** 무작정 재시도하면 장애를 악화 **시킬 수 있습니다.

JAVA
webClient.get()
        .uri("/api/data")
        .retrieve()
        .bodyToMono(DataResponse.class)
        .retryWhen(Retry.backoff(3, Duration.ofMillis(500))  // 최대 3회, 500ms부터 지수 백오프
                .maxBackoff(Duration.ofSeconds(5))            // 백오프 최대 5초
                .jitter(0.5)                                  // 50% 지터 추가 (동시 재시도 분산)
                .filter(ex -> ex instanceof WebClientResponseException.ServiceUnavailable
                           || ex instanceof ConnectTimeoutException)  // 특정 에러만 재시도
                .onRetryExhaustedThrow((spec, signal) ->
                        new ServiceUnavailableException("3회 재시도 후에도 실패: " + signal.failure().getMessage()))
        );

재시도 설계에서 중요한 포인트 몇 가지를 정리합니다.

  • ** 지수 백오프(Exponential Backoff)** — 500ms → 1s → 2s처럼 간격을 점점 늘려서, 장애 상태의 서버에 부하를 줄여 줍니다.
  • ** 지터(Jitter)** — 여러 클라이언트가 동시에 재시도하면 "재시도 폭풍"이 됩니다. 랜덤 지연을 추가해서 분산시킵니다.
  • ** 필터** — 4xx 클라이언트 에러는 재시도해도 결과가 같습니다. 5xx, 타임아웃, 커넥션 에러만 재시도하는 게 맞습니다.
  • ** 최대 횟수 제한** — 무한 재시도는 절대 안 됩니다. 보통 2~3회가 적당합니다.

에러 처리 — onStatus()

WebClient는 4xx/5xx 응답을 기본적으로 WebClientResponseException으로 변환합니다. 하지만 상태 코드별로 다른 처리가 필요할 때는 onStatus()를 사용합니다.

JAVA
webClient.get()
        .uri("/api/users/{id}", userId)
        .retrieve()
        .onStatus(
                status -> status.value() == 404,
                response -> Mono.error(new UserNotFoundException("사용자를 찾을 수 없습니다: " + userId))
        )
        .onStatus(
                status -> status.value() == 429,
                response -> {
                    // Rate Limit 응답에서 Retry-After 헤더 추출
                    String retryAfter = response.headers()
                            .asHttpHeaders()
                            .getFirst("Retry-After");
                    return Mono.error(new RateLimitException("요청 제한 초과. " + retryAfter + "초 후 재시도"));
                }
        )
        .onStatus(
                HttpStatusCode::is5xxServerError,
                response -> response.bodyToMono(ErrorBody.class)
                        .flatMap(body -> Mono.error(new ExternalServiceException(body.getMessage())))
        )
        .bodyToMono(UserResponse.class);

onStatus()에서 응답 바디를 읽을 수 있다는 점이 중요합니다. 외부 API가 에러 바디에 상세 정보를 담아 주는 경우가 많은데, 이걸 파싱해서 의미 있는 예외로 변환하면 디버깅이 훨씬 수월해집니다.


ExchangeFilterFunction — 로깅과 인증 토큰 주입

ExchangeFilterFunction은 모든 요청/응답을 가로채는 인터셉터입니다. 로깅, 인증 헤더 주입, 메트릭 수집 등에 활용합니다.

JAVA
// 요청/응답 로깅 필터
ExchangeFilterFunction loggingFilter = ExchangeFilterFunction.ofRequestProcessor(request -> {
    log.info("[요청] {} {}", request.method(), request.url());
    return Mono.just(request);
}).andThen(ExchangeFilterFunction.ofResponseProcessor(response -> {
    log.info("[응답] 상태코드: {}", response.statusCode());
    return Mono.just(response);
}));

// OAuth2 토큰 자동 주입 필터
ExchangeFilterFunction authFilter = (request, next) -> {
    return tokenProvider.getAccessToken()
            .flatMap(token -> {
                ClientRequest newRequest = ClientRequest.from(request)
                        .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
                        .build();
                return next.exchange(newRequest);
            });
};

WebClient webClient = WebClient.builder()
        .baseUrl("https://api.example.com")
        .filter(authFilter)       // 인증 필터 먼저
        .filter(loggingFilter)    // 로깅 필터 나중에
        .build();

필터 체인은 ** 등록된 순서대로 실행 **됩니다. 인증 헤더를 추가한 뒤에 로깅하면 토큰이 포함된 요청을 확인할 수 있고, 순서를 바꾸면 토큰 없는 원본 요청만 보입니다. 운영 환경에서는 보안을 고려해서 순서를 결정해야 합니다.


요청/응답 바디 처리

WebClient는 다양한 형태의 바디를 직렬화/역직렬화할 수 있습니다.

JAVA
// POST 요청 — 객체를 JSON으로 직렬화
webClient.post()
        .uri("/api/orders")
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(new OrderRequest("ITEM-001", 3))  // 객체 → JSON 자동 변환
        .retrieve()
        .bodyToMono(OrderResponse.class);

// 스트리밍 응답 — SSE나 NDJSON 같은 스트리밍 데이터 수신
webClient.get()
        .uri("/api/events/stream")
        .accept(MediaType.TEXT_EVENT_STREAM)
        .retrieve()
        .bodyToFlux(ServerEvent.class)  // Flux로 이벤트를 하나씩 수신
        .doOnNext(event -> log.info("수신된 이벤트: {}", event));

// Form 데이터 전송
webClient.post()
        .uri("/api/login")
        .contentType(MediaType.APPLICATION_FORM_URLENCODED)
        .body(BodyInserters.fromFormData("username", "admin")
                .with("password", "secret"))
        .retrieve()
        .bodyToMono(TokenResponse.class);

bodyToMono는 응답을 하나의 객체로, bodyToFlux는 스트리밍 형태로 받습니다. SSE(Server-Sent Events)나 NDJSON 같은 스트리밍 프로토콜을 다룰 때 bodyToFlux가 빛을 발합니다.


메모리 제한 설정

WebClient는 기본적으로 응답 바디의 인메모리 버퍼를 256KB 로 제한합니다. 대용량 응답을 받을 때는 이 값을 조정해야 합니다.

JAVA
WebClient webClient = WebClient.builder()
        .codecs(configurer -> configurer
                .defaultCodecs()
                .maxInMemorySize(10 * 1024 * 1024))  // 10MB로 확장
        .build();

하지만 무작정 늘리는 건 좋은 방법이 아닙니다. 대용량 데이터는 스트리밍으로 처리하는 편이 메모리 효율적입니다.

JAVA
// 대용량 응답은 스트리밍으로 처리
Flux<DataBuffer> dataStream = webClient.get()
        .uri("/api/large-file")
        .retrieve()
        .bodyToFlux(DataBuffer.class);

// DataBuffer를 파일로 직접 쓰기
DataBufferUtils.write(dataStream, Path.of("/tmp/downloaded.dat"))
        .block();

테스트 — MockWebServer

WebClient를 테스트할 때는 OkHttp의 MockWebServer가 가장 널리 사용됩니다. 실제 HTTP 서버를 띄우고 응답을 미리 정의해 두는 방식입니다.

JAVA
@ExtendWith(MockitoExtension.class)
class ApiClientTest {

    private MockWebServer mockServer;
    private ApiClient apiClient;

    @BeforeEach
    void setUp() throws Exception {
        mockServer = new MockWebServer();
        mockServer.start();

        // MockWebServer의 URL로 WebClient 생성
        WebClient webClient = WebClient.builder()
                .baseUrl(mockServer.url("/").toString())
                .build();
        apiClient = new ApiClient(webClient);
    }

    @AfterEach
    void tearDown() throws Exception {
        mockServer.shutdown();
    }

    @Test
    void 정상_응답을_파싱한다() {
        // 모의 응답 등록
        mockServer.enqueue(new MockResponse()
                .setResponseCode(200)
                .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .setBody("{\"id\": 1, \"name\": \"홍길동\"}"));

        // 실행 및 검증
        UserResponse result = apiClient.getUser(1L).block();

        assertThat(result.getName()).isEqualTo("홍길동");

        // 요청 검증
        RecordedRequest request = mockServer.takeRequest();
        assertThat(request.getPath()).isEqualTo("/api/users/1");
    }

    @Test
    void 타임아웃_시_재시도한다() {
        // 첫 번째 요청은 타임아웃, 두 번째는 성공
        mockServer.enqueue(new MockResponse()
                .setSocketPolicy(SocketPolicy.NO_RESPONSE));
        mockServer.enqueue(new MockResponse()
                .setResponseCode(200)
                .setBody("{\"id\": 1, \"name\": \"홍길동\"}"));

        UserResponse result = apiClient.getUserWithRetry(1L).block();

        assertThat(result.getName()).isEqualTo("홍길동");
        assertThat(mockServer.getRequestCount()).isEqualTo(2);  // 2번 호출 확인
    }
}

MockWebServer는 요청 순서까지 검증할 수 있어서, 재시도 로직이 의도대로 동작하는지 확인하기에 좋습니다.


WebClient vs RestClient — 어떤 걸 써야 할까

Spring 6 이후로는 선택지가 셋으로 늘었습니다. 상황에 따른 가이드를 정리합니다.

기준WebClientRestClientRestTemplate
프로그래밍 모델리액티브 (Mono/Flux)동기/블로킹동기/블로킹
** 기반 기술**Reactor Netty다양한 HTTP 클라이언트다양한 HTTP 클라이언트
** 스트리밍 지원**OXX
Spring WebFlux 프로젝트권장사용 가능 (블로킹 구간)비권장
Spring MVC 프로젝트사용 가능권장유지보수 모드
** 신규 프로젝트**리액티브 스택이면MVC 스택이면X

정리하면 이렇습니다.

  • WebFlux 프로젝트WebClient (논블로킹 체인을 유지해야 하므로)
  • MVC 프로젝트, 신규RestClient (깔끔한 fluent API, WebClient보다 학습 비용 낮음)
  • MVC 프로젝트, 기존RestTemplate 유지하되, 새 코드는 RestClient

WebFlux 환경에서 .block()을 남발하면 리액터 스케줄러가 블로킹되면서 성능이 급격히 떨어집니다. 논블로킹 스택을 선택한 이유가 사라지는 셈이니, 리액티브 체인을 끝까지 유지하는 것이 핵심입니다.


실전 WebClient 빈 구성 예시

마지막으로, 프로덕션에서 사용할 법한 WebClient 빈 설정을 하나로 모아 보겠습니다.

JAVA
@Configuration
public class WebClientConfig {

    @Bean
    public WebClient externalApiClient() {
        // 커넥션 풀 설정
        ConnectionProvider provider = ConnectionProvider.builder("external-api")
                .maxConnections(100)
                .maxIdleTime(Duration.ofSeconds(20))
                .maxLifeTime(Duration.ofMinutes(5))
                .pendingAcquireTimeout(Duration.ofSeconds(10))
                .metrics(true)
                .build();

        // HTTP 클라이언트 설정 (타임아웃 포함)
        HttpClient httpClient = HttpClient.create(provider)
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
                .responseTimeout(Duration.ofSeconds(5))
                .doOnConnected(conn ->
                        conn.addHandlerLast(new ReadTimeoutHandler(10))
                            .addHandlerLast(new WriteTimeoutHandler(10)));

        return WebClient.builder()
                .baseUrl("https://api.example.com")
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .codecs(configurer -> configurer
                        .defaultCodecs()
                        .maxInMemorySize(2 * 1024 * 1024))  // 2MB
                .filter(loggingFilter())
                .build();
    }

    private ExchangeFilterFunction loggingFilter() {
        return (request, next) -> {
            log.info("[API 호출] {} {}", request.method(), request.url());
            long start = System.currentTimeMillis();
            return next.exchange(request)
                    .doOnNext(response ->
                            log.info("[API 응답] {} - {}ms",
                                    response.statusCode(),
                                    System.currentTimeMillis() - start));
        };
    }
}

이 설정 하나로 커넥션 풀, 타임아웃, 메모리 제한, 로깅까지 모두 포함됩니다. 서비스별로 WebClient 빈을 분리해서, 외부 API 특성에 맞게 각각 다른 타임아웃과 풀 사이즈를 적용하는 것이 좋습니다.


정리

  • WebClient는 Reactor Netty 기반의 논블로킹 HTTP 클라이언트로, RestTemplate을 대체합니다.
  • 커넥션 풀 은 ConnectionProvider로 관리하며, maxConnections, maxIdleTime, maxLifeTime이 핵심 설정입니다.
  • 타임아웃 은 연결 / 응답 / 읽기·쓰기 세 계층으로 나뉘며, 각각 다른 시점에 작동합니다.
  • 재시도 는 Retry.backoff로 지수 백오프 + 지터를 적용하고, 반드시 재시도 대상 에러를 필터링해야 합니다.
  • 에러 처리 는 onStatus()로 상태 코드별 커스텀 예외를 만들 수 있습니다.
  • ExchangeFilterFunction 으로 로깅, 인증 토큰 주입, 메트릭 수집 등 횡단 관심사를 처리합니다.
  • MVC 프로젝트에서는 RestClient, WebFlux 프로젝트에서는 WebClient를 선택하면 됩니다.
댓글 로딩 중...