한 줄 정의
구조적 동시성은 동시 태스크의 수명을 하나의 코드 블록(스코프)에 묶어, 블록을 벗어나면 안에서 시작한 모든 작업이 반드시 끝나(완료·취소)도록 강제하는 동시성 패러다임입니다.
쉽게 말하면
회사 프로젝트를 떠올려 봅시다. 팀장(부모 태스크)이 팀원들(서브태스크)에게 일을 나눠 맡깁니다. 규칙은 단순합니다 — 팀장은 모든 팀원의 보고를 받기 전까지 자리를 뜰 수 없고, 한 명이라도 사고가 터지면 나머지 팀원의 작업을 즉시 중단시킵니다.
팀장이 없는 방식(ExecutorService + Future)에서는 일을 던져두고 각자 알아서 하라는 식이라, 프로젝트가 엎어져도 팀원들은 그 사실을 모른 채 쓸모없어진 일을 계속합니다.
StructuredTaskScope는 이 흩어진 작업들을 try 블록이라는 “회의실”에 가둡니다. 회의실 문을 나서는 순간, 안에서 시작한 모든 동시 작업은 반드시 끝나 있습니다 — 함수가 반환되면 지역 변수가 자동으로 사라지듯이.
왜 중요한가?
동시 작업을 시작하기는 쉽습니다. 진짜 문제는 그 작업들이 언제·어떻게 끝나는지를 통제하는 것입니다. 통제가 없으면 세 가지가 동시에 무너집니다.
- 실패 전파: 하나가 실패해도 다른 작업은 그 사실을 모릅니다.
- 취소: 부모를 멈춰도 자식은 계속 돕니다.
- 자원 정리: 끝나지 않은 작업이 스레드로 새어 나갑니다.
결과는 자원 누수와 진단조차 어려운 동시성 버그입니다. 구조적 동시성은 동시 실행에 부모-자식 위계를 강제해 이 통제를 되돌려줍니다. 실패와 취소가 위계를 따라 자연스럽게 전파되고, 작업은 자신을 연 스코프를 벗어나 존재할 수 없으며, 스레드 덤프에 관계가 드러나 디버깅이 추측에서 벗어납니다.
핵심 내용
비구조적 동시성의 문제
동시 작업을 ExecutorService에 제출하고 Future로 결과를 받는 방식에는 태스크 사이의 관계를 표현할 수단이 없습니다. 제출한 두 Future는 서로 완전히 독립적이라 한쪽의 결과가 다른 쪽에 아무 영향을 주지 못합니다.
상품 정보(5초)와 리뷰(1초 뒤 실패)를 병렬 조회한다고 합시다. 리뷰 조회가 예외를 던지면 전체 결과는 이미 무의미해집니다. 그런데 상품 조회는 이 사실을 알 방법이 없어 5초를 마저 실행하고 버려집니다. 부모가 죽어도 자식이 계속 도는 고아 스레드입니다.
flowchart TB P["fetchProductInfo() · 부모"] P -->|fork| A["fetchProduct() · 5초"] P -->|fork| B["fetchReviews() · 1초 후 실패"] B -.->|예외 전파| P P -.->|중지| X["부모 중지"] A -.->|취소 신호 없음| O["고아 스레드: 5초 마저 실행 후 폐기"]
이 구조의 결함은 본질적으로 goto와 같습니다. goto가 흐름을 아무 데로나 점프시켜 “지금 프로그램이 어떤 상태인지” 추적을 불가능하게 만들 듯, 서로 독립적인 스레드들은 예측 불가능한 순서로 실행돼 전체 상태를 파악할 수 없게 만듭니다. 스레드 덤프를 떠도 무관한 스택만 나열될 뿐, 어떤 작업이 어떤 작업의 일부인지 보이지 않습니다.
스코프: 동시 작업의 생애주기 경계
해법은 단순합니다. 동시 작업의 수명을 하나의 코드 블록에 묶는 것입니다. 구조적 프로그래밍이 goto를 중괄호 블록으로 길들였듯, 구조적 동시성은 흩어지던 스레드를 try 블록에 가둡니다. 블록을 나가는 순간 안에서 시작한 모든 작업은 반드시 끝나 있습니다. StructuredTaskScope가 바로 그 블록입니다.
try (var scope = StructuredTaskScope.open()) {
var productTask = scope.fork(() -> fetchProduct(productId)); // ① 동시 시작
var reviewsTask = scope.fork(() -> fetchReviews(productId));
scope.join(); // ② 모두 끝날 때까지 대기
return new ProductInfo(productTask.get(), reviewsTask.get()); // ③ 둘 다 성공해야 도달
}세 동작이 생애주기를 구성합니다.
| 동작 | 의미 |
|---|---|
fork(task) | 서브태스크를 (보통) 가상 스레드에서 즉시 동시 실행하고 Subtask 핸들을 반환. 이 시점에 결과값은 아직 없음 |
join() | 모든 서브태스크가 정책을 만족할 때까지 블로킹하는 단일 동기화 지점. 스코프 소유자가 단 한 번만 호출 |
close() | 남은 서브태스크를 인터럽트하고 완전히 끝날 때까지 기다린 뒤 스코프 종료. try-with-resources가 암묵 호출 |
fork()가 값이 아니라 핸들을 반환하는 점이 핵심입니다. 결과는 join() 이후에만 핸들.get()으로 꺼낼 수 있습니다. “시작”과 “결과 수거” 사이에 반드시 join() 장벽이 끼어 있어, 결과를 쓰기 전에 모든 작업의 운명이 결정됩니다.
고아 스레드가 사라지는 이유가 여기 있습니다. 하나가 실패하면 join()이 즉시 나머지 서브태스크에 인터럽트를 보내 취소하고, close()는 그들이 실제로 정리될 때까지 기다린 뒤에야 블록을 빠져나갑니다. 부모가 취소돼도 스코프 전체가 함께 종료됩니다. 새어 나갈 스레드가 구조적으로 존재할 수 없습니다.
JDK 25 프리뷰
구조적 동시성은 JDK 25 프리뷰 기능이라 컴파일·실행 시
--enable-preview플래그가 필요합니다 (java --enable-preview Main.java).
Joiner: 정책과 메커니즘의 분리
여기까지면 “모두 기다린다”는 고정된 도구일 뿐입니다. StructuredTaskScope를 강력하게 만드는 건 언제 끝낼지·무엇을 반환할지를 Joiner라는 별도 객체로 분리한 설계입니다. 스코프는 생애주기·취소·정리라는 메커니즘만 담당하고, 완료 정책은 Joiner가 정합니다.
Joiner는 세 개의 훅으로 이루어집니다.
| 훅 | 호출 시점 | 반환값의 의미 |
|---|---|---|
onFork(subtask) | 서브태스크를 포크할 때 | true → 스코프 취소 (새 태스크를 막음) |
onComplete(subtask) | 서브태스크가 끝났을 때 | true → 나머지 취소하고 조기 종료 / false → 계속 |
result() / exception() | join()이 반환·throw할 값 결정 | — |
onComplete이 받는 Subtask에는 상태가 있습니다: SUCCESS(성공), FAILED(예외 발생), UNAVAILABLE(시작 전 취소됨). 정책 로직은 이 상태를 보고 “지금 전체를 끝낼지”를 판단합니다. 빌트인이든 커스텀이든 모든 정책은 결국 이 세 훅의 구현일 뿐입니다.
자주 쓰는 정책은 Joiner의 정적 팩토리로 제공됩니다. “어떤 완료 의미가 필요한가”로 고릅니다.
| Joiner | 완료 조건 | join() 반환 | 언제 |
|---|---|---|---|
awaitAllSuccessfulOrThrow() (open() 기본) | 전부 성공 | void | 모두 성공해야 다음 단계가 의미 있을 때 |
allSuccessfulOrThrow() | 전부 성공 | Stream<Subtask> | 위 + 성공 결과를 한 번에 수거 |
anySuccessfulResultOrThrow() | 첫 성공 | 그 결과 | 같은 데이터를 여러 소스에서, 가장 빠른 응답만 채택 |
awaitAll() | 전부 완료 (성공·실패 무관) | null | 부분 성공도 가치 있을 때, 장애 격리 |
allUntil(Predicate) | Predicate가 true | Stream<Subtask> | ”둘 실패하면 중단” 같은 커스텀 조건 |
앞의 둘은 **빠른 실패(fail-fast)**입니다. 하나라도 실패하면 즉시 나머지를 취소하고 예외를 올려, 전부 성공해야 하는 작업(주문 = 결제+재고+배송)에서 쓸모없어진 작업을 더 기다리지 않게 합니다.
anySuccessfulResultOrThrow()는 경주입니다. 캐시·DB·외부 API에 같은 조회를 동시에 던지고 가장 먼저 성공한 결과를 채택한 뒤 나머지를 즉시 취소합니다.
awaitAll()은 정반대입니다. 하나가 실패해도 나머지를 취소하지 않고 끝까지 실행합니다. 다채널 알림(이메일이 실패해도 SMS·푸시는 보내야)이나 동시 연결 서버(한 연결이 죽어도 다른 연결은 살아야)처럼 장애 격리가 중요한 곳에 씁니다. join()이 null을 반환하므로, 성공·실패 결과는 AtomicInteger·CopyOnWriteArrayList 같은 공유 상태로 따로 수집합니다.
예외 처리
서브태스크가 던진 예외는 fail-fast 정책에서 join()이 StructuredTaskScope.FailedException으로 감싸 다시 던집니다. getCause()로 원래 예외를 꺼냅니다.
핵심 주의점은 awaitAll()은 FailedException을 던지지 않는다는 것입니다. 개별 실패와 무관하게 모두 완료될 때까지 기다리므로, 실패를 알려면 서브태스크 상태를 직접 검사하거나 공유 상태로 수집해야 합니다. 조이너 선택이 곧 예외 처리 방식을 결정합니다.
처리 전략은 세 가지입니다.
| 전략 | 방법 | 적합한 상황 |
|---|---|---|
| 지역 처리 | try-catch로 FailedException 잡고 getCause()로 분기 | 이 계층에서 복구 가능 |
| 상위 전파 | catch 생략, 시그니처에 FailedException 선언 | 상위가 더 풍부한 컨텍스트로 중앙 집중 처리 |
| 서브태스크 내 처리 | 서브태스크 안에서 잡고 기본값 반환 | 일부 소스 실패가 전체 실패가 아니어도 되는 선택적 작업 |
예외 타입별로 다른 복구가 필요하면 패턴 매칭이 깔끔합니다. 기술적 원인 대신 도메인 특화 예외를 정의하고 switch로 사용자 친화적 응답에 매핑합니다.
return switch (cause) {
case PaymentDeclinedException e -> new OrderResult("PAYMENT_FAILED", ...);
case InsufficientInventoryException e -> new OrderResult("OUT_OF_STOCK", ...);
default -> new OrderResult("SYSTEM_ERROR", ...);
};interrupted 상태 복원
InterruptedException을 잡아 처리할 때는 항상Thread.currentThread().interrupt()로 인터럽트 상태를 되돌려놓습니다.
커스텀 조이너
세 훅을 직접 구현하면 도메인에 맞는 완료 조율을 만들 수 있습니다. onFork는 “새 태스크를 시작할까”, onComplete는 “지금 끝낼까”이며, 둘 다 true=취소·조기종료, false=계속입니다.
| 커스텀 조이너 | 핵심 동작 | 용도 |
|---|---|---|
| 성공·실패 모두 수집 | onComplete에서 둘 다 큐에 담고 false | 일부 실패해도 최대 정보 확보 |
| 정족수(quorum) | 성공 수가 N 도달 시 true | 5개 노드 중 3개 성공하면 조기 완료 |
| 적응형(서킷 브레이커) | 최소 샘플 후 실패율이 한계 초과 시 true | 실패 폭주 시 조기 중단 |
| 요청 제한 | onFork에서 Semaphore.acquire, onComplete에서 release | 동시 실행 개수 제한 |
| 조건부 | onFork에서 조건 불만족 시 true | 헬스체크 실패 시 새 태스크 차단 |
메모리 일관성
fork/join은 락 없이도 스레드 간 가시성을 보장하는 동기화 지점입니다.
fork()전에 소유자가 한 변경 → 서브태스크에서 보임 (Thread.start()와 같은 happens-before)- 서브태스크가 한 변경 →
join()후 소유자에서 보임 (Thread.join()과 같음)
덕분에 fork 전 준비한 데이터와 서브태스크가 계산한 결과를 별도 동기화 없이 주고받습니다. 다만 여러 서브태스크가 공유 상태를 동시에 수정할 때는 여전히 ConcurrentHashMap·AtomicInteger 같은 동시성 자료구조가 필요합니다.
스코프 구성
open(joiner, cf -> ...) 오버로드로 스코프 동작을 조정합니다.
| 설정 | 효과 |
|---|---|
withTimeout(Duration) | 초과 시 join()이 TimeoutException. open() 시점부터 측정 → 무한 대기 방지 |
withThreadFactory | 이름 있는 스레드 생성 → 스레드 덤프·프로파일링에서 식별 |
withName | 모니터링에서 스코프 식별 |
가상·플랫폼 스레드 둘 다와 호환되지만(Thread.ofPlatform() 팩토리 지정 가능), 플랫폼 스레드는 자원 제약이 되살아나므로 확장성 좋은 가상 스레드와 결합할 때 진가를 발휘합니다.
중첩 스코프와 관측성
서브태스크가 또 자신의 스코프를 열면 부모-자식 트리가 만들어집니다. 복잡한 다단계 워크플로(예: 헤더·본문·메타데이터를 모은 뒤, 그 결과로 단어수·감정·요약을 다시 병렬 분석)를 각 단계가 자체 생애주기·에러 처리를 가진 채 표현합니다. 에러·취소는 트리를 따라 상위로 전파됩니다.
이 위계가 가장 빛나는 곳이 관측성입니다. 전통적 스레드 덤프는 무관한 스택이 뒤섞여 나오지만, 구조화된 스레드 덤프(jcmd <pid> Thread.dump_to_file -format=json)는 트리를 그대로 보여줍니다. 어떤 스레드가 어떤 스코프(논리적 연산)에 속하고 소유자가 누구인지가 드러나, 추측 없이 디버깅할 수 있습니다.
비교 / 트레이드오프
비구조적 vs 구조적 동시성
| 구분 | 비구조적 (ExecutorService + Future) | 구조적 (StructuredTaskScope) |
|---|---|---|
| 태스크 관계 | 독립적, 인식 못함 | 부모-자식 위계 |
| 실패 전파 | 안 됨 (각 Future 독립) | join()이 즉시 나머지 취소 + FailedException |
| 취소 | 수동, 서브태스크에 전파 안 됨 | 부모 취소 시 자동 종료 |
| 자원 정리 | 고아 스레드 발생 | 스코프 종료 시 보장 |
| 관측성 | 스레드 덤프에 관계 안 보임 | 구조화된 덤프에 위계 노출 |
내 생각
- 구조적 동시성의 본질은 “스레드의 수명을 코드 블록에 묶는 것” 한 문장입니다. goto가 흐름을 새게 했듯 동시성도 통제 없이 새는데, 블록(스코프)으로 가두면 “블록을 나가면 다 끝나 있다”가 보장됩니다. 동시성 코드를 순차 코드처럼 읽게 만드는 것이 핵심 가치입니다.
- 정책과 메커니즘의 분리가 진짜 설계 포인트입니다. 스코프는 생애주기·취소만 담당하고,
onFork/onComplete/result세 훅이 정책을 담습니다. fail-fast·경주·전부 대기·정족수·서킷 브레이커가 전부 같은 골격에 정책만 갈아 끼운 것임을 알면, 커스텀 조이너도 두렵지 않습니다. - 조이너 선택 = 예외 동작 선택입니다. fail-fast는
FailedException을 던지지만awaitAll은 안 던집니다. “전부 성공해야 하나 / 가장 빠른 것만 / 부분 성공도 OK”를 고르는 순간 에러 처리 방식까지 결정됩니다. - 관측성은 과소평가된 장점입니다. 구조화된 스레드 덤프로 “어떤 스레드가 어떤 요청에 속하는지”가 보이는 것은, 운영 중 동시성 버그를 추측 없이 잡게 해 줍니다.
관련 개념
- Ch03 직접 만들어보고 이해하는 최신 동시성 메커니즘 — 서브태스크가 올라타는 스케줄러(ForkJoinPool)와 컨티뉴에이션
- Ch02 쉼 없이 CPU를 활용하는 가볍고 부지런한 가상 스레드 — fork가 생성하는 가상 스레드의 동작