WebFlux & Web MVC 혼용해서 사용했을 때 스레드에 대한 질문
29
작성한 질문수 9
안녕하세요 강사님. 강의 잘 듣고 있습니다. 이번 시간에 강사님께서 Spring WebFlux와 Spring Web MVC를 혼용해서 사용하는 경우, 결국에는 Web MVC의 기본 스레드인 톰캣 스레드를 사용해서 스레드 고갈이 될 수 있다고 하셨습니다. 따라서 내부적으로는 WebFlux의 이벤트 루프 스레드가 아닌 톰캣 스레드를 사용해서 블로킹되는 현상이 발생한다로 해석하면 될까요?
저는 혼용할 경우, Mono/Flux 타입 응답에 한해서는 동시 요청 발생 시 이벤트 루프 스레드를 사용하여 IO 작업을 대기하지 않고 해당 스레드가 다른 일을 처리하러 가는 것으로 알고 있었습니다. 혹시 이러한 상황에서 어떤 스레드를 사용하는지 테스트 해볼만한 좋은 방식이 있을까요?
감사합니다.
답변 1
0
안녕하세요 딸기초콜릿님! 좋은 질문 감사합니다.
결론부터 말씀드리면, Spring Web MVC 환경이라면 컨트롤러에서 Mono나 Flux를 반환하더라도 요청을 받는 주체가 Tomcat인 이상 결국 톰캣의 서블릿 스레드가 사용됩니다.
1. 스레드 고갈 및 블로킹에 대해
spring-boot-starter-web (Web MVC) 기반 애플리케이션에서는 요청이 들어오는 시점부터 Tomcat의 Worker Thread가 할당됩니다. 컨트롤러 반환 타입을 Mono/Flux로 지정하거나 내부에서 WebClient 같은 비동기 클라이언트를 쓰더라도, 서블릿 요청-응답의 전체 라이프사이클 자체는 톰캣 스레드 위에서 관리됩니다.
따라서 I/O 대기나 비동기 처리가 일어나는 동안에도 서블릿 스레드가 점유(또는 Async Servlet 대기 상태)되기 때문에, MVC 환경에서 일부 Reactive 모듈을 혼용하는 것만으로는 톰캣 스레드 고갈 문제를 근본적으로 해결하기 어렵습니다. Netty 기반의 이벤트 루프 이점을 100% 활용하려면 애플리케이션 전체를 WebFlux 기반으로 구성해야 합니다.
2. 어떤 스레드가 일하는지 직접 확인해보는 방법
궁금하신 동작 흐름은 아래와 같은 간단한 테스트로 바로 눈으로 확인해보실 수 있습니다!
Thread.currentThread().getName() 로그 찍어보기
컨트롤러 진입 시점과 doOnNext, map 같은 Reactive 파이프라인 내부에서 스레드 이름을 출력해보세요. 컨트롤러를 처음 호출하는 스레드(http-nio-8080-exec-*)와 실제 non-blocking I/O 콜백을 처리하는 스레드(reactor-http-nio-*)가 어떻게 바뀌는지 바로 확인하실 수 있습니다.
k6 / JMeter + VisualVM 조합으로 관찰하기
부하 테스트 도구로 동시 요청을 200~300개 정도 흘려보시면서 VisualVM의 Threads 탭을 열어보세요. 톰캣 스레드가 MAX 수치까지 차오르며 WAITING 상태가 되는지, 그리고 톰캣 스레드 고갈 시 요청이 대기 상태에 빠지는지 실시간으로 모니터링해볼 수 있습니다.
BlockHound 활용하기
Reactor에서 제공하는 BlockHound 라이브러리를 적용해보는 것도 좋습니다. 이벤트 루프 스레드 내에서 블로킹 작업이 호출되면 즉시 에러(BlockingOperationError)를 던져주기 때문에, 현재 코드가 어느 스레드 영역에서 안전하게 도는지 검증하기에 아주 유용합니다.
답변이 이해에 도움이 되셨기를 바라며, 혹시 테스트해보시다가 잘 안 되거나 추가로 궁금한 점이 생기면 언제든 댓글 남겨주세요!
0
상세하고 친절한 답변 감사합니다 강사님. 강사님께서 조언해주신 대로 테스트해봤는데, 흥미로운 결과가 나왔습니다. 외부 API(Open AI) 요청 전에는 톰캣 스레드가 사용되었지만, 외부 API를 호출하는 동안에는 boundedElastic 스레드가 사용됩니다. 찾아보니 이건 톰캣 스레드, 이벤트 루프 스레드 둘 다 아닌, MVC에서 리액티브 API 사용 시 별도로 생성되는 스레드인 것 같습니다. (스레드 개수 기본값은 CPU 코어 수 x 10) 아무래도 톰캣 스레드가 모두 고갈되어서 애플리케이션 전체가 마비되는 것을 방지하기 위해 별도로 만든 스레드인 것 같은데, 제 생각이 맞을까요?
물론 이것이 톰캣 스레드가 고갈되는 현상을 방지하긴 해도 이러한 외부 API 호출 작업이 한꺼번에 몰리고 응답 시간이 길어진다면, 강사님 말씀대로 boundedElastic 스레드 자체도 고갈될 것 같긴 합니다. 따라서 역시 Netty 이점을 그대로 사용하려면 아예 애플리케이션 자체가 Web MVC 라이브러리를 제외하고 오로지 WebFlux만 사용하는 방식밖에 없을 것 같습니다.
톰캣 스레드에서 boundedElastic 스레드로 넘어가는 로그는 다음과 같습니다.

