Java 애플리케이션을 개발하다 보면 스레드 사용은 매우 중요한 요소다. 그동안 ExecutorService나 CompletableFuture를 사용하면서 기능적으로 하나의 트랜잭션 기능으로 묶인 여러 스레드 중에서 하나라도 예외가 발생하는 스레드가 있다면 나머지 스레드를 관리하기 위한 복잡한 에러 로직을 사용했을 것이다.(cancel() 호출..) 하지만 이번에 소개할 Structured Concurrency는 이러한 골치아픈 문제를 해결해 줄 것이다. 또한 JDK21 버전에서 정식 릴리즈된 가상 스레드에 맞춰 병렬로 실행 되는 여러 스레드에 대한 관리 측면에서도 많은 도움이 될 것 같다.
이번 포스팅에서는 기존 방식이 어떤 부분에서 위험했는지 그리고 Structured Concurrency가 어떻게 스레드 누수 문제를 해결해 주는지 미리 알아보자.
Structured Concurrency 히스토리
우선 간단히 Structured Concurrency의 히스토리를 알아 보자.
- JEP428 JEP437 – JDK19 처음으로 제안 되었고 JDK 20까지 Incubator 단계로 시작되었다.
- JEP453 – JDK21 Structured Concurrency가 Preview 상태로 승격됬다.
- JEP462 – JDK22 여전히 Prewiew 상태로 API 안정화 및 피드백을 반영한다.
- JEP480 – JDK23 Structured Concurrency의 세번째 Preview 상태.
- JEP499 – JDK24 API 개선, 구조 개선이 이루어졌다. 네번째 Preview 상태.
- JEP505 – JDK25 API 설계가 다듬어지고 정식 배포 전환을 위한 안정화 단계로 평가 된다. 다섯 번째 Preview.
- JEP525 – JDK26 차기 배포 버전 예정인 26 버전에도 여섯 번째 Preview로 연구 중이다.
아직 Structured Concurrency가 아직은 Preview 단계로 실험적이지만 추후에 정식 기능으로 릴리즈 되면 스레드 누수 방지를 위해서 사용효과가 꽤 클 것으로 기대된다. JDK25 버전에서 API 설계가 다듬어 진만큼 이전 JDK21 버전과 비교해 사용법이 일부 변경되었다.
Structured Concurrency 변경 포인트
이전 JDK 버전의 사용법과 JDK25에서의 사용법에 변경된 부분은 다음과 같다.
- 클래스 상속 제거: ShutdownOnFailure, ShutdownOnSuccess 같은 상속 기반 클래스가 제거되었다.
- Factory Method 도입: new StructuredTaskScope(…) 대신 StructuredTaskScope.open(…)을 사용한다.
- Joiner 도입: ‘실패하면 중단할 것인가’, ‘성공한 것만 가져올 것인가’와 같은 정책을 Joiner라는 객체에 위임한다.
- join()의 진화: join() 메서드가 단순히 대기만 하는 것이 아닌 Joiner가 처리한 결과를 반환하거나 예외를 던진다. (throwIfFailed()를 호출할 필요가 없어졌다.)

