본문으로 건너뛰기

안동민 개발노트

본문 시작

Structured Concurrency

StructuredTaskScope의 open·fork·join·close 순서를 실행하고 결과 접근·취소·문맥 상속을 관찰합니다.

구조화된 동시성은 Java 25의 JEP 505 다섯 번째 프리뷰 API입니다.

StructuredTaskScope는 형제 하위 작업을 하나의 코드 범위에 묶고 소유 작업이 join한 뒤 함께 범위를 벗어나게 합니다.

JDK 24까지의 예제와 달리 Java 25 API는 public 생성자 대신 open() 팩터리와 Joiner를 사용합니다.

컴파일과 실행에는 모두 프리뷰 플래그가 필요합니다.

StructuredTaskScope는 open·fork·join·read·close 순서를 강제한다

join 전에 Subtask.get을 읽거나 join 없이 범위를 나가면 형제 task 수명과 결과 상태가 구조를 벗어납니다.

  1. 1
    open scope

    owner enters

  2. 2
    fork siblings

    Subtask handles

  3. 3
    join

    completion policy 적용

  4. 4
    read result

    성공 상태에서 get/result

  5. 5
    close

    try-with-resources


join 전 Subtask.get 호출

아래 하위 작업은 래치에서 기다리는데 소유자가 join() 전에 결과를 읽습니다.

Subtask.get()은 완료 성공 상태가 아니므로 실제 IllegalStateException이 발생합니다.

preview/StructuredSubtaskBeforeJoinFailure.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.StructuredTaskScope;

public final class StructuredSubtaskBeforeJoinFailure {
    public static void main(String[] args) throws Exception {
        CountDownLatch release = new CountDownLatch(1);
        try (var scope = StructuredTaskScope.<String>open()) {
            StructuredTaskScope.Subtask<String> subtask =
                    scope.fork(
                            () -> {
                                release.await();
                                return "done";
                            });
            try {
                System.out.println(subtask.get());
            } finally {
                release.countDown();
                scope.join();
            }
        }
    }
}

실행 순서는 open → fork → join → 성공 결과 읽기 → close입니다.

범위의 소유권과 수명을 벗어나 하위 작업 핸들을 보관하지 않습니다.

javac --enable-preview --release 25 -Xlint:preview StructuredSubtaskBeforeJoinFailure.java
java --enable-preview StructuredSubtaskBeforeJoinFailure
# java.lang.IllegalStateException: Result is unavailable or subtask did not complete successfully

독립 조회의 요청 단위 결합

사용자와 게시글 조회가 모두 성공해야 응답을 만들 수 있다면 default 범위를 사용합니다.

하나가 실패하면 join()이 실패를 전파하고 나머지 작업을 취소하는 정책을 적용합니다.

preview/StructuredBoardResponse.java
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;

public final class StructuredBoardResponse {
    private record User(long id, String name) {}

    private record Summary(int posts, int comments) {}

    private record Response(User user, Summary summary) {}

    private static User loadUser() throws InterruptedException {
        Thread.sleep(Duration.ofMillis(5));
        return new User(7, "min");
    }

    private static Summary loadSummary() throws InterruptedException {
        Thread.sleep(Duration.ofMillis(5));
        return new Summary(3, 120);
    }

    static Response load() throws InterruptedException {
        try (var scope = StructuredTaskScope.open()) {
            StructuredTaskScope.Subtask<User> user = scope.fork(StructuredBoardResponse::loadUser);
            StructuredTaskScope.Subtask<Summary> summary =
                    scope.fork(StructuredBoardResponse::loadSummary);
            scope.join();
            return new Response(user.get(), summary.get());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(load());
    }
}

close()try-with-resources가 보장합니다.

join() 없이 블록을 나가거나 범위를 잘못 중첩하면 실행 환경에서 구조 위반을 감지할 수 있습니다.

Unstructured Future와 structured scope는 task 소유권의 모양이 다르다

Future handle이 method 밖으로 흩어지면 누가 실패·취소·종료를 수습하는지 call tree에서 보이지 않습니다.

  1. Future collection

    submit·get·cancel 위치 분산 · executor 별도 수명

  2. StructuredTaskScope

    lexical owner · sibling join/cancel · close 보장

  3. request-bound siblings

    scope에 적합 · parent와 함께 종료

  4. long-lived background task

    별도 service·lifecycle owner


Joiner 완료 정책과 결과 형태

모든 결과가 필요한지, 성공 하나면 되는지, 단순 완료만 기다리는지에 따라 Joiner를 선택합니다.

아래는 두 미러 서버 중 먼저 성공한 값을 결과로 받습니다.

실패한 형제 작업의 처리와 취소는 Joiner 정책이 담당합니다.

preview/AnySuccessfulMirrorResult.java
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;

public final class AnySuccessfulMirrorResult {
    private static String mirror(String name, long delayMillis, boolean fail) throws Exception {
        Thread.sleep(Duration.ofMillis(delayMillis));
        if (fail) {
            throw new IllegalStateException(name + " unavailable");
        }
        return name + "-result";
    }

