서비스 하나가 화면을 그리려고 회원 정보, 주문 내역, 추천 목록을 각기 다른 API에서 끌어오는 구조는 어디에나 있다. 순차로 부르면 느리니 병렬로 묶는데, 이때 ExecutorService와 Future로 짜 둔 코드가 의외로 조용히 스레드를 흘리는 경우를 자주 본다. 하나가 터졌는데 나머지는 백그라운드에서 끝까지 돌고 있는 것이다. JDK 21에서 미리보기로 들어온 구조적 동시성이 JDK 25에서 API를 크게 손봤길래, 예전 방식과 무엇이 달라지는지 코드로 정리해 둔다.
결론부터 말하자면, JDK 25의 StructuredTaskScope(JEP 505, 다섯 번째 미리보기)는 하위 작업들을 하나의 범위로 묶어 부모가 join()에서 함께 기다린다. 하위 작업 하나가 실패하면 나머지를 자동으로 취소하고, fork한 작업은 기본적으로 가상 스레드에서 돈다. ExecutorService/Future 조합에서 손으로 챙겨야 했던 취소와 정리를 구조가 대신 보장한다는 점이 핵심이다.
구조적 동시성이 무엇을 보장하나
구조적 동시성(structured concurrency)은 여러 하위 작업을 하나의 작업 단위로 취급하는 방식이다. 한 메서드가 여러 하위 작업으로 갈라져 실행됐다면, 그 하위 작업들은 반드시 같은 메서드 안에서 모두 끝나고 합쳐진 다음 밖으로 나가야 한다. try-with-resources 블록의 진입과 종료가 하위 작업의 생명주기를 감싸는 셈이다.
여기서 얻는 성질이 세 가지다. 하위 작업 하나가 실패하면 형제 작업은 더 돌 이유가 없으니 취소된다. 부모 스레드가 인터럽트를 받으면 하위 작업으로 전파된다. 블록을 벗어날 때는 모든 하위 작업이 이미 종료돼 있어 스레드가 뒤에 남지 않는다.
기존 ExecutorService/Future 방식이 새는 지점
가상 스레드가 생기면서 이런 코드를 많이 쓴다. 회원과 주문을 병렬로 부른 뒤 합치는 형태다.
Response handle() throws Exception {
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
try {
Future<String> user = pool.submit(() -> findUser());
Future<Integer> order = pool.submit(() -> fetchOrder());
return new Response(user.get(), order.get()); // 여기서 막힌다
} finally {
pool.shutdown();
}
}
막상 돌려보면 이 부분에서 자주 걸린다. user.get()이 먼저 예외를 던지면 그 자리에서 메서드를 빠져나간다. 그런데 order 작업은 취소된 적이 없다. findUser()가 즉시 실패했는데 fetchOrder()가 30초 타임아웃을 기다리는 원격 호출이라면, 결과를 아무도 쓰지 않는데도 그 30초 동안 스레드가 살아 원격 호출을 계속 붙들고 있다. 이것이 취소 누수다.
반대 순서도 문제다. order가 먼저 실패해 예외로 빠지면, 아직 실행 중인 user 작업은 finally의 shutdown()만으로는 즉시 멈추지 않는다. shutdown()은 새 작업만 막을 뿐 이미 실행 중인 작업은 그대로 두기 때문이다. 취소를 확실히 하려면 shutdownNow()를 부르고, 각 Future를 cancel(true)하고, 인터럽트를 하위 작업이 제대로 받는지까지 직접 챙겨야 한다. 이 배관 작업을 손으로 짜다 보면 어딘가에서 빠뜨리고, 그 자리에서 스레드가 샌다.
JDK 25 StructuredTaskScope 기본 사용
JDK 25에서는 같은 일을 이렇게 쓴다. 인자 없는 StructuredTaskScope.open()은 가장 흔한 정책, 즉 모든 하위 작업이 성공하기를 기다리되 하나라도 실패하면 곧바로 실패시키는 범위를 만든다.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
Response handle() throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Integer> order = scope.fork(() -> fetchOrder());
scope.join(); // 두 작업을 함께 기다리고 예외를 전파
// 여기 도달하면 둘 다 성공한 상태
return new Response(user.get(), order.get());
}
}
fork는 작업을 새 가상 스레드에서 시작하고 Subtask 핸들을 돌려준다. join()은 두 작업이 모두 끝날 때까지 부모 스레드를 붙잡는다. 둘 중 하나가 예외로 끝나면 join()이 나머지를 취소한 뒤 StructuredTaskScope.FailedException을 던지고, 원래 예외는 그 원인으로 감싸여 올라온다. try-with-resources의 close()는 블록을 벗어날 때 하위 작업이 전부 종료됐음을 다시 한번 보장한다.
앞의 취소 누수가 여기서는 구조상 생길 수 없다. findUser()가 실패하면 join()이 fetchOrder()를 취소하므로, 결과를 안 쓸 원격 호출이 30초를 채우는 일이 없다. Subtask::get은 성공한 작업에서만 호출해야 하고, 아직 성공하지 않은 상태에서 부르면 예외가 난다. 그래서 join()이 정상 반환한 뒤에만 get()을 부르는 순서를 지킨다.
가상 스레드와의 조합
StructuredTaskScope는 기본적으로 fork한 작업마다 새 가상 스레드를 만든다. 가상 스레드는 블로킹 호출에서 값싸게 양보하니, 원격 호출 수십 개를 동시에 fork해도 플랫폼 스레드를 그만큼 잡아먹지 않는다. 구조적 동시성이 노리는 지점이 정확히 이 조합이다. 수명이 짧고 개수가 많은 작업을 안전하게 갈랐다가 확실히 회수하는 것이다.
스레드 팩토리를 바꾸고 싶으면 open() 두 번째 인자로 설정 함수를 넘긴다. 가상 스레드에 이름을 붙이거나 플랫폼 스레드로 바꾸는 정도에 쓴다. 특별한 이유가 없으면 기본값(가상 스레드)을 그대로 두는 편을 권한다.
Joiner로 완료 정책 고르기
JDK 25의 가장 큰 변화가 Joiner다. 하위 작업들을 언제 멈추고 결과를 어떻게 낼지를 정하는 정책 객체이고, StructuredTaskScope.open(Joiner)로 넘긴다. 자주 쓰는 팩토리 메서드는 다음과 같다.
Joiner.allSuccessfulOrThrow()
모든 하위 작업이 성공하기를 기다리고, 하나라도 실패하면 나머지를 취소한 뒤 실패시킨다. join()은 성공한 Subtask의 스트림을 돌려준다. 같은 종류의 호출을 여러 개 병렬로 던지고 전부 필요한 경우에 쓴다.
import java.util.concurrent.StructuredTaskScope.Joiner;
<T> List<T> runAll(Collection<Callable<T>> tasks) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<T>allSuccessfulOrThrow())) {
tasks.forEach(scope::fork);
return scope.join().map(Subtask::get).toList();
}
}
Joiner.anySuccessfulResultOrThrow()
가장 먼저 성공한 하나의 결과를 받고 즉시 나머지를 취소한다. 같은 데이터를 여러 미러에서 받아 제일 빠른 응답만 쓰는 경쟁(race) 패턴에 맞는다.
<T> T race(Collection<Callable<T>> tasks) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<T>anySuccessfulResultOrThrow())) {
tasks.forEach(scope::fork);
return scope.join(); // 첫 성공 결과, 나머지는 취소됨
}
}
Joiner.awaitAll()과 awaitAllSuccessfulOrThrow()
awaitAll()은 성공이든 실패든 모든 작업이 끝날 때까지 기다리며 자동 취소를 걸지 않는다. 각 작업의 성패를 개별로 확인하고 싶을 때 쓴다. awaitAllSuccessfulOrThrow()는 인자 없는 open()과 같은 정책이지만 join()이 Void를 돌려주므로, 결과는 fork 때 잡아 둔 Subtask 핸들에서 직접 꺼낸다. 이 밖에 조건을 만족하면 멈추는 allUntil(Predicate)도 있다.
타임아웃 걸기
범위 전체에 마감 시간을 주려면 설정 함수에서 withTimeout을 쓴다. 시간이 지나면 남은 하위 작업이 취소되고 join()이 StructuredTaskScope.TimeoutException을 던진다.
import java.time.Duration;
<T> List<T> runAll(Collection<Callable<T>> tasks, Duration timeout)
throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.<T>allSuccessfulOrThrow(),
cf -> cf.withTimeout(timeout))) {
tasks.forEach(scope::fork);
return scope.join().map(Subtask::get).toList();
}
}
원격 호출 여러 개를 묶을 때 이 한 줄이 특히 유용하다. 개별 Future.get(timeout)을 작업마다 걸고 예외를 따로 처리하던 코드가, 범위 하나에 마감 시간을 주는 형태로 정리된다.
JDK 21~24와 JDK 25의 차이
구조적 동시성은 JDK 21(JEP 453)에 처음 미리보기로 들어왔고 JDK 24(JEP 499)까지 API가 거의 같았다. 그때는 정책을 서브클래스로 골랐다. 실패 시 전체 취소는 StructuredTaskScope.ShutdownOnFailure, 첫 성공에서 취소는 ShutdownOnSuccess였다.
// JDK 21 ~ 24 (JEP 453/462/480/499)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Integer> order = scope.fork(() -> fetchOrder());
scope.join();
scope.throwIfFailed(); // 실패한 하위 작업 예외를 다시 던짐
return new Response(user.get(), order.get());
}
JDK 25(JEP 505)에서 이 서브클래스들이 사라지고 Joiner 방식으로 통합됐다. 생성자 대신 정적 팩토리 open(...)을 쓰고, 정책은 Joiner 인자로 조합한다. ShutdownOnFailure는 인자 없는 open() 또는 Joiner.awaitAllSuccessfulOrThrow()로, ShutdownOnSuccess는 Joiner.anySuccessfulResultOrThrow()로 옮겨 적으면 된다. JDK 21~24 코드를 25로 올릴 때는 이 대응 관계만 맞춰 바꾸면 된다.
미리보기 기능이라 옵션이 필요하다
JDK 25 기준 아직 정식이 아닌 미리보기 API다. 컴파일과 실행 모두 --enable-preview가 필요하고, 컴파일에는 대상 릴리스도 함께 준다.
$ javac --release 25 --enable-preview Main.java $ java --enable-preview Main
패키지는 java.util.concurrent이고 Joiner와 Subtask는 StructuredTaskScope의 중첩 타입이다. 미리보기라 이후 릴리스에서 시그니처가 또 바뀔 수 있으니, 운영 코드에 넣기 전에 사용 중인 JDK 버전의 API 문서를 다시 확인해 두는 편이 좋다.
정리
여러 원격 호출을 병렬로 묶을 때 ExecutorService/Future 방식은 하위 작업 하나가 실패해도 나머지를 취소하는 일을 개발자가 손으로 챙겨야 하고, 그 배관에서 취소 누수와 스레드 릭이 난다. JDK 25의 StructuredTaskScope는 하위 작업을 하나의 범위로 묶어 실패 시 자동 취소와 타임아웃을 구조로 보장하고, 가상 스레드와 맞물려 많은 작업을 값싸게 갈랐다 회수한다. 전부 성공이 필요하면 open()이나 allSuccessfulOrThrow(), 첫 성공만 쓰면 anySuccessfulResultOrThrow()를 고르면 된다.