Published on

Java 21 Structured Concurrency 문법 실전

Authors

서버 애플리케이션에서 동시성은 대부분 "여러 I/O를 동시에 날리고, 빠르게 합치고, 실패하면 정리한다"로 요약됩니다. 문제는 전통적인 ExecutorService + Future 조합이 수명(lifetime) 관리를 코드 밖으로 밀어내기 쉽다는 점입니다. 작업이 어디서 시작됐는지, 누가 취소해야 하는지, 예외가 어디로 전파되는지 흐려지면 장애 대응이 어려워집니다.

Java 21의 Structured Concurrency는 이 문제를 **스코프(scope)**로 해결합니다. 여러 작업을 한 덩어리로 묶고, 그 덩어리가 끝날 때까지의 규칙(성공/실패/취소/타임아웃)을 명시적으로 선언합니다. 특히 Virtual Thread와 결합하면, I/O 바운드 요청을 더 단순한 코드로 더 많이 동시 처리할 수 있습니다.

참고: Structured Concurrency는 Java 21에서 프리뷰 API입니다. 컴파일/실행 시 --enable-preview가 필요합니다.

Structured Concurrency가 해결하는 것

1) 동시 작업의 수명과 소유권

기존 방식은 ExecutorService가 앱 전역에 있고, 특정 요청이 만든 작업이 요청 종료 후에도 남아버리는 실수를 만들기 쉽습니다. Structured Concurrency는 try-with-resources 스코프 안에서 작업을 만들고, 스코프가 닫히면 작업이 정리되도록 유도합니다.

2) 실패 전파(failure propagation)

Future.get()을 여기저기서 호출하면, 어떤 작업의 실패가 전체 요청 실패로 이어지는지 규칙이 불분명해집니다. ShutdownOnFailure, ShutdownOnSuccess처럼 정책이 코드에 드러나는 형태로 바뀝니다.

3) 취소(cancellation)와 타임아웃

요청 타임아웃이 났는데도 백그라운드에서 API 호출이 계속되는 상황은 흔한 리소스 누수 원인입니다. 구조화된 스코프는 취소 전파를 더 자연스럽게 만듭니다.

준비: Java 21 프리뷰 켜기

Gradle 예시

tasks.withType(JavaCompile).configureEach {
    options.compilerArgs += ["--enable-preview"]
}

tasks.withType(Test).configureEach {
    jvmArgs += ["--enable-preview"]
}

tasks.withType(JavaExec).configureEach {
    jvmArgs += ["--enable-preview"]
}

실행 예시

java --enable-preview -jar app.jar

핵심 문법: StructuredTaskScope

Java 21의 Structured Concurrency는 java.util.concurrent.StructuredTaskScope를 중심으로 합니다. 대표 구현이 아래 두 가지입니다.

  • StructuredTaskScope.ShutdownOnFailure: 하나라도 실패하면 나머지를 취소하고 전체 실패
  • StructuredTaskScope.ShutdownOnSuccess: 하나라도 성공하면 나머지를 취소하고 성공값 반환(경쟁 레이스)

예제 1: 여러 API를 동시에 호출하고 합치기

사용자 프로필 화면을 구성하려고 user, orders, recommendations를 동시에 가져오는 상황을 가정합니다.

import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;

record User(String id, String name) {}
record Orders(int count) {}
record Reco(String headline) {}
record ProfileView(User user, Orders orders, Reco reco) {}

class ProfileService {

    User fetchUser(String userId) {
        // 원격 호출 가정
        return new User(userId, "alice");
    }

    Orders fetchOrders(String userId) {
        return new Orders(3);
    }

    Reco fetchReco(String userId) {
        return new Reco("Try new items");
    }

    ProfileView loadProfile(String userId) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var userTask = scope.fork(() -> fetchUser(userId));
            var ordersTask = scope.fork(() -> fetchOrders(userId));
            var recoTask = scope.fork(() -> fetchReco(userId));

            scope.join();
            scope.throwIfFailed();

            return new ProfileView(userTask.get(), ordersTask.get(), recoTask.get());
        }
    }
}

포인트는 다음과 같습니다.

  • fork로 만든 작업은 스코프에 종속됩니다.
  • join으로 모든 작업이 끝날 때까지 기다립니다.
  • throwIfFailed가 실패를 한 곳으로 모아 전파합니다.

