동시에 실행하는 여러 작업을 하나의 블록 단위로 묶어 함께 시작하고 함께 끝내는 모델. 이름의 "구조적"은 구조적 프로그래밍(if/for 블록이 중첩되되 서로 교차하지 않는 것)에서 왔다.
무엇이 문제였나 — 흩어진 작업
// 예전 방식: 두 작업을 던져 놓고 각각 기다린다
Future<User> user = executor.submit(() -> fetchUser(id));
Future<Order> order = executor.submit(() -> fetchOrder(id));
User u = user.get(); // ← 여기서 예외가 나면?
Order o = order.get(); // order 작업은 계속 돌고 있다 (고아 작업)
문제는 셋이다.
- 고아 작업 — 하나가 실패해도 나머지는 계속 돈다. 취소를 직접 호출해야 하는데 잊기 쉽다.
- 취소가 전파되지 않는다 — 호출자가 포기해도 자식 작업은 모른다.
- 스택 트레이스가 끊긴다 — 자식 작업의 예외에 부모 정보가 없어 어디서 시작된 작업인지 알 수 없다.
해법 — 블록이 곧 수명
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> fetchUser(id));
var order = scope.fork(() -> fetchOrder(id));
scope.join(); // 둘 다 끝날 때까지 대기
return combine(user.get(), order.get());
} // 블록을 벗어나면 남은 자식 작업은 자동으로 취소된다
메모리·수명 관점에서 보면 자식 작업의 생명이 부모 블록 안에 완전히 포함된다. 블록을 벗어나는 순간 살아남은 자식이 없음이 보장되므로, 고아 작업이 구조적으로 불가능해진다. 예외도 부모로 모여 스택 트레이스가 이어진다.
가상 스레드와 짝을 이루는 기능이다 — 자식 작업 하나하나가 가상 스레드라서, 수십 개를 동시에 띄워도 비용이 거의 없다.
상태
Java 21부터 프리뷰로 반복 개선 중이고, Java 26 기준 6차 프리뷰다. API가 계속 다듬어지고 있으므로 버전별 시그니처가 다르다는 점은 알고 있어야 한다.
실패가 전파되는 모습
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> fetchUser(id)); // 200ms
var order = scope.fork(() -> fetchOrder(id)); // 실패!
scope.join(); // order가 실패하는 순간 user 작업도 취소된다
return combine(user.get(), order.get());
} // 블록을 벗어날 때 살아남은 자식이 없음이 보장된다
-
[예전 방식] — order 실패 → user 작업은 계속 돈다 (고아 작업)
- 취소를 직접 호출해야 하고, 잊기 쉽다
-
[구조적] — order 실패 → scope가 user를 취소 → 블록 종료
- 잊을 수가 없다. 문법이 강제한다
왜 "구조적"인가
이름은 구조적 프로그래밍에서 왔다. if·for 블록이 중첩되되 서로 교차하지 않아 코드 흐름을 눈으로 따라갈 수 있듯이, 작업의 수명도 블록 구조 안에 가둔다는 뜻이다. goto를 없앤 것과 같은 종류의 규율을 동시성에 적용한 셈이다.