포스트

스택 트레이스를 내주지 않는 가상 스레드

가상 스레드를 더 빠른 스레드로 소개하는 글을 종종 봅니다. JEP 444는 정반대로 못 박습니다. 속도가 아니라 처리량을 위한 것이라고요. 요청 하나에 스레드 하나를 붙이는 방식이 왜 먼저 막혔고, 리액티브가 그걸 풀면서 무엇을 가져갔는지 따라가면 가상 스레드가 되돌리려는 게 보입니다. Java 21 기준으로 정리했습니다.

스레드 수가 CPU보다 먼저 상한이 된다

JEP 444는 확장성 얘기를 리틀의 법칙에서 시작합니다. 처리 시간이 정해져 있으면 처리량을 올리는 방법은 하나뿐이라는 겁니다. 동시에 처리 중인 요청 수를 그만큼 늘리는 것이죠.

JEP가 든 숫자가 명확합니다. 평균 지연 50ms에 동시 10건을 처리해서 초당 200건이 나온다고 해보죠. 여기서 초당 2000건으로 올리려면 동시에 100건이 돌고 있어야 합니다. 지연이 그대로라면 늘릴 곳이 동시성 말고는 없거든요.

문제는 그 동시성의 실체입니다. 요청 하나에 스레드 하나를 붙이는 구조에서 동시 요청 수는 곧 스레드 수죠. 그리고 자바의 스레드 하나는 OS 스레드 하나입니다. JEP의 진단이 여기서 나옵니다. 요청마다 스레드를 하나씩 점유하는 구조에서는 OS 스레드가 비싸서 많이 만들 수가 없고, 그래서 CPU나 네트워크 커넥션 같은 다른 자원이 바닥나기 한참 전에 스레드 수가 먼저 처리량의 상한이 되어버린다는 겁니다.

저희 서비스도 이 구조입니다. 톰캣 스레드 풀 크기가 동시 처리량의 상한이고요. 트래픽이 늘면 풀을 키우는데 그건 OS 스레드를 그만큼 더 만드는 일이고, 어느 선을 넘어가면 CPU도 커넥션도 남아 있는데 요청을 받을 스레드가 없어서 큐에만 쌓이는 상태가 됩니다.

리액티브는 처리량을 주고 스택 트레이스를 가져간다

이걸 푸는 답으로 오래 쓰인 게 비동기 스타일입니다. I/O를 기다리는 동안 스레드를 붙잡지 말고 놓아주는 것이죠. WebFlux나 CompletableFuture 체인이 그겁니다.

대신 코드가 바뀝니다. JEP 444는 그 대가를 꽤 구체적으로 적어뒀습니다. 요청 처리 로직을 잘게 쪼개서 보통 람다로 쓰고 그걸 다시 순차 파이프라인으로 조립하게 되는데, 그러고 나면 스택 트레이스에 쓸 만한 맥락이 남지 않고, 디버거로는 요청 처리 흐름을 따라 들어갈 수 없고, 프로파일러는 어떤 작업의 비용을 그 호출자에 붙이지 못합니다.

JEP는 그 이유를 한 줄로 짚습니다. 애플리케이션의 동시성 단위가 비동기 파이프라인인데 플랫폼의 동시성 단위는 여전히 스레드라서, 둘이 어긋난다는 겁니다. 스택 트레이스가 비는 게 당연하죠. 런타임이 보기에 그 파이프라인은 하나의 요청이 아니거든요.

저는 이 대가를 가볍게 보지 않는 편입니다. 장애가 났을 때 스택 트레이스 하나로 범위를 좁히는 일이 실제로 잦거든요. 그게 안 되면 로그마다 상관관계 ID를 심고 그걸로 흐름을 이어붙이는 작업이 따라붙습니다. 팀이 그걸 감당할 수 있느냐는 그다음 문제고요.

코드를 그대로 두고 처리량만 가져오려는 시도

가상 스레드가 노리는 게 여기입니다. 처리량은 비동기 스타일만큼 얻되, 요청 하나에 스레드 하나라는 방식은 그대로 두겠다는 겁니다.

바뀌는 쪽은 런타임입니다. 가상 스레드에서 블로킹 I/O를 호출하면 런타임이 그걸 논블로킹 OS 호출로 바꾸고, 그 가상 스레드를 다시 진행할 수 있을 때까지 멈춰뒀다가 나중에 이어서 돌립니다. 코드는 여전히 동기입니다. try/catch가 그대로 걸리고, 스택 트레이스도 그대로 나오고, 디버거도 그대로 붙죠.

1
2
3
4
5
6
7
8
9
// 코드 모양은 플랫폼 스레드 때와 같다 (Java 21)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var req : requests) {
        executor.submit(() -> {
            var res = httpClient.send(toRequest(req), HttpResponse.BodyHandlers.ofString()); // 여기서 멈추고 자리를 내준다
            return repository.save(toEntity(res));
        });
    }
}

여기에 조건이 하나 붙습니다. 이 자동 전환은 java.* API의 블로킹 연산에 한합니다. 그 바깥에서 스레드를 붙잡으면 붙잡힌 채로 남고요. 자바 코드만 읽어서는 안 보이는 조건이라, 실제로 켤 때는 이쪽이 먼저 문제가 됩니다.

