본문 바로가기
Java

ExecutorService로 병렬 호출하면 왜 스레드가 새는가 - JDK 25 구조적 동시성 StructuredTaskScope로 실패 시 자동 취소

빅토르최·2026년 9월 7일·조회 2

서비스 하나가 화면을 그리려고 회원 정보, 주문 내역, 추천 목록을 각기 다른 API에서 끌어오는 구조는 어디에나 있다. 순차로 부르면 느리니 병렬로 묶는데, 이때 ExecutorServiceFuture로 짜 둔 코드가 의외로 조용히 스레드를 흘리는 경우를 자주 본다. 하나가 터졌는데 나머지는 백그라운드에서 끝까지 돌고 있는 것이다. JDK 21에서 미리보기로 들어온 구조적 동시성이 JDK 25에서 API를 크게 손봤길래, 예전 방식과 무엇이 달라지는지 코드로 정리해 둔다.

결론부터 말하자면, JDK 25의 StructuredTaskScope(JEP 505, 다섯 번째 미리보기)는 하위 작업들을 하나의 범위로 묶어 부모가 join()에서 함께 기다린다. 하위 작업 하나가 실패하면 나머지를 자동으로 취소하고, fork한 작업은 기본적으로 가상 스레드에서 돈다. ExecutorService/Future 조합에서 손으로 챙겨야 했던 취소와 정리를 구조가 대신 보장한다는 점이 핵심이다.

구조적 동시성이 무엇을 보장하나

구조적 동시성(structured concurrency)은 여러 하위 작업을 하나의 작업 단위로 취급하는 방식이다. 한 메서드가 여러 하위 작업으로 갈라져 실행됐다면, 그 하위 작업들은 반드시 같은 메서드 안에서 모두 끝나고 합쳐진 다음 밖으로 나가야 한다. try-with-resources 블록의 진입과 종료가 하위 작업의 생명주기를 감싸는 셈이다.

여기서 얻는 성질이 세 가지다. 하위 작업 하나가 실패하면 형제 작업은 더 돌 이유가 없으니 취소된다. 부모 스레드가 인터럽트를 받으면 하위 작업으로 전파된다. 블록을 벗어날 때는 모든 하위 작업이 이미 종료돼 있어 스레드가 뒤에 남지 않는다.

기존 ExecutorService/Future 방식이 새는 지점

가상 스레드가 생기면서 이런 코드를 많이 쓴다. 회원과 주문을 병렬로 부른 뒤 합치는 형태다.

Response handle() throws Exception {
    ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
    try {
        Future<String>  user  = pool.submit(() -> findUser());
        Future<Integer> order = pool.submit(() -> fetchOrder());

        return new Response(user.get(), order.get()); // 여기서 막힌다
    } finally {
        pool.shutdown();
    }
}

막상 돌려보면 이 부분에서 자주 걸린다. user.get()이 먼저 예외를 던지면 그 자리에서 메서드를 빠져나간다. 그런데 order 작업은 취소된 적이 없다. findUser()가 즉시 실패했는데 fetchOrder()가 30초 타임아웃을 기다리는 원격 호출이라면, 결과를 아무도 쓰지 않는데도 그 30초 동안 스레드가 살아 원격 호출을 계속 붙들고 있다. 이것이 취소 누수다.

반대 순서도 문제다. order가 먼저 실패해 예외로 빠지면, 아직 실행 중인 user 작업은 finallyshutdown()만으로는 즉시 멈추지 않는다. shutdown()은 새 작업만 막을 뿐 이미 실행 중인 작업은 그대로 두기 때문이다. 취소를 확실히 하려면 shutdownNow()를 부르고, 각 Futurecancel(true)하고, 인터럽트를 하위 작업이 제대로 받는지까지 직접 챙겨야 한다. 이 배관 작업을 손으로 짜다 보면 어딘가에서 빠뜨리고, 그 자리에서 스레드가 샌다.

JDK 25 StructuredTaskScope 기본 사용

JDK 25에서는 같은 일을 이렇게 쓴다. 인자 없는 StructuredTaskScope.open()은 가장 흔한 정책, 즉 모든 하위 작업이 성공하기를 기다리되 하나라도 실패하면 곧바로 실패시키는 범위를 만든다.

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

Response handle() throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {

        Subtask<String>  user  = scope.fork(() -> findUser());
        Subtask<Integer> order = scope.fork(() -> fetchOrder());

        scope.join();   // 두 작업을 함께 기다리고 예외를 전파

        // 여기 도달하면 둘 다 성공한 상태
        return new Response(user.get(), order.get());
    }
}

fork는 작업을 새 가상 스레드에서 시작하고 Subtask 핸들을 돌려준다. join()은 두 작업이 모두 끝날 때까지 부모 스레드를 붙잡는다. 둘 중 하나가 예외로 끝나면 join()이 나머지를 취소한 뒤 StructuredTaskScope.FailedException을 던지고, 원래 예외는 그 원인으로 감싸여 올라온다. try-with-resources의 close()는 블록을 벗어날 때 하위 작업이 전부 종료됐음을 다시 한번 보장한다.

