Project Reactor 사례로 알아보기

Jayson·2025년 9월 19일
0
post-thumbnail

서론: 14.2초의 기다림, 원인은 I/O 병목

https://github.com/jsoonworld/foodiePass

해외 미식 여행객을 위한 서비스 'Foodiepass'는 사용자가 음식점 메뉴 사진을 올리면, 텍스트를 인식(OCR)하고 사용자의 언어로 번역한 뒤, 현지 통화 기준으로 가격을 변환해 주는 핵심 기능을 가지고 있었어요. 이 과정은 세 개의 서로 다른 외부 API(OCR, 번역, 환율 조회)를 순차적으로 호출하며 구현되었죠. 하지만 서비스 트래픽이 증가하면서 심각한 성능 문제가 발생하기 시작했습니다. 부하 테스트 결과, p95 응답 시간이 무려 14.2초에 달했습니다.

p95 응답 시간이란 전체 요청 중 95%는 해당 시간보다 빠르게 처리되지만, 나머지 5%의 사용자는 그보다 더 긴 시간을 기다려야 함을 의미하는 지표예요. 평균 응답 시간은 소수의 매우 빠른 응답에 의해 왜곡될 수 있기 때문에, 시스템이 부하를 받을 때 실제 사용자 경험의 최악 시나리오를 보여주는 이 '꼬리 지연 시간(Tail Latency)'이 훨씬 중요한 성능 척도가 됩니다. 14.2초라는 시간은 사실상 서비스 이탈로 이어질 수 있는 수치였어요.

문제의 근원은 복잡한 연산이 아니었어요. 바로 I/O-Bound 병목 현상이었습니다. 각 API를 호출하고 응답을 기다리는 동안, 애플리케이션 스레드는 아무 일도 하지 않고 그저 대기하며 시간을 낭비하고 있었죠. 이는 마치 배달 기사가 첫 식당에서 음식이 포장될 때까지 기다렸다가, 포장이 끝나야만 다음 식당으로 출발하는 비효율적인 방식과 같아요.

이 글의 목표는 전통적인 Blocking I/O 모델의 한계를 진단하고, Spring WebFlux와 Project Reactor를 도입하여 어떻게 이 I/O 병목을 해결하고 성능을 개선했는지 그 원리와 과정을 공유하는 것입니다.


스레드의 발목을 잡는 블로킹 I/O (Blocking I/O)

Foodiepass 서비스의 성능 저하 원인을 이해하려면, 전통적인 Spring MVC 애플리케이션이 동작하는 'Thread-per-Request' 모델을 살펴봐야 해요. Apache Tomcat 같은 서블릿 컨테이너는 요청을 처리하기 위해 내부적으로 스레드 풀(Thread Pool)을 유지합니다. 새로운 요청이 들어오면 풀에서 스레드를 하나 할당해 해당 요청의 전체 과정을 전담하게 하죠.

문제는 RestTemplate을 사용해 외부 API를 호출하는 것과 같은 I/O(Input/Output) 작업이 발생할 때 시작돼요.

  1. 시스템 호출 및 대기 상태 전환: 애플리케이션 스레드가 외부 API 호출을 위해 운영체제(OS)에 네트워크 I/O를 요청하는 시스템 콜(System Call)을 발생시켜요.
  2. CPU 자원 반납: 커널은 응답이 즉시 도착하지 않았음을 확인하고, 해당 스레드를 '대기(Waiting)' 상태로 전환합니다. 이 상태의 스레드는 CPU 점유를 포기하고, OS 스케줄러의 관리 대상에서 제외돼요. 즉, 응답이 올 때까지 아무런 작업을 수행하지 못하고 멈춰있게 됩니다.
  3. 메모리 점유: 중요한 점은 스레드가 대기 상태에 있더라도 여전히 스택을 위한 메모리(통상 1MB)를 계속 점유하고 있다는 것이에요.
  4. 작업 재개 및 컨텍스트 스위칭: I/O 작업이 완료되어 응답이 도착하면, 커널은 해당 스레드를 다시 '실행 가능(Runnable)' 상태로 변경해요. 이 과정에서 컨텍스트 스위칭(Context Switching) 비용이 발생합니다.

이러한 Blocking I/O 모델은 시스템 전체에 심각한 결과를 초래할 수 있어요.

  • 처리량(Throughput) 저하: 스레드 풀의 개수는 한정되어 있어요. 외부 API 응답이 1초 걸린다면, 해당 스레드는 1초 동안 다른 요청을 처리하지 못하고 묶여있게 됩니다.
  • 스레드 풀 고갈: 동시 사용자 수가 증가할수록, 스레드 풀의 모든 스레드가 대기 상태에 빠져 고갈되는 현상이 발생해요. 이는 결국 시스템 장애의 원인이 됩니다.

