FireDrago
[치즈픽] 가상 스레드 도입기 (부하 테스트와 스레드 최적화) 본문
치즈픽 - Cheese-Pick
cheesepick.me

치지직의 상위 N개의 방송을 동적으로 인식하고, 자동으로 하이라이트 타임스탬프를 찍는 '치즈픽' 서비스를 운영 중이다.
치즈픽의 핵심 엔진은 실시간으로 수백 개의 방송 채팅 웹소켓에 연결하여 `Kafka`를 통해 수집과 분석을 비동기적으로 분리하고,
`Redis TimeSeries`를 활용해 시간 기반 채팅 데이터를 집계하여 하이라이트 분석하고, api 모듈에 전송하는 역할을 하고 있다. 전형적인 I/O 집약적인 작업이다.
2코어 2GB램 (t4g.medium 과 유사)의 제한된 로컬 리소스 환경에서 스프링 기본 스레드 (200개)를 사용했다. 하지만 부하테스트를 진행하며, 이 구조의 한계를 발견하고 가상스레드를 적용한 과정을 정리했다.
1. 플랫폼 스레드의 한계와 병목

| 측정 항목 | 플랫폼 스레드 (2000TPS 유입 시) | 비고 |
| CPU 사용률 | 13.7% | CPU 자원은 여유 있음 |
| 활성 스레드 수 | 244개 (대부분 대기 상태) | 스레드풀 고갈 |
| 채팅 큐잉 P95 지연 시간 | 1.2초 (1,200ms) | 실시간 처리 지연 발생 |
| 힙 메모리 사용량 | 최대 1.20 GB | 최대 60% 수준 무난 |
| 모니터링 상태 | 블랙아웃 (400ms) | Prometheus 스크랩 요청 처리 불가 |
스레드 200개가 모두 웹소켓 수신과 kafka 전송 등 I/O 대기에 빠지면서 스레드 기아(Thread Starvation) 상태가 되었다. CPU 사용량은 높지 않은데도, 정작 일을 할 수 있는 스레드가 없어 prometheus 요청이 넘어오지 않아, 대시보드에 지표가 표시되지 않는 장애를 겪기도 했다.
2. 왜 가상 스레드인가?
I/O 병목을 해결하기 위한 가장 전통적인 방법은 비동기 논블로킹(Asynchronous Non-blocking) 방식을 사용하는 것이다. Spring 생태계에서는 Spring WebFlux가 대표적이다. 하지만 이번 프로젝트에서는 WebFlux 대신 Java25의 가상스레드를 선택했다. 그 이유는 두가지 이다.
1. 러닝 커브와 패러다임 전환
WebFlux를 도입하려면 `Mono`와 `Flux`라는 리액티브 프로그래밍 패러다임을 익혀야 한다. 또한, 로직 전반을 메서드 체이닝 형태로 작성해야 하므로 코드의 가독성이 떨어지고, 디버깅 시 스택 트레이스 추적이 매우 까다롭다.
2. 기존 동기식 코드의 장점 유지
반면 가상 스레드는 기존의 동기식/블로킹 코드를 그대로 작성해도 된다. JVM이 알아서 I/O 대기(Blocking)가 발생할때 실제 OS 스레드를 다른 가상스레드에게 Unmount 하기 때문이다. 즉 WebFlux 수준의 높은 처리량을 확보하면서도, 기존 MVC 방식의 쉬운 코드를 유지 할 수 있다는 것이 가상 스레드를 선택한 가장 큰 이유였다.
3. 스레드 전환을 위한 리팩토링
테스트의 신뢰성을 높이기 위해서는 애플리케이션 코드를 수정하지 않고, 환경 설정만으로 플랫폼 스레드와 가상스레드를 전환 할 수 있어야 했다. 이를 위해 `application.yml` 설정값에 따라 스레드 풀(Executor)을 동적으로 주입 (DI)하도록 리팩토링을 진행했다.
// application-local.yml
spring:
threads:
virtual:
enabled: true # 플랫폼 스레드/ 가상 스레드 전환
app:
thread:
mode: virtual # platform 또는 virtual 로 동적 전환
`spring:threads:virtual:enabled: true` : 스프링의 표준 인프라 설정이 가상스레드로 전환된다. Tomcat, @Async, @Scheduled 등이 가상스레드 기반으로 작동하도록 설정한다.
`app:thread:mode: virtual` : 비지니스 로직 제어용 커스텀 설정이다. 수동으로 주입해서 사용하는 비지니스 로직 전용 설정이다.
@Configuration
public class ThreadConfig {
@Value("${app.thread.mode:platform}")
private String threadMode;
@Bean(name = "customExecutor")
public Executor customExecutor() {
if ("virtual".equalsIgnoreCase(threadMode)) {
// 가상 스레드 풀 반환
return Executors.newVirtualThreadPerTaskExecutor();
} else {
// 기존 플랫폼 스레드 풀 (Tomcat 기본 수준인 200개로 설정)
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(200);
executor.setMaxPoolSize(200);
executor.setThreadNamePrefix("platform-exec-");
executor.initialize();
return executor;
}
}
}
이제 분석 로직이나 웹소켓 Handler 등 I/O가 발생하는 로직에서 `@Qualifier("customExecutor")`를 주입받아 사용하게 함으로써, yaml 설정 한 줄만 바꾸면 즉각적인 가상 <-> 플랫폼 스레드의 전환이 가능한 구조가 되었다.
4. 가상 스레드 도입 결과 : 3000TPS 처리량 달성

