Spring 7기 프로젝트/모임 플렛폼 프로젝트

WebClient의 비동기 처리와 블로킹: 성능 최적화의 핵심 이해

JuNo_12 2025. 8. 9. 16:57

들어가며

Spring WebClient를 사용할 때 많은 개발자들이 놓치는 중요한 개념이 있습니다. 바로 네트워크 레벨의 비동기 처리애플리케이션 레벨의 블로킹을 구분하는 것입니다. 이번 포스트에서는 WebClient의 내부 동작 원리를 깊이 있게 분석하고, 실제 성능 최적화 방안을 제시하겠습니다.


WebClient의 내부 아키텍처

톰캣과 네티의 역할 분리

많은 개발자들이 혼동하는 부분이 톰캣과 네티의 역할입니다. Spring Boot 애플리케이션에서는 다음과 같은 구조로 동작합니다.

클라이언트 요청 → [톰캣 서버] → Controller → Service → [네티 클라이언트] → 외부 API
                    ↑                                        ↑
                 서버 역할                               클라이언트 역할

톰캣은 외부에서 들어오는 HTTP 요청을 처리하는 서버 역할을 담당하고, 네티는 우리 애플리케이션에서 외부 API로 나가는 요청을 처리하는 클라이언트 역할을 담당합니다.

 

WebClient의 기본 설정

WebClient는 기본적으로 Reactor Netty를 사용합니다.

// 기본 WebClient도 내부적으로 네티 사용
WebClient webClient = WebClient.create();

// 명시적 네티 설정
WebClient webClient = WebClient.builder()
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .build();

비동기 처리의 두 가지 레벨

네트워크 레벨: 이미 비동기

네티는 이벤트 루프 기반의 비동기 I/O를 사용합니다.

HttpClient httpClient = HttpClient.create()
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
    .responseTimeout(Duration.ofSeconds(10))
    .doOnConnected(conn ->
        conn.addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS)));

이 설정에서 네티는 다음과 같이 동작합니다:

  1. 이벤트 루프 스레드가 모든 I/O 작업을 담당
  2. 논블로킹 소켓을 사용하여 효율적인 네트워크 처리
  3. 커넥션 풀을 통한 연결 재사용
  4. 적은 스레드로 많은 동시 연결 처리

 

애플리케이션 레벨: 블로킹 사용

하지만 애플리케이션 코드에서는 동기적으로 사용하는 경우가 많습니다.

@Service
public class UserService {
    
    public UserDto getUser(Long userId) {
        // 네티는 비동기로 처리하지만
        // 톰캣 스레드는 여기서 블로킹
        return webClient.get()
            .uri("/users/{id}", userId)
            .retrieve()
            .bodyToMono(UserDto.class)
            .block(); // 블로킹 지점
    }
}

성능 병목 지점 분석

현재 처리 흐름

1. 톰캣 스레드가 WebClient 호출
2. 네티 이벤트 루프가 비동기로 HTTP 요청 처리
3. .block()으로 인해 톰캣 스레드가 응답 대기
4. 네티가 응답을 받으면 톰캣 스레드에게 결과 전달
5. 톰캣 스레드가 처리 재개

 

문제점 식별

네티 레벨: 이미 최적화되어 있음

  • 커넥션 풀 재사용
  • Keep-Alive 연결
  • 이벤트 기반 처리

애플리케이션 레벨: 비효율적

  • 톰캣 스레드 블로킹
  • 동시성 제한
  • 리소스 낭비

실제 성능 차이

동기 처리의 한계

여러 외부 API를 호출해야 하는 경우:

public OrderSummaryDto getOrderSummary(Long orderId) {
    UserDto user = userClient.getUser(orderId);        // 100ms
    PaymentDto payment = paymentClient.getPayment(orderId); // 200ms
    ProductDto product = productClient.getProduct(orderId); // 150ms
    
    return OrderSummaryDto.of(user, payment, product);
    // 총 소요시간: 450ms
}

 

비동기 처리의 효율성

public Mono<OrderSummaryDto> getOrderSummaryAsync(Long orderId) {
    Mono<UserDto> userMono = userClient.getUserAsync(orderId);
    Mono<PaymentDto> paymentMono = paymentClient.getPaymentAsync(orderId);
    Mono<ProductDto> productMono = productClient.getProductAsync(orderId);
    
    return Mono.zip(userMono, paymentMono, productMono)
        .map(tuple -> OrderSummaryDto.of(
            tuple.getT1(), tuple.getT2(), tuple.getT3()
        ));
    // 총 소요시간: 200ms (가장 느린 API 기준)
}

점진적 비동기 전환 전략

1단계: 고비용 작업부터 시작

결제나 외부 API 호출이 많은 도메인부터 비동기로 전환합니다.

@Service
public class PaymentService {
    
