제목, 태그, 카테고리로 검색

모든 글
약 8분 분량 프로젝트/빌려조잉

Inbound Thread를 빨리 반환하면 더 많은 요청을 받을 수 있다

목차

배경: Spring WebSocket STOMP의 구조

일반적인 WebSocket 라이브러리(Netty, Ktor 등)는 EventLoop 방식으로 동작해서 Thread Pool 설정이 필요 없어요. 하지만 Spring WebSocket STOMP는 Inbound/Outbound Channel에 각각 Thread Pool을 사용하는 구조입니다.

Spring WebSocket STOMP 구조

  • Inbound Thread Pool: 클라이언트 → 서버 메시지 처리
  • Outbound Thread Pool: 서버 → 클라이언트 메시지 전송

이 글은 Spring WebSocket STOMP를 사용할 때 Thread Pool을 효율적으로 활용하는 방법에 대한 내용이에요.


0. 정상 상태

서버 환경: EC2 t3.medium (2 vCPU, 4GB RAM), Spring Boot 3.x + WebSocket STOMP.

Inbound Thread Pool: Spring WebSocket STOMP의 clientInboundChannel 기본 corePoolSize는 Runtime.getRuntime().availableProcessors() * 2 = 4개 (t3.medium 2 vCPU 기준). 이 4개의 스레드가 모든 클라이언트의 메시지 전송을 처리한다.

동시 접속: 테스트 환경 기준 20명, 채팅방 50개. 피크 시 초당 10-20건의 메시지 전송.

성능 기대치: 채팅 메시지 전송은 사용자가 즉시 전달되었다고 느껴야 한다. Inbound Thread가 블로킹되면 다른 사용자의 메시지 처리가 밀리면서 체감 지연이 발생한다.


1. 문제: Thread가 I/O 대기 중에 멈춘다

Spring WebSocket STOMP Handler는 기본적으로 동기 방식이에요.

Thread가 일하는 시간을 분석해봤어요.

측정 조건: EC2 t3.medium, Inbound Thread 2개, 단일 메시지 처리 기준. MongoDB/Redis 같은 서버.

Inbound Thread 1개

  • MongoDB 저장 대기: 100ms (일 안 함)
  • Redis 발행 대기: 10ms (일 안 함)
  • 실제로 CPU 쓰는 시간: <1ms

Thread가 99%의 시간을 그냥 기다리는 데만 씀. Thread 4개 × 110ms 점유 = 초당 최대 ~36건 처리. 피크 트래픽(초당 10-20건)에서 병목 직전이다.


Blocking I/O의 본질

MongoDB 저장 과정을 자세히 보면:

MongoDB 저장 (100ms)

  1. 네트워크 패킷 전송 (1ms) ← CPU 사용
  2. MongoDB 서버 응답 대기 (98ms) ← CPU 안 씀
  3. 네트워크 응답 수신 (1ms) ← CPU 사용

100ms 중 98ms는 CPU가 놀고 있음

운영체제 관점에서 보면:

Thread 1은 98ms 동안 아무 일도 안 했지만 Thread Pool의 자리를 차지해요. 다른 메시지는 Thread 1이 돌아올 때까지 기다려야 합니다.


비동기 처리 방법 검토

Blocking I/O 문제를 해결하기 위한 방법을 검토했어요.

1. Spring @Async

별도 Thread Pool을 만들어서 작업을 위임해요. Inbound Thread는 즉시 반환되지만, I/O 대기 중인 Thread가 @Async Thread Pool로 이동했을 뿐 — 전체 시스템에서 블로킹되는 Thread 수는 동일해요. Thread Pool 크기를 N으로 설정하면 동시에 N개까지만 처리 가능하고, 초과 요청은 큐에서 대기한다. Thread 수만 늘어나고 근본적인 해결이 안 됩니다. Coroutine과의 차이: Coroutine은 I/O 대기 중 Thread를 반환하고, I/O 완료 시 다시 Thread를 할당받는 구조라 같은 Thread 수로 더 많은 동시 요청을 처리할 수 있다.

2. Project Reactor (Reactive Programming)

완전한 Non-blocking을 구현할 수 있지만, 기존 JPA, JDBC 코드를 전부 Reactive로 바꿔야 해요. 6주 프로젝트에서 전체 스택을 바꾸기엔 리스크가 컸습니다.