기존 방식의 문제점
부모 자식 간의 관계 단절
기존의 비동기 프로그래밍(Unstructured Concurrency)의 가장 큰 문제는 작업 간의 관계가 끊겨 있다는 점이다.
부모 스레드에서 자식 스레드를 생성하면 논리적으로 부모-자식 관계이지만 JVM 입장에서는 이 둘은 아무 상관없는 스레드일 뿐인 것이다.
public static void main() {
legacy1();
System.out.println("Main 스레드 종료!!");
}
static void legacy() {
// 기존 방식 (ExecutorService)
// 부모가 죽어도 자식은 좀비처럼 계속 돈다.
ExecutorService es = Executors.newFixedThreadPool(1);
// 자식 스레드: 5초 걸리는 무거운 작업
Future<?> child = es.submit(() -> {
try {
Thread.sleep(5000);
System.out.println("자식: 작업 완료! (하지만 아무도 듣지 않는다..)");
} catch (InterruptedException e) {
// 취소 로직이 있어도, 호출을 안 해주면 무용지물
}
});
try {
// 부모 스레드: 갑작스러운 에러 발생
throw new RuntimeException("부모 종료!");
}
catch ( Exception e ) {
System.out.println("예외 발생 감지! 응답을 실패로 처리하고 종료!");
}
es.shutdown();
}
Java위 코드를 실행하면 결과는 다음과 같다.
예외 발생 감지! 응답을 실패로 처리하고 종료!
Main 스레드 종료!!
.... 5초후 ....
자식: 작업 완료! (하지만 아무도 듣지 않는다..)Plaintext부모 스레드에 예외가 발생하여 종료가 되어도 자식스레드는 5초간 CPU를 점유하며 계속해서 실행되고 있는 것이다.
이것이 바로 스레드 누수(Thread Leak)이다.
형제 관계도 단절
부모 자식 관계가 아닌 어떤 서로 연관성이 있는 두 스레드가 병렬로 처리되고 있다가 해보자. 하나의 스레드에서 예외가 발생하면 다른 모든 처리를 바로 종료시키고 실패로 처리하는 것이 리소스 사용에 있어서 더 유리할 것이다.
public static void main() {
legacy2();
System.out.println("Main 스레드 종료!!");
}
static void legacy2() {
CompletableFuture<Void> taskA = CompletableFuture.runAsync( () -> {
try {
Thread.sleep( 100 );
System.out.println("Task A: 에러 발생!!");
throw new RuntimeException( "Task A 예외 발생 ");
}
catch ( InterruptedException e ) {
throw new RuntimeException(e);
}
} );
CompletableFuture<Void> taskB = CompletableFuture.runAsync( () -> {
System.out.println("무거운 작업 시작 (5초 소요)");
try {
Thread.sleep( 5000 );
System.out.println("Task B: 작업 완료! (하지만 아무도 듣지 않는다");
}
catch ( InterruptedException e ) {
throw new RuntimeException( e );
}
throw new RuntimeException( "예외 발생!!!" );
} );
CompletableFuture<Void> all = CompletableFuture.allOf( taskA, taskB );
try {
all.join();
}
catch ( Exception e ) {
System.out.println("예외 발생 감지! 응답을 실패로 처리하고 종료!");
}
}JavaTaskA과 TaskB 두 개의 스레드가 서로 연관된 형제 관계라고 하더라도 한쪽에서 예외가 발생하면 바로 종료 되지 않고 모든 스레드가 완료될 때까지 실행하게 된다.
결과는 다음과 같다.
무거운 작업 시작 (5초 소요)
Task A: 에러 발생!!
.... 5초후 ....
Task B: 작업 완료! (하지만 아무도 듣지 않는다)
예외 발생 감지! 응답을 실패로 처리하고 종료!
Main 스레드 종료!!Plaintext우리가 원하는 것은 TaskA에서 에러가 발생하면 TaskB 역시 바로 종료하고 동작을 끝내는 것이지만 TaskA에서 예외가 발생해도 TaskB는 계속해서 실행되고 있다.

