Java 17-25와 Kotlin 코루틴, 그리고 실전 비동기 아키텍처 완전 가이드

Java 17에서 25로의 진화, Virtual Threads(가상 스레드)와 Kotlin Coroutines의 관계, Coroutine Dispatcher의 내부 동작 방식부터 Armeria/WebFlux 기반 Pure Non-blocking 환경에서의 실전 성능 최적화 전략까지 한눈에 정리합니다.


1. Java 17 ➔ Java 25: 핵심 스펙 변화 요약

Java 17(2021)부터 Java 25(2025)까지 모두 LTS(Long Term Support) 버전으로, 비동기/동시성 모델 및 언어 생태계 전반에 큰 변화가 있었습니다.

  • 동시성 및 스레드 모델 혁신
    • Virtual Threads (Java 21, JEP 444): OS 스레드를 1:1로 점유하지 않고 JVM이 직접 수백만 개의 수평 스레드를 관리하는 수직 확장 동시성 모델 도입. (Java 24 JEP 491을 통해 synchronized 블록 내부 Pinning 이슈 개선)
    • Scoped Values (Java 25, JEP 506): ThreadLocal의 메모리 낭비와 불변성 문제를 해결하기 위한 단방향 불변 데이터 전달 구조 표준화.
  • 언어 생산성 및 문법 강화
    • Pattern Matching & Record (Java 21): switch 문 패턴 매칭 및 Record 구조 분해(Deconstruction) 정식화.
    • Flexible Constructor Bodies (Java 25): 생성자 내 super() 호출 전 인자 검증 및 초기화 로직 작성 허용.
    • Module Import (import module, Java 25): import module java.base;와 같이 모듈 단위 단일 임포트 지원.
  • GC 및 런타임 성능
    • Generational ZGC: Ultra-low Latency GC인 ZGC가 세대별 모드로 통합되어 처리량(Throughput) 및 메모리 효율 비약적 향상.
    • Compact Object Headers (Java 25): 64비트 JVM 환경에서 객체 헤더 크기를 8바이트로 축소 옵션 제공.

2. Virtual Threads vs Kotlin Coroutines

Both Virtual Threads and Coroutines solve the problem of high OS thread creation costs, but they operate at different layers.

구분 Virtual Threads (가상 스레드) Kotlin Coroutines (코루틴)
관리 주체 JVM 런타임 (Java 언어/플랫폼) Kotlin 라이브러리 (컴파일러 + kotlinx.coroutines)
블로킹 처리 동기식(Blocking) 코드를 작성해도 JVM이 I/O 차단을 감지하여 캐리어 스레드를 자동 반납 suspend 키워드를 통해 명시적으로 중단점(Suspension Point) 지정
코드 스타일 기존 동기식 코드베이스 수정 없이 경량화 가능 suspend, Flow 등 전용 비동기 패러다임 적용

가상 스레드와 코루틴의 연결

가상 스레드가 도입되어도 코루틴을 대체하는 것은 아닙니다. 가상 스레드는 코루틴의 Dispatcher(디스패처) 엔진으로 조합할 수 있습니다.

// Java 21+ Virtual Thread Executor 기반 Coroutine Dispatcher
val Dispatchers.VT: CoroutineDispatcher = 
    Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()

// 코루틴 내 Blocking I/O 작업 시 OS 스레드 고갈 걱정 없이 실행
CoroutineScope(Dispatchers.VT).launch {
    val result = blockingRepository.findData() // Blocking Call
}

3. Coroutine Dispatchers 깊이 보기: Default vs IO

Dispatchers.Default 스레드 수

Dispatchers.Default는 CPU 연산 작업을 위해 설계되었으며, 기본 스레드 수는 $\max(2, \text{CPU 코어 수})$입니다.

내부 스레드 공유 메커니즘 (Shared Worker Pool)

Dispatchers.DefaultDispatchers.IO는 독립된 두 개의 스레드 풀을 각각 만드는 것이 아니라, 하나의 내부 공용 스레드 풀(Global Scheduler / Worker Pool)을 공유합니다.

       [ Dispatchers.Default ]            [ Dispatchers.IO ]
        (허용 한도: CPU 코어 수)           (허용 한도: Max(64, CPU 코어 수))
                  │                                  │
                  └─────────────────┬────────────────┘
                                    ▼
                     [ 공용 Scheduler / Worker Pool ]
             [ Worker-1 ]  [ Worker-2 ]  [ Worker-3 ] ...
  • 동시성 한도(Parallelism Limit) 차이:

  • Default: 동시 실행 한도를 CPU 코어 수로 제한.

  • IO: 동시 실행 한도를 $\max(64, \text{CPU 코어 수})$로 제한.

  • Q. Default=2, IO=64일 때 총 생성 가능한 최대 스레드 수는?

  • 정답: 64개

  • 두 디스패처가 하나의 풀을 공유하며 상한선(Policy)만 다르게 적용한 뷰(View) 구조이기 때문에, 최대 스레드 수는 64개를 넘지 않습니다.