많은 개발자들이 서버의 CPU 사용률은 낮은데 응답 시간이 느려지는 현상을 겪곤 해요. 이는 전형적인 I/O-Bound 시스템의 특징입니다. 스레드들이 대부분의 시간을 I/O를 '기다리며' 낭비하고 있기 때문이죠.


Project Reactor의 핵심: Mono, Flux, 그리고 Scheduler

Blocking I/O의 문제를 해결하기 위해 Spring 5부터 도입된 Spring WebFlux는 Project Reactor라는 라이브러리를 기반으로 해요. 이는 Non-Blocking I/O 모델을 구현하기 위한 핵심 도구들을 제공합니다.

Mono와 Flux: 비동기 데이터 스트림의 표현

Project Reactor에서 모든 비동기 데이터 시퀀스는 Publisher를 통해 표현돼요. MonoFlux는 Project Reactor가 제공하는 가장 중요한 두 가지 Publisher 구현체입니다.

  • Mono: 0개 또는 1개의 데이터 아이템을 비동기적으로 전달하는 Publisher예요. 결과가 하나이거나 없을 수 있는 비동기 작업을 표현하는 데 사용됩니다. Java의 CompletableFuture<T>Optional<T>의 리액티브 버전이라고 생각할 수 있어요.
  • Flux: 0개부터 N개까지, 잠재적으로 무한한 개수의 데이터 아이템을 비동기적으로 전달하는 Publisher입니다. 여러 결과를 반환하는 비동기 작업을 표현하며, Java의 List<T>Stream<T>에 해당하지만 데이터가 순차적으로 스트리밍된다는 차이가 있어요.

이러한 리액티브 타입들은 그 자체로 작업을 수행하지 않아요. 대신 데이터 흐름을 선언적으로 정의하고, 최종 소비자인 Subscriber가 구독(subscribe())을 시작하는 시점에 비로소 실행됩니다.

Scheduler: 비동기 작업의 실행자

Scheduler는 리액티브 파이프라인의 각 단계가 어떤 스레드에서 실행될지 결정하는 역할을 해요. Project Reactor는 다양한 시나리오에 최적화된 여러 Scheduler를 제공합니다.

  • Schedulers.parallel(): CPU 코어 수와 동일한 개수의 스레드를 가진 고정 크기 스레드 풀이에요. CPU-Bound 연산에 최적화되어 있습니다.
  • Schedulers.boundedElastic(): 필요에 따라 스레드를 동적으로 생성하지만, 생성 수에 상한선이 있는 스레드 풀입니다. I/O-Bound 작업이나 레거시 블로킹 코드를 실행하는 데 적합해요.

WebClient와 같이 논블로킹으로 동작하는 I/O 라이브러리를 사용하면, 대부분 개발자가 직접 Scheduler를 지정할 필요가 없어요. 내부적으로 관리되는 소수의 이벤트 루프 스레드에서 모든 I/O 이벤트를 효율적으로 처리하기 때문입니다.


실전 리팩토링: Mono.zip으로 여러 API 동시 호출하기

이제 순차적인 블로킹 호출을 병렬적인 논블로킹 호출로 전환하는 구체적인 리팩토링 과정을 살펴볼게요.

Before: 블로킹 방식의 순차적 API 호출

기존 코드는 RestTemplate을 사용하여 세 개의 외부 API를 순서대로 호출했어요.

// Before: Blocking and Sequential
@Service
public class FoodiepassServiceBlocking {

    private final RestTemplate restTemplate;

    public FoodiepassServiceBlocking(RestTemplateBuilder builder) {
        this.restTemplate = builder.build();
    }

    public FoodiepassResponse getFoodiepassDetails(String imageId, String textToTranslate, String currencyCode) {
        long startTime = System.currentTimeMillis();
        
        // 1. OCR API 호출 (이 스레드는 여기서 응답이 올 때까지 BLOCK 됨)
        OcrResponse ocrResponse = restTemplate.getForObject("http://external-service/ocr/{imageId}", OcrResponse.class, imageId);
        System.out.println("OCR API completed in " + (System.currentTimeMillis() - startTime) + " ms");

        // 2. 번역 API 호출 (이 스레드는 여기서 다시 BLOCK 됨)
        TranslationResponse translationResponse = restTemplate.getForObject("http://external-service/translate?text={text}", TranslationResponse.class, textToTranslate);
        System.out.println("Translation API completed in " + (System.currentTimeMillis() - startTime) + " ms");

        // 3. 환율 API 호출 (이 스레드는 여기서 또다시 BLOCK 됨)
        CurrencyResponse currencyResponse = restTemplate.getForObject("http://external-service/currency?code={code}", CurrencyResponse.class, currencyCode);
        System.out.println("All APIs completed in " + (System.currentTimeMillis() - startTime) + " ms");

        // 결과 조합 후 반환
        return new FoodiepassResponse(ocrResponse, translationResponse, currencyResponse);
    }
}

