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