    // 기존 동기 메서드 유지 (호환성)
    public PaymentResponseDto processPayment(PaymentRequest request) {
        return processPaymentAsync(request).block();
    }
    
    // 새로운 비동기 메서드 추가
    public Mono<PaymentResponseDto> processPaymentAsync(PaymentRequest request) {
        return paymentClient.createPayment(request)
            .flatMap(payment -> paymentClient.confirmPayment(payment.getPaymentKey()))
            .flatMap(confirmed -> notificationClient.sendSuccess(confirmed))
            .onErrorResume(error -> paymentClient.rollbackPayment(error));
    }
}

 

2단계: 이벤트 기반 처리

Spring의 비동기 이벤트를 활용합니다.

@Component
public class PaymentEventHandler {
    
    @Async
    @EventListener
    public void handlePaymentCompleted(PaymentCompletedEvent event) {
        paymentService.processPaymentAsync(event.getPaymentRequest())
            .subscribe(
                result -> log.info("Payment processed: {}", result),
                error -> log.error("Payment failed: {}", error)
            );
    }
}

 

3단계: 컨트롤러 레벨 비동기

Spring MVC에서도 비동기 응답을 지원합니다.

@RestController
public class PaymentController {
    
    @PostMapping("/payments")
    public DeferredResult<PaymentResponseDto> createPayment(
            @RequestBody PaymentRequest request) {
        
        DeferredResult<PaymentResponseDto> deferredResult = new DeferredResult<>();
        
        paymentService.processPaymentAsync(request)
            .subscribe(
                deferredResult::setResult,
                deferredResult::setErrorResult
            );
        
        return deferredResult;
    }
}

모니터링 및 성능 측정

메트릭 수집

비동기 전환의 효과를 측정하기 위한 메트릭을 수집합니다.

@Component
public class WebClientMetrics {
    
    private final MeterRegistry meterRegistry;
    
    public ExchangeFilterFunction metricsFilter() {
        return ExchangeFilterFunction.ofRequestProcessor(request -> {
            Timer.Sample sample = Timer.start(meterRegistry);
            request.attributes().put("timer.sample", sample);
            return Mono.just(request);
        }).andThen(ExchangeFilterFunction.ofResponseProcessor(response -> {
            Timer.Sample sample = response.request().attributes().get("timer.sample");
            sample.stop(Timer.builder("webclient.request")
                .tag("uri", response.request().getURI().getPath())
                .tag("status", String.valueOf(response.statusCode().value()))
                .register(meterRegistry));
            return Mono.just(response);
        }));
    }
}

성능 지표

다음 지표들을 통해 개선 효과를 측정할 수 있습니다:

  • 응답 시간: 평균, P95, P99 응답 시간
  • 처리량: 초당 처리 가능한 요청 수
  • 스레드 사용률: 톰캣 스레드 풀 사용률
  • 에러율: 타임아웃 및 실패율

주의사항과 트레이드오프

트랜잭션 관리

비동기 처리에서는 트랜잭션 컨텍스트가 전파되지 않습니다.

@Transactional
public Mono<PaymentResponseDto> processPayment(PaymentRequest request) {
    // 이 트랜잭션은 비동기 체인에서 유지되지 않음
    return paymentRepository.save(payment)
        .flatMap(saved -> externalPaymentService.process(saved)); // 별도 트랜잭션
}

 

에러 핸들링

비동기 체인에서의 에러 처리는 더 세심한 주의가 필요합니다.

public Mono<PaymentResponseDto> processPaymentAsync(PaymentRequest request) {
    return paymentClient.createPayment(request)
        .timeout(Duration.ofSeconds(30))
        .retry(2)
        .onErrorResume(TimeoutException.class, ex -> 
            Mono.error(new PaymentTimeoutException("Payment timed out", ex)))
        .onErrorResume(ex -> 
            Mono.error(new PaymentProcessingException("Payment failed", ex)));
}

결론

WebClient를 사용할 때는 네트워크 레벨과 애플리케이션 레벨의 비동기 처리를 구분해서 이해하는 것이 중요합니다. 네티는 이미 효율적인 비동기 I/O를 제공하고 있지만, .block() 사용으로 인해 애플리케이션 레벨에서 병목이 발생할 수 있습니다.

 

특히 외부 API 호출이 빈번한 결제나 알림 도메인에서는 비동기 전환을 통해 상당한 성능 향상을 얻을 수 있습니다. 전체 시스템을 한 번에 바꾸기보다는 점진적으로 전환하면서 성능 지표를 모니터링하는 것이 안전하고 효과적인 접근법입니다.

 

비동기 프로그래밍은 복잡성을 증가시키지만, 올바르게 적용하면 시스템의 처리량과 응답성을 크게 개선할 수 있는 강력한 도구입니다.