앞의 취소 누수가 여기서는 구조상 생길 수 없다. findUser()가 실패하면 join()fetchOrder()를 취소하므로, 결과를 안 쓸 원격 호출이 30초를 채우는 일이 없다. Subtask::get은 성공한 작업에서만 호출해야 하고, 아직 성공하지 않은 상태에서 부르면 예외가 난다. 그래서 join()이 정상 반환한 뒤에만 get()을 부르는 순서를 지킨다.

가상 스레드와의 조합

StructuredTaskScope는 기본적으로 fork한 작업마다 새 가상 스레드를 만든다. 가상 스레드는 블로킹 호출에서 값싸게 양보하니, 원격 호출 수십 개를 동시에 fork해도 플랫폼 스레드를 그만큼 잡아먹지 않는다. 구조적 동시성이 노리는 지점이 정확히 이 조합이다. 수명이 짧고 개수가 많은 작업을 안전하게 갈랐다가 확실히 회수하는 것이다.

스레드 팩토리를 바꾸고 싶으면 open() 두 번째 인자로 설정 함수를 넘긴다. 가상 스레드에 이름을 붙이거나 플랫폼 스레드로 바꾸는 정도에 쓴다. 특별한 이유가 없으면 기본값(가상 스레드)을 그대로 두는 편을 권한다.

Joiner로 완료 정책 고르기

JDK 25의 가장 큰 변화가 Joiner다. 하위 작업들을 언제 멈추고 결과를 어떻게 낼지를 정하는 정책 객체이고, StructuredTaskScope.open(Joiner)로 넘긴다. 자주 쓰는 팩토리 메서드는 다음과 같다.

Joiner.allSuccessfulOrThrow()

모든 하위 작업이 성공하기를 기다리고, 하나라도 실패하면 나머지를 취소한 뒤 실패시킨다. join()은 성공한 Subtask의 스트림을 돌려준다. 같은 종류의 호출을 여러 개 병렬로 던지고 전부 필요한 경우에 쓴다.

import java.util.concurrent.StructuredTaskScope.Joiner;

<T> List<T> runAll(Collection<Callable<T>> tasks) throws InterruptedException {
    try (var scope = StructuredTaskScope.open(Joiner.<T>allSuccessfulOrThrow())) {
        tasks.forEach(scope::fork);
        return scope.join().map(Subtask::get).toList();
    }
}

Joiner.anySuccessfulResultOrThrow()

가장 먼저 성공한 하나의 결과를 받고 즉시 나머지를 취소한다. 같은 데이터를 여러 미러에서 받아 제일 빠른 응답만 쓰는 경쟁(race) 패턴에 맞는다.

<T> T race(Collection<Callable<T>> tasks) throws InterruptedException {
    try (var scope = StructuredTaskScope.open(Joiner.<T>anySuccessfulResultOrThrow())) {
        tasks.forEach(scope::fork);
        return scope.join();   // 첫 성공 결과, 나머지는 취소됨
    }
}

Joiner.awaitAll()과 awaitAllSuccessfulOrThrow()

awaitAll()은 성공이든 실패든 모든 작업이 끝날 때까지 기다리며 자동 취소를 걸지 않는다. 각 작업의 성패를 개별로 확인하고 싶을 때 쓴다. awaitAllSuccessfulOrThrow()는 인자 없는 open()과 같은 정책이지만 join()Void를 돌려주므로, 결과는 fork 때 잡아 둔 Subtask 핸들에서 직접 꺼낸다. 이 밖에 조건을 만족하면 멈추는 allUntil(Predicate)도 있다.

타임아웃 걸기

범위 전체에 마감 시간을 주려면 설정 함수에서 withTimeout을 쓴다. 시간이 지나면 남은 하위 작업이 취소되고 join()StructuredTaskScope.TimeoutException을 던진다.

import java.time.Duration;

<T> List<T> runAll(Collection<Callable<T>> tasks, Duration timeout)
        throws InterruptedException {
    try (var scope = StructuredTaskScope.open(
            Joiner.<T>allSuccessfulOrThrow(),
            cf -> cf.withTimeout(timeout))) {
        tasks.forEach(scope::fork);
        return scope.join().map(Subtask::get).toList();
    }
}

원격 호출 여러 개를 묶을 때 이 한 줄이 특히 유용하다. 개별 Future.get(timeout)을 작업마다 걸고 예외를 따로 처리하던 코드가, 범위 하나에 마감 시간을 주는 형태로 정리된다.

JDK 21~24와 JDK 25의 차이

구조적 동시성은 JDK 21(JEP 453)에 처음 미리보기로 들어왔고 JDK 24(JEP 499)까지 API가 거의 같았다. 그때는 정책을 서브클래스로 골랐다. 실패 시 전체 취소는 StructuredTaskScope.ShutdownOnFailure, 첫 성공에서 취소는 ShutdownOnSuccess였다.

