Backend/SpringBoot

MVC 환경에서 WebClient

Dean83 2026. 1. 5. 12:48

외부 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와 사용방식 및 구성이 비슷하다.