thenApply 같은 비동기 접미사 없는 메서드는 "이전 단계를 완료한 스레드" 에서 실행됩니다. 아직 완료되지 않았으면 완료시키는 스레드가, 이미 완료됐으면 thenApply 를 호출한 스레드가 실행합니다.
| 메서드 | 실행 스레드 |
|---|---|
thenApply(f) |
이전 단계를 끝낸 스레드 |
thenApply(f) 이미 완료된 future |
호출한 스레드 (동기 실행) |
thenApplyAsync(f) |
commonPool |
thenApplyAsync(f, executor) |
지정한 executor |
여기서 실무 함정이 하나 나옵니다. thenApply 에 무거운 작업을 넣으면 future 가 이미 완료된 경우 요청 스레드가 그 작업을 직접 하게 됩니다. 반대로 thenApplyAsync 로 넘기면 ThreadLocal(MDC 의 요청 ID 등) 이 따라가지 않습니다.
executor 를 지정하지 않으면 ForkJoinPool.commonPool() 을 씁니다. 크기는 CPU 코어 수 - 1 이고, 병렬 스트림도 같은 풀을 씁니다. 여기서 DB 조회나 HTTP 호출처럼 기다리는 작업을 돌리면 코어 수만큼만 동시에 진행되고 나머지는 줄을 섭니다.
블로킹 I/O 는 반드시 전용 풀 또는 가상 스레드 executor 로 보냅니다. 코어가 8개인 서버에서 외부 API 20개를 commonPool 로 부르면 3배 느려지는 것을 3절에서 확인합니다.
| 작업 종류 | executor |
|---|---|
| CPU 계산 | commonPool 또는 코어 수 풀 |
| 블로킹 I/O (DB, HTTP, 파일) | 가상 스레드 executor |
| 순서·격리가 필요한 작업 | 단일 스레드 또는 전용 풀 |
어떤 단계가 예외로 끝나면 그 뒤의 thenApply·thenAccept 는 실행되지 않고 예외만 다음 단계로 넘어갑니다. 예외를 잡는 단계는 exceptionally, handle, whenComplete 셋입니다.
| 메서드 | 받는 것 | 결과 |
|---|---|---|
exceptionally(f) |
예외만 | 대체값으로 복구 |
handle(f) |
값 또는 예외 | 둘 다 처리해 새 값 |
whenComplete(f) |
값 또는 예외 | 관찰만, 결과 그대로 |
위치가 중요합니다. exceptionally 는 자기보다 앞 단계의 예외만 봅니다. 뒤에서 난 예외는 잡지 못하므로 보통 체인 맨 끝에 둡니다.
예외는 감싸져서 나옵니다. join() 은 CompletionException, get() 은 ExecutionException 으로 감싸고, 원본은 getCause() 에 있습니다. exceptionally 로 들어오는 예외도 대개 CompletionException 입니다.
orTimeout(t) 은 시간 안에 완료되지 않으면 future 를 TimeoutException 으로 끝냅니다. completeOnTimeout(v, t) 는 대체값으로 끝냅니다. 둘 다 JDK 9 에 추가됐습니다.
그러나 뒤에서 도는 작업은 계속 돕니다. CompletableFuture 는 자기를 실행하는 스레드를 모르므로 인터럽트할 수 없습니다. 타임아웃이 잦으면 풀이 좀비 작업으로 가득 차 새 요청이 줄을 섭니다. 진짜 취소는 HTTP 클라이언트나 JDBC 의 자체 타임아웃으로 걸어야 합니다.
실패하면 잠시 기다렸다가 다시 시도하는 로직은 동기 코드에서는 for 문입니다. 비동기에서는 스레드를 붙잡지 않고 기다려야 하므로 delayedExecutor 로 다음 시도를 예약하고 thenCompose 로 이어 붙입니다.
시도 1 ─실패─▶ 100ms 대기 ─▶ 시도 2 ─실패─▶ 200ms 대기 ─▶ 시도 3 ─성공─▶ 값
└─실패─▶ 예외대기 시간을 두 배씩 늘리는 것이 지수 백오프입니다. 장애 난 서버를 재시도 폭풍으로 더 눕히지 않기 위한 예의입니다.
가상 스레드는 블로킹 호출을 만나면 캐리어 스레드에서 내려와(unmount) 다른 가상 스레드에게 자리를 줍니다. 그래서 캐리어 몇 개로 수만 개를 돌릴 수 있습니다.
예외가 피닝(pinning)입니다. JDK 21 에서는 synchronized 블록 안이나 네이티브 메서드 안에서 블로킹하면 내려오지 못하고 캐리어를 붙잡습니다. 캐리어가 코어 수만큼밖에 없으므로, 피닝된 가상 스레드가 코어 수를 넘으면 나머지는 전부 멈춥니다.
| 상황 | JDK 21 | 대책 |
|---|---|---|
synchronized 안에서 I/O·sleep |
피닝 | ReentrantLock 으로 교체 |
| 네이티브 메서드 안 블로킹 | 피닝 | 피하기 어려움 |
ReentrantLock 안에서 블로킹 |
정상 unmount | 그대로 사용 |
JDK 24 부터 synchronized 피닝은 해결됐습니다(JEP 491). 그래도 오래된 드라이버·라이브러리가 synchronized 를 많이 쓰므로, -Djdk.tracePinnedThreads=short 로 실측해 보고 결정합니다.
가상 스레드 10만 개가 동시에 DB 에 가도 커넥션 풀이 10개면 10개씩만 처리됩니다. 나머지는 풀에서 대기하고, 풀 대기 타임아웃(HikariCP 기본 30초) 에 걸려 예외가 쏟아집니다. 플랫폼 스레드 200개 시절에는 스레드 수가 자연스러운 제한이었는데, 그 제한이 사라진 것입니다.
동시성 제한을 명시적으로 겁니다. Semaphore(n) 으로 자원 수만큼만 통과시키는 것이 가장 단순한 방법입니다. 가상 스레드를 풀링하는 것은 권장하지 않습니다. 가상 스레드는 싸게 만들고 버리는 것이고, 제한은 세마포어의 몫입니다.
spring.threads.virtual.enabled=true 한 줄로 Tomcat 요청 스레드, @Async, @Scheduled 가 가상 스레드로 바뀝니다. 켜기 전 점검 목록입니다.
synchronized 안에서 I/O 하는 코드와 라이브러리를 tracePinnedThreads 로 찾습니다.ThreadLocal 은 가상 스레드 수만큼 복제되므로 줄입니다.