JavaConcurrencySpring BootPerformance

Java 25 StructuredTaskScope로 여러 API 병렬 호출을 하나의 작업 단위로 묶기

Java 25 구조적 동시성(JEP 505, 5차 프리뷰)의 StructuredTaskScope와 Joiner API를 직접 예제로 돌려보며, 여러 외부 API 병렬 호출의 에러 전파·취소·관측성을 개선한 과정을 정리합니다.

Srue2026년 7월 9일
Java 25 StructuredTaskScope로 여러 API 병렬 호출을 하나의 작업 단위로 묶기

사이드 프로젝트로 만들던 상품 상세 API가 유독 굼떴습니다. 한 페이지를 그리려면 재고 서비스, 가격 서비스, 리뷰 서비스 세 곳을 각각 호출해야 했는데, 처음엔 별생각 없이 순차로 불렀습니다. 세 호출이 각각 150ms쯤 걸리면 합쳐서 450ms. 병렬로 묶으면 150ms대로 줄일 수 있다는 건 알았지만, ExecutorServiceFuture로 직접 병렬화한 코드가 늘 마음에 걸렸습니다.

마침 Java 25에 구조적 동시성(Structured Concurrency)이 5차 프리뷰(JEP 505)로 들어오면서 StructuredTaskScope API가 꽤 달라졌다는 이야기를 들었습니다. Java 21 시절 튜토리얼과 코드가 안 맞는다는 말도 있어서, 이번 기회에 직접 예제를 돌려보며 무엇이 바뀌었고 실제 백엔드 코드에 어떻게 녹일 수 있는지 정리해 봤습니다. 운영에 넣은 기능은 아니라 실측 수치는 아직 없지만, 재현 가능한 코드와 프리뷰 기능의 함정 위주로 남깁니다.

순차 호출을 병렬로 바꾸려다 만난 벽

먼저 아무 장치 없는 순차 버전입니다. 읽기는 편하지만 세 호출의 지연이 그대로 더해집니다.

ProductService.java — 순차 호출 (느림)
public ProductDetail getProductDetail(long id) {
    Stock stock = stockClient.fetch(id);        // 약 150ms
    Price price = priceClient.fetch(id);        // 약 150ms
    List<Review> reviews = reviewClient.fetch(id); // 약 150ms
    return new ProductDetail(stock, price, reviews);
}

그래서 ExecutorService로 병렬화했는데, 겉보기엔 멀쩡해도 신경 쓸 게 많았습니다.

ProductService.java — ExecutorService + Future (before)
private final ExecutorService pool = Executors.newFixedThreadPool(10);
 
public ProductDetail getProductDetail(long id) {
    Future<Stock> stock   = pool.submit(() -> stockClient.fetch(id));
    Future<Price> price   = pool.submit(() -> priceClient.fetch(id));
    Future<List<Review>> reviews = pool.submit(() -> reviewClient.fetch(id));
    try {
        return new ProductDetail(stock.get(), price.get(), reviews.get());
    } catch (ExecutionException e) {
        // 재고 조회가 실패해도 가격·리뷰 Future는 계속 실행된다 → 수동 취소 필요
        stock.cancel(true);
        price.cancel(true);
        reviews.cancel(true);
        throw new RuntimeException(e.getCause());
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException(e);
    }
}

이 코드가 안고 있던 문제를 정리하면 이렇습니다.

  • 취소가 전파되지 않는다: stock.get()에서 먼저 블로킹되는 사이 price 호출이 실패해도 알아채지 못합니다. 그동안 reviews는 계속 돌아가고, 실패를 인지한 뒤에야 나머지를 수동으로 취소해야 합니다. 이 cancel 호출을 한 줄이라도 빠뜨리면 스레드가 응답 없는 서버를 붙잡고 남습니다.
  • 부모-자식 관계가 코드에도 스택에도 안 드러난다: 세 서브태스크는 요청 스레드의 자식인데, 스레드 풀은 그 관계를 모릅니다. 스레드 덤프를 떠도 어떤 요청이 어떤 하위 호출을 띄웠는지 이어지지 않습니다.
  • 에러 처리가 흩어진다: 성공 경로와 취소 로직이 try/catch 여기저기에 섞여, 호출이 네 개, 다섯 개로 늘면 금세 손대기 싫은 코드가 됩니다.

문제의 뿌리는 하나였습니다. 세 호출은 논리적으로 하나의 작업 단위인데, ExecutorService는 그걸 그냥 독립된 태스크 세 개로만 다룬다는 점입니다.

StructuredTaskScope가 바꾸는 그림