만드는 법은 이미 쓰던 API 그대로다

가상 스레드는 java.lang.Thread입니다. 새 타입이 아니거든요. 기존 코드가 최소한의 변경으로 넘어오게 하는 것이 JEP 444가 내건 목표 중 하나였습니다.

1
2
3
4
5
6
// 이름 붙여서 만들고 나중에 시작
Thread t = Thread.ofVirtual().name("duke").unstarted(runnable);
t.start();

// 만들면서 바로 시작
Thread.startVirtualThread(runnable);

같은 Thread지만 의미가 달라진 게 셋 있습니다.

  • 언제나 데몬 스레드입니다. setDaemon(false)로 바꿀 수 없습니다.
  • 우선순위가 NORM_PRIORITY로 고정이고 setPriority는 아무 효과가 없습니다.
  • 스레드 그룹의 실제 구성원이 아닙니다. getThreadGroup()을 불러도 "VirtualThreads"라는 이름만 형식적으로 돌아옵니다.

ThreadLocal은 그대로 씁니다. Java 21에서 가상 스레드는 스레드 로컬을 항상 지원하는데, 기존 라이브러리가 손대지 않고 동작하게 하려는 판단이었죠. 다만 JEP는 용도 하나를 경고합니다. 스레드 풀에서 여러 작업이 하나의 스레드를 나눠 쓰던 시절처럼 비싼 자원을 스레드 로컬에 담아 재사용하게 만들지 말라는 건데, 가상 스레드는 평생 작업 하나만 돌리도록 만들어진 것이라 그 패턴이 아예 성립하지 않거든요. 어디서 스레드 로컬을 건드리는지 알고 싶으면 jdk.traceVirtualThreadLocals 시스템 프로퍼티로 스택 트레이스를 뽑을 수 있습니다.

스케줄러가 자리를 뺏어오지는 않는다

가상 스레드를 실제로 굴리는 건 FIFO 모드로 도는 work-stealing ForkJoinPool입니다. 여기서 parallelism은 가상 스레드를 스케줄링하는 데 쓸 플랫폼 스레드 수고요. 기본값은 가용 프로세서 수이고 jdk.virtualThreadScheduler.parallelism으로 조정합니다. 상한은 jdk.virtualThreadScheduler.maxPoolSize가 잡습니다.

이 절에서 제일 중요한 사실은 따로 있습니다. 이 스케줄러는 타임 셰어링을 구현하지 않습니다. 가상 스레드는 자기가 멈출 때 자리를 내주는 것이지, 스케줄러가 시간을 재서 뺏어오는 게 아니거든요.

그래서 자리를 안 내주는 코드가 있으면 그대로 눌러앉습니다. OS 스케줄러가 플랫폼 스레드를 알아서 선점해주던 감각으로 접근하면 여기서 어긋나죠.

빠른 스레드가 아니다

JEP 444에 이 문장이 그대로 있습니다. 가상 스레드는 더 빠른 스레드가 아니고, 플랫폼 스레드보다 코드를 빨리 돌리지 않습니다. 노리는 건 처리량이지 지연이 아니라고요.

조건도 같이 적혀 있습니다. 동시 작업 수가 수천 개를 넘고 워크로드가 CPU 바운드가 아닐 때 처리량이 의미 있게 오릅니다. 뒤집으면 동시 작업이 수십 개인 서비스에서는 켜도 달라질 게 없다는 뜻이죠.

그래서 한 건이 느린 문제에는 답이 되지 못합니다. 요청 하나의 지연은 그대로 남거든요.

켜기 전에 재봐야 할 것

여기까지가 가상 스레드가 무엇을 되돌리려 하는지에 대한 얘기입니다. 실제로 켤지는 별개고, 저는 아직 재보지 않은 게 셋 있습니다.

첫째는 동시 작업 수입니다. 수천 개를 넘어야 의미가 있다는데, 저희 서비스의 피크 시점 동시 요청 수를 그 기준으로 세어본 적이 없습니다. 스레드 풀이 포화되는 걸 본 적은 있어도 그때 동시 요청이 수천 개 규모였는지는 다른 얘기죠.

둘째는 java.* 바깥입니다. 자동 전환이 표준 API 안에서만 일어난다면, 저희가 쓰는 드라이버와 클라이언트 중에 그 바깥에서 스레드를 붙잡는 게 어디인지 알아야 넘어갈지를 정할 수 있는데, 이건 코드를 읽어서 찾는 종류가 아니라 실제로 돌려보고 관측해야 나오는 것 같습니다.

셋째는 parallelism 기본값입니다. 가용 프로세서 수를 따른다는데, CPU limit이 걸린 EKS 파드에서 JVM이 그 값을 몇으로 보는지 확인한 적이 없고, 여기가 예상보다 작게 잡히면 가상 스레드를 아무리 많이 띄워도 그걸 실어 나를 플랫폼 스레드부터 모자라는 셈이 됩니다.

이 셋을 재기 전에는 켠다는 판단을 못 하겠습니다. 그리고 다 재고 나서도 켜지 못하는 자리가 따로 있는데, 그건 다음 글에서 이어서 적겠습니다.

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