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 병목을 해결하고 성능을 개선했는지 그 원리와 과정을 공유하는 것입니다.
Foodiepass 서비스의 성능 저하 원인을 이해하려면, 전통적인 Spring MVC 애플리케이션이 동작하는 'Thread-per-Request' 모델을 살펴봐야 해요. Apache Tomcat 같은 서블릿 컨테이너는 요청을 처리하기 위해 내부적으로 스레드 풀(Thread Pool)을 유지합니다. 새로운 요청이 들어오면 풀에서 스레드를 하나 할당해 해당 요청의 전체 과정을 전담하게 하죠.
문제는 RestTemplate
을 사용해 외부 API를 호출하는 것과 같은 I/O(Input/Output) 작업이 발생할 때 시작돼요.
이러한 Blocking I/O 모델은 시스템 전체에 심각한 결과를 초래할 수 있어요.
많은 개발자들이 서버의 CPU 사용률은 낮은데 응답 시간이 느려지는 현상을 겪곤 해요. 이는 전형적인 I/O-Bound 시스템의 특징입니다. 스레드들이 대부분의 시간을 I/O를 '기다리며' 낭비하고 있기 때문이죠.
Blocking I/O의 문제를 해결하기 위해 Spring 5부터 도입된 Spring WebFlux는 Project Reactor라는 라이브러리를 기반으로 해요. 이는 Non-Blocking I/O 모델을 구현하기 위한 핵심 도구들을 제공합니다.
Project Reactor에서 모든 비동기 데이터 시퀀스는 Publisher
를 통해 표현돼요. Mono와 Flux는 Project Reactor가 제공하는 가장 중요한 두 가지 Publisher
구현체입니다.
Publisher
예요. 결과가 하나이거나 없을 수 있는 비동기 작업을 표현하는 데 사용됩니다. Java의 CompletableFuture<T>
나 Optional<T>
의 리액티브 버전이라고 생각할 수 있어요.Publisher
입니다. 여러 결과를 반환하는 비동기 작업을 표현하며, Java의 List<T>
나 Stream<T>
에 해당하지만 데이터가 순차적으로 스트리밍된다는 차이가 있어요.이러한 리액티브 타입들은 그 자체로 작업을 수행하지 않아요. 대신 데이터 흐름을 선언적으로 정의하고, 최종 소비자인 Subscriber
가 구독(subscribe())을 시작하는 시점에 비로소 실행됩니다.
Scheduler는 리액티브 파이프라인의 각 단계가 어떤 스레드에서 실행될지 결정하는 역할을 해요. Project Reactor는 다양한 시나리오에 최적화된 여러 Scheduler를 제공합니다.
Schedulers.parallel()
: CPU 코어 수와 동일한 개수의 스레드를 가진 고정 크기 스레드 풀이에요. CPU-Bound 연산에 최적화되어 있습니다.Schedulers.boundedElastic()
: 필요에 따라 스레드를 동적으로 생성하지만, 생성 수에 상한선이 있는 스레드 풀입니다. I/O-Bound 작업이나 레거시 블로킹 코드를 실행하는 데 적합해요.WebClient
와 같이 논블로킹으로 동작하는 I/O 라이브러리를 사용하면, 대부분 개발자가 직접 Scheduler를 지정할 필요가 없어요. 내부적으로 관리되는 소수의 이벤트 루프 스레드에서 모든 I/O 이벤트를 효율적으로 처리하기 때문입니다.
이제 순차적인 블로킹 호출을 병렬적인 논블로킹 호출로 전환하는 구체적인 리팩토링 과정을 살펴볼게요.
기존 코드는 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 호출 시간의 합과 거의 같아요.
리팩토링의 핵심은 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의 호출 시간에 수렴하게 돼요.
이것이 Non-Blocking I/O로 전환했을 때 얻을 수 있는 극적인 성능 향상의 핵심 원리입니다.
항목 (Aspect) | 블로킹 (Spring MVC) | 논블로킹 (Spring WebFlux) |
---|---|---|
핵심 기술 | RestTemplate | WebClient |
I/O 모델 | 동기적 Blocking I/O | 비동기적 Non-Blocking I/O |
스레딩 모델 | Thread-per-Request | Event Loop (소수의 스레드) |
지연 시간 모델 | 순차적 (모든 호출 시간의 합) | 동시적 (가장 느린 호출의 시간) |
핵심 연산자 | 해당 없음 (명령형 코드) | Mono.zip |
Foodiepass 서비스는 이 리팩토링을 통해 p95 응답 시간을 14.2초에서 6.32ms로 단축하여 2,246배의 성능 개선을 이뤄냈고, 초당 처리량 또한 8.7배 증가했어요.
이 사례가 보여주듯, Project Reactor는 시스템이 대부분의 시간을 네트워크 호출이나 데이터베이스 조회 같은 I/O 작업을 기다리는 데 사용하는 I/O-Bound 환경에서 진정한 가치를 발휘해요. 반면, 복잡한 알고리즘 수행처럼 CPU 자원을 많이 소모하는 CPU-Bound 작업에서는 큰 이점을 제공하지 않을 수 있습니다.
리액티브 프로그래밍으로의 전환은 단순한 라이브러리 변경 이상의 패러다임 변화를 요구하며 상당한 학습 곡선이 필요해요. 하지만 애플리케이션의 성능 한계가 I/O 대기 시간 때문이라면, Project Reactor는 그 어떤 것보다 효과적인 해결책이 될 수 있습니다.