Structured Concurrency의 동작
이제 Structured Concurrency의 동작을 살펴보자.
부모 자식간의 관계 유지
Structured Concurrency는 부모 스레드와 자식 스레드간의 관계를 유지함으로써 부모가 종료되면 자식도 바로 종료된다.
public static void main() {
structuredscope1();
System.out.println("Main 스레드 종료!!");
}
static void structuredscope1() {
// try-with-resources로 Scope 생성 (자동 close 보장)
try ( var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow() ) ) {
// 1. Fork: 자식 스레드 생성 (Scope의 관리를 받음)
StructuredTaskScope.Subtask<Void> fork = scope.fork( () -> {
try {
Thread.sleep( 5000 );
System.out.println("자식: 작업 완료!");
}
catch ( InterruptedException e ) {
throw new RuntimeException( e );
}
} );
// 2. 부모 로직에서 에러 발생 가정
if (true) throw new RuntimeException("부모 에러!");
scope.join();// 대기
} catch (Exception e) {
System.out.println(e.getMessage());
// 3. try 블록을 벗어나는 순간 scope.close()가 호출됨.
// -> 실행 중이던 자식 스레드(task)에 즉시 'Interruption' 신호가 전송됨.
System.out.println("Scope가 닫히면서 자식 스레드가 안전하게 종료되었습니다.");
}
}JavaJoiner의 awaitAllSuccessfulOrThrow()는 모든 자식이 성공할 때까지 기다리는 것이다. StructuredTaskScope내의 스레드가 5초 동안 일을 하는 동안 부모 로직에서 예외가 발생했을 때 어떻게 처리되는지 결과를 통해 알아보자.
부모 에러!
Scope가 닫히면서 자식 스레드가 안전하게 종료되었습니다.
Main 스레드 종료!!Plaintext부모가 종료되면서 자식 스레드가 마지막까지 실행되지 않고 바로 종료되는 것을 확인할 수 있다.
형제 관계 유지
StructuredTaskScope로 묶인 두개의 스레드 작업이 병렬로 실행될 때 하나의 스레드에서 예외가 발생한 경우를 살펴보자.
public static void main() {
structuredscope2();
System.out.println("Main 스레드 종료!!");
}
static void structuredscope2() {
// try-with-resources로 Scope 생성 (자동 close 보장)
try ( var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow() ) ) {
// 1. Fork: 자식 스레드 생성 (Scope의 관리를 받음)
StructuredTaskScope.Subtask<Void> taskA = scope.fork( () -> {
try {
Thread.sleep( 300 );
System.out.println("Task A: 에러 발생!!");
throw new RuntimeException( "Task A 예외 발생 ");
}
catch ( InterruptedException e ) {
throw new RuntimeException( e );
}
} );
StructuredTaskScope.Subtask<Void> taskB = scope.fork( () -> {
System.out.println("무거운 작업 시작 (5초 소요)");
try {
Thread.sleep( 5000 );
System.out.println("Task B: 작업 완료!");
}
catch ( InterruptedException e ) {
throw new RuntimeException( e );
}
} );
scope.join();// 대기
} catch (Exception e) {
System.out.println(e.getMessage());
// 3. try 블록을 벗어나는 순간 scope.close()가 호출됨.
// -> 실행 중이던 자식 스레드(task)에 즉시 'Interruption' 신호가 전송됨.
System.out.println("Scope가 닫히면서 자식 스레드가 안전하게 종료되었습니다.");
}
}
Java앞서 설명했듯이 Joiner의 awaitAllSuccessfulOrThrow()는 모든 자식 형제 스레드가 성공할 때까지 기다리는 것이다. 형제 중 하나라도 예외가 발생하면 모든 스레드는 종료한다. 결과를 통해 확인해 보자.
무거운 작업 시작 (5초 소요)
Task A: 에러 발생!!
java.lang.RuntimeException: Task A 예외 발생
Scope가 닫히면서 자식 스레드가 안전하게 종료되었습니다.
Main 스레드 종료!!PlaintextTaskA에서 예외가 발생하면 TaskB의 동작이 종료된다.