4. [실전] Armeria + WebFlux + Coroutine 최적 디스패처 전략

Q1. 이미 Non-blocking 라이브러리(R2DBC, WebClient 등)를 쓰는데 Virtual Threads가 이득을 줄까?

추가적인 이득은 거의 없습니다.
이미 Non-blocking 라이브러리는 I/O 요청 시 스레드를 차단하지 않고 Event Loop에 등록 후 반납하므로, 스레드 차단 자체가 발생하지 않아 가상 스레드가 개입할 여지가 없습니다.

Q2. Pure Async 스택에서 모든 요청을 Dispatchers.IO로 넘기면 처리량이 늘어날까?

아니요, 오히려 성능이 저하됩니다.
전 구간이 Non-blocking인 환경에서 처리량 한계는 스레드 부족이 아닌 CPU 자원 병목 때문입니다. 스레드를 64개로 늘리면 컨텍스트 스위칭 오버헤드와 CPU 캐시 미스만 폭증하여 전체 응답 지연(Latency)이 나빠집니다.

Q3. 디스패처를 명시하지 않으면 무엇으로 동작하는가?

Armeria 또는 Spring WebFlux 환경에서 디스패처를 명시하지 않으면 Dispatchers.Default로 넘어가지 않고, 요청을 수신한 Netty/Armeria의 Event Loop 스레드 위에서 코루틴이 지속 실행됩니다.

[ 클라이언트 요청 ] ➔ [ Armeria / Netty Event Loop Thread ]
                             │ (디스패처 명시 X)
                             ▼
                    [ Coroutine 실행 ] ➔ [ Non-blocking DB / Network Call ]
  • Zero Context Switching: 스레드 전환 없이 단일 Event Loop 스레드가 요청 수신부터 비즈니스 로직, I/O 발송까지 처리하여 극상의 성능을 냅니다.

Q4. 동시 I/O 처리(map { async {} }.awaitAll()) 시 디스패처를 명시해야 할까?

  • Pure Non-blocking I/O (WebClient, Armeria Client 등):
  • _디스패처를 지정하지 않는 것이 베스트*_입니다. Event Loop 스레드 하나만으로도 수천 개의 async 작업을 대기 없이 즉시 네트워크로 발송할 수 있습니다.
  • Blocking I/O (JPA, RestTemplate, File I/O 등):
  • _반드시 Dispatchers.IO (또는 가상 스레드 디스패처)를 지정*_해야 합니다. 디스패처를 지정하지 않으면 Event Loop 스레드가 Block되어 서버 전체 요청 처리 능력이 마비됩니다.
// 1. Non-blocking I/O: 디스패처 지정 없이 사용
val results = requests.map { req ->
    async { nonBlockingClient.get(req) }
}.awaitAll()

// 2. Blocking I/O: Dispatchers.IO 지정 필수
val results = requests.map { req ->
    async(Dispatchers.IO) { blockingClient.get(req) }
}.awaitAll()

💡 최종 요약 & Best Practice

  1. Pure Non-blocking/Async 스택 (Armeria, WebFlux, R2DBC, Coroutines):
  • 디스패처 전환(withContext)을 아예 하지 않고 Event Loop 스레드를 타는 것이 가장 빠릅니다.
  • Virtual Threads 도입이나 Dispatchers.IO 전환은 불필요합니다.
  1. Blocking I/O 라이브러리가 섞여 있는 환경 (JPA/JDBC, RestTemplate 등):
  • Blocking 구간만 핀포인트로 Dispatchers.IO 또는 Virtual Thread Dispatcher로 격리하여 실행합니다.
  1. CPU 병목 상황에서의 대처:
  • 스레드 수 확장(Dispatchers.IO)은 대안이 되지 못하며, Scale-Up(코어 증설) 또는 Scale-Out(인스턴스 확장)이 유일한 해법입니다.

gemini와의 대화 중..

'Develop' 카테고리의 다른 글

몽고에 저장되는 json에 쓸수 있는 정규 표현식  (0) 2023.08.10
WebClient 에러 처리  (0) 2023.01.10
Protobuf reserved  (0) 2022.06.25
젠킨스 파이프라인에서 다른 job 실행  (0) 2022.04.01
mysql rows to columns  (0) 2021.10.21

+ Recent posts