    public static void main(String[] args) throws InterruptedException {
        var joiner = StructuredTaskScope.Joiner.<String>anySuccessfulResultOrThrow();
        try (var scope = StructuredTaskScope.open(joiner)) {
            scope.fork(() -> mirror("primary", 5, true));
            scope.fork(() -> mirror("secondary", 10, false));
            String result = scope.join();
            System.out.println(result);
        }
    }
}

“먼저 완료”와 “먼저 성공”은 다릅니다.

첫 작업이 실패해도 두 번째 성공을 기다립니다.

두 작업이 모두 외부 부수 효과를 만들면 나머지 작업 취소만으로 이미 발생한 효과를 롤백할 수 없습니다.


구조화된 작업의 ScopedValue

Java 25의 정식 기능인 ScopedValue와 프리뷰 API인 StructuredTaskScope를 결합하면 요청 문맥이 fork된 하위 작업에 상속됩니다.

가변 ThreadLocal 값을 복사하는 방식보다 상위·하위 작업의 코드 범위가 분명합니다.

preview/StructuredScopedContext.java
import java.util.concurrent.StructuredTaskScope;

public final class StructuredScopedContext {
    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    static String gather() throws InterruptedException {
        try (var scope = StructuredTaskScope.open()) {
            var first = scope.fork(() -> REQUEST_ID.get() + ":user");
            var second = scope.fork(() -> REQUEST_ID.get() + ":summary");
            scope.join();
            return first.get() + " | " + second.get();
        }
    }

    public static void main(String[] args) throws Exception {
        String result =
                ScopedValue.where(REQUEST_ID, "req-37").call(StructuredScopedContext::gather);
        System.out.println(result);
    }
}
Joiner는 필요한 결과와 종료 조건을 scope 정책으로 정한다

모든 결과가 필요한 요청과 하나의 성공이면 되는 mirror 요청에 같은 join 정책을 쓰면 불필요한 대기나 실패 처리가 생깁니다.

  1. anySuccessfulResultOrThrow

  2. 모두 필요

    join 후 각 Subtask 성공 확인

  3. 완료만 대기

    결과 없는 completion 정책

  4. 특수 quorum

    custom Joiner로 명시


관찰할 실패·취소 항목

  • 하위 작업 예외가 join에서 어떤 래퍼와 원인으로 전파되는지 기록합니다.
  • 형제 작업이 블로킹 호출에서 interrupt에 반응하는지 확인합니다.
  • 상위 작업 interrupt가 범위와 하위 작업에 어떻게 전달되는지 봅니다.
  • 시간 제한 구성이 기한 초과 작업을 취소하는지 측정합니다.
  • JFR·스레드 덤프에서 상위 작업과 하위 작업 관계가 어떻게 보이는지 관찰합니다.

프리뷰 API의 시그니처는 다음 릴리스에서 달라질 수 있으므로 래퍼 인터페이스 뒤에 실험 구현으로 둡니다.

운영 서비스의 public 시그니처에 StructuredTaskScope 타입을 직접 노출하지 않습니다.

Structured Concurrency는 결과뿐 아니라 실패·취소·관찰 관계를 기록한다

성공 출력만 확인하면 sibling이 interrupt에 반응하는지와 parent-child 관계가 진단 도구에 보이는지 알 수 없습니다.

관찰확인근거
failurejoin 전파·causeexception chain
cancellationsibling interrupt 반응task log
lifetimejoin 없이 close 거부runtime failure
contextScopedValue 상속 범위child output
diagnosticsJFR/thread dump 관계recording
StructuredTaskScopeExecutorService를 대체하나요?

아닙니다.

JEP의 목표도 모든 동시성 API를 대체하는 것이 아닙니다.

상위 요청의 코드 범위 안에서 관련 하위 작업을 병렬로 분기하고 join하는 문제에 적합합니다.

장기 백그라운드 큐, 예약 실행, 독립 작업 제출에는 기존 실행기나 메시징 시스템이 더 적합합니다.


연습 문제

기본 범위에서 집중 점수와 복습 점수를 fork하고 join 뒤 합계를 반환하세요.

하나가 실패하면 합계를 만들지 않아야 합니다.

해설 보기
preview/StructuredScoreSolution.java
import java.util.concurrent.StructuredTaskScope;

public final class StructuredScoreSolution {
    private static int focusScore() {
        return 70;
    }

    private static int reviewScore() {
        return 25;
    }

    static int total() throws InterruptedException {
        try (var scope = StructuredTaskScope.open()) {
            var focus = scope.fork(StructuredScoreSolution::focusScore);
            var review = scope.fork(StructuredScoreSolution::reviewScore);
            scope.join();
            return focus.get() + review.get();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("total=" + total());
    }
}

실행 명령은 컴파일·실행 모두 프리뷰 플래그를 포함하며 결과는 total=95입니다.