| 측정 항목 | 플랫폼 스레드 (2,000 TPS) | 가상 스레드 (3,000TPS) | 비교 결과 |
| 처리량 (TPS) | 1,928TPS | 2,840TPS | 부하 1.5배 안정적 수용 |
| 활성 스레드 수 | 244개 (스레드 풀 고갈) | 48개 (안정적 유지) | 스레드 점유 80% 감소 |
| 채팅 큐잉 P95 지연 시간 | 1.2초 (1,200ms) | 32ms | 약 97% 지연 시간 단축 |
| CPU 사용률 | 13.7% | 17.7% | 유사한 자원 사용 |
| 힙 메모리 사용량 | 최대 1.2GB | 최대 1.25GB | 유사한 자원 사용 |
| 모니터링 | 블랙아웃 (스레드 풀 고갈) | 문제없이 모니터링 | 원활한 모니터링 |
결과는 만족스러웠다. 유입되는 트래픽은 1.5배가 늘어났음에도 불구하고, 채팅 수입부터 kafka에 큐잉 되기 까지의 P95 지연 시간은 1.2초에서 32ms로 약 97% 감소했다. 플랫폼 스레드 환경에서는 200개가 넘는 스레드가 I/O 블로킹에 빠졌지만, 가상스레드 환경에서는 활성 스레드가 단 48개만을 유지했다. I/O 블로킹이 발생할 때마다 가상 스레드가 캐리어 스레드를 효율적으로 반납하며 300개의 웹소켓 연결을 가볍게 처리했다.
5. 아키텍쳐로 부하 한계 극복하기
트래픽이 몰려 장애가 발생하면 가장 먼저 서버 스케일업(Scale-up)을 고민하게 된다. 나 역시 초기에는 오라클 무료 티어(ARM 4코어 24GB)나 값비싼 AWS 인스턴스로 이사 가야 하나?라는 고민을 했다.
하지만 원인은 하드웨어가 아니라 소프트웨어의 I/O 블로킹에 있었다. 하드웨어 스펙은 단 1도 올리지 않고, 단지 Java 25의 가상 스레드로 스레드 모델만 변경했을 뿐인데 시스템의 한계를 2배 이상 끌어올릴 수 있었다.
'프로젝트' 카테고리의 다른 글
| [치즈픽] Redis vs 애플리케이션 비즈니스 로직은 어디에 둬야 할까? (0) | 2026.05.27 |
|---|---|
| [치즈픽] 실시간 채팅 분석 파이프라인 구축기 (Kafka, Redis TS) (0) | 2026.04.14 |
| [치즈픽] 하이브리드 아키텍쳐 도입기 (OCI + Home Server) (0) | 2026.03.20 |
| [치즈픽] 치지직 채팅 화력분석 알고리즘 개선기 (0) | 2026.03.19 |
| [Kafka] Kafka를 사용할때 살펴봐야할 6가지 (2) (0) | 2026.03.03 |