Spring WebClient 완전 정리 — 비동기 HTTP 클라이언트 실전 가이드

2026-05-03AWS SAA-C03 스터디

Spring WebClient 핵심 정리 — WebClient.builder() Bean 등록·retrieve() vs exchangeToMono()·bodyToMono·bodyToFlux·onStatus() 에러 처리·타임아웃·Retry.backoff·Mono.zip 병렬 호출까지. RestTemplate·RestClient가 WebFlux에서 위험한 이유, exchange() deprecated 함정, 싱글톤 Bean 패턴을 코드와 비유로 풀어 정리.

📚 Spring WebFlux 핵심 정리 · 8편 / 14편 — 비동기 HTTP 클라이언트 실전 가이드

이 글은 Spring WebFlux 핵심 정리 시리즈의 여덟 번째 편입니다. 지금까지 WebFlux로 서버를 만드는 방법을 다뤘다면, 이번 8편은 WebFlux 서버에서 다른 서버(외부 API·마이크로서비스)를 호출할 때 쓰는 도구 — WebClient입니다.

RestTemplate으로 외부 API를 호출해 왔다면, WebFlux 환경에서는 그게 큰 문제가 됩니다. RestTemplate은 블로킹 방식이라 이벤트 루프 스레드를 점유해서 다른 모든 요청까지 멈추게 만들거든요. WebClient는 비동기 논블로킹으로 동작해서 이벤트 루프 스레드를 막지 않아요. 이번 편의 핵심 질문은 세 가지입니다. "WebClient를 어떻게 설정하고 Bean으로 등록하는가, retrieve()와 exchangeToMono()의 차이는 무엇인가, 에러·타임아웃·재시도를 어떻게 처리하는가" — 이 세 가지만 잡으면 충분합니다.

본문 흐름은 비동기 택배사 비유를 따라 풀어 가요. WebClient = "비동기 택배사" — 물건(요청)을 접수하고 바로 다른 일을 합니다. 택배가 도착했다는 알림(이벤트)이 오면 그때 처리하는 구조예요. RestTemplate은 "직원이 택배 도착까지 회사 앞에서 기다리는 구조"예요.

학습 노트

이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, Reactive Streams 명세, 여러 공개 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

WebClient는 spring-boot-starter-webflux에 포함돼 있어요. Spring MVC 프로젝트에서도 별도 의존성 추가 없이 사용할 수 있습니다.

WebClient가 처음엔 왜 어렵게 느껴질까요

이유는 네 가지예요.

첫째, RestTemplate이 이미 잘 동작하는데 굳이? 라는 의문이 듭니다. restTemplate.getForObject("/url", Product.class) 한 줄로 잘 쓰던 코드인데, WebClient는 체이닝이 길고 낯설어 보입니다.

둘째, retrieve() vs exchangeToMono() 선택에서 막힙니다. 비슷해 보이는데 언제 뭘 써야 하는지, exchangeToMono()에서 왜 응답 본문을 직접 소비해야 하는지 처음엔 이유가 안 보여요.

셋째, 에러 처리가 Spring MVC와 다릅니다. RestTemplate은 404 응답이 오면 HttpClientErrorException을 던져줬어요. WebClient는 기본적으로 예외를 자동으로 던지지 않고, onStatus()로 명시적으로 처리해야 합니다.

넷째, 매번 WebClient.create()로 생성하면 연결 풀이 공유되지 않습니다. Netty 기반 연결 풀은 Bean으로 등록해서 재사용할 때 진짜 효율이 나와요.

해결법은 하나예요. WebClient를 "비동기 택배사"로 잡고 풀면 갑자기 명확해집니다. 택배를 접수(요청 시작)하고 다른 일을 하다가, 도착 알림(Mono 완료 이벤트)이 오면 그때 처리해요. 기다리는 동안 이벤트 루프가 다른 수천 건의 요청을 처리할 수 있는 구조입니다.

WebClient 생성과 Bean 등록

WebClient는 반드시 싱글톤 Bean으로 등록해서 재사용해야 합니다. 매 요청마다 생성하면 Netty 연결 풀이 공유되지 않아 커넥션 풀의 이점이 사라져요.

@Configuration
public class WebClientConfig {

