공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능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정리‹ 이전다음 ›

2. 핵심 원리

2.1 각 단계는 어느 스레드에서 실행되는가

thenApply 같은 비동기 접미사 없는 메서드는 "이전 단계를 완료한 스레드" 에서 실행됩니다. 아직 완료되지 않았으면 완료시키는 스레드가, 이미 완료됐으면 thenApply 를 호출한 스레드가 실행합니다.

메서드 실행 스레드
thenApply(f) 이전 단계를 끝낸 스레드
thenApply(f) 이미 완료된 future 호출한 스레드 (동기 실행)
thenApplyAsync(f) commonPool
thenApplyAsync(f, executor) 지정한 executor

여기서 실무 함정이 하나 나옵니다. thenApply 에 무거운 작업을 넣으면 future 가 이미 완료된 경우 요청 스레드가 그 작업을 직접 하게 됩니다. 반대로 thenApplyAsync 로 넘기면 ThreadLocal(MDC 의 요청 ID 등) 이 따라가지 않습니다.

2.2 commonPool 은 CPU 작업용입니다

executor 를 지정하지 않으면 ForkJoinPool.commonPool() 을 씁니다. 크기는 CPU 코어 수 - 1 이고, 병렬 스트림도 같은 풀을 씁니다. 여기서 DB 조회나 HTTP 호출처럼 기다리는 작업을 돌리면 코어 수만큼만 동시에 진행되고 나머지는 줄을 섭니다.

블로킹 I/O 는 반드시 전용 풀 또는 가상 스레드 executor 로 보냅니다. 코어가 8개인 서버에서 외부 API 20개를 commonPool 로 부르면 3배 느려지는 것을 3절에서 확인합니다.

작업 종류 executor
CPU 계산 commonPool 또는 코어 수 풀
블로킹 I/O (DB, HTTP, 파일) 가상 스레드 executor
순서·격리가 필요한 작업 단일 스레드 또는 전용 풀

2.3 예외는 체인을 따라 "건너뛰며" 전파됩니다

어떤 단계가 예외로 끝나면 그 뒤의 thenApply·thenAccept 는 실행되지 않고 예외만 다음 단계로 넘어갑니다. 예외를 잡는 단계는 exceptionally, handle, whenComplete 셋입니다.

메서드 받는 것 결과
exceptionally(f) 예외만 대체값으로 복구
handle(f) 값 또는 예외 둘 다 처리해 새 값
whenComplete(f) 값 또는 예외 관찰만, 결과 그대로

위치가 중요합니다. exceptionally 는 자기보다 앞 단계의 예외만 봅니다. 뒤에서 난 예외는 잡지 못하므로 보통 체인 맨 끝에 둡니다.

예외는 감싸져서 나옵니다. join() 은 CompletionException, get() 은 ExecutionException 으로 감싸고, 원본은 getCause() 에 있습니다. exceptionally 로 들어오는 예외도 대개 CompletionException 입니다.

2.4 타임아웃은 기다림만 끊습니다

orTimeout(t) 은 시간 안에 완료되지 않으면 future 를 TimeoutException 으로 끝냅니다. completeOnTimeout(v, t) 는 대체값으로 끝냅니다. 둘 다 JDK 9 에 추가됐습니다.

그러나 뒤에서 도는 작업은 계속 돕니다. CompletableFuture 는 자기를 실행하는 스레드를 모르므로 인터럽트할 수 없습니다. 타임아웃이 잦으면 풀이 좀비 작업으로 가득 차 새 요청이 줄을 섭니다. 진짜 취소는 HTTP 클라이언트나 JDBC 의 자체 타임아웃으로 걸어야 합니다.

2.5 재시도는 thenCompose 재귀로 씁니다

실패하면 잠시 기다렸다가 다시 시도하는 로직은 동기 코드에서는 for 문입니다. 비동기에서는 스레드를 붙잡지 않고 기다려야 하므로 delayedExecutor 로 다음 시도를 예약하고 thenCompose 로 이어 붙입니다.

text
시도 1 ─실패─▶ 100ms 대기 ─▶ 시도 2 ─실패─▶ 200ms 대기 ─▶ 시도 3 ─성공─▶ 값
                                                        └─실패─▶ 예외

대기 시간을 두 배씩 늘리는 것이 지수 백오프입니다. 장애 난 서버를 재시도 폭풍으로 더 눕히지 않기 위한 예의입니다.

2.6 가상 스레드와 피닝

가상 스레드는 블로킹 호출을 만나면 캐리어 스레드에서 내려와(unmount) 다른 가상 스레드에게 자리를 줍니다. 그래서 캐리어 몇 개로 수만 개를 돌릴 수 있습니다.

예외가 피닝(pinning)입니다. JDK 21 에서는 synchronized 블록 안이나 네이티브 메서드 안에서 블로킹하면 내려오지 못하고 캐리어를 붙잡습니다. 캐리어가 코어 수만큼밖에 없으므로, 피닝된 가상 스레드가 코어 수를 넘으면 나머지는 전부 멈춥니다.

상황 JDK 21 대책
synchronized 안에서 I/O·sleep 피닝 ReentrantLock 으로 교체
네이티브 메서드 안 블로킹 피닝 피하기 어려움
ReentrantLock 안에서 블로킹 정상 unmount 그대로 사용

JDK 24 부터 synchronized 피닝은 해결됐습니다(JEP 491). 그래도 오래된 드라이버·라이브러리가 synchronized 를 많이 쓰므로, -Djdk.tracePinnedThreads=short 로 실측해 보고 결정합니다.

2.7 병목은 스레드가 아니라 자원입니다

가상 스레드 10만 개가 동시에 DB 에 가도 커넥션 풀이 10개면 10개씩만 처리됩니다. 나머지는 풀에서 대기하고, 풀 대기 타임아웃(HikariCP 기본 30초) 에 걸려 예외가 쏟아집니다. 플랫폼 스레드 200개 시절에는 스레드 수가 자연스러운 제한이었는데, 그 제한이 사라진 것입니다.

동시성 제한을 명시적으로 겁니다. Semaphore(n) 으로 자원 수만큼만 통과시키는 것이 가장 단순한 방법입니다. 가상 스레드를 풀링하는 것은 권장하지 않습니다. 가상 스레드는 싸게 만들고 버리는 것이고, 제한은 세마포어의 몫입니다.

2.8 Spring Boot 에서

spring.threads.virtual.enabled=true 한 줄로 Tomcat 요청 스레드, @Async, @Scheduled 가 가상 스레드로 바뀝니다. 켜기 전 점검 목록입니다.

  • 커넥션 풀 크기와 DB 세션 한도를 확인하고, 필요하면 세마포어를 둡니다.
  • synchronized 안에서 I/O 하는 코드와 라이브러리를 tracePinnedThreads 로 찾습니다.
  • 요청마다 큰 값을 넣는 ThreadLocal 은 가상 스레드 수만큼 복제되므로 줄입니다.
  • CPU 계산이 주된 작업이면 이득이 없습니다. 가상 스레드는 기다리는 작업용입니다.
핵심 원리
  • 2.1 각 단계는 어느 스레드에서 실행되는가
  • 2.2 commonPool 은 CPU 작업용입니다
  • 2.3 예외는 체인을 따라 "건너뛰며" 전파됩니다
  • 2.4 타임아웃은 기다림만 끊습니다
  • 2.5 재시도는 thenCompose 재귀로 씁니다
  • 2.6 가상 스레드와 피닝
  • 2.7 병목은 스레드가 아니라 자원입니다
  • 2.8 Spring Boot 에서
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제