StructuredTaskScope.Joiner 정책
StructuredTaskScope.Joiner는 여러 자식 스레드의 예외 및 완료 결과에 따른 처리 결과에 대한 몇가지 정책을 가지고 있다.
- awaitAllSuccessfulOrThrow()
- 모든 작업이 성공할 때까지 기다린다. 만약 하나라도 실패한다면 즉시 나머지 작업을 취소하고 예외를 던진다. join() 호출시 결과는 없다.
- 서로 다른 타입의 결과를 가져올 때 결과는 join()이 아닌 미리 선언해 둔 SubTask에서 가져와야 한다.
// 결과값이 없으므로 Subtask 변수에서 get() 해야 함
try (var scope = StructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow())) {
Subtask<String> taskA = scope.fork(() -> "A");
Subtask<Integer> taskB = scope.fork(() -> 1);
scope.join(); // 여기서 예외 발생 가능
// taskA.get(), taskB.get() 사용
}Java- allSuccessfulOrThrow()
- awaitAllSuccessfulOrThrow와 같이 동작하지만 join() 호출시 성공한 결과들을 Stream<T>로 묶어서 반환한다.
- 동일한 타입의 결과를 한번에 리스트로 받고 싶을 때 사용하면 유용하다.
// 타입을 <String>으로 명시해야 함
try (var scope = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow())) {
scope.fork(() -> "A");
scope.fork(() -> "B");
Stream<String> results = scope.join(); // ["A", "B"] 스트림 반환
}Java- anySuccessfulResultOrThrow()
- 작업 중 가장 먼저 성공한 작업이 나오면 나머지 작업은 취소하고 join() 호출시 성공한 작업의 결과를 반환한다. 모두 실패하면 예외를 던진다.
- 동일한 기능을 하는 여러 서버 중 가장 빠른 응답이 필요한 경우 사용하기 좋다. (Redundancy)
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> slowApi());
scope.fork(() -> fastApi());
String winner = scope.join(); // "Fast Result" 반환
}Java- awaitAll()
- 끝까지 간다. 성공이든 실패든 상관없이 모두 끝날때까지 기다린다. join() 호출시 결과는 없다.
- 대시보드 처럼 일부 위젯 항목에 에러가 발생해도 나머지는 보여줘야 하는 경우에 사용하기 좋겠다.
try (var scope = StructuredTaskScope.open(Joiner.awaitAll())) {
var taskA = scope.fork(() -> "Success");
var taskB = scope.fork(() -> { throw new Exception("Fail"); });
scope.join(); // 예외 안 던짐. 그냥 기다림.
// 개발자가 직접 상태 확인
if (taskA.state() == Subtask.State.SUCCESS) { ... }
}Java
타임아웃과 이름표, Scope를 실무용으로 길들이기
구조적 동시성을 실무에 올리려면 open()에 Config 람다를 하나 더 넘긴다. cf -> cf.withTimeout(...).withName(...).withThreadFactory(...) 형태로 타임아웃, scope 이름, 스레드 팩토리를 지정한다. 타임아웃을 걸어두면 join()에서 TimeoutException이 나면서 남은 Subtask가 전부 자동 취소된다. 외부 API 여러 개를 묶어 호출할 때 전체 상한 시간을 한 줄로 거는 셈이다.
try (var scope = StructuredTaskScope.open(
Joiner.<Integer>allSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(200)) // 전체 작업 상한 200ms
.withName("order-aggregation") // JFR/스레드 덤프에 노출될 이름
.withThreadFactory(Thread.ofVirtual()
.name("agg-", 0).factory()))) { // 가상 스레드 팩토리 지정
subtasks.forEach(scope::fork);
scope.join().forEach(sub -> System.out.println(sub.get()));
} catch (StructuredTaskScope.TimeoutException e) {
System.out.println("200ms 초과로 전체 취소됨");
} catch (StructuredTaskScope.FailedException e) {
System.out.println("실패: " + e.getCause().getMessage());
}JavawithTimeout은 scope 전체에 걸리는 마감 시한이다. 시한을 넘기면 진행 중인 Subtask 스레드에 interrupt가 걸리고 join()이 TimeoutException을 던진다. withName은 JFR 이벤트나 스레드 덤프에서 이 scope를 구분하게 해주므로, 운영 환경에서 병목을 추적할 계획이라면 이름을 붙여두는 편이 낫다. 세 옵션 모두 생략 가능하며, 필요한 것만 체이닝하면 된다.

Scoped Value와 손잡으면 컨텍스트가 저절로 따라온다
구조적 동시성의 진짜 강점은 Scoped Value와 결합할 때 드러난다. fork()로 만든 자식 Subtask는 부모 scope의 ScopedValue 바인딩을 그대로 상속한다. 요청 ID나 인증 주체 같은 컨텍스트를 ThreadLocal처럼 메서드 인자로 일일이 넘기지 않아도, 자식 가상 스레드에서 바로 읽힌다.
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
String handle(String traceId) throws Exception {
// 이 바인딩이 fork로 생성된 모든 자식 Subtask에 전파된다
return ScopedValue.where(TRACE_ID, traceId).call(() -> {
try (var scope = StructuredTaskScope.open(
Joiner.<String>allSuccessfulOrThrow())) {
scope.fork(() -> callUser(TRACE_ID.get())); // 자식이 TRACE_ID를 그대로 읽음
scope.fork(() -> callOrder(TRACE_ID.get()));
return scope.join()
.map(Subtask::get)
.collect(Collectors.joining(","));
}
});
}JavaScopedValue.where(…).call(…)로 감싼 범위 안에서 연 scope의 자식들은 별도 전달 없이 TRACE_ID를 조회한다. Scoped Value는 불변이고 범위를 벗어나면 자동으로 사라지므로, 가상 스레드 수천 개가 오가는 상황에서도 ThreadLocal의 정리 누락 문제가 재현되지 않는다. Scoped Value 자체의 동작 원리와 ThreadLocal 대비 이점은 Scoped Value 가이드에서 따로 정리했다.