ShutdownOnFailure를 쓰면, 예를 들어 orders가 실패했을 때 user, reco가 아직 진행 중이면 스코프가 자동으로 취소를 전파합니다. 이때 각 작업은 인터럽트/취소에 반응하도록 작성되어야 효과가 큽니다(아래에서 다룹니다).

실전 패턴 1: 전체 요청 타임아웃 걸기

대부분의 서비스는 전체 SLA가 있고, 그 안에서 여러 백엔드 호출을 병렬로 수행합니다. 이때는 스코프 단위로 타임아웃을 걸어야 "요청은 끝났는데 백엔드 호출은 계속" 상태를 막을 수 있습니다.

Java 21에서는 joinUntil을 활용할 수 있습니다.

import java.time.Instant;
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.TimeoutException;

class TimeoutExample {
    String slowCall() throws InterruptedException {
        Thread.sleep(200);
        return "ok";
    }

    String callWithTimeout() throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var t = scope.fork(this::slowCall);

            var deadline = Instant.now().plus(Duration.ofMillis(100));
            scope.joinUntil(deadline);

            // 타임아웃이면 아직 완료되지 않은 작업이 있을 수 있으므로
            // 정책적으로 취소/실패 처리
            if (!t.state().isDone()) {
                scope.shutdown();
                throw new TimeoutException("deadline exceeded");
            }

            scope.throwIfFailed();
            return t.get();
        }
    }
}

운영 관점에서는 타임아웃을 걸 때 백오프/재시도와 결합되는 경우가 많습니다. 예를 들어 외부 API가 429를 반환할 때 재시도 정책을 어떻게 둘지 고민한다면, 별도 글인 OpenAI API 429 RateLimit 재시도·백오프 실무에서 재시도 설계 관점을 함께 참고할 수 있습니다.

실전 패턴 2: 첫 성공만 필요할 때(레이스)

CDN 엔드포인트가 여러 개거나, 지역별 백엔드 중 가장 빠른 곳을 택하고 싶을 때 ShutdownOnSuccess가 직관적입니다.

import java.util.concurrent.StructuredTaskScope;

class RaceExample {
    String callA() throws InterruptedException {
        Thread.sleep(80);
        return "A";
    }

    String callB() throws InterruptedException {
        Thread.sleep(30);
        return "B";
    }

    String fastest() throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
            scope.fork(this::callA);
            scope.fork(this::callB);

            scope.join();
            return scope.result();
        }
    }
}

이 패턴을 쓸 때 주의점은 **부작용(side effect)**입니다. 예를 들어 결제 승인처럼 중복 호출이 위험한 작업은 레이스에 넣으면 안 됩니다. 레이스는 GET 성격의 idempotent 호출이나, 캐시 조회, 미러링된 읽기 전용 엔드포인트에 적합합니다.

실전 패턴 3: 부분 성공(Partial Success) 다루기

현실에서는 "추천은 실패해도 프로필은 보여주자" 같은 요구가 많습니다. 하지만 Structured Concurrency의 기본 정책은 보통 "전체 성공" 또는 "전체 실패"로 설계되어 있습니다.

부분 성공을 구현하려면 ShutdownOnFailure 대신 기본 StructuredTaskScope를 사용하고, 각 서브태스크의 결과/예외를 개별적으로 수집하는 방식이 안전합니다.

import java.util.Optional;
import java.util.concurrent.StructuredTaskScope;

record PartialProfile(String user, Optional<String> reco) {}

class PartialSuccessExample {

    String user() { return "user"; }

    String reco() {
        throw new RuntimeException("reco backend down");
    }

    PartialProfile load() throws InterruptedException {
        try (var scope = new StructuredTaskScope<String>()) {
            var userTask = scope.fork(this::user);
            var recoTask = scope.fork(this::reco);

            scope.join();

            String user = userTask.get();

            Optional<String> reco = Optional.empty();
            if (recoTask.state().isFailed()) {
                // 로깅/메트릭 기록 후 무시
                // recoTask.exception() 같은 접근은 API 버전에 따라 다를 수 있어
                // 상태 기반으로 분기하는 편이 이식성이 좋습니다.
            } else {
                reco = Optional.ofNullable(recoTask.get());
            }

            return new PartialProfile(user, reco);
        }
    }
}