    // 기본 서비스용 WebClient
    @Bean("productWebClient")
    public WebClient productWebClient() {
        return WebClient.builder()
                .baseUrl("http://product-service:7070")
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }

    // 외부 API (API 키 자동 주입)
    @Bean("externalApiWebClient")
    public WebClient externalApiWebClient(
            @Value("${external.api.url}") String apiUrl,
            @Value("${external.api.key}") String apiKey) {
        return WebClient.builder()
                .baseUrl(apiUrl)
                .defaultHeader("X-API-Key", apiKey)
                .build();
    }
}

// 서비스에서 Bean 주입하여 사용
@Service
public class ProductService {

    private final WebClient webClient;

    public ProductService(@Qualifier("productWebClient") WebClient webClient) {
        this.webClient = webClient;
    }
}

여기서 시험 함정이 하나 있어요. WebClient를 매번 WebClient.create()로 생성하지 마세요. 연결 풀이 공유되지 않아 메모리 낭비와 성능 저하가 발생합니다. Bean으로 등록해서 주입받아 재사용하는 게 맞는 패턴이에요.

GET 요청 — bodyToMono와 bodyToFlux

가장 기본적인 HTTP GET 요청 패턴입니다.

// 단일 객체 조회 — bodyToMono()
Mono<Product> product = webClient.get()
        .uri("/products/{id}", productId)
        .retrieve()
        .bodyToMono(Product.class);

// 목록 조회 — bodyToFlux()
Flux<Product> products = webClient.get()
        .uri("/products")
        .retrieve()
        .bodyToFlux(Product.class);

// 동적 쿼리 파라미터 — UriComponentsBuilder
Flux<Product> filteredProducts = webClient.get()
        .uri(uriBuilder -> uriBuilder
            .path("/products")
            .queryParam("page", 1)
            .queryParam("size", 10)
            .queryParamIfPresent("name", Optional.ofNullable(nameFilter))
            .build()
        )
        .retrieve()
        .bodyToFlux(Product.class);

비유로 한 줄 정리 — WebClient = "비동기 택배사". 물건을 보내고(요청하고) 다른 일을 하다가 도착 알림(Mono/Flux 완료)이 오면 그때 처리하는 구조예요.

POST·PUT·DELETE 요청

// POST — 객체 전송
CustomerDto newCustomer = new CustomerDto(null, "Alice", "alice@example.com");

Mono<CustomerDto> savedCustomer = webClient.post()
        .uri("/customers")
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(newCustomer)           // 단일 객체 본문 설정
        .retrieve()
        .bodyToMono(CustomerDto.class);

// PUT — 업데이트
Mono<CustomerDto> updated = webClient.put()
        .uri("/customers/{id}", customerId)
        .contentType(MediaType.APPLICATION_JSON)
        .bodyValue(updateData)
        .retrieve()
        .bodyToMono(CustomerDto.class);

// DELETE — 본문 없음 (204 No Content)
Mono<Void> deleted = webClient.delete()
        .uri("/customers/{id}", customerId)
        .retrieve()
        .bodyToMono(Void.class);

retrieve() vs exchangeToMono() — 언제 무엇을 쓰는가

가장 자주 헷갈리는 부분이에요. 명확하게 정리합니다.

retrieve() — 고수준 API. 성공 응답 본문만 가져오고 에러는 onStatus()로 처리.

webClient.get()
        .uri("/products/{id}", id)
        .retrieve()
        // 4xx 클라이언트 에러 → 커스텀 예외로 변환
        .onStatus(HttpStatusCode::is4xxClientError,
            response -> response.bodyToMono(ProblemDetail.class)
                .flatMap(pd -> Mono.error(new ProductNotFoundException(pd.getDetail())))
        )
        // 5xx 서버 에러 → 서버 에러 예외
        .onStatus(HttpStatusCode::is5xxServerError,
            response -> Mono.error(new ServiceUnavailableException("External service unavailable"))
        )
        .bodyToMono(Product.class);

exchangeToMono() — 저수준 API. 상태 코드·헤더·쿠키·본문 전체에 접근 가능.

