공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 09 / 10

CompletableFuture 심화와 가상 스레드 실전

어느 스레드에서, 어떤 풀로, 실패하면 어떻게
섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

3. 코드 예제

소스: java-src/advanced/09_async_virtual (FakeApi, Retry, Main).

bash
cd java-src/advanced/09_async_virtual
javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 Main

Main 은 첫 줄에서 캐리어 스레드를 2개로 제한합니다. 6절 피닝을 코어 수와 무관하게 재현하기 위한 설정이며, 실무에서는 건드리지 않습니다.

java
System.setProperty("jdk.virtualThreadScheduler.parallelism", "2");
System.setProperty("jdk.tracePinnedThreads", "short");

예제 1: 어느 스레드에서 실행되는가

java
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
text
(a) 미완료 future 에 thenApply: 조회@ForkJoinPool.commonPool-worker-1 → [email protected]
(b) 완료된 future 에 thenApply: 완료됨 → thenApply@main
(c) thenApplyAsync: 완료됨 → [email protected]
(d) 다른 스레드의 ThreadLocal: requestId=없음 (main 은 REQ-1)

(b) 가 핵심입니다. 같은 thenApply 인데 future 상태에 따라 실행 스레드가 바뀝니다. (d) 는 ThreadLocal 이 워커 스레드로 따라가지 않는 것을 보여 줍니다. 로그의 MDC 값이 비동기 단계에서 사라지는 이유입니다.

예제 2: 어떤 풀에서 돌리는가

200ms 걸리는 호출 20개를 세 가지 executor 로 돌립니다.

java
CompletableFuture.supplyAsync(() -> api.slow(name, 200));            // commonPool
CompletableFuture.supplyAsync(() -> api.slow(name, 200), fixed20);   // 고정 풀
CompletableFuture.supplyAsync(() -> api.slow(name, 200), virtual);   // 가상 스레드
text
CPU 8개, commonPool 병렬도 7
commonPool  618 ms  (예: 호출[email protected])
고정 풀 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() 으로 찍었습니다.

예제 3: 예외 전파

java
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("뒤에서 실패"); });
text
(a) 끝에 exceptionally: 대체값 (CompletionException ← 재고 조회 실패)
(b) exceptionally 가 앞에 있으면: join → CompletionException ← IllegalArgumentException
(c) get() 은 ExecutionException 으로 감쌈
(d) handle: 실패를 값으로: 재고 조회 실패

(a) 에서 thenApply 안의 출력이 없습니다. 예외가 그 단계를 건너뛰었습니다. exceptionally 가 받은 예외는 CompletionException 이고 원본은 getCause() 입니다.

예제 4: 타임아웃은 작업을 멈추지 못합니다

java
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(); // "캐시된 값"
text
  [ 1592 ms] orTimeout: TimeoutException
  [ 1904 ms] completeOnTimeout: 캐시된 값
  [ 1905 ms] 이 시점 호출 수 2, 풀은 아직 바쁨: 2개
  [ 2595 ms] 풀 종료, 느린 작업들은 끝까지 돌았음

호출자는 300ms 만에 돌아왔지만 풀의 스레드 2개는 1초를 꽉 채워 일했습니다. 크기 2인 풀이라면 세 번째 요청은 그동안 줄을 섭니다.

예제 5: 재시도와 지수 백오프

java
String r = Retry.withBackoff(() -> api.flaky(2), 4, 100, ex).join();   // 2번 실패 후 성공
Retry.withBackoff(() -> api2.flaky(9), 3, 100, ex)
     .exceptionally(e -> "최종 실패: " + e.getMessage());
text
  [ 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 로 평탄화하는 부분입니다.

java
Executor delayed = CompletableFuture.delayedExecutor(delayMs, MILLISECONDS, executor);
return CompletableFuture.supplyAsync(() -> null, delayed)
        .thenCompose(x -> attempt(task, n + 1, max, delayMs * 2, executor));

예제 6: 피닝 실측

가상 스레드 20개가 각자 다른 락을 잡고 200ms 잠듭니다. 락이 다르므로 서로 기다릴 이유가 없습니다.

java
ex.submit(() -> { synchronized (monitors[k]) { FakeApi.sleep(200); } });   // 피닝
ex.submit(() -> { locks[k].lock(); try { FakeApi.sleep(200); } finally { locks[k].unlock(); } });
text
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 ms

synchronized 버전은 캐리어 2개에 2개씩만 진행돼 10배 느립니다. 첫 두 줄은 tracePinnedThreads 가 찍은 피닝 위치입니다. 운영 로그에 reason:MONITOR 가 보이면 그 줄의 synchronized 를 ReentrantLock 으로 바꿉니다.

예제 7: 동시성 제한

가상 스레드 200개가 20ms 작업을 합니다. 커넥션이 10개뿐이라고 가정하고 세마포어를 겁니다.

java
Semaphore connections = new Semaphore(10);
connections.acquireUninterruptibly();
try { FakeApi.sleep(20); } finally { connections.release(); }
text
제한 없음            33 ms
Semaphore(10)   620 ms

스레드는 200개를 만들 수 있어도 자원이 10개면 10개 속도입니다. 가상 스레드 도입 후 응답이 빨라지지 않는다면 이 숫자를 먼저 봅니다.

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다.

cd java-src\advanced\09_async_virtual
javac -encoding UTF-8 *.java && java Main
코드 예제
  • 예제 1: 어느 스레드에서 실행되는가
  • 예제 2: 어떤 풀에서 돌리는가
  • 예제 3: 예외 전파
  • 예제 4: 타임아웃은 작업을 멈추지 못합니다
  • 예제 5: 재시도와 지수 백오프
  • 예제 6: 피닝 실측
  • 예제 7: 동시성 제한
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제