이 시리즈는 Spring의 비동기 처리를 처음부터 단계별로 다룬다.
2. ThreadPoolTaskExecutor 설정과 스레드 풀 관리
6. WebClient를 활용한 비동기 HTTP 통신 ← 현재 문서
지금까지 다룬 @Async는 애플리케이션 내부의 메서드를 비동기로 실행하는 것이었다. 하지만 실무에서는 외부 API를 호출하는 작업도 많다. 결제 API, 알림 API, 외부 데이터 조회 등. 이런 외부 HTTP 호출을 비동기로 처리하는 것이 WebClient다.
RestTemplate은 동기 방식이라 외부 API 응답을 기다리는 동안 스레드가 블로킹된다. WebClient는 논블로킹 방식으로 외부 API 응답을 기다리는 동안 스레드를 놓아준다.
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-webflux'
spring-boot-starter-webflux에 WebClient가 포함되어 있다. Spring MVC 프로젝트에서도 WebClient만 골라 쓸 수 있다. Webflux로 전환할 필요는 없다.
WebClient 설정
WebClient도 타임아웃, 로깅 등을 설정하는 것이 좋다. 빈으로 등록해서 재사용한다.
@Configuration
@Slf4j
public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.responseTimeout(Duration.ofSeconds(5))
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS))
.addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS))
);
return WebClient.builder()
.baseUrl("http://localhost:8080")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.filter(logRequest())
.filter(logResponse())
.build();
}
private ExchangeFilterFunction logRequest() {
return ExchangeFilterFunction.ofRequestProcessor(request -> {
log.info("WebClient 요청: {} {}", request.method(), request.url());
return Mono.just(request);
});
}
private ExchangeFilterFunction logResponse() {
return ExchangeFilterFunction.ofResponseProcessor(response -> {
log.info("WebClient 응답: {}", response.statusCode());
return Mono.just(response);
});
}
}
CONNECT_TIMEOUT_MILLIS— 서버에 연결하는 데 최대 3초를 기다린다. 연결 자체가 안 되면 빠르게 실패한다.responseTimeout— 연결은 됐지만 응답이 5초 안에 안 오면 타임아웃 처리한다.ReadTimeoutHandler/WriteTimeoutHandler— 읽기/쓰기 작업의 타임아웃을 설정한다.filter()— 모든 요청/응답에 대한 로깅 필터를 추가한다. 디버깅에 유용하다.
외부 API가 응답하지 않을 때 무한 대기에 빠지면, 스레드가 점유된 채로 풀리지 않는다. 타임아웃은 외부 연동의 기본 방어선이다.
Mono와 Flux 기초
WebClient는 반환 타입으로 Mono와 Flux를 사용한다. 이것은 Project Reactor의 타입인데, 간단하게만 이해하면 충분하다.
- Mono — 0개 또는 1개의 데이터를 비동기로 반환한다.
CompletableFuture<T>와 비슷한 역할이다. - Flux — 0개 이상(여러 개)의 데이터를 비동기 스트림으로 반환한다.
핵심은 Mono나 Flux를 반환한다고 해서 바로 실행되는 것이 아니라는 것이다. 구독(subscribe)이 일어나야 실제로 실행된다. Spring MVC의 컨트롤러에서 Mono를 반환하면, Spring이 자동으로 구독을 처리해준다.
기본적인 GET 요청
먼저 간단한 조회 API를 호출해보자.
@Service
@RequiredArgsConstructor
@Slf4j
public class InventoryService {
private final WebClient webClient;
public Mono<Map> checkStock(String productId) {
log.info("[{}] 재고 확인 시작: {}", Thread.currentThread().getName(), productId);
return webClient.get()
.uri("/mock/inventory/check/{productId}", productId)
.retrieve()
.bodyToMono(Map.class)
.doOnSuccess(result -> {
log.info("[{}] 재고 확인 완료: {}", Thread.currentThread().getName(), result);
});
}
}
webClient.get()— GET 요청을 준비한다..uri()— 요청할 URI를 설정한다. Path Variable을 사용할 수 있다..retrieve()— 요청을 보내고 응답을 받을 준비를 한다..bodyToMono(Map.class)— 응답 본문을Map으로 변환한다. DTO 클래스를 지정할 수도 있다..doOnSuccess()— 성공 시 사이드 이펙트를 수행한다 (로깅 등). 데이터를 변환하지는 않는다.
컨트롤러에서는 Mono를 그대로 반환한다.
@GetMapping("/webclient/inventory/{productId}")
public Mono<Map<String, Object>> checkInventory(@PathVariable String productId) {
return inventoryService.checkStockWithRetry(productId);
}
에러 처리와 재시도
외부 API는 언제든 실패할 수 있다. 상태 코드별 에러 처리와 재시도 로직을 추가한다.
public Mono<Map<String, Object>> checkStockSafe(String productId) {
return webClient.get()
.uri("/mock/inventory/check-error/{productId}", productId)
.retrieve()
.onStatus(
status -> status.is4xxClientError() || status.is5xxServerError(),
response -> {
log.error("API 에러 발생: {}", response.statusCode());
return Mono.error(new RuntimeException("재고 확인 실패: " + response.statusCode()));
}
)
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.onErrorReturn(error -> {
log.error("재고 확인 중 예외 발생", error);
return true;
}, Map.of(
"productId", productId,
"inStock", false,
"error", true,
"message", "재고 확인 실패"
));
}
onStatus()— 응답 상태 코드를 검사한다. 4xx나 5xx 에러일 때 적절한 예외를 발생시킨다.onErrorReturn()— 예외 발생 시 기본값을 반환한다. 외부 API가 실패해도 서비스 전체가 죽지 않도록 하는 폴백 패턴이다.
재시도 로직은 Reactor의 retryWhen()을 사용한다.
public Mono<Map<String, Object>> checkStockWithRetry(String productId) {
return webClient.get()
.uri("/mock/inventory/check-error/{productId}", productId)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
.maxBackoff(Duration.ofSeconds(10))
.doBeforeRetry(signal ->
log.warn("재시도 #{} - 사유: {}", signal.totalRetries() + 1, signal.failure().getMessage())
)
)
.onErrorResume(ex -> {
log.error("3번 재시도했으나 최종 실패! 원인: {}", ex.getMessage());
return Mono.just(Map.of(
"productId", productId,
"stock", "UNKNOWN",
"message", "잠시 후 다시 시도해 주세요."
));
});
}
retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))— 최대 3번 재시도하며, 지수 백오프로 대기 시간이 점점 늘어난다. 4편에서 다룬 Resilience4j와 같은 패턴이지만, Reactor 자체 API를 사용한다.maxBackoff()— 재시도 간격의 상한선이다. 지수 백오프가 10초를 넘지 않도록 한다.onErrorResume()— 모든 재시도가 실패한 후 최종 폴백 처리다.onErrorReturn()과 달리Mono를 반환할 수 있어 더 유연하다.
POST 요청
외부 API에 데이터를 보내는 POST 요청도 살펴보자.
public Mono<PostResponseDto> createPostToJsonPlaceHolder(PostRequestDto requestDto) {
WebClient client = WebClient.create("https://jsonplaceholder.typicode.com");
return client.post()
.uri("/posts")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestDto)
.retrieve()
.bodyToMono(PostResponseDto.class);
}
post()— POST 요청을 준비한다.contentType(MediaType.APPLICATION_JSON)— Content-Type 헤더를 설정한다.bodyValue(requestDto)— 요청 본문에 객체를 담는다. Jackson이 자동으로 JSON 직렬화한다.
요청/응답 DTO는 record로 간결하게 정의한다.
public record PostRequestDto(String title, String body, int userId) {}
public record PostResponseDto(int id, String title, String body, int userId) {}
여러 API를 동시에 호출하고 결과 합치기 — zip
외부 API 두 개를 동시에 호출하고, 두 결과를 합쳐 하나의 응답을 만들어야 할 때 Mono.zip()을 사용한다.
public Mono<UserDashboard> getDashboard(int userId) {
WebClient client = WebClient.create("https://jsonplaceholder.typicode.com");
Mono<UserResponse> userMono = client.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserResponse.class)
.subscribeOn(Schedulers.boundedElastic());
Mono<List<TodoResponse>> todoMono = client.get()
.uri("/user/{userId}/todos", userId)
.retrieve()
.bodyToFlux(TodoResponse.class)
.collectList()
.subscribeOn(Schedulers.boundedElastic());
return Mono.zip(userMono, todoMono, (user, todoList) -> {
String companyName = user.company().name();
int total = todoList.size();
int completed = (int) todoList.stream().filter(TodoResponse::completed).count();
String achievement = total > 0 ? (completed * 100 / total) + "% 달성" : "할 일 없음";
return new UserDashboard(
user.name(), user.email(), companyName,
todoList, total, completed, achievement
);
});
}
subscribeOn(Schedulers.boundedElastic())— 각 API 호출을 별도의 스레드에서 실행한다. 이렇게 해야 두 요청이 병렬로 동시에 실행된다.bodyToFlux(TodoResponse.class)— 응답이 JSON 배열일 때 사용한다. 여러 개의 데이터를 스트림으로 받는다.collectList()—Flux를Mono<List>로 변환한다. 모든 데이터를 모아서 리스트로 만든다.Mono.zip()— 두Mono가 모두 완료되면, 두 결과를 합쳐서 새로운 값을 만든다.CompletableFuture.thenCombine()과 같은 역할이다.
사용자 정보 조회 2초, 할 일 목록 조회 1.5초가 걸린다면, 순차 실행은 3.5초이지만 zip으로 병렬 실행하면 2초면 끝난다.
Flux로 여러 데이터 받기
응답이 JSON 배열인 경우 bodyToFlux()를 사용한다.
Mono<List<TodoResponse>> todoMono = client.get()
.uri("/user/{userId}/todos", userId)
.retrieve()
.bodyToFlux(TodoResponse.class)
.collectList()
.subscribeOn(Schedulers.boundedElastic());
bodyToFlux()— 응답 본문의 JSON 배열을 하나씩TodoResponse객체로 변환하며 스트림으로 내보낸다.collectList()— 스트림의 모든 요소를 수집해서List<TodoResponse>로 만든다.
bodyToMono(List.class)로도 받을 수 있지만, bodyToFlux().collectList()가 더 타입 안전하고, 필요하면 스트림 중간에 filter()나 map()으로 데이터를 가공할 수 있다.
자주 하는 실수
Mono를 구독하지 않음
// 잘못된 코드 - 실행되지 않는다!
webClient.get().uri("/api/data").retrieve().bodyToMono(String.class);
Mono와 Flux는 구독하기 전까지 아무것도 실행하지 않는다. 컨트롤러에서 반환하면 Spring이 자동으로 구독해주지만, 서비스 내부에서 사이드 이펙트만 실행하려면 .subscribe()를 호출하거나 .block()으로 동기 대기해야 한다.
block()을 남용
Mono.block()은 비동기 작업을 동기로 변환한다. 편리하지만, WebClient의 논블로킹 장점을 완전히 없앤다. 가능하면 Mono/Flux 체인을 유지하고, 최종 반환 시점에서만 결과를 처리하자.
타임아웃 미설정
WebClient에 타임아웃을 설정하지 않으면, 외부 API가 응답하지 않을 때 무한 대기에 빠진다. 연결 타임아웃, 응답 타임아웃, 읽기/쓰기 타임아웃을 모두 설정하자.
면접 Q&A
Q. @Async의 동작 원리를 설명해주세요.
A. Spring AOP 프록시 기반이다. @Async가 붙은 메서드를 가진 빈을 프록시로 감싸고, 외부에서 이 메서드를 호출하면 프록시가 TaskExecutor에 작업을 제출한다. 같은 클래스 내부에서 호출하면 프록시를 거치지 않아 비동기가 동작하지 않는다.
Q. ThreadPoolTaskExecutor에서 요청이 들어올 때의 처리 순서를 설명해주세요.
A. core 스레드 여유 확인 → core가 모두 바쁘면 큐에 대기 → 큐가 가득 차면 max까지 추가 스레드 생성 → max도 가득 차면 거부 정책 실행. 중요한 점은 큐가 가득 차야 비로소 추가 스레드가 생긴다는 것이다. maxPoolSize가 먼저 동작하는 것이 아니다.
Q. (함정) @Async를 쓰면 RestTemplate도 비동기로 동작하나요?
A. 아니다. @Async 메서드 안에서 RestTemplate을 호출하면, 비동기 스레드에서 실행되긴 하지만 RestTemplate 자체는 동기 블로킹이다. 해당 비동기 스레드가 응답을 기다리며 블로킹된다. 논블로킹 HTTP 통신이 필요하면 WebClient를 사용해야 한다.
Q. (함정) ThreadLocal에 저장된 값이 @Async 메서드에서도 유지되나요?
A. 아니다. ThreadLocal은 스레드별 독립 저장소이므로, 다른 스레드에서 실행되는 @Async 메서드에서는 값이 null이다. TaskDecorator를 사용해서 메인 스레드의 컨텍스트를 캡처한 뒤 비동기 스레드에 복원해야 한다.
Q. WebClient와 RestTemplate의 차이는 무엇인가요?
A. RestTemplate은 동기 블로킹 방식으로, 외부 API 응답을 기다리는 동안 스레드가 점유된다. WebClient는 논블로킹 방식으로, 응답을 기다리는 동안 스레드를 놓아주어 다른 작업을 처리할 수 있다. Spring 5부터 RestTemplate은 유지 모드이고, WebClient 사용이 권장된다.
Q. Mono.zip()과 CompletableFuture.thenCombine()의 차이는?
A. 역할은 비슷하다. 둘 다 두 비동기 작업의 결과를 합친다. 차이는 Mono.zip()은 Reactor 기반(논블로킹), thenCombine()은 CompletableFuture 기반(블로킹 가능)이다. WebClient와 함께 쓸 때는 Mono.zip()이 자연스럽다.
정리
- WebClient는 Spring의 논블로킹 비동기 HTTP 클라이언트다. Spring MVC에서도 사용 가능하다.
- Mono는 단일 결과, Flux는 여러 결과를 비동기로 처리한다.
- onStatus()로 HTTP 상태 코드별 에러를 처리하고, retryWhen()으로 재시도한다.
- Mono.zip()으로 여러 API 호출을 병렬로 실행하고 결과를 합칠 수 있다.
- 타임아웃 설정은 외부 API 연동의 필수 방어선이다.