webClient.get()
        .uri("/products/{id}", id)
        .exchangeToMono(response -> {
            if (response.statusCode().is2xxSuccessful()) {
                return response.bodyToMono(Product.class);
            } else if (response.statusCode().is4xxClientError()) {
                // 반드시 응답 본문 소비 (미소비 시 메모리 누수!)
                return response.bodyToMono(ProblemDetail.class)
                        .flatMap(pd -> Mono.error(new ProductNotFoundException(pd.getDetail())));
            } else {
                return response.bodyToMono(String.class)
                        .flatMap(body -> Mono.error(new RuntimeException("Server error: " + body)));
            }
        });
항목retrieve()exchangeToMono()
수준고수준저수준
접근 가능 정보응답 본문만상태 코드, 헤더, 쿠키, 본문 전체
에러 처리onStatus()로 추가직접 statusCode() 확인
코드 복잡도간단상대적으로 복잡
본문 소비 책임자동 처리개발자가 직접 소비

여기서 시험 함정이 하나 있어요. .exchange()는 deprecated가 됐습니다. 이전에는 .exchange()를 썼지만 응답 본문을 항상 수동으로 소비해야 하는 책임 때문에 메모리 누수가 잦았어요. Spring 5.3부터는 .exchangeToMono()·.exchangeToFlux()로 대체됐습니다. .retrieve()가 더 간결하고, .exchangeToMono()는 응답 헤더·상태에 따라 세밀하게 분기해야 할 때 씁니다.

타임아웃 설정

외부 서비스가 응답하지 않을 때 무한 대기를 방지하는 타임아웃은 필수입니다.

// WebClient 레벨 타임아웃 (모든 요청에 적용)
HttpClient httpClient = HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)  // 연결 타임아웃: 5초
        .responseTimeout(Duration.ofSeconds(10));              // 응답 타임아웃: 10초

WebClient webClient = WebClient.builder()
        .baseUrl("http://external-service:8080")
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .build();

// 요청별 타임아웃 (개별 요청에 적용)
webClient.get()
        .uri("/slow-endpoint")
        .retrieve()
        .bodyToMono(Response.class)
        .timeout(Duration.ofSeconds(5))                      // 5초 이내 응답 없으면 TimeoutException
        .onErrorReturn(TimeoutException.class, new Response("timeout-default"));

재시도 — Retry.backoff

일시적인 네트워크 오류나 서버 과부하에 대응하는 재시도 패턴이에요.

webClient.get()
        .uri("/unreliable-endpoint")
        .retrieve()
        .bodyToMono(Response.class)
        .retryWhen(Retry.backoff(3, Duration.ofMillis(500))  // 최대 3회, 초기 500ms 대기
            .maxBackoff(Duration.ofSeconds(5))                // 최대 지연: 5초
            .jitter(0.1)                                      // ±10% 지터 (동시 재시도 분산)
            .filter(e -> e instanceof WebClientResponseException.InternalServerError)  // 5xx만 재시도
        );

재시도 전략은 세 가지를 자주 씁니다.

// 고정 지연 재시도
.retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)))

// 지수 백오프 재시도 (권장)
.retryWhen(Retry.backoff(3, Duration.ofMillis(500))
        .maxBackoff(Duration.ofSeconds(5))
        .jitter(0.5))

// 4xx 에러는 재시도 안 함 — 클라이언트 문제이므로 재시도해도 의미 없음
.retryWhen(Retry.backoff(3, Duration.ofMillis(500))
        .filter(ex -> !(ex instanceof WebClientResponseException wcre
                && wcre.getStatusCode().is4xxClientError())))

Mono.zip() — 여러 서비스 병렬 호출

마이크로서비스 환경에서 여러 서비스를 동시에 호출하고 결과를 합치는 패턴이에요.

@Service
@Slf4j
public class AggregatorService {

    private final WebClient stockWebClient;
    private final WebClient customerWebClient;

