포스트

블로킹만 값싸지는 가상 스레드

가상 스레드를 켜면 처리량이 공짜로 오른다는 기대가 있습니다. 실제로 값싸지는 건 블로킹 대기 하나뿐이고요. synchronized 안에서는 스레드가 묶이고, 풀에 담으면 상한이 되살아나고, 동시성이 무제한이면 DB·외부 API가 먼저 막힙니다. Java 21 기준으로 어디까지 값싼지, 어디서 기대가 깨지는지 따져봤습니다.

가상 스레드는 대기를 값싸게 만든다

가상 스레드는 OS 스레드를 1:1로 감싸지 않습니다. JDK가 M:N으로 스케줄링하죠. 실제로 코드를 실행할 때만 플랫폼 스레드(carrier) 위에 올라타고, 블로킹 지점을 만나면 carrier에서 내려옵니다. 내려온 자리에는 다른 가상 스레드가 올라타고요. 그래서 스레드 하나가 소켓 응답을 기다리는 동안에도 OS 스레드는 놀지 않습니다. JEP 444가 Java 21에 정식으로 넣은 게 이겁니다.

여기서 값싸지는 건 정확히 대기입니다. 수만 개가 동시에 무언가를 기다려도 OS 스레드는 몇 개면 됩니다. 대기가 아닌 것은 값싸지지 않습니다. 그리고 carrier에서 한 번 내려오지 못하면, 수만 개의 가상 스레드가 몇 개의 OS 스레드를 나눠 쓰던 그림 자체가 그 지점에서 통째로 무너지죠.

synchronized 안에서는 carrier가 붙잡힌다

가상 스레드가 carrier에서 내려오려면 블로킹 지점이 내려와도 되는 지점이어야 합니다. synchronized 블록이나 메서드 안에서 블로킹되면 내려오지 못합니다. JEP 444는 이걸 pinning이라고 부르는데, 이때는 carrier인 플랫폼 스레드가 같이 묶입니다. native 메서드나 foreign function 안에서도 마찬가지고요.

문제는 synchronized가 우리 코드에만 있는 게 아니라는 겁니다. 오래된 라이브러리나 커넥션 풀 내부, 일부 드라이버가 뜨거운 경로의 임계 구역을 synchronized로 잡고 그 안에서 I/O를 기다리면, 몇 개 안 되는 carrier가 거기서 전부 묶이면서 처리량이 오르기는커녕 플랫폼 스레드 풀로 돌리던 때보다 도리어 나빠질 수 있습니다.

1
2
3
4
// 이 안에서 블로킹 I/O 를 하면 가상 스레드가 carrier 에서 못 내려온다 (Java 21)
synchronized (lock) {
    externalClient.call(); // 여기서 pinning
}

JEP 491이 이 synchronized pinning을 걷어냅니다. 가상 스레드가 monitor를 carrier와 독립적으로 잡을 수 있게 바뀌거든요. 다만 이게 들어간 건 JDK 24입니다. 그러니 Java 21에 머무는 동안은 판단이 하나 따라붙습니다. 새로 붙이는 라이브러리가 뜨거운 경로에서 synchronized로 블로킹하는지를 보게 되는 거죠. 같은 기능을 ReentrantLock으로 잡는 라이브러리라면 그 안에서도 carrier에서 내려올 수 있습니다. 21에서는 이 차이가 실질적입니다.

풀에 넣으면 얻는 게 없다

가상 스레드를 처음 붙일 때 가장 흔한 실수가 기존 ExecutorService 풀에 그냥 태우는 겁니다. JEP 444가 이걸 대놓고 막습니다. 가상 스레드는 싸고 넉넉하므로 절대 풀링하면 안 되고, 작업마다 새 가상 스레드를 만들라고요.

풀은 비싼 자원을 재사용하려고 존재합니다. 가상 스레드는 비싸지 않으니 재사용할 이유가 없죠. 고정 크기 풀에 가상 스레드를 담으면 그 크기가 다시 동시성 상한이 되고, 값싸게 만들려던 이점이 거기서 막힙니다. 플랫폼 스레드를 재사용하던 때로 되돌아가는 셈이고요.

1
2
3
4
5
6
7
// ✗ 가상 스레드를 고정 풀에 가둔다. 풀 크기가 다시 상한이 된다
var pool = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());

// ○ 작업마다 새로 만든다
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    tasks.forEach(executor::submit);
}

무제한 동시성이 DB·외부 API를 먼저 두드린다

풀을 없애고 작업마다 가상 스레드를 만들면 동시성에 상한이 사라집니다. 요청이 만 개 들어오면 가상 스레드도 만 개가 뜨고요. 그런데 그 만 개가 전부 DB 커넥션을 기다리면 어떻게 될까요.