구조적 동시성의 발상은 단순합니다. 함께 시작한 하위 작업은 하나의 스코프 안에서 함께 끝나야 한다는 것. 스코프를 try-with-resources 블록으로 감싸면, 블록을 벗어나기 전에 모든 서브태스크가 완료·실패·취소 중 하나로 정리됩니다. 하나가 실패하면 형제들은 자동으로 취소되고, 부모가 취소되면 자식도 함께 취소됩니다. ExecutorService에서 손으로 챙기던 일이 언어 구조로 보장되는 셈입니다.

한 가지 덧붙이면, StructuredTaskScope가 서브태스크를 띄우는 기본 스레드는 가상 스레드(Virtual Thread)입니다. 그래서 수백 개를 fork해도 부담이 적습니다. 가상 스레드 자체를 실무에 적용했던 이야기는 Virtual Threads를 WebFlux 없이 도입한 경험에 따로 정리해 뒀는데, 구조적 동시성은 그 위에 "작업의 경계"를 얹어 주는 도구라고 이해하면 편합니다.

Java 21에서 쓰던 코드가 Java 25에서 안 돌아간다

여기서 한 번 짚고 넘어가야 합니다. 인터넷에 도는 예제 상당수는 Java 21(JEP 453, 1차 프리뷰) 기준이고, Java 25에서는 그대로 컴파일되지 않습니다. Java 21에서는 ShutdownOnFailure, ShutdownOnSuccess 같은 하위 클래스를 new로 직접 만들고, 완료를 기다린 뒤 throwIfFailed()를 별도로 호출해야 했습니다.

Java 21 (JEP 453) — 이제는 컴파일되지 않는 옛 방식
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<Stock> stock = scope.fork(() -> stockClient.fetch(id));
    Subtask<Price> price = scope.fork(() -> priceClient.fetch(id));
 
    scope.join();          // 완료 대기
    scope.throwIfFailed(); // 실패 시 예외 던지기 (별도 호출이 필요했다)
 
    return new ProductDetail(stock.get(), price.get(), List.of());
}

Java 25(JEP 505)에서는 공개 생성자가 사라지고 정적 팩토리 StructuredTaskScope.open(...)으로 통일됐습니다. 그리고 "언제 스코프를 끝낼지"를 결정하는 정책이 Joiner 인터페이스로 분리됐습니다. throwIfFailed() 같은 별도 호출도 사라져서, join()이 결과를 반환하거나 실패 예외를 바로 던집니다.

항목Java 21 (JEP 453)Java 25 (JEP 505)
스코프 생성new ShutdownOnFailure()StructuredTaskScope.open(joiner)
종료 정책하위 클래스 상속Joiner 구현체 주입
실패 전파join()throwIfFailed() 별도 호출join()FailedException을 직접 던짐
join() 반환값스코프 자신(void 성격)Joiner가 정한 타입 R

한 가지 정직하게 덧붙이면, 이 API는 프리뷰 5차라 세부가 계속 움직이고 있습니다. 예컨대 allSuccessfulOrThrow()는 Java 25에서 Stream<Subtask<T>>를 돌려주지만 Java 26(JEP 525)에서는 List<T>로 다시 바뀝니다. 그래서 아래 코드는 정확히 Java 25 기준임을 전제로 읽어 주세요.

상품 상세 페이지: 세 개의 API를 하나로 묶기

이제 처음의 ExecutorService 코드를 StructuredTaskScope로 옮겨 봤습니다. 인자 없는 open()은 "모든 서브태스크가 성공하면 통과, 하나라도 실패하면 나머지를 취소하고 예외"라는 정책(awaitAllSuccessfulOrThrow)을 기본으로 씁니다.

ProductService.java — StructuredTaskScope (after)
public ProductDetail getProductDetail(long id) throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<Stock> stock   = scope.fork(() -> stockClient.fetch(id));
        Subtask<Price> price   = scope.fork(() -> priceClient.fetch(id));
        Subtask<List<Review>> reviews = scope.fork(() -> reviewClient.fetch(id));
 
        // 하나라도 실패하면 FailedException, 남은 서브태스크는 자동 취소된다
        scope.join();
 
        return new ProductDetail(stock.get(), price.get(), reviews.get());
    }
}

ExecutorService 버전에서 신경 쓰던 세 가지가 여기서 전부 사라집니다. join() 하나가 "전원 성공 대기 + 첫 실패 시 나머지 취소"를 맡고, 블록을 벗어나면 스코프가 닫히며 남은 스레드가 정리됩니다. stock.get()join() 이후, 그것도 성공한 서브태스크에 대해서만 호출할 수 있어서 실수로 미완료 결과를 읽는 경로 자체가 막혀 있습니다.

