Virtual Thread 마스터 — Structured Concurrency

2026-05-03확률과 통계 마스터 노트

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 초과

시리즈 다른 편

공식 문서: JEP 453 — Structured Concurrency / JEP 446 — Scoped Values 에서 더 깊이.

다음 글(7편)에서는 Performance — 처리량·지연 측정, JFR로 분석, 메모리 사용 비교, GC 영향까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!