소스: java-src/advanced/09_async_virtual (FakeApi, Retry, Main).
cd java-src/advanced/09_async_virtual
javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 MainMain 은 첫 줄에서 캐리어 스레드를 2개로 제한합니다. 6절 피닝을 코어 수와 무관하게 재현하기 위한 설정이며, 실무에서는 건드리지 않습니다.
System.setProperty("jdk.virtualThreadScheduler.parallelism", "2");
System.setProperty("jdk.tracePinnedThreads", "short");CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> api.slow("조회", 100));
f.thenApply(v -> v + " → thenApply@" + Thread.currentThread().getName());
CompletableFuture<String> done = CompletableFuture.completedFuture("완료됨");
done.thenApply(v -> ...); // 이미 완료 → 호출자 스레드
done.thenApplyAsync(v -> ...); // commonPool(a) 미완료 future 에 thenApply: 조회@ForkJoinPool.commonPool-worker-1 → thenApply@ForkJoinPool.commonPool-worker-1
(b) 완료된 future 에 thenApply: 완료됨 → thenApply@main
(c) thenApplyAsync: 완료됨 → thenApplyAsync@ForkJoinPool.commonPool-worker-1
(d) 다른 스레드의 ThreadLocal: requestId=없음 (main 은 REQ-1)(b) 가 핵심입니다. 같은 thenApply 인데 future 상태에 따라 실행 스레드가 바뀝니다. (d) 는 ThreadLocal 이 워커 스레드로 따라가지 않는 것을 보여 줍니다. 로그의 MDC 값이 비동기 단계에서 사라지는 이유입니다.
200ms 걸리는 호출 20개를 세 가지 executor 로 돌립니다.
CompletableFuture.supplyAsync(() -> api.slow(name, 200)); // commonPool
CompletableFuture.supplyAsync(() -> api.slow(name, 200), fixed20); // 고정 풀
CompletableFuture.supplyAsync(() -> api.slow(name, 200), virtual); // 가상 스레드CPU 8개, commonPool 병렬도 7
commonPool 618 ms (예: 호출0@ForkJoinPool.commonPool-worker-1)
고정 풀 20 207 ms (예: 호출0@pool-1-thread-1)
가상 스레드 218 ms (예: 호출0@VirtualThread[#48]/runnable@ForkJoinPool-1-worker-2)commonPool 은 7개씩 세 번에 나눠 돌아 3배 걸렸습니다. 가상 스레드는 캐리어가 2개뿐인데도 20개가 동시에 기다렸습니다. sleep 에서 캐리어를 내려놓기 때문입니다. 가상 스레드는 기본 이름이 없어 toString() 으로 찍었습니다.
CompletableFuture<Integer> failing = CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("재고 조회 실패");
});
failing.thenApply(n -> "재고 " + n) // 건너뜀
.exceptionally(ex -> "대체값 (" + ex.getCause().getMessage() + ")");
CompletableFuture.supplyAsync(() -> "시작")
.exceptionally(ex -> "여긴 안 옴") // 앞에 있으면 뒤 예외를 못 봄
.thenApply(s -> { throw new IllegalArgumentException("뒤에서 실패"); });(a) 끝에 exceptionally: 대체값 (CompletionException ← 재고 조회 실패)
(b) exceptionally 가 앞에 있으면: join → CompletionException ← IllegalArgumentException
(c) get() 은 ExecutionException 으로 감쌈
(d) handle: 실패를 값으로: 재고 조회 실패(a) 에서 thenApply 안의 출력이 없습니다. 예외가 그 단계를 건너뛰었습니다. exceptionally 가 받은 예외는 CompletionException 이고 원본은 getCause() 입니다.
ExecutorService ex = Executors.newFixedThreadPool(2);
CompletableFuture<String> slow = CompletableFuture.supplyAsync(() -> api.slow("느린 API", 1000), ex);
slow.orTimeout(300, TimeUnit.MILLISECONDS).join(); // TimeoutException
CompletableFuture.supplyAsync(() -> api.slow("느린 API", 1000), ex)
.completeOnTimeout("캐시된 값", 300, TimeUnit.MILLISECONDS).join(); // "캐시된 값" [ 1592 ms] orTimeout: TimeoutException
[ 1904 ms] completeOnTimeout: 캐시된 값
[ 1905 ms] 이 시점 호출 수 2, 풀은 아직 바쁨: 2개
[ 2595 ms] 풀 종료, 느린 작업들은 끝까지 돌았음호출자는 300ms 만에 돌아왔지만 풀의 스레드 2개는 1초를 꽉 채워 일했습니다. 크기 2인 풀이라면 세 번째 요청은 그동안 줄을 섭니다.
String r = Retry.withBackoff(() -> api.flaky(2), 4, 100, ex).join(); // 2번 실패 후 성공
Retry.withBackoff(() -> api2.flaky(9), 3, 100, ex)
.exceptionally(e -> "최종 실패: " + e.getMessage()); [ 2676 ms] 시도 1 실패: 일시 장애 #1
[ 2843 ms] 시도 2 실패: 일시 장애 #2
[ 3113 ms] 결과: 성공 (3번째 시도)
[ 3176 ms] 시도 1 실패: 일시 장애 #1
[ 3348 ms] 시도 2 실패: 일시 장애 #2
[ 3617 ms] 시도 3 실패: 일시 장애 #3
[ 3618 ms] 최종 실패: java.lang.IllegalStateException: 일시 장애 #3간격이 약 170ms, 270ms 로 늘어납니다. 호출 자체 50ms 에 대기 100ms, 200ms 가 더해진 값입니다. Retry 의 핵심은 handle 로 실패를 받아 delayedExecutor 에 다음 시도를 예약하고 thenCompose 로 평탄화하는 부분입니다.
Executor delayed = CompletableFuture.delayedExecutor(delayMs, MILLISECONDS, executor);
return CompletableFuture.supplyAsync(() -> null, delayed)
.thenCompose(x -> attempt(task, n + 1, max, delayMs * 2, executor));가상 스레드 20개가 각자 다른 락을 잡고 200ms 잠듭니다. 락이 다르므로 서로 기다릴 이유가 없습니다.
ex.submit(() -> { synchronized (monitors[k]) { FakeApi.sleep(200); } }); // 피닝
ex.submit(() -> { locks[k].lock(); try { FakeApi.sleep(200); } finally { locks[k].unlock(); } });VirtualThread[#84]/runnable@ForkJoinPool-1-worker-2 reason:MONITOR
Main.lambda$section6$21(Main.java:153) <== monitors:1
synchronized 안에서 sleep: 2050 ms
ReentrantLock 안에서 sleep: 205 mssynchronized 버전은 캐리어 2개에 2개씩만 진행돼 10배 느립니다. 첫 두 줄은 tracePinnedThreads 가 찍은 피닝 위치입니다. 운영 로그에 reason:MONITOR 가 보이면 그 줄의 synchronized 를 ReentrantLock 으로 바꿉니다.
가상 스레드 200개가 20ms 작업을 합니다. 커넥션이 10개뿐이라고 가정하고 세마포어를 겁니다.
Semaphore connections = new Semaphore(10);
connections.acquireUninterruptibly();
try { FakeApi.sleep(20); } finally { connections.release(); }제한 없음 33 ms
Semaphore(10) 620 ms스레드는 200개를 만들 수 있어도 자원이 10개면 10개 속도입니다. 가상 스레드 도입 후 응답이 빨라지지 않는다면 이 숫자를 먼저 봅니다.