3. Virtual Threads (Java 21)

JVM이 관리하는 경량 스레드로 수백만 개 생성 가능해요. 가장 깔끔한 해결책이지만, 당시 프로젝트가 Java 17 기반이었거든요. Java 21 업그레이드는 Spring Boot 버전 변경과 의존성 충돌 위험이 따랐습니다.

4. Kotlin Coroutine (선택)

우리 프로젝트가 이미 Kotlin 기반이었기 때문에 suspend만 붙이면 기존 코드와 자연스럽게 통합돼요. JPA, JDBC를 그대로 쓸 수 있고, Reactor보다 학습 곡선이 완만합니다. 다만 JPA Lazy Loading과 충돌할 수 있다는 점은 인지하고 있었어요(이 문제는 별도 글에서 다뤄요).


Coroutine 적용

Coroutine을 사용하면 Thread를 즉시 반환할 수 있어요.

Thread 점유 시간 비교

Before (Blocking)

  • Inbound Thread 점유 시간: 150ms (I/O 완료까지 대기)

After (Coroutine)

  • Inbound Thread 점유 시간: <1ms (즉시 반환)
  • I/O 작업은 Dispatchers.IO 스레드 풀에서 별도 처리

Java CompletableFuture로도 동일하게 가능하다

사실 Java CompletableFuture로도 같은 효과를 낼 수 있어요.

Java 버전

Kotlin Coroutine 버전

둘 다 동일한 효과예요. Inbound Thread를 빨리 반환하고, I/O 작업은 별도 스레드 풀에서 처리합니다.


왜 Coroutine을 선택했나

Java CompletableFuture로도 가능한데 Coroutine을 선택한 이유:

  1. 채팅 파트는 내가 맡은 영역 - 기술 선택의 자유가 있었어요
  2. 프로젝트가 이미 Kotlin 기반 - 별도 설정 없이 바로 적용 가능했습니다
  3. 코드 가독성 - launch { } 블록이 CompletableFuture 체이닝보다 직관적

주의: 진짜 Non-blocking은 아니다

현재 구현

  • Inbound Thread: 즉시 반환
  • Dispatchers.IO Thread: 150ms 동안 blocking

진짜 Non-blocking이 되려면

  • Reactive MongoDB Driver 필요
  • suspend 함수 + awaitSingle() 조합

현재 구현은 Inbound Thread Pool의 처리량을 높이는 것이 목적이에요. 전체 시스템이 Non-blocking이 된 건 아닙니다.


실제 구현

ChatMessageService

WebSocket Controller


결과

측정 조건: EC2 t3.medium, Inbound Thread 2개 기본 설정 유지, 20명 동시 접속

지표BeforeAfter
Inbound Thread 점유 시간150ms/건<1ms/건
이론적 최대 처리량 (Thread 4개)~36건/초수천 건/초 (I/O가 아닌 CPU 바운드만)
Inbound Thread 활용도I/O 대기로 99% 유휴즉시 반환 후 다음 요청 처리
실제 병목 지점Inbound Thread PoolMongoDB/Redis I/O (별도 스레드에서 처리)

※ I/O 작업(MongoDB 저장, Redis 발행)의 총 소요 시간 자체는 여전히 150ms다. 하지만 이 작업을 Coroutine이 별도 I/O 스레드에서 비동기로 처리하므로, Inbound Thread는 즉시 반환되어 다음 메시지를 받을 수 있다. 병목이 “Thread Pool 크기”에서 “I/O 대역폭”으로 이동한 것이 핵심.

Background: Spring WebSocket STOMP Architecture

Typical WebSocket libraries (Netty, Ktor, etc.) use an EventLoop model that doesn’t require Thread Pool configuration. However, Spring WebSocket STOMP uses separate Thread Pools for Inbound/Outbound Channels.

Spring WebSocket STOMP Structure

  • Inbound Thread Pool: Handles client → server messages
  • Outbound Thread Pool: Handles server → client messages

This post covers how to efficiently utilize Thread Pools when using Spring WebSocket STOMP.


0. Normal State

Server environment: EC2 t3.medium (2 vCPU, 4GB RAM), Spring Boot 3.x + WebSocket STOMP.