이 방식의 총 소요 시간은 각 API 호출 시간의 합과 거의 같아요.
TotalTimeTimeOCR+TimeTranslate+TimeCurrencyTotalTime \approx Time_{OCR} + Time_{Translate} + Time_{Currency}

After: 논블로킹 동시 호출

리팩토링의 핵심은 RestTemplate을 논블로킹 HTTP 클라이언트인 WebClient로 교체하고, 각 API 호출을 독립적인 Mono로 정의한 뒤, Mono.zip을 사용하여 동시에 실행하는 것이에요.

// After: Non-Blocking and Concurrent
@Service
public class FoodiepassServiceReactive {

    private final WebClient webClient;

    public FoodiepassServiceReactive(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.baseUrl("http://external-service").build();
    }

    public Mono<FoodiepassResponse> getFoodiepassDetails(String imageId, String textToTranslate, String currencyCode) {
        // 1. 각 API 호출을 Mono로 정의.
        Mono<OcrResponse> ocrMono = webClient.get()
                .uri("/ocr/{imageId}", imageId)
                .retrieve()
                .bodyToMono(OcrResponse.class);

        Mono<TranslationResponse> translationMono = webClient.get()
                .uri("/translate?text={text}", textToTranslate)
                .retrieve()
                .bodyToMono(TranslationResponse.class);

        Mono<CurrencyResponse> currencyMono = webClient.get()
                .uri("/currency?code={code}", currencyCode)
                .retrieve()
                .bodyToMono(CurrencyResponse.class);

        // 2. Mono.zip을 사용하여 3개의 Mono를 동시에 실행하도록 조합.
        return Mono.zip(ocrMono, translationMono, currencyMono)
                .map(tuple -> {
                    // 3. 세 개의 Mono가 모두 완료되면, 그 결과들이 Tuple 형태로 전달됨.
                    OcrResponse ocr = tuple.getT1();
                    TranslationResponse translation = tuple.getT2();
                    CurrencyResponse currency = tuple.getT3();
                    
                    // 4. Tuple에서 각 결과를 추출하여 최종 응답 객체를 생성.
                    return new FoodiepassResponse(ocr, translation, currency);
                });
    }
}

Mono.zip은 인자로 전달된 모든 Publisher를 동시에 구독하여 외부 API 호출이 거의 동시에 시작되게 해요. 그리고 모든 Mono가 성공적으로 완료되면, 각 결과를 Tuple 객체에 담아 다운스트림으로 전달합니다.

이러한 동시 실행 덕분에 총 소요 시간은 가장 오래 걸리는 단일 API의 호출 시간에 수렴하게 돼요.
TotalTimeMax(TimeOCR,TimeTranslate,TimeCurrency)TotalTime \approx Max(Time_{OCR}, Time_{Translate}, Time_{Currency})
이것이 Non-Blocking I/O로 전환했을 때 얻을 수 있는 극적인 성능 향상의 핵심 원리입니다.

항목 (Aspect)블로킹 (Spring MVC)논블로킹 (Spring WebFlux)
핵심 기술RestTemplateWebClient
I/O 모델동기적 Blocking I/O비동기적 Non-Blocking I/O
스레딩 모델Thread-per-RequestEvent Loop (소수의 스레드)
지연 시간 모델순차적 (모든 호출 시간의 합)동시적 (가장 느린 호출의 시간)
핵심 연산자해당 없음 (명령형 코드)Mono.zip

결론: I/O-Bound 문제 해결을 위한 효과적인 방법

Foodiepass 서비스는 이 리팩토링을 통해 p95 응답 시간을 14.2초에서 6.32ms로 단축하여 2,246배의 성능 개선을 이뤄냈고, 초당 처리량 또한 8.7배 증가했어요.

이 사례가 보여주듯, Project Reactor는 시스템이 대부분의 시간을 네트워크 호출이나 데이터베이스 조회 같은 I/O 작업을 기다리는 데 사용하는 I/O-Bound 환경에서 진정한 가치를 발휘해요. 반면, 복잡한 알고리즘 수행처럼 CPU 자원을 많이 소모하는 CPU-Bound 작업에서는 큰 이점을 제공하지 않을 수 있습니다.

리액티브 프로그래밍으로의 전환은 단순한 라이브러리 변경 이상의 패러다임 변화를 요구하며 상당한 학습 곡선이 필요해요. 하지만 애플리케이션의 성능 한계가 I/O 대기 시간 때문이라면, Project Reactor는 그 어떤 것보다 효과적인 해결책이 될 수 있습니다.

profile
Small Big Cycle

0개의 댓글