한 가지 유의할 점은 join()InterruptedException을 던진다는 것입니다. 위에서는 메서드 시그니처로 흘려보냈지만, 웹 요청 스레드라면 잡아서 인터럽트 상태를 복원(Thread.currentThread().interrupt())하는 편이 안전합니다. 실패 시 던지는 StructuredTaskScope.FailedException은 언체크 예외라, 전역 예외 핸들러에서 원인(getCause())을 꺼내 응답으로 매핑하면 됩니다.

Joiner로 실패 정책을 갈아끼우기

open()의 진짜 매력은 Joiner를 바꿔 끼우면 같은 fork 구조에서 종료·실패 정책만 달라진다는 데 있습니다. 예제를 돌려보며 자주 쓸 법한 세 가지를 정리했습니다.

결과가 전부 필요할 때 — allSuccessfulOrThrow

여러 엔드포인트에서 같은 타입 결과를 모아야 할 때 씁니다. Java 25에서 join()은 완료된 서브태스크의 Stream을 돌려줍니다.

여러 시세 소스를 병렬 조회
List<Quote> fetchQuotes(List<URI> sources) throws InterruptedException {
    try (var scope = StructuredTaskScope.open(Joiner.<Quote>allSuccessfulOrThrow())) {
        sources.forEach(uri -> scope.fork(() -> quoteClient.fetch(uri)));
        return scope.join()          // Stream<Subtask<Quote>>
                    .map(Subtask::get)
                    .toList();
    }
}

가장 빠른 응답만 필요할 때 — anySuccessfulResultOrThrow

같은 데이터를 여러 리플리카에서 받아올 수 있다면, 제일 먼저 성공한 하나만 취하고 나머지를 취소하는 경합(race) 패턴이 깔끔합니다. join()이 첫 성공 결과를 그대로 반환합니다.

가장 빨리 응답한 리플리카 채택
Quote fastestQuote(List<URI> replicas) throws InterruptedException {
    try (var scope = StructuredTaskScope.open(Joiner.<Quote>anySuccessfulResultOrThrow())) {
        replicas.forEach(uri -> scope.fork(() -> quoteClient.fetch(uri)));
        return scope.join(); // 첫 성공 결과, 나머지 서브태스크는 자동 취소
    }
}

일부 실패를 허용해야 할 때 — awaitAll

상품 상세에서 재고·가격은 필수지만 리뷰는 없어도 페이지를 그릴 수 있습니다. 이럴 땐 실패해도 형제를 취소하지 않고 전원 완료까지 기다리는 awaitAll을 쓰고, 서브태스크 상태를 직접 확인합니다.

핵심 데이터는 필수, 부가 데이터는 실패 허용
ProductDetail getWithOptionalReviews(long id) throws InterruptedException {
    try (var scope = StructuredTaskScope.open(Joiner.<Object>awaitAll())) {
        Subtask<Stock> stock   = scope.fork(() -> stockClient.fetch(id));
        Subtask<Price> price   = scope.fork(() -> priceClient.fetch(id));
        Subtask<List<Review>> reviews = scope.fork(() -> reviewClient.fetch(id));
 
        scope.join(); // 실패가 있어도 취소 없이 전원 완료까지 대기
 
        // 필수 데이터는 실패하면 그대로 요청을 실패시킨다
        if (stock.state() == Subtask.State.FAILED) {
            throw new IllegalStateException("재고 조회 실패", stock.exception());
        }
        if (price.state() == Subtask.State.FAILED) {
            throw new IllegalStateException("가격 조회 실패", price.exception());
        }
 
        // 리뷰는 부가 정보 → 실패하면 빈 목록으로 대체
        List<Review> reviewList = reviews.state() == Subtask.State.SUCCESS
                ? reviews.get()
                : List.of();
 
        return new ProductDetail(stock.get(), price.get(), reviewList);
    }
}

awaitAll을 쓸 땐 get()을 함부로 부르면 안 됩니다. 성공하지 않은 서브태스크에 get()을 호출하면 IllegalStateException이 나서, 반드시 state()SUCCESS를 확인한 뒤 값을 꺼내야 합니다. 이 부분은 앞의 awaitAllSuccessfulOrThrow와 정반대의 사고방식이라, 어떤 서브태스크가 필수이고 어떤 게 선택인지부터 정하고 Joiner를 골라야 했습니다.

타임아웃과 취소 전파에서 걸린 지점

외부 API를 부를 때 타임아웃은 선택이 아니라 필수입니다. open()의 두 번째 인자로 설정 함수를 넘겨 스코프 전체에 마감 시한을 걸 수 있습니다.

