ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring WebClient와 버추얼 스레드, 어떻게 조합할까?
    Server/Spring Boot 2026. 1. 9. 16:04
    728x90
    반응형

    Spring WebClient와 버추얼 스레드, 어떻게 조합할까?

    Spring에서 HTTP 클라이언트를 선택할 때 고민이 많다. RestTemplate은 deprecated 예정이고, WebClient는 리액티브라 러닝 커브가 있다. 그런데 Java 21 버추얼 스레드가 등장하면서 판도가 바뀌었다.

    이 글에서는 RestTemplate, WebClient, RestClient를 비교하고, 버추얼 스레드 환경에서 어떤 선택이 최선인지 정리한다.


    HTTP 클라이언트 3종 비교

    RestTemplate (레거시)

    @Service
    @RequiredArgsConstructor
    public class UserService {
    
        private final RestTemplate restTemplate;
    
        public UserResponse getUser(Long userId) {
            return restTemplate.getForObject(
                "https://api.example.com/users/{id}",
                UserResponse.class,
                userId
            );
        }
    }
    • 동기/블로킹 방식
    • 사용하기 쉬움
    • Spring 5부터 유지보수 모드 (신규 기능 추가 없음)
    • 스레드 풀 고갈 문제 발생 가능

    WebClient (리액티브)

    @Service
    @RequiredArgsConstructor
    public class UserService {
    
        private final WebClient webClient;
    
        public Mono<UserResponse> getUser(Long userId) {
            return webClient.get()
                .uri("/users/{id}", userId)
                .retrieve()
                .bodyToMono(UserResponse.class);
        }
    
        // 블로킹으로 사용할 경우
        public UserResponse getUserBlocking(Long userId) {
            return getUser(userId).block();
        }
    }
    • 비동기/논블로킹 방식
    • Reactor 기반 (Mono, Flux)
    • 높은 처리량
    • 러닝 커브가 높고, 디버깅이 어려움

    RestClient (Spring 6.1+)

    @Service
    @RequiredArgsConstructor
    public class UserService {
    
        private final RestClient restClient;
    
        public UserResponse getUser(Long userId) {
            return restClient.get()
                .uri("/users/{id}", userId)
                .retrieve()
                .body(UserResponse.class);
        }
    }
    • 동기/블로킹 방식
    • WebClient와 유사한 fluent API
    • RestTemplate의 후계자
    • Spring 6.1 / Spring Boot 3.2부터 지원

    버추얼 스레드 환경에서의 선택

    핵심 포인트

    버추얼 스레드는 블로킹 I/O를 효율적으로 처리한다. I/O 대기 시 캐리어 스레드를 반환하고, 완료되면 다시 할당받는다.

    이 말은 곧 굳이 리액티브 프로그래밍을 하지 않아도 높은 처리량을 얻을 수 있다는 뜻이다.

    구분 WebClient (리액티브) RestClient + 버추얼 스레드
    코드 복잡도 높음 낮음
    디버깅 어려움 (스택 트레이스 복잡) 쉬움 (일반 동기 코드)
    처리량 높음 높음 (동등 수준)
    러닝 커브 Reactor 학습 필요 기존 지식 활용 가능
    에러 처리 onErrorResume, onErrorMap 등 try-catch

    결론: RestClient + 버추얼 스레드 조합 추천

    리액티브의 복잡성 없이 높은 처리량을 얻을 수 있다.


    RestClient 설정 방법

    1. Bean 등록

    @Configuration
    public class RestClientConfig {
    
        @Bean
        public RestClient restClient(RestClient.Builder builder) {
            return builder
                .baseUrl("https://api.example.com")
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .requestFactory(clientHttpRequestFactory())
                .build();
        }
    
        private ClientHttpRequestFactory clientHttpRequestFactory() {
            JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory();
            factory.setReadTimeout(Duration.ofSeconds(5));
            return factory;
        }
    }

    JdkClientHttpRequestFactory는 Java 11+의 HttpClient를 사용하며, 버추얼 스레드와 궁합이 좋다.

    2. 버추얼 스레드 활성화

    spring:
      threads:
        virtual:
          enabled: true

    3. 실제 사용 예시

    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final RestClient restClient;
    
        public OrderDetailResponse getOrderDetail(Long orderId) {
            // 여러 API를 순차 호출해도 버추얼 스레드 덕분에 효율적
            UserResponse user = getUser(orderId);
            PaymentResponse payment = getPayment(orderId);
            DeliveryResponse delivery = getDelivery(orderId);
    
            return OrderDetailResponse.of(user, payment, delivery);
        }
    
        private UserResponse getUser(Long orderId) {
            return restClient.get()
                .uri("/orders/{id}/user", orderId)
                .retrieve()
                .body(UserResponse.class);
        }
    
        private PaymentResponse getPayment(Long orderId) {
            return restClient.get()
                .uri("/orders/{id}/payment", orderId)
                .retrieve()
                .body(PaymentResponse.class);
        }
    
        private DeliveryResponse getDelivery(Long orderId) {
            return restClient.get()
                .uri("/orders/{id}/delivery", orderId)
                .retrieve()
                .body(DeliveryResponse.class);
        }
    }

    병렬 호출이 필요할 때

    순차 호출보다 병렬 호출이 필요하면 StructuredTaskScope를 활용한다.

    public OrderDetailResponse getOrderDetailParallel(Long orderId) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Supplier<UserResponse> userTask = scope.fork(() -> getUser(orderId));
            Supplier<PaymentResponse> paymentTask = scope.fork(() -> getPayment(orderId));
            Supplier<DeliveryResponse> deliveryTask = scope.fork(() -> getDelivery(orderId));
    
            scope.join();
            scope.throwIfFailed();
    
            return OrderDetailResponse.of(
                userTask.get(),
                paymentTask.get(),
                deliveryTask.get()
            );
        }
    }

    3개의 API 호출이 동시에 실행되어 총 소요 시간이 가장 느린 API 응답 시간과 같아진다.

    StructuredTaskScope는 JDK 21에서 Preview 기능이다. --enable-preview 옵션 필요.


    WebClient를 써야 하는 경우

    모든 상황에서 RestClient가 정답은 아니다. 다음 경우에는 WebClient가 더 적합하다.

    • 스트리밍 응답 처리: SSE, 대용량 파일 다운로드
    • 기존 리액티브 스택: 이미 WebFlux 기반으로 구축된 서비스
    • 배압(Backpressure) 제어가 필요한 경우
    // 스트리밍 예시 - WebClient가 적합
    public Flux<ServerSentEvent<String>> streamEvents() {
        return webClient.get()
            .uri("/events/stream")
            .retrieve()
            .bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {});
    }

    에러 처리 비교

    WebClient 방식

    public Mono<UserResponse> getUser(Long userId) {
        return webClient.get()
            .uri("/users/{id}", userId)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, response ->
                Mono.error(new UserNotFoundException(userId)))
            .onStatus(HttpStatusCode::is5xxServerError, response ->
                Mono.error(new ExternalApiException("서버 오류")))
            .bodyToMono(UserResponse.class)
            .timeout(Duration.ofSeconds(5))
            .retryWhen(Retry.backoff(3, Duration.ofMillis(500)));
    }

    RestClient 방식

    public UserResponse getUser(Long userId) {
        try {
            return restClient.get()
                .uri("/users/{id}", userId)
                .retrieve()
                .body(UserResponse.class);
        } catch (HttpClientErrorException.NotFound e) {
            throw new UserNotFoundException(userId);
        } catch (HttpServerErrorException e) {
            throw new ExternalApiException("서버 오류");
        }
    }

    RestClient는 일반적인 try-catch로 처리할 수 있어 직관적이다.


    마이그레이션 가이드

    RestTemplate → RestClient

    RestTemplate RestClient
    getForObject(url, Class) get().uri(url).retrieve().body(Class)
    postForObject(url, body, Class) post().uri(url).body(body).retrieve().body(Class)
    exchange(url, method, entity, Class) method(method).uri(url).body(body).retrieve().body(Class)

    WebClient → RestClient (동기 사용 시)

    // Before: WebClient
    webClient.get()
        .uri("/users/{id}", userId)
        .retrieve()
        .bodyToMono(UserResponse.class)
        .block();
    
    // After: RestClient
    restClient.get()
        .uri("/users/{id}", userId)
        .retrieve()
        .body(UserResponse.class);

    .block() 호출 없이 깔끔해진다.


    정리

    상황 추천 클라이언트
    신규 프로젝트 + JDK 21 RestClient + 버추얼 스레드
    기존 WebFlux 프로젝트 WebClient 유지
    스트리밍/SSE 처리 WebClient
    레거시 마이그레이션 RestClient로 점진적 전환

    버추얼 스레드 덕분에 동기 코드의 단순함비동기의 처리량을 동시에 얻을 수 있게 되었다. 리액티브가 필수가 아닌 선택이 된 셈이다.

    다음 글에서는 버추얼 스레드 환경에서 발생할 수 있는 실전 트러블슈팅 사례를 다룬다.

    728x90
    반응형

    댓글

Designed by Tistory.