Java 21 Virtual Thread 마스터 노트 시리즈 6편. Structured Concurrency가 비구조적 비동기의 문제를 푸는 방식, StructuredTaskScope의 ShutdownOnFailure·ShutdownOnSuccess 정책, fork·join·throwIfFailed 흐름, 자동 취소·실패 전파, ScopedValue로 컨텍스트 전달, 실전 패턴까지.
이 글은 Java 21 Virtual Thread 마스터 노트 시리즈의 여섯 번째 편입니다. 1~5편이 Virtual Thread 자체였다면, 이번엔 그것을 단위로 관리하는 패턴 — Structured Concurrency.
여러 Virtual Thread를 하나의 단위로. 한 작업 실패 시 모두 자동 취소. 자식 스레드 누수 방지. 비동기의 구조화.
처음 Structured Concurrency가 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, "비구조적 비동기 문제"가 막연합니다. 둘째, ShutdownOnFailure vs ShutdownOnSuccess 차이가 헷갈립니다.
해결법은 한 가지예요. "모두 성공해야 = OnFailure / 하나만 성공해야 = OnSuccess" 한 줄. 한쪽 실패 시 나머지 자동 취소. 이 매핑만 잡으면 끝.
비구조적 비동기의 문제
// 전통 ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(10);
Future<String> f1 = executor.submit(() -> fetchUser());
Future<String> f2 = executor.submit(() -> fetchOrders());
// f1 실패 → f2는 그래도 계속 실행 (자원 낭비)
// 호출자가 명시 cancel 안 하면 누수
문제:
- 자식 스레드 누수
- 명시 취소 안 하면 영영 실행
- 에러 전파 어려움
- 흐름 추적 X (단편화)
Structured Concurrency 해결
import java.util.concurrent.StructuredTaskScope;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> fetchUser());
Subtask<String> orders = scope.fork(() -> fetchOrders());
scope.join(); // 모두 끝까지 또는 실패까지 대기
scope.throwIfFailed(); // 하나라도 실패 → 예외
return new Combined(user.get(), orders.get());
} // scope 자동 정리
핵심:
- try-with-resources = 자동 정리
- 자식 작업이 부모 스코프 안에서 시작·끝
- 한 작업 실패 → 다른 작업 자동 취소
- 명확한 흐름
StructuredTaskScope — Preview
// JEP 453 (Java 21+ Preview)
// 컴파일·실행 시 --enable-preview 필요
// Java 24+ — 정식 (JEP 480 등)
여기서 정말 중요한 시험 함정 — Java 21에선 Preview. --enable-preview 필수. Java 24+ 정식. 운영 환경 Preview 사용 신중.
ShutdownOnFailure — 모두 성공 정책
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> fetchUser());
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders());
Subtask<Profile> profile = scope.fork(() -> fetchProfile());
scope.join();
scope.throwIfFailed(IOException::new);
return new Combined(user.get(), orders.get(), profile.get());
}
흐름:
1. 3 작업 동시 시작
2. join() — 모두 완료 또는 한 작업 실패까지 대기
3. 한 작업 실패 → 다른 작업 자동 취소 (interrupt)
4. throwIfFailed() — 실패 있으면 예외 던짐
여기서 시험 함정이 하나 있어요. join() = 블로킹. 그러나 Virtual Thread면 unmount OK. 캐리어 점유 X.
사용처
- 모든 데이터 필요 — 사용자 + 주문 + 프로필
- 부분 실패 = 전체 실패
ShutdownOnSuccess — 가장 빠른 하나
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromMirror1());
scope.fork(() -> fetchFromMirror2());
scope.fork(() -> fetchFromMirror3());
scope.join();
return scope.result(); // 가장 빠른 응답
}
흐름:
1. 3 작업 동시
2. 하나 성공 → 다른 작업 자동 취소
3. 모두 실패 → 예외
4. 가장 빠른 결과 반환
사용처
- CDN 미러 — 가장 빠른 서버
- Race·Hedging — 첫 응답 사용
- 타임아웃 회피
fork — 작업 추가
Subtask<T> task = scope.fork(callable);
// callable = Callable<T>
새 Virtual Thread 시작. scope 안에서.
join·joinUntil
// 끝까지 대기
scope.join();
// 데드라인까지만
scope.joinUntil(Instant.now().plusSeconds(5));
joinUntil = 시간 제한.
Subtask 상태
Subtask<String> task = scope.fork(callable);
scope.join();
State state = task.state(); // SUCCESS·FAILED·UNAVAILABLE
if (state == State.SUCCESS) {
String result = task.get();
} else if (state == State.FAILED) {
Throwable e = task.exception();
}
여기서 정말 중요한 시험 함정 — Subtask.get()은 SUCCESS 시만. FAILED·UNAVAILABLE 시 IllegalStateException. state 먼저 체크.
자동 취소
try (var scope = new ShutdownOnFailure()) {
Subtask<A> t1 = scope.fork(() -> {
return slowTask(); // 5초 작업
});
Subtask<B> t2 = scope.fork(() -> {
Thread.sleep(1000);
throw new RuntimeException("Failed!");
});
scope.join();
// t1: interrupted (자동 취소)
// t2: failed
}
t2 실패 → scope이 t1을 자동 interrupt → t1의 Thread.sleep·I/O가 InterruptedException → 빠른 종료.
여기서 시험 함정이 하나 있어요. 자동 취소는 interrupt 기반. CPU 집약 코드 = isInterrupted() 체크 안 하면 안 멈춤. I/O·sleep은 자동.
중첩 Scope
try (var outer = new ShutdownOnFailure()) {
Subtask<List<User>> users = outer.fork(() -> {
// 안에서 또 다른 scope
try (var inner = new ShutdownOnFailure()) {
Subtask<List<User>> a = inner.fork(() -> fetchFromA());
Subtask<List<User>> b = inner.fork(() -> fetchFromB());
inner.join();
inner.throwIfFailed();
return merge(a.get(), b.get());
}
});
Subtask<Stats> stats = outer.fork(() -> computeStats());
outer.join();
outer.throwIfFailed();
}
병렬 + 직렬 자유 합성.
ScopedValue — 컨텍스트 전달
final static ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "alice").run(() -> {
try (var scope = new ShutdownOnFailure()) {
Subtask<Profile> p = scope.fork(() -> {
// VT 안에서도 USER 접근
return fetchProfile(USER.get());
});
scope.join();
scope.throwIfFailed();
}
});
여기서 정말 중요한 시험 함정 — ScopedValue = ThreadLocal 대안 + 자동 전파. Structured Scope 안 자식 VT가 자동 상속. ThreadLocal보다 안전·효율.
ThreadLocal vs ScopedValue
| 측면 | ThreadLocal | ScopedValue |
|---|---|---|
| 변경 가능 | O | X (불변) |
| 정리 | 수동 (remove()) |
자동 (스코프 종료) |
| 자식 스레드 | InheritableThreadLocal | 자동 |
| Virtual Thread | OK but 메모리 위험 | 친화 |
| 타입 | 임의 | 임의 |
여기서 정말 중요한 시험 함정 — 수백만 VT = ThreadLocal 메모리 폭주. ScopedValue가 안전. Java 21 Preview, 24+ 정식.
커스텀 정책 — Custom Scope
public class FirstSuccessOrAllFailedScope<T> extends StructuredTaskScope<T> {
private volatile T firstResult;
private final List<Throwable> failures = new CopyOnWriteArrayList<>();
@Override
protected void handleComplete(Subtask<? extends T> subtask) {
switch (subtask.state()) {
case SUCCESS -> {
if (firstResult == null) {
firstResult = subtask.get();
shutdown();
}
}
case FAILED -> failures.add(subtask.exception());
}
}
public T result() {
if (firstResult != null) return firstResult;
throw new RuntimeException("All failed");
}
}
특수 정책 직접 구현.
실전 — 마이크로서비스 합성
public CombinedResponse handleRequest(RequestId id) {
try (var scope = new ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> userService.fetch(id));
Subtask<List<Order>> orders = scope.fork(() -> orderService.fetch(id));
Subtask<Recommendations> rec = scope.fork(() -> recService.fetch(id));
scope.joinUntil(Instant.now().plusSeconds(2)); // 전체 2초 데드라인
scope.throwIfFailed();
return new CombinedResponse(user.get(), orders.get(), rec.get());
} catch (TimeoutException e) {
throw new ServiceTimeoutException(e);
}
}
3 마이크로서비스 병렬 호출 + 데드라인 + 자동 취소.
TimeoutException
try (var scope = new ShutdownOnFailure()) {
scope.fork(longTask);
scope.joinUntil(Instant.now().plusSeconds(5)); // 5초
} catch (TimeoutException e) {
// 시간 초과
}
데드라인 초과 = TimeoutException + 자동 자식 취소.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 6편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 비구조적 비동기 문제 — 자식 누수·명시 취소·에러 전파·흐름 추적
- Structured Concurrency = 자식 작업이 부모 스코프 안 시작·끝
StructuredTaskScope+ try-with-resources- Java 21 Preview (
--enable-preview) / Java 24+ 정식 - ShutdownOnFailure = 모두 성공해야 (하나 실패 시 모두 취소)
- ShutdownOnSuccess = 하나만 성공해야 (성공 시 나머지 취소)
- 사용처 — OnFailure (모든 데이터) / OnSuccess (CDN·Race·Hedging)
scope.fork(callable)= 새 VT 시작scope.join()= 끝까지 대기 (Virtual Thread → unmount OK)scope.joinUntil(Instant)= 데드라인throwIfFailed()= 실패 있으면 예외- Subtask 상태 — SUCCESS·FAILED·UNAVAILABLE
Subtask.get()SUCCESS 시만 (state 먼저)- 자동 취소 = interrupt 기반 (CPU 집약은
isInterrupted체크) - 중첩 Scope OK — 병렬 + 직렬 자유
- ScopedValue = 컨텍스트 전달, 자동 상속
- ThreadLocal vs ScopedValue — 불변·자동 정리·자식 자동 상속
- 수백만 VT = ThreadLocal 위험 → ScopedValue
- 커스텀 정책 —
StructuredTaskScope상속·handleComplete오버라이드 - 실전 — 마이크로서비스 병렬 호출 + 데드라인 + 자동 취소
- TimeoutException =
joinUntil초과
시리즈 다른 편
- 1편 — 동시성 기초·Java Thread
- 2편 — Carrier·Mount·Unmount
- 3편 — API·Builder·ExecutorService
- 4편 — Pinning·synchronized·ReentrantLock
- 5편 — Spring Boot 통합
- 6편 — Structured Concurrency (현재 글)
- 7편 — Performance·JFR·메모리
- 8편 — Patterns·실전·안티패턴
공식 문서: JEP 453 — Structured Concurrency / JEP 446 — Scoped Values 에서 더 깊이.
다음 글(7편)에서는 Performance — 처리량·지연 측정, JFR로 분석, 메모리 사용 비교, GC 영향까지 풀어 갑니다.