[자바] 21. 가상 스레드 실전 : 언제 쓰고 언제 조심하나 (Java 21)

자바 버전 여행 21편 - 언제 쓰고 언제 조심하나

작성자

카테고리:

* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

(자바 버전 여행 ㉑)

앞 편에서 가상 스레드가 만 개도 가볍게 돈다는 걸 봤다. 그렇다고 아무 데나 쓰면 되는 건 아니다. 이번 편은 실무에서 언제 효과가 있고, 무엇을 조심해야 하는지를 정리한다.

I/O 바운드엔 강하고, CPU 바운드엔 무의미하다

가상 스레드가 빛나는 건 기다리는 시간이 많은 작업이다. DB 조회, 외부 API 호출, 파일 읽기처럼 대기하는 동안 캐리어를 놓아주기 때문이다. 반대로 계산만 빡세게 하는 작업(이미지 처리, 암호 연산)은 애초에 대기가 없어서, 가상 스레드로 바꿔도 빨라지지 않는다. 코어 수만큼 도는 건 똑같다.

작업 성격예가상 스레드
I/O 바운드DB·API·파일 대기효과 큼 (권장)
CPU 바운드계산·인코딩이득 거의 없음

스레드를 풀에 담지 마라

사고방식을 바꿔야 한다. 가상 스레드는 워낙 싸서 풀링하지 않는다. “작업 하나에 스레드 하나”를 그냥 새로 만든다. 그래서 newVirtualThreadPerTaskExecutor()가 기본이고, 가상 스레드를 newFixedThreadPool처럼 몇 개로 제한해 재사용하면 오히려 이점이 사라진다.

가장 큰 함정 — pinning

가상 스레드는 대기할 때 캐리어에서 내려와야 한다. 그런데 synchronized 블록 안에서 대기하면 내려오지 못하고 캐리어에 ‘고정(pinning)’된다. 캐리어가 묶이면 그동안 다른 가상 스레드가 못 올라타, 어렵게 얻은 이점이 깎인다.

// 위험 — synchronized 안에서 I/O 대기 → pinning
synchronized (lock) {
    callSlowApi();     // 이 대기 동안 캐리어가 묶인다
}

// 권장 — ReentrantLock으로 바꾸면 pinning 없이 대기 가능
lock.lock();
try {
    callSlowApi();
} finally {
    lock.unlock();
}

정리하면 synchronized 블록 안에서 오래 걸리는 I/O를 하지 말 것. 짧은 임계 구역이면 괜찮지만, 대기가 길면 ReentrantLock으로 옮긴다. (참고로 이 pinning 제약은 이후 버전에서 계속 완화되는 중이다.)

직접 해보기 — pinning 관찰하고 피하기

말로는 감이 안 온다. pinning을 직접 켜서 관찰해 보자.

  1. synchronized 블록 안에서 Thread.sleep(대기 흉내)을 하는 작업을 가상 스레드로 여러 개 돌린다.
  2. -Djdk.tracePinnedThreads=full 옵션을 주고 실행해, pinning 경고 로그가 찍히는지 본다.
  3. 같은 코드를 ReentrantLock으로 바꿔 경고가 사라지는지 확인한다.
// 실행: java -Djdk.tracePinnedThreads=full Pin.java
static final Object lock = new Object();

static void work() throws InterruptedException {
    synchronized (lock) {   // ReentrantLock으로 바꾸면 pinning 사라짐
        Thread.sleep(200);  // 대기 중 캐리어에 고정됨
    }
}
// 여러 가상 스레드로 work()를 submit 해 로그를 관찰

실행해 보면 이렇게 나온다.

Thread[#..,VirtualThread...] ... <== monitors:1   (pinning 발생 표시)

여기서 조금씩 바꿔 보자.

  • 작업 개수를 늘려 pinning 시 전체 시간이 어떻게 늘어지는지 본다.
  • synchronized를 ReentrantLock으로 교체하고 같은 옵션으로 재실행해 로그 차이를 비교한다.

한 걸음 더 — 스프링 부트 등 프레임워크에서 가상 스레드를 켜는 설정(예: spring.threads.virtual.enabled=true)이 어떤 효과인지 찾아보자.

✅ 확인 — 가상 스레드가 I/O 바운드에서만 이득이라는 것, pinning이 왜 생기고 어떻게 피하는지 설명할 수 있으면 모듈 E는 끝이다.

pinning 피하기 — synchronized → ReentrantLock

pinning은 가상 스레드가 synchronized 블록(또는 네이티브 호출) 안에서 블로킹될 때 캐리어 스레드에 묶여 못 떨어지는 현상이다. 그럼 캐리어가 놀지 못해 동시성 이점이 준다. 해법은 블로킹을 감싸는 synchronized를 ReentrantLock으로 바꾸는 것이다.

// 이러면 pinning 위험
synchronized (lock) { callSlowIo(); }

// 이렇게 (가상 스레드가 많은 코드)
lock.lock();
try { callSlowIo(); } finally { lock.unlock(); }

참고로 이 synchronized pinning 문제는 이후 JDK에서 크게 개선됐지만, 자바 21 LTS 기준으로는 여전히 챙겨야 할 항목이다.

ThreadLocal 재점검

가상 스레드는 수십만 개가 뜰 수 있다. 그래서 각자 ThreadLocal에 큰 값을 담으면 메모리가 폭증한다. 무거운 ThreadLocal 캐시 패턴은 재고하자. 자바 21은 대안으로 ScopedValue(프리뷰)를 제시하는데, 불변이고 스코프 기반이라 가상 스레드에 더 잘 맞는다.

구조적 동시성 — 관련 작업을 한 단위로

여러 가상 스레드로 병렬 호출할 때 StructuredTaskScope(프리뷰)로 '자식 작업들을 한 단위로' 묶으면, 하나가 실패하면 나머지 취소·전체 대기 같은 걸 깔끔히 다룬다. try-with-resources처럼 스코프가 끝나면 자동 정리된다. 흩뿌린 Future를 손으로 관리하는 것보다 훨씬 안전하다.

실무 도입 체크리스트

  • 쓰는 프레임워크가 이미 지원하나(최신 톰캣·스프링 등은 가상 스레드 옵션 제공).
  • 커넥션 풀 크기 재조정 — 스레드는 많아도 DB 커넥션은 유한하다.
  • 블로킹 라이브러리와의 궁합·pinning 로그 확인.
  • CPU 바운드 작업엔 쓰지 않기.

가상 스레드는 '켜면 빨라지는 마법'이 아니라, I/O 대기가 많은 곳에서 단순한 코드로 큰 동시성을 얻는 도구다. 함정만 피하면 앞 편에서 본 잠재력을 실무에서 그대로 누릴 수 있다.

자바 21의 핵심까지 다뤘다. 마지막 모듈은 실무로 내려온다. 다음 편은 버전마다 조용히 바뀌어 온 가비지 컬렉터의 진화와, 내 자바가 지금 어떤 GC로 도는지다.

자주 묻는 질문 (FAQ)

가상 스레드는 어디에 효과적인가요?

DB·API 대기 같은 I/O 바운드 작업입니다. CPU만 쓰는 계산 작업은 가상 스레드로 바꿔도 빨라지지 않습니다.

pinning이 뭔가요?

synchronized 블록 안에서 대기하면 가상 스레드가 캐리어에 고정되어 이점이 깎이는 현상입니다. 긴 I/O는 ReentrantLock으로 옮기세요.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다