StructuredTaskScope로 미리 보는 다음 단계
병렬로 여러 작업을 시키면 생기는 흔한 버그
주문 상세 화면을 만든다고 합시다. 주문 정보, 배송 상태, 리뷰 목록을 각각 다른 서비스에서 병렬로 가져와야 빠릅니다.
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<Order> orderFuture = executor.submit(() -> fetchOrder(orderId));
Future<Shipping> shippingFuture = executor.submit(() -> fetchShipping(orderId));
Future<List<Review>> reviewsFuture = executor.submit(() -> fetchReviews(orderId));
Order order = orderFuture.get();
Shipping shipping = shippingFuture.get();
List<Review> reviews = reviewsFuture.get();간단해 보이지만 이 코드에는 흔한 함정이 있습니다. fetchShipping 이 실패해서 예외를 던지면 어떻게 될까요? shippingFuture.get() 에서 예외가 발생하겠지만, 이미 시작된 fetchReviews 작업은 누가 취소하나요? 아무도 취소하지 않습니다. 그 작업은 백그라운드에서 계속 실행되다가 결과가 버려집니다. 리소스를 낭비하는 정도로 끝나면 다행이고, 그 작업이 다른 부수 효과(로그 기록, 카운터 증가)를 갖고 있다면 더 골치 아파집니다.
이런 "형제 작업 중 하나가 실패했을 때 나머지를 정리하는" 로직을 매번 직접 짜는 건 번거롭고 실수하기 쉽습니다. try-finally 를 겹겹이 쌓고, 취소 플래그를 수동으로 관리하는 코드가 되기 십상입니다.
StructuredTaskScope 가 제안하는 모델
구조화된 동시성(Structured Concurrency)은 이 문제를 언어 차원의 규칙으로 풀자는 제안입니다. 핵심 아이디어는 하나입니다 — 하위 작업들의 생명주기가 반드시 그것을 시작한 블록의 생명주기 안에 완전히 포함되어야 한다. 마치 중첩된 함수 호출처럼, 부모가 끝나기 전에 자식도 반드시 끝나거나 취소됩니다.
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow())) {
Subtask<Order> orderTask = scope.fork(() -> fetchOrder(orderId));
Subtask<Shipping> shippingTask = scope.fork(() -> fetchShipping(orderId));
Subtask<List<Review>> reviewsTask = scope.fork(() -> fetchReviews(orderId));
scope.join(); // 모두 끝나거나, 하나가 실패하면 나머지를 자동 취소
Order order = orderTask.get();
Shipping shipping = shippingTask.get();
List<Review> reviews = reviewsTask.get();
}fetchShipping 이 예외를 던지면, scope.join() 이 그 예외를 감지하고 아직 끝나지 않은 fetchReviews 를 자동으로 취소합니다. 취소 로직을 직접 짤 필요가 없습니다. try 블록을 벗어나는 순간 모든 하위 작업이 확실히 정리된다는 것도 구조적으로 보장됩니다 — 코드를 눈으로 읽는 것만으로 "이 블록 밖으로 새어나가는 스레드는 없다"는 걸 알 수 있습니다.
아직은 미리보기라는 점을 분명히 해둡니다
StructuredTaskScope 는 Java 21 부터 프리뷰 기능으로 제공되기 시작했고, 이후 버전에서도 API 가 계속 다듬어지고 있습니다(위 예제의 Joiner 방식도 초기 프리뷰의 shutdownOnFailure() API 에서 바뀐 결과입니다). 프리뷰 기능은 정식 API 로 확정되기 전까지 버전이 바뀔 때마다 세부 API 가 달라질 수 있습니다. 운영 코드에 지금 당장 도입하기보다는, 실행 시 --enable-preview 플래그가 필요하다는 점부터 감안하고 사이드 프로젝트나 내부 도구에서 먼저 익혀보길 권합니다.
이 장을 넣은 이유는 실전 채택을 권하기 위해서가 아니라, 가상 스레드가 열어준 방향이 어디로 가고 있는지 보여주기 위해서입니다. 동기 스타일 코드를 유지하면서 동시성을 안전하게 다루자는 흐름은 Thread API 개선에서 멈추지 않고, 여러 작업을 묶어서 다루는 상위 구조로 이어지고 있습니다. 지금 당장 실무에 쓸 도구라기보다, 다음 몇 년간 자바 동시성 코드가 어떤 모양으로 바뀔지 보여주는 신호로 봐 주시면 됩니다.