    public Mono<TradeInfo> getTradeInfo(Integer customerId, String ticker) {
        // Mono.zip: 두 Mono가 모두 완료될 때 결과를 결합 (병렬 실행)
        return Mono.zip(
            stockWebClient.get()
                .uri("/stocks/{ticker}", ticker)
                .retrieve()
                .bodyToMono(StockPrice.class)
                .onErrorResume(e -> Mono.error(new StockNotFoundException(ticker))),

            customerWebClient.get()
                .uri("/customers/{id}", customerId)
                .retrieve()
                .bodyToMono(CustomerInfo.class)
                .onErrorResume(e -> Mono.error(new CustomerNotFoundException(customerId)))
        )
        .map(tuple -> new TradeInfo(tuple.getT1(), tuple.getT2()));
    }
}

Mono.zip()은 두 서비스를 동시에 호출해서 둘 다 완료되면 결합합니다. flatMap으로 순차 호출하는 것보다 빠르고 효율적이에요.

ExchangeFilterFunction — WebClient 인터셉터

WebFilter가 서버 요청을 가로채는 것처럼, ExchangeFilterFunction은 WebClient가 보내는 요청을 가로챕니다.

// 요청 로깅 필터
public static ExchangeFilterFunction logRequest() {
    return ExchangeFilterFunction.ofRequestProcessor(request -> {
        log.info("→ {} {}", request.method(), request.url());
        return Mono.just(request);
    });
}

// JWT 토큰 자동 주입 필터
public static ExchangeFilterFunction bearerTokenFilter(TokenProvider tokenProvider) {
    return (request, next) -> tokenProvider.getToken()
            .flatMap(token -> {
                ClientRequest authorizedRequest = ClientRequest.from(request)
                        .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
                        .build();
                return next.exchange(authorizedRequest);
            });
}

// WebClient에 필터 적용
WebClient webClient = WebClient.builder()
        .baseUrl("http://api.example.com")
        .filter(logRequest())
        .filter(bearerTokenFilter(tokenProvider))
        .build();

자주 만나는 함정 — 시험 직전 압축 노트

여기까지가 WebFlux 8편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • WebClient = 비동기 논블로킹 HTTP 클라이언트 — 이벤트 루프 스레드를 블로킹하지 않음
  • 싱글톤 Bean으로 등록해서 재사용 — 매번 WebClient.create() 생성 금지 (연결 풀 공유 안 됨)
  • WebClient.builder().baseUrl().defaultHeader().build() — 빌더 패턴 생성
  • .mutate().baseUrl().build() — 불변 객체이므로 설정 변경 시 새 인스턴스 생성
  • bodyToMono(Class) — 단일 객체 응답 / bodyToFlux(Class) — 스트리밍 또는 배열 응답
  • retrieve()는 간결, exchangeToMono()는 상태 코드·헤더 세밀 제어
  • .exchange() deprecated → .exchangeToMono() 사용
  • exchangeToMono() 사용 시 에러 응답이라도 반드시 응답 본문 소비 (미소비 = 메모리 누수)
  • onStatus(HttpStatusCode::is4xxClientError, response -> ...) — 4xx 에러 처리
  • onStatus(HttpStatusCode::is5xxServerError, response -> ...) — 5xx 에러 처리
  • RestTemplate·RestClient은 블로킹 — WebFlux 이벤트 루프에서 사용 시 전체 마비 위험
  • .timeout(Duration.ofSeconds(5)) — 요청별 타임아웃 (외부 서비스 무한 대기 방지)
  • HttpClient.create().responseTimeout(Duration) — WebClient 레벨 응답 타임아웃
  • Retry.backoff(3, Duration.ofMillis(500)).maxBackoff().jitter() — 지수 백오프 재시도
  • 4xx 에러는 재시도 안 함 — 클라이언트 문제, 재시도해도 결과 동일
  • Mono.zip()으로 병렬 호출flatMap 순차 체인보다 빠름
  • ExchangeFilterFunction — WebClient 요청 인터셉터 (로깅, 토큰 자동 주입)
  • URI 변수: 인덱스 기반 .uri("/path/{id}", id) / Map 기반 .uri("/path/{id}", Map.of("id", 1)) / UriBuilder (동적 쿼리 파라미터)
  • 기본 메모리 버퍼 256KB — 큰 응답 시 codecs().defaultCodecs().maxInMemorySize(10MB) 늘려야 함
  • SSE 스트림 구독: .accept(MediaType.TEXT_EVENT_STREAM).retrieve().bodyToFlux(Class)

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!