1. 소개
스레드가 각자 따로 연주할 때
일반적인 멀티스레딩은 종종 지휘자 없는 리허설을 떠올리게 합니다. 각 스레드는 다른 연주자처럼 자기 멜로디만 연주하고 다른 이들을 듣지 않습니다. 어떤 스레드는 먼저 끝나서 쉬고, 어떤 스레드는 한 코드에서 막혀 있고, 어떤 스레드는 악보를 틀려 오류를 냅니다. 결과는 심포니가 아니라 난장판: 누가 어디서 엇나갔는지 파악하기도 어렵고, 모두를 한꺼번에 멈추는 건 더 큰 과제입니다.
Structured Concurrency는 이 문제를 해결합니다. 흩어진 스레드를 진짜 앙상블로 바꿉니다: 모든 작업은 하나의 “지휘자” 아래에 묶여 있습니다. 지휘자가 그만이라고 하면 오케스트라는 멈춥니다. 누군가 실수하면 나머지는 전체 조화를 해치지 않도록 깔끔히 멈춥니다. 모든 결과와 오류는 코드 구석구석이 아니라 중앙에서 수집됩니다.
상상해 보세요: 연주자들을 제멋대로 연주하도록 내버려 두지 않고, 한 공간에 모읍니다. 지휘자도 있고, 파트보도 있으며, 트럼펫이 삑사리를 내더라도 오케스트라는 무너지지 않고 품위 있게 연주를 마무리합니다.
Structured Concurrency가 제공하는 것
- 단일 “스코프” 작업: 모든 하위 작업은 하나의 코드 블록 안에서 살고, 수명 주기는 이 블록에 의해 제한됩니다.
- 예측 가능한 종료: 부모 스레드는 모든 하위 작업이 끝나기 전까지 끝나지 않습니다.
- 중앙 집중식 취소: 하나라도 실패하거나 부모가 종료를 결정하면 모든 하위 작업이 올바르게 취소됩니다.
- 일관된 오류 처리: 하위 작업의 오류가 집계되어 “원인 트리”(tree of causes)를 얻을 수 있습니다.
- 깔끔하고 읽기 쉬운 코드: “떠다니는” 스레드도, 잊힌 작업도, 취소 경쟁도 없습니다.
Structured Concurrency는 단지 새로운 API가 아니라 새로운 사고방식입니다. 작업은 일반 코드 블록(예: try-with-resources)처럼 구조화되어야 합니다.
2. Java에서 Structured Concurrency의 상태
이 강의를 작성하는 시점 기준으로 Structured Concurrency는 Preview 상태(Java 21–23)이며, Java 24/25에서 GA(General Availability)로 진행될 것으로 예상됩니다. API는 jdk.incubator.concurrent 패키지에 있습니다. 프로덕션에 사용하기 전에 사용 중인 JDK 버전의 최신 릴리스 노트를 반드시 확인하세요!
주요 클래스:
- StructuredTaskScope — 작업 그룹을 관리하는 기본 클래스입니다.
- 변형: StructuredTaskScope.ShutdownOnFailure, StructuredTaskScope.ShutdownOnSuccess — 작업 종료 정책입니다.
StructuredTaskScope의 핵심 개념
모델: fork, join, 그리고 결과 점검
지휘자(즉, 부모 작업)가 신호를 주면 하위 작업들은 각자 파트를 수행하러 흩어집니다. 이 순간을 fork라고 합니다 — 마치 연주자들을 각기 다른 방에서 자신의 구간을 연주하게 하는 것과 같습니다.
그 다음 join의 시간이 오면 — 지휘자가 지휘봉을 들고 모두가 돌아와 함께 마지막 화음을 맞춥니다.
그리고 나서 각 참가자에게 어떻게 되었는지 물어볼 수 있습니다:
- resultNow()로 오류 없이 연주가 끝났다면 즉시 결과를 받기;
- throwIfFailed()로 누가 삑사리를 냈는지 확인하기. 누군가 악보에서 길을 잃었다면 단일 예외가 던져집니다 — 마치 지휘자가 “오케스트라에 문제가 있었으니 다시 시작합니다”라고 말하는 것처럼요.
종료 정책
어떤 지휘자든 음악을 언제 멈출지에 대한 자신만의 규칙이 있습니다. Structured Concurrency에서는 이것이 종료 정책으로 정해집니다:
- ShutdownOnFailure — 한 명이라도 박자를 놓치면 지휘자가 손을 내립니다: “스톱! 처음부터 다시.” 나머지는 곧바로 연주를 멈춥니다.
- ShutdownOnSuccess — 반대로, 누군가 자신의 파트를 완벽히 연주하면 지휘자는 만족합니다: “그만, 더 이상 필요 없어, 이미 승자가 있어.” 나머지는 조용해집니다 — 첫 번째 성공 응답 정책입니다.
가상 스레드와 함께 사용하기
각 하위 작업은 StructuredTaskScope가 가상 스레드에서 실행합니다. 마치 이해심 많고 빠르며 무대 조건을 따지지 않는 연주자가 한 명씩 있는 오케스트라와 같습니다. 수백, 수천 개를 마음껏 만들어도 됩니다 — 무거운 스레드가 아니라, 필요할 때 정확히 울리는 거의 무게 없는 음표와 같습니다.
3. 예제: HTTP 요청 집계기
실전 과제를 살펴봅시다: 데이터 소스가 세 개(예: 서로 다른 세 서버) 있고, 우리는 모두의 응답을 받아 집계하거나, 혹은 가장 먼저 성공한 한 명의 응답만 받기를 원합니다.
변형 1: “모두 성공해야 함” (ShutdownOnFailure)
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
public class AggregatorAllSuccess {
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchFromSource1());
Future<String> f2 = scope.fork(() -> fetchFromSource2());
Future<String> f3 = scope.fork(() -> fetchFromSource3());
scope.join(); // 모든 작업이 끝날 때까지 대기
scope.throwIfFailed(); // 하나라도 실패하면 예외를 던짐
// 모든 작업이 성공 — 결과를 집계
String result = f1.resultNow() + f2.resultNow() + f3.resultNow();
System.out.println("집계 결과: " + result);
}
}
static String fetchFromSource1() { /* ... */ return "A"; }
static String fetchFromSource2() { /* ... */ return "B"; }
static String fetchFromSource3() { /* ... */ return "C"; }
}
무슨 일이 일어나는가:
- 세 작업 모두가 병렬로(가상 스레드에서) 시작됩니다.
- 하나라도 실패하면 나머지는 취소되고 예외가 던져집니다.
- 모두 성공하면 결과를 안전하게 집계할 수 있습니다.
변형 2: “첫 번째 유효한 성공이면 충분” (ShutdownOnSuccess)
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
public class AggregatorFirstSuccess {
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
Future<String> f1 = scope.fork(() -> fetchFromSource1());
Future<String> f2 = scope.fork(() -> fetchFromSource2());
Future<String> f3 = scope.fork(() -> fetchFromSource3());
scope.join(); // 첫 번째 성공까지 대기
scope.throwIfFailed(); // 모두 실패한 경우 예외를 던짐
String result = scope.result(); // 첫 번째로 성공한 작업의 결과
System.out.println("첫 번째 성공 결과: " + result);
}
}
static String fetchFromSource1() { /* ... */ return "A"; }
static String fetchFromSource2() { /* ... */ return "B"; }
static String fetchFromSource3() { /* ... */ return "C"; }
}
무슨 일이 일어나는가:
- 한 작업이 성공하는 순간 나머지는 취소됩니다.
- 모두 실패한 경우 예외가 던져집니다.
4. 자동 취소와 디그레이드
StructuredTaskScope는 정책이 요구하면 남은 작업의 취소를 스스로 처리합니다. 예를 들어 한 작업이 실패했을 때(ShutdownOnFailure), 또는 한 작업이 성공적으로 끝났을 때(ShutdownOnSuccess), 나머지 작업은 취소 신호(interrupt)를 받습니다.
예시: 타임아웃과 함께 올바르게 종료하기
import jdk.incubator.concurrent.StructuredTaskScope;
import java.time.Instant;
import java.util.concurrent.Future;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchWithTimeout());
Future<String> f2 = scope.fork(() -> fetchWithTimeout());
scope.joinUntil(Instant.now().plusSeconds(2)); // 최대 2초 동안 대기
scope.throwIfFailed();
String result = f1.resultNow() + f2.resultNow();
System.out.println(result);
}
작업이 2초 내에 완료되지 않으면 예외가 발생하고 모든 작업이 취소됩니다.
5. 오류와 예외 처리
하위 작업의 예외가 스코프로 라우팅되는 방식
연주 중 누군가 결국 음을 틀릴 때가 있습니다 — StructuredTaskScope는 모른 척하지 않습니다. 누가 정확히 실수했는지 차분히 기록해 두었다가, 지휘자에게 완전한 보고서를 전달합니다. throwIfFailed()를 호출하면 집계된 예외가 던져집니다 — 일종의 종합 보고서로 “오늘 삑사리를 낸 목록은 다음과 같습니다.”가 되는 셈이죠. 필요하다면 이 “원인 트리”를 펼쳐서 누가 구체적으로 문제였는지 볼 수 있습니다. 특정 연주자에 대해 알고 싶다면 Future.exceptionNow()가 그 파트가 어떻게 끝났는지 알려줍니다.
취소가 실패가 아닌 경우
중요한 점: 작업 취소가 항상 오류를 의미하는 것은 아닙니다. 지휘자가 “공연 종료”를 외치면 연주자들은 그냥 악기를 내려놓습니다 — 이는 cancelled일 뿐, failed가 아닙니다. 오류로 간주되는 건 누군가 실제로 잘못 연주했을 때이고, 그 예외가 종합 보고서에 포함됩니다.
예시: 원인 트리
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> { throw new RuntimeException("오류 1"); });
Future<String> f2 = scope.fork(() -> { throw new RuntimeException("오류 2"); });
scope.join();
scope.throwIfFailed(); // 두 가지 원인을 포함한 예외를 던짐
} catch (Exception e) {
e.printStackTrace();
// e.getSuppressed()로 suppressed 예외를 확인할 수 있음
}
6. CompletableFuture와의 비교
StructuredTaskScope와 CompletableFuture는 모두 병렬 작업을 실행할 수 있지만 다음과 같은 차이가 있습니다:
- StructuredTaskScope는 작업이 논리적으로 연결되어 함께 종료/취소되어야 할 때(작업 계층) 유용합니다.
- CompletableFuture는 계층 없이 작업을 합성할 때(예: 변환 체인, 리액티브 시나리오) 적합합니다.
StructuredTaskScope가 코드를 단순화하는 경우:
- 블록을 빠져나가기 전에 모든 하위 작업이 끝났음을 보장해야 할 때.
- 중앙 집중식 취소와 오류 처리가 필요할 때.
- “떠다니는” 작업이 남지 않게 하는 게 중요할 때.
CompletableFuture가 더 편한 경우:
- 작업이 서로 관련 없고 각자 생명 주기를 가져도 될 때.
- 복잡한 합성(thenCombine, thenCompose 등)이 필요할 때.
7. 실습: HTTP 요청 집계기
과제: 3개의 소스에 요청을 보내고, 첫 번째 성공 응답 받기
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
public class HttpAggregator {
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
Future<String> f1 = scope.fork(() -> httpRequest("https://api1.example.com"));
Future<String> f2 = scope.fork(() -> httpRequest("https://api2.example.com"));
Future<String> f3 = scope.fork(() -> httpRequest("https://api3.example.com"));
scope.join();
scope.throwIfFailed();
String result = scope.result();
System.out.println("첫 번째 성공 응답: " + result);
}
}
static String httpRequest(String url) throws Exception {
// 요청 시뮬레이션(원하면 HttpClient를 사용할 수 있음)
Thread.sleep((long) (Math.random() * 1000));
if (Math.random() < 0.3) throw new RuntimeException("요청 오류: " + url);
return "응답: " + url;
}
}
과제: 한 하위 작업이 실패하면 나머지를 올바르게 종료하기
import jdk.incubator.concurrent.StructuredTaskScope;
import java.util.concurrent.Future;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> httpRequest("https://api1.example.com"));
Future<String> f2 = scope.fork(() -> httpRequest("https://api2.example.com"));
scope.join();
scope.throwIfFailed();
String result = f1.resultNow() + f2.resultNow();
System.out.println("두 응답: " + result);
} catch (Exception e) {
System.err.println("작업 중 하나에서 오류 발생: " + e.getMessage());
}
8. StructuredTaskScope 사용 시 흔한 실수
실수 1: join() 또는 throwIfFailed() 호출을 누락함.
join()을 호출하지 않으면 블록을 빠져나갈 때까지 작업이 끝나지 않을 수 있습니다. throwIfFailed()를 호출하지 않으면 하위 작업의 오류가 눈치채이지 않은 채로 남습니다.
실수 2: 작업 완료 전에 결과를 가져오려 함.
작업이 끝나기 전에 resultNow()를 호출하면 IllegalStateException이 발생합니다. 먼저 join()으로 완료를 기다리세요.
실수 3: 취소를 무시함.
작업이 취소되었다면(예: 스코프 정책 때문에) 해당 결과를 가져오려고 하지 마세요 — 예외가 발생합니다.
실수 4: 서로 다른 종료 정책을 혼용함.
스코프 내부에서 작업을 수동으로 취소하려고 하지 마세요 — ShutdownOnFailure 또는 ShutdownOnSuccess 정책을 사용하세요.
실수 5: 오래 걸리는 CPU-bound 작업을 가상 스레드에서 실행함.
StructuredTaskScope는 기본적으로 가상 스레드를 사용합니다 — I/O-bound 작업에는 최적이지만, 무거운 계산을 가속해 주지는 않습니다.
실수 6: 스코프를 닫지 않음(try-with-resources 없음).
StructuredTaskScope는 AutoCloseable을 구현합니다 — 항상 try-with-resources를 사용해 모든 작업의 종료를 보장하세요.
GO TO FULL VERSION