Spring WebClient 핵심 정리 — WebClient.builder() Bean 등록·retrieve() vs exchangeToMono()·bodyToMono·bodyToFlux·onStatus() 에러 처리·타임아웃·Retry.backoff·Mono.zip 병렬 호출까지. RestTemplate·RestClient가 WebFlux에서 위험한 이유, exchange() deprecated 함정, 싱글톤 Bean 패턴을 코드와 비유로 풀어 정리.
이 글은 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)
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.