커넥션 풀은 가상 스레드가 아닙니다. HikariCP 기본값이 10 근처죠. 가상 스레드 만 개가 커넥션 10개를 두고 경쟁하면 앞단만 값싸게 늘어났을 뿐 병목은 그 10개에 그대로 남고, 늘어난 만 개는 결국 커넥션을 받으려고 줄을 서면서 대기 큐만 길어집니다. 외부 API도 같습니다. 상대가 초당 몇 건으로 막아둔 곳에 만 개가 동시에 나가면 그쪽에서 막아버리죠.

그래서 가상 스레드로 넘어갈 때 없앤 상한을, 자원 앞에서는 다시 세워야 합니다. 세우는 수단은 풀이 아니라 세마포어고요. JEP 444도 동시성을 제한할 거면 스레드 풀 대신 세마포어 같은 걸 쓰라고 짚고요.

1
2
3
4
5
6
7
8
9
10
11
// DB 를 두드리는 구간만 커넥션 풀 크기에 맞춰 조인다
private final Semaphore dbGate = new Semaphore(10);

void handle() throws InterruptedException {
    dbGate.acquire();
    try {
        repository.save(entity); // 동시 진입을 10 으로 제한
    } finally {
        dbGate.release();
    }
}

세마포어 크기를 얼마로 잡느냐는 가상 스레드가 정해주지 않습니다. 다운스트림이 정하죠. 커넥션 풀이 10이면 그 근처, 외부 API가 초당 50건이면 거기에 맞춰야 합니다. 가상 스레드는 상한을 어디에 둘 것인가라는 질문을 없애주지 않습니다. 그 상한을 앞단 스레드 개수에서 다운스트림으로 옮길 뿐이거든요.

같은 1만 건에서 진입 제한이 있고 없고가 어떻게 갈리는지 보면 이렇습니다.

flowchart TD
    A["요청 1만 건<br/>작업마다 가상 스레드 1개"] --> B{"DB 구간<br/>진입 제한"}
    B -->|"없음<br/>풀을 없애 상한이 사라진 상태"| C["1만 개가 커넥션 10개를<br/>두고 경쟁 · 대기 큐만 길어짐"]
    B -->|"Semaphore(10)<br/>커넥션 풀 크기에 맞춰"| D["동시 진입 10<br/>상한이 커넥션 10으로 이동"]
    classDef bad fill:#c62828,color:#fff,stroke:#b71c1c
    classDef fix fill:#2e7d32,color:#fff,stroke:#1b5e20
    class C bad
    class D fix

CPU를 쓰는 일은 빨라지지 않는다

한 줄이면 되는 얘기지만 오해가 잦아서 적습니다. 가상 스레드는 대기를 값싸게 할 뿐, 연산을 빠르게 하지 않습니다. 이미지 리사이즈나 암복호화처럼 CPU를 붙잡는 작업은 carrier에서 내려올 일이 없으니 이점이 없죠. 이런 건 코어 수에 맞춘 플랫폼 스레드 풀이 여전히 맞습니다.

그래서 이 스택이라면 어디에 켜볼까

Spring Boot는 3.2부터 spring.threads.virtual.enabled=true 한 줄로 켭니다. Java 21이 있어야 동작하고요. 켜면 Tomcat이 요청마다 가상 스레드를 쓰는 실행기로 바뀝니다.

1
2
3
4
spring:
  threads:
    virtual:
      enabled: true   # Spring Boot 3.2+, Java 21 필요

한 줄이라 켜기는 쉽습니다. 그냥 켜지지 않는 건 위 네 가지 때문이고요. 값어치가 분명한 건 블로킹 I/O로 대기하는 경로입니다. 외부 연동 응답을 기다리는 요청 처리, 큐에서 받아 DB를 두드리는 컨슈머 같은 것들이요. 반대로 뜨거운 경로에 synchronized 블로킹이 끼거나, CPU를 붙잡거나, 다운스트림이 좁으면 켜기 전에 먼저 봐야 합니다. 세마포어로 조일 자리를 정하고, pinning이 실제로 나는지 확인하고 나서 켜는 순서인 거죠.

아직 21에 남은 것

JDK 24로 올리면 synchronized 함정은 걷힙니다. 그래도 pinning 자체가 없어지는 건 아니에요. native 메서드와 foreign function 안에서 묶이는 건 JEP 444가 처음부터 짚은 한계라 24에서도 그대로고, JEP 491이 앞으로 할 일로 남겨둔 것은 클래스 로딩과 초기화 중에 나는 pinning입니다. 그래서 진단용 JFR 이벤트는 synchronized가 빠진 뒤에도 남습니다. DB·외부 API 포화는 JDK 버전과 무관하게 그대로고요.

아직 21이라 저에게 먼저 필요한 건 관측입니다. pinning이 어디서 나는지 JFR의 jdk.VirtualThreadPinned 이벤트로 찍어보지 않으면, 라이브러리 안에서 조용히 묶이는 걸 알 방법이 없거든요. 세마포어 크기도 커넥션 풀과 외부 API 지표를 보기 전에는 추정일 뿐입니다. 그 숫자들을 실제로 재보는 게 남은 숙제입니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.