Structured Concurrency란?
JDK 21에서 프리뷰로 도입된 Structured Concurrency는 동시 실행되는 태스크들을 하나의 논리적 단위로 묶어 관리하는 패턴이다. StructuredTaskScope를 사용하면 여러 비동기 작업의 생명주기를 부모 스코프에 바인딩하여, 태스크 누수(task leak)와 취소 전파 문제를 근본적으로 해결한다.
// 기존 방식: CompletableFuture — 취소 전파 없음, 누수 위험
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> fetchOrders(id));
User user = userFuture.join(); // 여기서 예외 발생 시
List<Order> orders = ordersFuture.join(); // 이 태스크는 계속 실행됨 (누수!)
// Structured Concurrency: 스코프 내 태스크 자동 관리
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> fetchUser(id));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(id));
scope.join(); // 모든 태스크 완료 대기
scope.throwIfFailed(); // 하나라도 실패 시 예외
return new UserProfile(user.get(), orders.get());
} // 스코프 종료 시 미완료 태스크 자동 취소
ShutdownOnFailure vs ShutdownOnSuccess
StructuredTaskScope는 두 가지 기본 정책을 제공한다.
| 정책 | 동작 | 사용 사례 |
|---|---|---|
| ShutdownOnFailure | 하나 실패 시 나머지 모두 취소 | 모든 결과가 필요한 경우 |
| ShutdownOnSuccess | 하나 성공 시 나머지 모두 취소 | 가장 빠른 응답만 필요 |
// ShutdownOnFailure: 모든 결과 필요 (AND 패턴)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> userService.findById(userId));
var balanceTask = scope.fork(() -> accountService.getBalance(userId));
var ordersTask = scope.fork(() -> orderService.findRecent(userId, 10));
scope.join().throwIfFailed();
return new Dashboard(
userTask.get(),
balanceTask.get(),
ordersTask.get()
);
}
// ShutdownOnSuccess: 가장 빠른 응답 사용 (OR 패턴)
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromPrimaryCache(key));
scope.fork(() -> fetchFromSecondaryCache(key));
scope.fork(() -> fetchFromDatabase(key));
scope.join();
return scope.result(); // 가장 먼저 성공한 결과, 나머지 자동 취소
}
Spring Boot 3.2+에서 활용
Spring Boot 3.2+는 Virtual Thread를 공식 지원하며, Structured Concurrency와 자연스럽게 결합된다. StructuredTaskScope.fork()로 생성된 태스크는 Virtual Thread에서 실행된다.
// application.yml — Virtual Thread 활성화
spring:
threads:
virtual:
enabled: true
// JVM 옵션 (JDK 21 프리뷰 기능)
--enable-preview
@Service
public class ProductAggregationService {
private final ProductRepository productRepo;
private final ReviewService reviewService;
private final InventoryService inventoryService;
private final PricingService pricingService;
public ProductDetail getProductDetail(Long productId)
throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var product = scope.fork(() -> productRepo.findById(productId)
.orElseThrow(() -> new NotFoundException("상품 없음")));
var reviews = scope.fork(() -> reviewService.getByProductId(productId));
var stock = scope.fork(() -> inventoryService.getStock(productId));
var price = scope.fork(() -> pricingService.calculatePrice(productId));
scope.join().throwIfFailed();
return ProductDetail.builder()
.product(product.get())
.reviews(reviews.get())
.stockCount(stock.get())
.finalPrice(price.get())
.build();
}
}
}
4개의 독립적인 호출이 병렬로 실행되면서, 하나라도 실패하면 나머지가 즉시 취소된다. 이벤트 기반 비동기 처리와 달리 동기적 코드 스타일을 유지하면서 병렬성을 확보한다.
CompletableFuture vs Structured Concurrency
| 비교 | CompletableFuture | StructuredTaskScope |
|---|---|---|
| 취소 전파 | 수동 구현 필요 | 자동 |
| 태스크 누수 | 발생 가능 | 스코프 종료 시 자동 정리 |
| 예외 처리 | exceptionally/handle 체인 | try-catch 그대로 |
| 스레드 모델 | ForkJoinPool (플랫폼 스레드) | Virtual Thread |
| 디버깅 | 스택 트레이스 끊김 | 부모-자식 관계 유지 |
| 코드 스타일 | 콜백/체인 (함수형) | 동기적 (명령형) |
커스텀 StructuredTaskScope 구현
기본 정책 외에 커스텀 셧다운 로직이 필요하면 StructuredTaskScope를 상속한다.
// N개 중 K개 성공 시 종료하는 커스텀 스코프
public class ShutdownOnNSuccess<T> extends StructuredTaskScope<T> {
private final int requiredSuccesses;
private final AtomicInteger successCount = new AtomicInteger(0);
private final Queue<T> results = new ConcurrentLinkedQueue<>();
public ShutdownOnNSuccess(int requiredSuccesses) {
this.requiredSuccesses = requiredSuccesses;
}
@Override
protected void handleComplete(Subtask<? extends T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS) {
results.add(subtask.get());
if (successCount.incrementAndGet() >= requiredSuccesses) {
shutdown(); // N개 성공 → 나머지 취소
}
}
}
public List<T> results() {
return List.copyOf(results);
}
}
// 사용: 3개 캐시 중 2개 성공하면 반환
try (var scope = new ShutdownOnNSuccess<CacheEntry>(2)) {
scope.fork(() -> redis.get(key));
scope.fork(() -> memcached.get(key));
scope.fork(() -> localCache.get(key));
scope.join();
return scope.results();
}
타임아웃과 에러 핸들링
// 타임아웃 설정
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser(id));
var orders = scope.fork(() -> fetchOrders(id));
// 3초 타임아웃: 초과 시 미완료 태스크 인터럽트
scope.joinUntil(Instant.now().plusSeconds(3));
scope.throwIfFailed();
return new UserProfile(user.get(), orders.get());
}
// 부분 실패 허용 패턴
try (var scope = new StructuredTaskScope<Object>()) {
var user = scope.fork(() -> fetchUser(id));
var recommendations = scope.fork(() -> {
try {
return recommendService.getFor(id);
} catch (Exception e) {
return List.of(); // 추천 실패 → 빈 리스트
}
});
scope.join();
return new UserPage(user.get(), (List<?>) recommendations.get());
}
ScopedValue: ThreadLocal 대체
Structured Concurrency와 함께 도입된 ScopedValue는 Virtual Thread 환경에서 ThreadLocal을 대체한다. fork된 자식 태스크에 컨텍스트가 자동 전파된다.
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public void handleRequest(HttpServletRequest req) {
ScopedValue.where(REQUEST_ID, req.getHeader("X-Request-Id"))
.run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 자식 태스크에서도 REQUEST_ID 접근 가능
scope.fork(() -> {
log.info("Request: {}", REQUEST_ID.get());
return fetchUser();
});
scope.join().throwIfFailed();
}
});
}
정리
JDK 21 Structured Concurrency는 CompletableFuture의 취소 전파 부재, 태스크 누수, 디버깅 어려움을 근본적으로 해결한다. ShutdownOnFailure(AND 패턴)와 ShutdownOnSuccess(OR 패턴)로 대부분의 병렬 처리를 커버하고, 커스텀 스코프로 확장 가능하다. Spring Boot 3.2+에서 Virtual Thread와 결합하면 동기적 코드 스타일로 고성능 병렬 처리를 구현할 수 있다.