외부 API 호출을 통해 웹 통신을 할때 사용한다. 비동기로 동작하기에 다수의 API를 동시에 호출 할 수 있다.
spring MVC 환경으로 개발 할 경우는 동기로 동작하게 하기 위해서 block을 사용할 수 있다.
따라서, 동기 호출 및 비동기 호출 두가지로 나눠볼 수 있다.
Build.gradle 추가
implementation "org.springframework.boot:spring-boot-starter-webflux"
- webflux를 추가하게 되며, Spring MVC와 WebFlux를 같이 추가하게 되면, 무거워고, MVC로 동작을 한다.
Bean 생성
WebClient를 bean으로 등록을 하고, 사용처에서 주입을 받아서 사용하게 된다.
@Configuration
@Slf4j
public class WebClientConfig {
@Bean
public WebClient externalApiWebClient(WebClient.Builder builder) {
// 1️⃣ Netty HttpClient 설정 (Timeout / Connection)
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000)
.responseTimeout(Duration.ofSeconds(3))
.doOnConnected(conn ->
conn.addHandlerLast(new ReadTimeoutHandler(3))
.addHandlerLast(new WriteTimeoutHandler(3))
);
return builder
// 2️⃣ Base URL
.baseUrl("https://api.example.com")
// 3️⃣ Client Connector
.clientConnector(new ReactorClientHttpConnector(httpClient))
// 4️⃣ 기본 Header
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
// 5️⃣ 요청 전 Filter
.filter(requestLoggingFilter())
// 6️⃣ 응답 후 Filter
.filter(responseLoggingFilter())
// 7️⃣ 공통 에러 처리 Filter
.filter(errorHandlingFilter())
.build();
}
/**
* 요청 전 처리 (Logging, Header 추가 등)
*/
private ExchangeFilterFunction requestLoggingFilter() {
return ExchangeFilterFunction.ofRequestProcessor(request -> {
log.info("➡️ [WebClient] {} {}", request.method(), request.url());
request.headers().forEach((name, values) ->
values.forEach(value ->
log.debug("➡️ Header {}={}", name, value)
)
);
return Mono.just(request);
});
}
/**
* 응답 후 처리 (Status, Header 확인)
*/
private ExchangeFilterFunction responseLoggingFilter() {
return ExchangeFilterFunction.ofResponseProcessor(response -> {
log.info("⬅️ [WebClient] Status {}", response.statusCode());
response.headers().asHttpHeaders().forEach((name, values) ->
values.forEach(value ->
log.debug("⬅️ Header {}={}", name, value)
)
);
return Mono.just(response);
});
}
/**
* 공통 에러 처리
*/
private ExchangeFilterFunction errorHandlingFilter() {
return ExchangeFilterFunction.ofResponseProcessor(response -> {
if (response.statusCode().isError()) {
return response.bodyToMono(String.class)
.defaultIfEmpty("")
.flatMap(body -> {
log.error("❌ [WebClient] Error Response: status={}, body={}",
response.statusCode(), body);
return Mono.error(new ExternalApiException(
response.statusCode(),
body
));
});
}
return Mono.just(response);
});
}
}
- 요청 전, 후로 필터로 인터셉터 하여 요청을 보내기 전, 후 처리를 할 수 있다.
- baseUrl 등 기본 설정을 하고, 다른곳에서 이를 주입받아 사용한다.
주입받아서 호출하는 예
- 동기, 비동기 요청으로 구분한 여러가지 예
- queryParameter 및 동적 queryParameter , 헤더 사용 예
- 다수의 id List를 통해 동시 요청 예
- List 응답 예 2가지
@Service
@RequiredArgsConstructor
public class ExternalApiService {
private final WebClient externalApiWebClient;
/**
* 1️⃣ 동기 호출 – 어려운 예
* - 외부 API 2개 호출
* - 첫 번째 응답에 따라 두 번째 호출 여부 결정
* - block() 사용 (MVC)
*/
public FinalResponse getDataSyncComplex() {
ExternalResponse main =
externalApiWebClient.get()
.uri(uriBuilder -> uriBuilder
.path("/v1/data/{id}/detail")
.queryParam("includeMeta", true)
.build(main.getId())
)
// 요청별 Header 추가
.header("X-Request-Id", UUID.randomUUID().toString())
.header(HttpHeaders.AUTHORIZATION, "Bearer access-token")
.retrieve()
.bodyToMono(ExternalResponse.class)
.block();
if (main == null) {
throw new IllegalStateException("외부 API 응답 없음");
}
return FinalResponse.of(main);
}
/**
* ✔ 비동기 호출
* ✔ if 조건에 따라 서로 다른 Query Parameter 구성
* ✔ 우선순위 / 배타 조건 표현 가능
*/
public Mono<List<ExternalResponse>> getDataAsyncWithConditionalQuery(
String keyword,
Long categoryId,
Boolean active,
LocalDate from,
LocalDate to
) {
return externalApiWebClient.get()
.uri(uriBuilder -> {
UriBuilder builder = uriBuilder
.path("/v1/data/search");
// 1️⃣ keyword가 있으면 keyword 검색
if (keyword != null && !keyword.isBlank()) {
builder.queryParam("keyword", keyword);
}
// 2️⃣ categoryId가 있으면 category 검색
if (categoryId != null) {
builder.queryParam("categoryId", categoryId);
}
// 3️⃣ active 값이 있을 때만 추가
if (active != null) {
builder.queryParam("active", active);
}
// 4️⃣ 날짜 조건은 둘 다 있을 때만 추가 (배타/조합 조건)
if (from != null && to != null) {
builder.queryParam("from", from);
builder.queryParam("to", to);
}
// 5️⃣ from만 있고 to가 없을 때
if (from != null && to == null) {
builder.queryParam("from", from);
}
// 6️⃣ to만 있고 from이 없을 때
if (from == null && to != null) {
builder.queryParam("to", to);
}
return builder.build();
})
// 요청별 Header
.header(HttpHeaders.AUTHORIZATION, "Bearer access-token")
.header("X-Request-Id", UUID.randomUUID().toString())
.retrieve()
.bodyToFlux(ExternalResponse.class)
.collectList();
}
/**
* 2️⃣ 비동기 호출 – 어려운 예
* - 외부 API 2개 병렬 호출
* - Mono.zip으로 결과 결합
* - block() 없음
*/
public Mono<FinalResponse> getDataAsyncComplex() {
Mono<ExternalResponse> mainMono =
externalApiWebClient.get()
.uri("/v1/data")
.retrieve()
.bodyToMono(ExternalResponse.class);
Mono<DetailResponse> detailMono =
externalApiWebClient.get()
.uri("/v1/data/detail")
.retrieve()
.bodyToMono(DetailResponse.class);
return Mono.zip(mainMono, detailMono)
.map(tuple ->
FinalResponse.of(
tuple.getT1(),
tuple.getT2()
)
);
}
/**
* ✔ ID 리스트를 기준으로 외부 API 동시 호출
* ✔ Flux + flatMap
* ✔ 동시성(concurrency) 제한 가능
*/
public Mono<List<DetailResponse>> getDetailsByIdsAsync(
List<Long> ids
) {
return Flux.fromIterable(ids)
// flatMap → 비동기 병렬 호출
// 두 번째 인자는 동시에 실행할 최대 요청 수
.flatMap(
id -> externalApiWebClient.get()
.uri(uriBuilder -> uriBuilder
.path("/v1/data/{id}")
.queryParam("includeMeta", true)
.build(id)
)
.header(HttpHeaders.AUTHORIZATION, "Bearer access-token")
.header("X-Request-Id", UUID.randomUUID().toString())
.retrieve()
.bodyToMono(DetailResponse.class),
5 // ⬅ 동시에 최대 5개 요청
)
// 모든 요청이 끝난 후 List로 수집
.collectList();
}
/**
* 3️⃣ List 리턴 – ParameterizedTypeReference 사용
* - JSON 배열 응답 처리
* - 제네릭 타입 소거 문제 해결
*/
public List<ExternalResponse> getListWithTypeReference() {
return externalApiWebClient.get()
.uri("/v1/data/list")
.retrieve()
.bodyToMono(
new ParameterizedTypeReference<List<ExternalResponse>>() {}
)
.block();
}
/**
* 4️⃣ Flux → List 처리
* - 스트리밍 응답 처리
* - Flux를 List로 변환하여 MVC에서 사용
*/
public List<ExternalResponse> getListFromFlux() {
return externalApiWebClient.get()
.uri("/v1/data/stream")
.retrieve()
.bodyToFlux(ExternalResponse.class)
// 1️⃣ 필터 1: 활성 데이터만
.filter(ExternalResponse::isActive)
// 2️⃣ 필터 2: 특정 타입만
.filter(response -> "PREMIUM".equals(response.getType()))
// 3️⃣ map: 외부 응답 → 내부 DTO 변환
.map(response -> ExternalDto.from(response))
.collectList()
.block();
}
}
- 리턴은 Mono 혹은 Flux로 리턴을 한다.
- BodyToMono : 단일결과
- BodyToFlux : 다중결과 혹은 스트림
- 동기로 동작하는 경우는 block을 이용한다.
- List로 응답을 받아올 경우에는 ParameterizedTypeReference<List<타입>>() 을 이용하여야 한다.
- 혹은 비동기의 경우 BodyToFlux를 통해 스트림을 받아온 후, 리스트로 변환할 수 있다.
오류처리
public List<ExternalDto> getWithErrorHandling() {
return externalApiWebClient.get()
.uri(uriBuilder -> uriBuilder
.path("/v1/data/stream")
.queryParam("type", "PREMIUM")
.build()
)
.header(HttpHeaders.AUTHORIZATION, "Bearer access-token")
.retrieve()
// 1️⃣ HTTP 상태 코드 기반 예외 처리
.onStatus(
HttpStatusCode::is4xxClientError,
response -> response.bodyToMono(String.class)
.map(body -> new ExternalClientException(
"외부 API 4xx 오류: " + body
))
)
.onStatus(
HttpStatusCode::is5xxServerError,
response -> response.bodyToMono(String.class)
.map(body -> new ExternalServerException(
"외부 API 5xx 오류: " + body
))
)
// 2️⃣ 정상 응답 바디 처리
.bodyToFlux(ExternalResponse.class)
// 3️⃣ timeout (응답 지연 시)
.timeout(Duration.ofSeconds(3))
// 5️⃣ fallback 처리 (최종 실패 시)
.onErrorResume(ex -> {
log.error("외부 API 최종 실패, fallback 실행", ex);
// 대체 데이터 반환 (빈 리스트 or 캐시)
return Flux.empty();
})
// 4️⃣ 비즈니스 필터링
.filter(ExternalResponse::isActive)
// 5️⃣ 변환
.map(ExternalDto::from)
// 6️⃣ Flux → List
.collectList()
// 7️⃣ MVC 환경 block
.block();
}
- onStatus 를 통해 특정 응답을 받았을 경우 처리를 별도로 해줄 수 있다.
- timeout 을 통해 응답 대기 timeout 값을 지정할 수 있다.
- onErrorResume 을 통해 기본값을 리턴함으로서 fallback을 구현할 수 있다.
Retry
실패시 Retry 할 수 있다.
public List<ExternalDto> getWithRetry() {
return externalApiWebClient.get()
.uri(uriBuilder -> uriBuilder
.path("/v1/data")
.queryParam("type", "PREMIUM")
.build()
)
.header(HttpHeaders.AUTHORIZATION, "Bearer access-token")
.retrieve()
// 1️⃣ HTTP 상태 코드 기반 예외 처리
.onStatus(
HttpStatusCode::is4xxClientError,
response -> response.bodyToMono(String.class)
.map(body -> new ExternalClientException(
"외부 API 4xx 오류: " + body
))
)
.onStatus(
HttpStatusCode::is5xxServerError,
response -> response.bodyToMono(String.class)
.map(body -> new ExternalServerException(
"외부 API 5xx 오류: " + body
))
)
// 2️⃣ 정상 응답 처리
.bodyToFlux(ExternalResponse.class)
// 3️⃣ timeout
.timeout(Duration.ofSeconds(3))
// 4️⃣ retry 정책
.retryWhen(
Retry.backoff(3, Duration.ofMillis(300))
.maxBackoff(Duration.ofSeconds(2))
.filter(this::isRetryableException)
.onRetryExhaustedThrow((spec, signal) ->
new ExternalRetryExhaustedException(
"외부 API 재시도 초과",
signal.failure()
)
)
)
// 5️⃣ 비즈니스 필터
.filter(ExternalResponse::isActive)
// 6️⃣ 변환
.map(ExternalDto::from)
// 7️⃣ Flux → List
.collectList()
// 8️⃣ MVC 환경 block
.block();
}
서킷브레이커
@CircuitBreaker 어노테이션을 통해 외부 API가 오류가 발생할때 장애를 확산시키지 않기 위해 서킷 브레이커를 설정 할 수 있다.
해당 부분은 여기서 확인 가능하다. (https://dean83.tistory.com/398)
동시 요청 처리 예
@Service
@RequiredArgsConstructor
@Slf4j
public class ExternalApiService {
private final WebClient externalApiWebClient;
/**
* 1️⃣ ID 리스트 기반 동시 요청
* - 여러 ID를 동시에 호출
* - 개별 실패는 무시
*/
public List<ExternalDto> getByIdsConcurrently(List<Long> ids) {
return Flux.fromIterable(ids)
// ID별 동시 호출
.flatMap(id ->
externalApiWebClient.get()
.uri("/v1/data/{id}", id)
.retrieve()
.bodyToMono(ExternalResponse.class)
// 개별 요청 보호
.timeout(Duration.ofSeconds(2))
// ID 하나 실패해도 전체는 유지
.onErrorResume(ex -> {
log.warn("ID {} 호출 실패", id, ex);
return Mono.empty();
})
)
// 비즈니스 필터
.filter(ExternalResponse::isActive)
// 외부 → 내부 DTO
.map(ExternalDto::from)
// Flux → List
.collectList()
// MVC 환경
.block();
}
/**
* 2️⃣ 서로 다른 엔드포인트 zip 결합 요청
* - detail + stat 결합
*/
public CombinedDto getCombinedData(Long id) {
Mono<DetailResponse> detailMono =
externalApiWebClient.get()
.uri("/v1/detail/{id}", id)
.retrieve()
.bodyToMono(DetailResponse.class)
.timeout(Duration.ofSeconds(2));
Mono<StatResponse> statMono =
externalApiWebClient.get()
.uri("/v1/stat/{id}", id)
.retrieve()
.bodyToMono(StatResponse.class)
.timeout(Duration.ofSeconds(2));
return Mono.zip(detailMono, statMono)
// 두 응답을 하나로 결합
.map(tuple -> CombinedDto.of(
tuple.getT1(),
tuple.getT2()
))
// zip 중 하나라도 실패 시 fallback
.onErrorResume(ex -> {
log.error("zip 요청 실패 (id={})", id, ex);
return Mono.just(CombinedDto.empty());
})
// MVC 환경
.block();
}
}
- FlatMap을 통해 list 에 있는 항목을 각각 개별로 동시 요청할 수 있다.
- 다수의 요청을 정의 하고 다수의 요청을 zip을 통해 동시에 요청할 수 있다.
- CompletableFuture와 사용방식 및 구성이 비슷하다.
'Backend > SpringBoot' 카테고리의 다른 글
| Local Cache, Caffein (0) | 2026.01.05 |
|---|---|
| 서킷 브레이커 (0) | 2026.01.05 |
| Task Decorator (@Async 에서 MDC, SecurityContextHolder 정보 활용) (0) | 2026.01.02 |
| @Async 설정 및 사용 (0) | 2026.01.02 |
| [Spring Security] SessionID 관련 주요 필터, 이벤트처리 및 설정 (0) | 2025.12.17 |