Inbound Thread Pool: Spring WebSocket STOMP’s clientInboundChannel default corePoolSize is Runtime.getRuntime().availableProcessors() * 2 = 4 threads (t3.medium, 2 vCPU). These 4 threads handle all client messages.

Concurrent users: 20 in test, 50 chatrooms. Peak: 10-20 msgs/sec.

Performance expectation: Chat messages should feel instant. When Inbound Threads block, other users’ messages queue up, causing perceived delay.


1. Problem: Threads Stall During I/O Waits

Spring WebSocket STOMP Handlers operate synchronously by default.

Analyzing a single Inbound Thread’s time breakdown:

  • MongoDB save wait: 100ms (no work done)
  • Redis publish wait: 10ms (no work done)
  • Actual CPU time: <1ms

The thread spends 99% of its time just waiting.


The Nature of Blocking I/O

Looking at the MongoDB save process in detail:

MongoDB Save (100ms)

  1. Network packet send (1ms) - CPU active
  2. MongoDB server response wait (98ms) - CPU idle
  3. Network response receive (1ms) - CPU active

98ms out of 100ms, the CPU is idle.

From the OS perspective:

Thread 1 does nothing for 98ms but still occupies a slot in the Thread Pool. Other messages must wait until Thread 1 returns.


Evaluating Async Processing Options

Four approaches were evaluated to solve the blocking I/O problem.

1. Spring @Async

Delegates work to a separate Thread Pool. However, threads are still blocked during I/O waits. It just increases the number of threads without fundamentally solving the problem.

2. Project Reactor (Reactive Programming)

Achieves true Non-blocking, but requires rewriting all JPA/JDBC code to Reactive. Too risky for a 6-week project.

3. Virtual Threads (Java 21)

Lightweight JVM-managed threads that can scale to millions. The cleanest solution, but our project was on Java 17. Upgrading to Java 21 risked Spring Boot version changes and dependency conflicts.

4. Kotlin Coroutine (Chosen)

Since our project was already Kotlin-based, adding suspend integrates naturally with existing code. JPA and JDBC can be used as-is, and the learning curve is gentler than Reactor. The potential conflict with JPA Lazy Loading was noted (covered in a separate post).


Applying Coroutines

With Coroutines, threads can be returned immediately.

Thread Occupancy Comparison

Before (Blocking)

  • Inbound Thread occupancy: 150ms (waits until I/O completes)

After (Coroutine)

  • Inbound Thread occupancy: <1ms (returned immediately)
  • I/O work handled separately in Dispatchers.IO thread pool

Java CompletableFuture Achieves the Same Effect

Java CompletableFuture can produce the same result.

Java Version

Kotlin Coroutine Version

Both achieve the same effect. The Inbound Thread is returned quickly, and I/O work is processed in a separate thread pool.


Why Coroutine Was Chosen

Reasons for choosing Coroutine over Java CompletableFuture:

  1. I owned the chat module - Freedom in technology choices
  2. Project was already Kotlin-based - No additional setup required
  3. Code readability - launch { } blocks are more intuitive than CompletableFuture chaining

Caveat: This Is Not True Non-blocking

Current Implementation

  • Inbound Thread: Returned immediately
  • Dispatchers.IO Thread: Blocked for 150ms

For True Non-blocking

  • Reactive MongoDB Driver required
  • suspend functions + awaitSingle() combination

The current implementation aims to increase Inbound Thread Pool throughput. The entire system has not become Non-blocking.


Actual Implementation

ChatMessageService

WebSocket Controller


Results

Measurement conditions: EC2 t3.medium, 2 Inbound Threads (default), 20 concurrent users

MetricBeforeAfter
Inbound Thread occupancy150ms/msg<1ms/msg
Theoretical max throughput (4 threads)~36 msgs/secThousands/sec (CPU-bound only)
Inbound Thread utilization99% idle on I/O waitsImmediately returned for next request
Actual bottleneckInbound Thread PoolMongoDB/Redis I/O (handled in separate threads)

Note: Total I/O time (MongoDB save, Redis publish) is still 150ms. But Coroutines handle this asynchronously on separate I/O threads, so Inbound Threads are immediately returned for the next message. The bottleneck shifted from “Thread Pool size” to “I/O bandwidth.”

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.