부분 성공은 사용자 경험을 지키는 대신 장애를 숨길 수 있으므로, 반드시 **관측성(로그/메트릭/트레이싱)**을 같이 설계해야 합니다. 프론트 성능/UX 관점에서 병렬 로딩이 사용자 지표에 어떤 영향을 주는지까지 연결해 보려면 Chrome INP 급락? Long Task 추적·분해 실전도 함께 읽어볼 만합니다.

취소가 제대로 먹히게 만드는 법

Structured Concurrency가 취소를 전파해도, 작업 코드가 취소 신호를 무시하면 리소스는 계속 소모됩니다. 실전에서 중요한 체크리스트는 아래와 같습니다.

1) 인터럽트 가능한 블로킹 호출 사용

  • Thread.sleep은 인터럽트에 반응합니다.
  • 많은 I/O 라이브러리도 인터럽트/타임아웃을 지원합니다.

2) 루프 작업은 Thread.currentThread().isInterrupted() 확인

CPU 바운드 작업이나 폴링 루프는 반드시 인터럽트 플래그를 확인해 빠르게 탈출해야 합니다.

void cpuWork() {
    while (true) {
        if (Thread.currentThread().isInterrupted()) {
            return;
        }
        // do work
    }
}

3) HTTP 클라이언트 타임아웃을 별도로 설정

스코프 타임아웃만 믿으면, 내부 HTTP 호출이 OS 레벨에서 오래 붙잡히는 상황이 생길 수 있습니다. 클라이언트 레벨에서 connect timeout, read timeout을 함께 설정하는 것이 안전합니다.

Virtual Thread와 함께 쓸 때의 감각

Structured Concurrency는 Virtual Thread와 결합될 때 체감이 큽니다.

  • 요청당 스레드 모델을 유지하면서도 동시 처리량을 끌어올리기 쉬움
  • 복잡한 콜백/리액티브 체인을 "순차 코드처럼" 작성 가능

다만 다음은 여전히 주의해야 합니다.

  • 동기화 블로킹(예: 오래 잡는 synchronized)은 병목이 될 수 있음
  • DB 커넥션 풀, 외부 API 쿼터 같은 외부 리소스 한도가 진짜 병목일 수 있음

특히 외부 API가 과부하로 429529를 내는 상황에서는 동시성 자체보다 큐잉/백오프/서킷 브레이커가 더 중요해집니다. 이런 관점은 Claude API 529 Overloaded 재시도·큐잉 패턴 정리도 참고하면 좋습니다.

테스트와 운영에서의 팁

1) 로그에 스코프 단위 컨텍스트를 남기기

여러 태스크가 동시에 돌면 로그가 섞입니다. 요청 ID를 MDC에 넣거나, 태스크 시작/종료 시점을 구조적으로 남겨야 디버깅이 가능합니다.

2) 실패 정책을 먼저 결정하고 코드로 고정하기

  • "하나라도 실패하면 전체 실패"ShutdownOnFailure
  • "가장 빠른 성공 하나"ShutdownOnSuccess
  • "부분 성공 허용"이면 기본 스코프 + 상태 기반 수집

이 정책이 흔들리면, 장애 시나리오에서 시스템의 행동이 예측 불가능해집니다.

3) 예외 타입을 경계에서 정규화

스코프 내부에서 발생한 예외를 그대로 던지면 컨트롤러/핸들러에서 분기 처리가 복잡해집니다. DomainException, UpstreamException 같은 형태로 경계에서 정규화하면 운영이 편해집니다.

정리

Java 21 Structured Concurrency는 "동시 작업을 한 덩어리로 묶고, 그 덩어리의 규칙을 명시한다"는 점에서 서버 코드의 복잡도를 크게 낮춥니다. 실전에서는 다음 3가지만 먼저 적용해도 효과가 큽니다.

  1. 병렬 I/O는 StructuredTaskScope로 묶어 수명과 예외 전파를 명확히 하기
  2. 스코프 단위 타임아웃과 취소 전파로 리소스 누수 줄이기
  3. 전체 실패/레이스/부분 성공 중 정책을 먼저 정하고 그에 맞는 스코프를 선택하기

프리뷰 기능인 만큼 API가 바뀔 수는 있지만, "스코프 기반 동시성"이라는 방향성 자체는 Java의 동시성 코드를 더 안전하고 읽기 쉽게 만드는 강력한 도구입니다.