// JDK 21 ~ 24 (JEP 453/462/480/499)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String>  user  = scope.fork(() -> findUser());
    Subtask<Integer> order = scope.fork(() -> fetchOrder());

    scope.join();
    scope.throwIfFailed();   // 실패한 하위 작업 예외를 다시 던짐

    return new Response(user.get(), order.get());
}

JDK 25(JEP 505)에서 이 서브클래스들이 사라지고 Joiner 방식으로 통합됐다. 생성자 대신 정적 팩토리 open(...)을 쓰고, 정책은 Joiner 인자로 조합한다. ShutdownOnFailure는 인자 없는 open() 또는 Joiner.awaitAllSuccessfulOrThrow()로, ShutdownOnSuccessJoiner.anySuccessfulResultOrThrow()로 옮겨 적으면 된다. JDK 21~24 코드를 25로 올릴 때는 이 대응 관계만 맞춰 바꾸면 된다.

미리보기 기능이라 옵션이 필요하다

JDK 25 기준 아직 정식이 아닌 미리보기 API다. 컴파일과 실행 모두 --enable-preview가 필요하고, 컴파일에는 대상 릴리스도 함께 준다.

$ javac --release 25 --enable-preview Main.java
$ java --enable-preview Main

패키지는 java.util.concurrent이고 JoinerSubtaskStructuredTaskScope의 중첩 타입이다. 미리보기라 이후 릴리스에서 시그니처가 또 바뀔 수 있으니, 운영 코드에 넣기 전에 사용 중인 JDK 버전의 API 문서를 다시 확인해 두는 편이 좋다.

정리

여러 원격 호출을 병렬로 묶을 때 ExecutorService/Future 방식은 하위 작업 하나가 실패해도 나머지를 취소하는 일을 개발자가 손으로 챙겨야 하고, 그 배관에서 취소 누수와 스레드 릭이 난다. JDK 25의 StructuredTaskScope는 하위 작업을 하나의 범위로 묶어 실패 시 자동 취소와 타임아웃을 구조로 보장하고, 가상 스레드와 맞물려 많은 작업을 값싸게 갈랐다 회수한다. 전부 성공이 필요하면 open()이나 allSuccessfulOrThrow(), 첫 성공만 쓰면 anySuccessfulResultOrThrow()를 고르면 된다.

자주 묻는 질문

StructuredTaskScope는 정식 기능인가, 미리보기인가?

JDK 25 기준으로 아직 미리보기다. JEP 505가 다섯 번째 미리보기이고, 컴파일과 실행 모두 --enable-preview 옵션이 필요하다. 컴파일에는 --release 25를 함께 준다. 미리보기라 이후 릴리스에서 API 시그니처가 바뀔 수 있으니 운영 투입 전에 사용 중인 JDK의 문서를 확인하는 것이 좋다.

fork한 작업은 어떤 스레드에서 실행되나?

기본적으로 fork마다 새 가상 스레드가 만들어진다. 가상 스레드는 블로킹 호출에서 값싸게 양보하므로 원격 호출 수십 개를 동시에 fork해도 플랫폼 스레드를 그만큼 소모하지 않는다. 스레드 팩토리를 바꾸려면 open의 두 번째 인자인 설정 함수에서 withThreadFactory로 지정한다.

하위 작업 하나가 실패하면 나머지는 어떻게 되나?

기본 open()이나 Joiner.allSuccessfulOrThrow()를 쓰면 실패가 감지되는 순간 join()이 나머지 하위 작업을 취소하고 StructuredTaskScope.FailedException을 던진다. 원래 예외는 그 원인으로 감싸여 올라온다. 덕분에 Future 방식에서 생기던 취소 누수, 즉 결과를 안 쓸 작업이 계속 도는 문제가 구조상 생기지 않는다.

JDK 21~24에서 쓰던 ShutdownOnFailure 코드는 어떻게 옮기나?

JDK 25에서 ShutdownOnFailure와 ShutdownOnSuccess 서브클래스가 사라지고 Joiner로 통합됐다. ShutdownOnFailure는 인자 없는 open() 또는 Joiner.awaitAllSuccessfulOrThrow()로, ShutdownOnSuccess는 Joiner.anySuccessfulResultOrThrow()로 바꾸면 된다. 생성자 new 대신 정적 팩토리 open(...)을 쓰고 throwIfFailed() 호출은 없앤다.

타임아웃은 작업별로 거나, 범위 전체에 거나?

범위 전체에 건다. open의 설정 함수에서 cf.withTimeout(Duration)을 주면 그 시간이 지날 때 남은 하위 작업이 모두 취소되고 join()이 StructuredTaskScope.TimeoutException을 던진다. Future.get(timeout)을 작업마다 걸고 예외를 개별 처리하던 코드가 범위 하나에 마감 시간을 주는 형태로 단순해진다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.