WebClient에 대한 이해와 적용
Spring Boot에서 외부 API를 호출할 때 가장 먼저 떠올릴 수 있는 방식은 RestClient이다.
요청을 보내고 응답을 받은 뒤 다음 로직을 실행하는 단순한 구조라면 RestClient만으로도 충분하다.
하지만 AI 기능처럼 응답 시간이 길거나, 여러 외부 API를 동시에 호출하거나, 답변을 조금씩 스트리밍해야 하는 상황이라면 이야기가 달라진다.
이때 고려할 수 있는 HTTP Client가 WebClient이다.
이 글에서는 WebClient가 무엇인지, 언제 필요한지, Spring Boot에서 FastAPI를 호출할 때 어떻게 적용할 수 있는지 정리한다.
1. WebClient란 무엇인가
WebClient는 Spring WebFlux에서 제공하는 HTTP Client이다.
Spring 공식 문서에서는 WebClient를 HTTP 요청을 수행하기 위한 논블로킹, 리액티브 클라이언트로 설명한다.
간단히 말하면 다음과 같다.
WebClient는 Spring Boot에서 외부 API를 비동기, 논블로킹 방식 으로 호출할 수 있게 해주는 HTTP Client이다.
예를 들어 Spring Boot가 FastAPI 서버에 AI 질문을 보내는 상황을 생각해 보자.
Spring Boot
↓
FastAPI
↓
AI 응답 생성
↓
Spring Boot가 응답 수신
일반적인 요청/응답이라면 RestClient로도 충분하다.
하지만 다음과 같은 상황이라면 WebClient가 더 잘 맞는다.
AI 응답이 오래 걸리는 경우
AI 응답을 실시간으로 스트리밍하는 경우
여러 외부 API를 동시에 호출해야 하는 경우
요청 처리 스레드를 오래 붙잡고 싶지 않은 경우
2. RestClient와 WebClient의 차이
RestClient와 WebClient의 가장 큰 차이는 처리 방식이다.
| 구분 | RestClient | WebClient |
|---|---|---|
| 처리 방식 | 동기식 | 비동기, 논블로킹 |
| 반환 타입 | 일반 객체 | Mono, Flux |
| 사용 난이도 | 쉬움 | 상대적으로 어려움 |
| 적합한 상황 | 짧은 요청/응답 | 스트리밍, 동시 호출, 긴 요청 |
| 대표 예시 | 일반 REST API 호출 | AI 스트리밍, 외부 API 조합 |
RestClient는 요청을 보내면 응답이 돌아올 때까지 현재 흐름이 기다린다.
요청 보냄
→ 응답을 기다림
→ 응답을 받은 뒤 다음 코드 실행
반면 WebClient는 응답을 기다리는 동안 호출 스레드를 계속 붙잡아 두지 않는 방식으로 동작할 수 있다.
요청 보냄
→ 응답이 준비되면 후속 처리 실행
→ 스트리밍 응답이면 조각 단위로 처리 가능
그래서 WebClient는 단순히 “새로운 HTTP Client”라기보다, 리액티브 흐름을 다룰 수 있는 HTTP Client에 가깝다.
3. Mono와 Flux 이해하기
WebClient를 사용하면 Mono와 Flux를 자주 보게 된다.
처음 보면 어렵게 느껴질 수 있지만, 기본 개념은 단순하다.
| 타입 | 의미 | 예시 |
|---|---|---|
Mono<T> | 0개 또는 1개의 결과 | AI 답변 하나 |
Flux<T> | 0개 이상의 여러 결과 | 스트리밍 응답 조각 |
예를 들어 FastAPI에서 AI 답변을 한 번에 반환한다면 Mono가 어울린다.
Mono<AiAnswerResponse>
반대로 FastAPI가 AI 답변을 토큰이나 문장 단위로 계속 흘려보낸다면 Flux가 어울린다.
Flux<String>
정리하면 다음과 같다.
한 번에 끝나는 응답은
Mono, 여러 조각으로 이어지는 응답은Flux로 생각하면 된다.
4. 의존성 추가
Spring Boot에서 WebClient를 사용하려면 보통 spring-boot-starter-webflux 의존성을 추가한다.
Maven은 다음과 같다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Gradle은 다음과 같다.
implementation 'org.springframework.boot:spring-boot-starter-webflux'
중요한 점은 WebClient를 사용한다고 해서 전체 애플리케이션을 반드시 WebFlux 방식으로 바꿔야 하는 것은 아니라는 점이다.
Spring MVC 기반 프로젝트에서도 외부 API 호출용으로 WebClient를 사용할 수 있다.
다만 반환 타입이 Mono, Flux가 되기 때문에 서비스와 컨트롤러에서 이 흐름을 어떻게 다룰지 결정해야 한다.
5. WebClient 기본 설정
FastAPI 서버를 호출하는 WebClient를 Bean으로 등록해 보자.
package com.example.ai;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
@Configuration
public class FastApiWebClientConfig {
@Bean
public WebClient fastApiWebClient(WebClient.Builder builder) {
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(30));
return builder
.baseUrl("http://localhost:8000")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}
여기서 중요한 설정은 baseUrl과 timeout이다.
| 설정 | 의미 |
|---|---|
baseUrl | FastAPI 서버 기본 주소 |
responseTimeout | 응답 대기 시간 제한 |
clientConnector | 실제 HTTP Client 연결 설정 |
AI API는 일반 API보다 응답 시간이 길어질 수 있다.
그래서 timeout을 명시하지 않으면 장애 상황에서 요청이 너무 오래 붙잡힐 수 있다.
6. FastAPI 일반 응답 호출하기
먼저 AI 답변을 한 번에 받는 API를 호출해 보자.
요청 DTO는 다음과 같이 만들 수 있다.
package com.example.ai;
public record AiAnswerRequest(
String question
) {
}
응답 DTO는 다음과 같다.
package com.example.ai;
import java.util.List;
public record AiAnswerResponse(
String answer,
List<String> references
) {
}
FastAPI 호출 Client는 다음과 같이 작성할 수 있다.
package com.example.ai;
import java.time.Duration;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@Component
public class FastApiAiClient {
private final WebClient fastApiWebClient;
public FastApiAiClient(WebClient fastApiWebClient) {
this.fastApiWebClient = fastApiWebClient;
}
public Mono<AiAnswerResponse> ask(AiAnswerRequest request) {
return fastApiWebClient.post()
.uri("/api/v1/ai/ask")
.bodyValue(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, response ->
Mono.error(new IllegalArgumentException("FastAPI 요청 값이 올바르지 않습니다.")))
.onStatus(HttpStatusCode::is5xxServerError, response ->
Mono.error(new IllegalStateException("FastAPI 서버 처리 중 오류가 발생했습니다.")))
.bodyToMono(AiAnswerResponse.class)
.timeout(Duration.ofSeconds(30));
}
}
핵심은 다음 부분이다.
.retrieve()
.bodyToMono(AiAnswerResponse.class)
retrieve()는 응답을 어떻게 꺼낼지 선언하는 메서드이고, bodyToMono()는 응답 body를 하나의 객체로 변환한다.
즉, 이 코드는 다음 의미를 가진다.
FastAPI에 POST 요청을 보내고, 응답 body를
AiAnswerResponse하나로 변환한다.
7. Service에서 사용하는 방식
Service에서는 Mono를 그대로 반환할 수 있다.
package com.example.ai;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class AiService {
private final FastApiAiClient fastApiAiClient;
public AiService(FastApiAiClient fastApiAiClient) {
this.fastApiAiClient = fastApiAiClient;
}
public Mono<AiAnswerResponse> ask(String question) {
return fastApiAiClient.ask(new AiAnswerRequest(question));
}
}
컨트롤러도 Mono를 반환하도록 만들 수 있다.
package com.example.ai;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/ai")
public class AiController {
private final AiService aiService;
public AiController(AiService aiService) {
this.aiService = aiService;
}
@PostMapping("/ask")
public Mono<AiAnswerResponse> ask(@RequestBody AiAnswerRequest request) {
return aiService.ask(request.question());
}
}
이 구조에서는 Spring Boot가 FastAPI 응답을 받은 뒤 사용자에게 결과를 반환한다.
Frontend
↓
Spring Boot Controller
↓
AiService
↓
FastApiAiClient
↓
FastAPI
8. block()은 언제 써야 할까
WebClient를 처음 사용할 때 자주 보이는 코드가 block()이다.
AiAnswerResponse response = fastApiAiClient.ask(request).block();
block()은 Mono나 Flux의 결과가 나올 때까지 현재 스레드를 기다리게 만든다.
즉, WebClient를 사용해 놓고 다시 동기식 흐름으로 바꾸는 것이다.
물론 모든 block()이 잘못된 것은 아니다.
예를 들어 배치 작업, 커맨드라인 실행, 테스트 코드처럼 동기식으로 결과가 반드시 필요한 경계에서는 사용할 수 있다.
하지만 웹 요청 처리 흐름 안에서 습관적으로 block()을 사용하면 WebClient의 장점이 줄어든다.
정리하면 다음과 같다.
웹 요청 처리 흐름에서는 가능하면
Mono와Flux를 유지하고, 정말 필요한 경계에서만block()을 사용한다.
9. AI 스트리밍 응답 처리
WebClient가 특히 유용한 상황은 스트리밍이다.
예를 들어 FastAPI가 AI 응답을 한 번에 반환하지 않고, 토큰이나 문장 단위로 계속 보내는 구조를 생각해 보자.
FastAPI 응답:
안
녕
하
세
요
이런 응답은 하나의 객체가 아니라 여러 조각의 흐름이다.
그래서 Mono가 아니라 Flux로 받는 것이 자연스럽다.
Spring Boot Client 코드는 다음과 같이 작성할 수 있다.
package com.example.ai;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
@Component
public class FastApiStreamingClient {
private final WebClient fastApiWebClient;
public FastApiStreamingClient(WebClient fastApiWebClient) {
this.fastApiWebClient = fastApiWebClient;
}
public Flux<String> stream(AiAnswerRequest request) {
return fastApiWebClient.post()
.uri("/api/v1/ai/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(request)
.retrieve()
.bodyToFlux(String.class);
}
}
컨트롤러도 text/event-stream으로 반환할 수 있다.
package com.example.ai;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/ai")
public class AiStreamingController {
private final FastApiStreamingClient streamingClient;
public AiStreamingController(FastApiStreamingClient streamingClient) {
this.streamingClient = streamingClient;
}
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestBody AiAnswerRequest request) {
return streamingClient.stream(request);
}
}
이렇게 하면 흐름은 다음과 같다.
Frontend
↓
Spring Boot
↓
FastAPI
↓
AI 응답 조각을 Flux로 수신
↓
Frontend에 스트리밍 전달
10. FastAPI 스트리밍 예시
FastAPI에서는 StreamingResponse를 사용해서 간단한 스트리밍 API를 만들 수 있다.
import asyncio
from fastapi import FastAPI
from pydantic import BaseModel
from starlette.responses import StreamingResponse
app = FastAPI()
class AiAnswerRequest(BaseModel):
question: str
@app.post("/api/v1/ai/stream")
async def stream_ai(request: AiAnswerRequest):
async def generate():
chunks = [
"질문을 분석했습니다.",
"관련 문서를 검색했습니다.",
f"'{request.question}'에 대한 답변을 생성합니다.",
]
for chunk in chunks:
yield f"data: {chunk}\n\n"
await asyncio.sleep(0.5)
return StreamingResponse(generate(), media_type="text/event-stream")
실제 LLM을 붙이면 chunks 배열 대신 LLM에서 나오는 토큰이나 문장 조각을 yield하면 된다.
이 구조에서는 FastAPI가 응답을 끝까지 만든 뒤 한 번에 보내지 않는다.
생성되는 대로 조금씩 Spring Boot에 전달하고, Spring Boot는 다시 Frontend로 전달할 수 있다.
11. 여러 API를 동시에 호출하기
WebClient는 여러 비동기 호출을 조합할 때도 유용하다.
예를 들어 FastAPI에서 RAG 검색 결과와 AI 요약 결과를 각각 받아와야 한다고 하자.
RAG 검색 API
AI 요약 API
이 둘이 서로 의존하지 않는다면 순서대로 기다릴 필요가 없다.
Mono.zip()을 사용하면 두 결과를 조합할 수 있다.
public Mono<AiCombinedResponse> askWithSearch(AiAnswerRequest request) {
Mono<RagSearchResponse> search = fastApiWebClient.post()
.uri("/api/v1/ai/rag/search")
.bodyValue(request)
.retrieve()
.bodyToMono(RagSearchResponse.class);
Mono<AiAnswerResponse> answer = fastApiWebClient.post()
.uri("/api/v1/ai/ask")
.bodyValue(request)
.retrieve()
.bodyToMono(AiAnswerResponse.class);
return Mono.zip(search, answer)
.map(tuple -> new AiCombinedResponse(tuple.getT1(), tuple.getT2()));
}
이런 구조는 외부 API 호출이 많아질수록 장점이 커진다.
다만 무조건 동시에 많이 호출하는 것이 좋은 것은 아니다.
FastAPI 서버, LLM API, Vector DB가 감당할 수 있는 동시 요청 수를 고려해야 한다.
12. timeout과 retry
외부 API를 호출할 때 timeout은 필수에 가깝다.
AI API는 다음 이유로 응답이 늦어질 수 있다.
LLM 응답 지연
Vector DB 검색 지연
문서 분석 작업 지연
FastAPI Worker 부하
네트워크 지연
그래서 요청마다 timeout을 둘 수 있다.
return fastApiWebClient.post()
.uri("/api/v1/ai/ask")
.bodyValue(request)
.retrieve()
.bodyToMono(AiAnswerResponse.class)
.timeout(Duration.ofSeconds(30));
일시적인 네트워크 오류라면 retry도 고려할 수 있다.
return fastApiWebClient.post()
.uri("/api/v1/ai/ask")
.bodyValue(request)
.retrieve()
.bodyToMono(AiAnswerResponse.class)
.timeout(Duration.ofSeconds(30))
.retry(1);
다만 retry는 조심해야 한다.
AI 요청은 비용이 발생하거나 같은 작업이 중복 실행될 수 있기 때문이다.
예를 들어 결제, 데이터 변경, 문서 임베딩 생성 같은 요청은 단순 retry가 위험할 수 있다.
정리하면 다음과 같다.
조회성 요청은 제한적인 retry를 고려할 수 있지만, 비용이 크거나 상태를 변경하는 요청은 멱등성과 중복 실행을 먼저 설계해야 한다.
13. 에러 처리는 어디까지 해야 할까
WebClient의 retrieve()는 기본적으로 4xx, 5xx 응답을 에러로 처리한다.
하지만 서비스에서 사용자가 이해할 수 있는 에러로 바꾸려면 onStatus()를 사용할 수 있다.
return fastApiWebClient.post()
.uri("/api/v1/ai/ask")
.bodyValue(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, response ->
Mono.error(new IllegalArgumentException("AI 요청 형식이 올바르지 않습니다.")))
.onStatus(HttpStatusCode::is5xxServerError, response ->
Mono.error(new IllegalStateException("AI 서버가 일시적으로 응답하지 않습니다.")))
.bodyToMono(AiAnswerResponse.class);
실무에서는 보통 다음을 구분한다.
| 구분 | 예시 | 처리 방향 |
|---|---|---|
| 4xx | 질문 값 누락, 잘못된 요청 | 사용자 요청 오류로 처리 |
| 5xx | FastAPI 내부 오류 | 서버 오류 또는 재시도 가능 오류로 처리 |
| timeout | 응답 지연 | 사용자에게 지연 안내 |
| 연결 실패 | FastAPI 서버 다운 | 장애 또는 fallback 처리 |
에러 처리는 복잡하게 시작할 필요는 없다.
처음에는 timeout, 4xx, 5xx만 명확히 나눠도 충분하다.
14. WebClient를 적용할 때 주의할 점
WebClient는 강력하지만 무조건 도입할 필요는 없다.
단순한 요청/응답만 있는 프로젝트에서 모든 외부 호출을 리액티브 방식으로 바꾸면 오히려 코드가 어려워질 수 있다.
적용할 때는 다음 기준을 먼저 확인하는 것이 좋다.
| 질문 | 판단 |
|---|---|
| 응답을 스트리밍해야 하는가? | 그렇다면 WebClient가 적합 |
| 여러 API를 동시에 호출해야 하는가? | WebClient 고려 |
| 요청 시간이 길고 스레드를 붙잡기 싫은가? | WebClient 고려 |
| 단순 CRUD API 호출인가? | RestClient로 충분 |
| 팀이 Reactor 흐름에 익숙한가? | 학습 비용 고려 |
특히 Mono, Flux를 중간에 계속 block()으로 끊어 버린다면 WebClient를 쓰는 이점이 줄어든다.
WebClient를 도입한다면 가능한 한 흐름 끝까지 리액티브 타입을 유지하는 편이 좋다.
15. Spring Boot와 FastAPI 구조에서의 선택 기준
Spring Boot와 FastAPI를 함께 쓴다면 다음처럼 나눠 생각할 수 있다.
| 상황 | 추천 |
|---|---|
| 일반 AI 질문/응답 | RestClient 또는 WebClient |
| 짧고 단순한 RAG 검색 | RestClient |
| AI 답변 실시간 출력 | WebClient + Flux |
| 여러 AI API 동시 호출 | WebClient |
| PDF 분석, 임베딩 생성 등 오래 걸리는 작업 | RabbitMQ 같은 메시지 큐 |
| 작업 상태를 나중에 조회 | 메시지 큐 + DB/Redis |
즉, WebClient는 메시지 큐를 대체하는 기술이 아니다.
실시간 HTTP 응답 처리와 스트리밍에 강한 HTTP Client이다.
작업 자체가 몇 분 이상 걸리거나 백그라운드 처리가 필요하다면 메시지 큐가 더 적합하다.
마무리
WebClient는 Spring Boot에서 외부 API를 비동기, 논블로킹 방식으로 호출할 수 있게 해주는 HTTP Client이다.
특히 FastAPI와 AI 기능을 연동할 때 다음 상황에서 유용하다.
AI 응답 스트리밍
긴 외부 API 요청
여러 API 동시 호출
논블로킹 처리 흐름 유지
하지만 모든 상황에서 WebClient가 정답은 아니다.
짧고 단순한 요청/응답이라면 RestClient가 더 읽기 쉽고 유지보수하기 편할 수 있다.
정리하면 다음과 같다.
| 상황 | 선택 |
|---|---|
| 단순 요청/응답 | RestClient |
| 비동기 처리 | WebClient |
| 스트리밍 응답 | WebClient + Flux |
| 오래 걸리는 백그라운드 작업 | 메시지 큐 |
결국 중요한 것은 기술 이름이 아니라 작업의 성격이다.
Spring Boot와 FastAPI를 함께 사용할 때는 처음부터 복잡한 구조로 시작하기보다, 단순 요청은 RestClient로 처리하고 스트리밍이나 동시 호출이 필요해지는 지점에서 WebClient를 도입하는 것이 현실적이다.
참고자료
Spring Framework
- Spring Framework 공식 문서 - REST Clients
- Spring Framework 공식 문서 - WebClient
- Spring Framework 공식 문서 - WebClient retrieve()