[운영 중인 실서비스의 외부 API (Open AI) 호출 코드]
private Flux<ServerSentEvent<AiChunkResponse>> stream(AiCategory category, String context,
String question, String cacheKey) {
// 캐시 히트: 저장된 전체 답변을 이벤트 1개로 전송한다(가짜 청크 분할 없이 바로 노출).
String cached = cacheService.get(cacheKey);
if (cached != null) {
return Flux.just(token(cached), done());
}
String system = COMMON_RULES + "\n" + category.getPromptSection() + "\n[데이터]\n" + context;
// tool calling 제거로 OpenAI 호출은 1라운드지만, usage 청크가 반복될 수 있어 변할 때만 합산한다.
AtomicInteger inSum = new AtomicInteger();
AtomicInteger outSum = new AtomicInteger();
AtomicReference<String> lastUsageKey = new AtomicReference<>("");
// 캐시 미스: 실시간 스트리밍하면서 토큰을 누적했다가 완료 시 1회 저장한다.
StringBuilder answerBuf = new StringBuilder();
log.info("[Before External IO] Current Thread Name = {}", Thread.currentThread().getName());
return chatClient.prompt()
.system(system)
.user(question)
.stream()
.chatResponse()
.doOnNext(resp -> {
log.info("[While External IO] Current Thread Name = {}", Thread.currentThread().getName());
Usage usage = resp.getMetadata().getUsage();
if (usage == null || usage.getTotalTokens() == null || usage.getTotalTokens() == 0) return;
int in = usage.getPromptTokens() != null ? usage.getPromptTokens() : 0;
int out = usage.getCompletionTokens() != null ? usage.getCompletionTokens() : 0;
String key = in + ":" + out;
if (!key.equals(lastUsageKey.getAndSet(key))) {
inSum.addAndGet(in);
outSum.addAndGet(out);
}
})
.doOnComplete(() -> {
log.info("[AI 토큰] category={} in={} out={} total={}",
category, inSum.get(), outSum.get(), inSum.get() + outSum.get());
cacheService.save(cacheKey, answerBuf.toString());
})
.mapNotNull(resp -> {
// 텍스트 없는 청크(usage/finishReason 전용)는 getText()=null →
// 그대로 내보내면 클라이언트에 'null' 문자열로 찍힌다. 이런 청크는 방출하지 않는다.
if (resp.getResult() == null || resp.getResult().getOutput() == null) return null;
String chunk = resp.getResult().getOutput().getText();
if (chunk == null || chunk.isEmpty()) return null;
answerBuf.append(chunk);
return token(chunk);
})
.concatWith(Mono.just(done()))
.onErrorResume(e -> Mono.just(
ServerSentEvent.builder(new AiChunkResponse("처리 중 오류가 발생했습니다."))
.event("error").build()));
}
네이버 API | '검색 - 책' API 제공 중단
0
11
0
ArticleReadService 관련 질문
0
8
0
kafka 이벤트 발행 실패 시 at-least-once를 보장하는 방법이 궁금합니다.
1
27
1
domain에 @Entity 와 Repository를 함께 둔 이유가 궁금합니다
1
20
1
Part 2 듣기전 복습하던 중, 아키텍처에 관한 질문이 있습니다.
0
25
2
테스트는 인터페이스를 대상으로 만드는것에 대해 궁금한 점이 있습니다.
0
13
1
아무도 모르게 책 내시면 모르실 줄 알고!!
1
41
2
mtputty.com 사용하실 때 주의하세요(질문X/공유)
0
25
2
VirtualBox가 아닌 VMware를 사용해도 되나요
0
24
2
Jib 이미지 빌드에서 docker credential 관련으로 이슈가 있다면
1
56
1
스크립트에 대해 질문 있습니다.
1
50
1
같은 사용자가 연속된 중복 호출할 경우 어떻게 되는지 궁금합니다!
2
51
1
안녕하세요 강사님 질문 있습니다.
0
92
1
bitnami/kafka 말고 다른 이미지를 사용하는 방법 알려주세요.
0
254
2
카프카 질문
1
150
2
안녕하세요. 타임아웃 시간에 관하여 질문있습니다.
1
157
2
분산 시스템 인증/인가 관련 질문 ..
1
116
2
안녕하세요 주문처리에 관하여 질문있습니다 ㅠ ㅁ ㅜ
2
212
2
[데이터 버저닝을 활용한 멱등성 처리] 멱등성 보장을 위한 version 비교 질문
1
154
2
데이터 버저닝 질문
1
147
2
복제 관련 질문입니다!
2
129
2
@Transactional선언 메서드 정상동작하는건가요?
1
124
2
영상 편집이 잘못된 것 같아요. (순서가 중간에 계속 바뀜)
0
102
3
혹시 강의자료랑 강의 안에 나오는 pdf와 같은거가요?
1
124
2