스코프 전체에 200ms 타임아웃
try (var scope = StructuredTaskScope.open(
        Joiner.<Quote>allSuccessfulOrThrow(),
        cf -> cf.withTimeout(Duration.ofMillis(200)))) {
 
    sources.forEach(uri -> scope.fork(() -> quoteClient.fetch(uri)));
    return scope.join().map(Subtask::get).toList();
 
} catch (StructuredTaskScope.TimeoutException e) {
    // 200ms 안에 못 끝나면 남은 서브태스크가 취소되고 이 예외가 올라온다
    throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "시세 조회 지연", e);
}

여기서 직접 돌려보다 헷갈렸던 대목이 취소의 실제 동작입니다. 스코프가 서브태스크를 취소한다는 건 그 스레드에 인터럽트를 건다는 뜻인데, 서브태스크가 인터럽트에 반응하지 않는 블로킹 호출을 물고 있으면 곧바로 멈추지 않습니다. RestClient나 HTTP 클라이언트 쪽 연결·응답 타임아웃을 스코프 타임아웃과 별개로 챙겨 둬야, 취소 신호가 실제 조기 종료로 이어졌습니다. 취소와 인터럽트가 늘 교과서대로 맞물리지 않는다는 건 예전에 트랜잭션과 동시성을 정리하며 데었던 감각과 비슷했습니다. 구조가 취소를 "요청"해 줄 뿐, 실제로 멈추는 건 각 태스크의 몫입니다.

preview 기능이라 감수해야 했던 것들

가장 크게 체감한 건 이게 여전히 프리뷰라는 점입니다. StructuredTaskScopejava.util.concurrent 패키지에 있지만, 컴파일과 실행 양쪽에 --enable-preview가 필요합니다.

pom.xml — maven-compiler-plugin
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <release>25</release>
    <compilerArgs>
      <arg>--enable-preview</arg>
    </compilerArgs>
  </configuration>
</plugin>

컴파일만 통과했다고 끝이 아닙니다. 실행할 때도 JVM에 같은 플래그를 줘야 하고, 빠뜨리면 UnsupportedClassVersionError류의 에러로 멈춥니다.

실행 시에도 플래그 필요
java --enable-preview -jar build/libs/app.jar

Spring Boot에서는 테스트 실행(surefire/failsafe)과 bootRun, 그리고 최종 실행 커맨드까지 세 군데 모두 플래그를 맞춰 줘야 했습니다. 프리뷰로 컴파일된 클래스 파일에는 마이너 버전 플래그가 찍혀서, 정확히 같은 메이저 버전(Java 25) JVM에서만 실행된다는 점도 유의해야 합니다. 라이브러리로 배포할 코드나 당장 운영에 올릴 서비스에 넣기엔 아직 이르고, 앞서 말했듯 JEP 505(Java 25) → JEP 525(Java 26)로 넘어가며 allSuccessfulOrThrow의 반환 타입 같은 시그니처가 또 바뀌기 때문에, 지금은 팀 내부에서 손에 익히고 설계를 검증하는 용도로 두는 게 맞다고 판단했습니다.

순차 vs 구조적 동시성: 무엇이 달라졌나

예제 수준에서 정리한 비교입니다.

항목순차 / ExecutorService (변경 전)StructuredTaskScope (변경 후)
지연(3개 호출)순차는 합산, 수동 병렬은 관리 부담병렬, 가장 느린 호출 기준
실패 시 취소수동 cancel, 누락 위험스코프가 자동 전파
에러 처리try/catch에 분산join() 한 지점으로 수렴
관측성부모-자식 관계 단절스코프 단위로 묶임

마무리

처음엔 StructuredTaskScope를 "ExecutorService를 조금 편하게 감싼 것" 정도로 여겼습니다. 그런데 예제를 하나씩 돌려보며 든 생각은, 이 API의 핵심이 성능이 아니라 경계라는 점이었습니다. 함께 시작한 일은 함께 끝난다는 규칙을 언어 구조로 강제하니, 취소 누락이나 좀비 스레드처럼 그동안 조심해서 피하던 실수들이 애초에 발생할 수 없는 형태로 바뀌었습니다.

동시에, 프리뷰 기능을 다룰 때의 태도도 다시 배웠습니다. 21에서 25로 오며 API가 갈아엎였고 26에서 또 바뀐다는 사실은, 신기능을 빨리 쓰는 것보다 "무엇이 왜 바뀌는지"를 따라가는 게 더 오래 남는 자산이라는 걸 일깨워 줬습니다. 당장 운영에 넣진 않겠지만, 여러 외부 호출을 하나의 작업 단위로 사고하는 방식만큼은 지금 코드에도 그대로 적용할 수 있었습니다. 결국 좋은 동시성 코드는 스레드를 많이 다루는 코드가 아니라, 작업의 경계를 분명히 그은 코드라는 생각이 남았습니다.