FireDrago

[치즈픽] 가상 스레드 도입기 (부하 테스트와 스레드 최적화) 본문

프로젝트

[치즈픽] 가상 스레드 도입기 (부하 테스트와 스레드 최적화)

화이용 2026. 4. 13. 18:58

치즈픽 - Cheese-Pick

 

치즈픽 - Cheese-Pick

 

cheesepick.me

 

Engine 모듈의 흐름

치지직의 상위 N개의 방송을 동적으로 인식하고, 자동으로 하이라이트 타임스탬프를 찍는 '치즈픽' 서비스를 운영 중이다.

치즈픽의 핵심 엔진은 실시간으로 수백 개의 방송 채팅 웹소켓에 연결하여 `Kafka`를 통해 수집과 분석을 비동기적으로 분리하고,

`Redis TimeSeries`를 활용해 시간 기반 채팅 데이터를 집계하여 하이라이트 분석하고, api 모듈에 전송하는 역할을 하고 있다. 전형적인 I/O 집약적인 작업이다.

 

2코어 2GB램 (t4g.medium 과 유사)의 제한된 로컬 리소스 환경에서 스프링 기본 스레드 (200개)를 사용했다. 하지만 부하테스트를 진행하며, 이 구조의 한계를 발견하고 가상스레드를 적용한 과정을 정리했다.

 

1. 플랫폼 스레드의 한계와 병목

200개 웹소켓 2000TPS (웹소켓 하나당 초당 10개의 채팅)

측정 항목 플랫폼 스레드 (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 처리량 달성

300개 웹소켓 3000TPS (웹소켓 하나당 초당 10개의 채팅)

측정 항목 플랫폼 스레드 (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배 이상 끌어올릴 수 있었다.