ExecutorService로는 안 됐던 것, 뭐가 다른가
ExecutorService는 제출한 작업의 수명을 호출 스택과 분리한다. 그래서 예외 전파, 취소, 대기가 전부 손으로 해야 하는 영역이었다. 구조적 동시성은 fork한 Subtask를 try-with-resources 블록에 묶어, 하나가 실패하면 형제를 자동으로 취소하고 블록을 벗어날 때 전부 정리한다.
| 항목 | ExecutorService + Future | StructuredTaskScope |
|---|---|---|
| 작업 수명 | 호출 스택과 무관, 누수 가능 | 블록 스코프에 종속, 누수 없음 |
| 하나 실패 시 | 나머지 수동 cancel 필요 | 형제 자동 취소 |
| 예외 전파 | future.get()에서 개별 확인 | join()에서 즉시 전파 |
| 취소 전파 | 수동 shutdownNow() | 블록 종료 시 자동 interrupt |
| 관측성 | 스레드 풀 단위 | scope 계층으로 추적 |
| 스레드 모델 | 플랫폼/풀 스레드 | 기본 가상 스레드 |
표의 핵심은 “구조”다. ExecutorService는 작업을 던져두고 결과를 나중에 회수하는 구조라, 실패 하나가 나머지를 붙잡아두거나 취소되지 않은 스레드가 남는 일이 흔했다. 구조적 동시성은 코드 블록의 수명과 작업의 수명을 일치시켜, 이런 실수 자체를 문법적으로 막아버린다. 같은 코드를 짜도 누수가 끼어들 틈이 줄어드는 것이다.
자주 묻는 질문
구조적 동시성은 Java 25에서 정식 기능인가?
아니다. 구조적 동시성은 Java 25에서도 JEP 505 기준 preview 기능이다. 컴파일과 실행 시 --enable-preview 플래그가 필요하다. 반면 함께 자주 쓰이는 Scoped Value는 JEP 506으로 Java 25에서 정식(permanent) 기능이 되었다. 두 기능의 성숙도가 다르다는 점을 구분해 두는 편이 좋다.
StructuredTaskScope에서 ShutdownOnFailure는 왜 사라졌나?
초기 preview의 ShutdownOnFailure, ShutdownOnSuccess 서브클래스는 Java 25에서 Joiner 팩토리로 통합됐다. ShutdownOnFailure는 allSuccessfulOrThrow 또는 awaitAllSuccessfulOrThrow로, ShutdownOnSuccess는 anySuccessfulResultOrThrow로 대체된다. open(Joiner) 하나로 정책을 갈아끼우는 형태라 커스텀 Joiner 구현까지 열려 확장이 쉬워졌다.
fork()는 항상 가상 스레드를 만드나?
기본값은 그렇다. 별도 설정이 없으면 fork()는 가상 스레드로 Subtask를 실행한다. 다만 Config의 withThreadFactory로 원하는 스레드 팩토리를 넘기면 플랫폼 스레드로도 바꿀 수 있다. 대부분의 I/O 병렬 호출에서는 기본 가상 스레드가 무난한 선택이다.
구조적 동시성을 쓰면 성능이 좋아지나?
성능보다 정확성을 위한 기능이다. 처리량 자체는 가상 스레드가 담당하고, 구조적 동시성은 그 위에서 실패, 취소, 정리를 구조적으로 보장한다. 스레드 누수와 취소 누락이 사라지면서 안정성이 올라가는 것이지, 단순 벤치마크 수치를 끌어올리는 도구는 아니다.

Structured Concurrency는 아직까지 Preview 기능이지만 조만간 정식 버전으로 출시되지 않을까 기대한다.
Java 25에서 정식 릴리즈된 Scoped Value와도 찰떡궁합으로 잘 맞고, Java 21에서 정식 릴리즈 된 가상스레드를 관리하는데도 상당히 유용할 것이다.
