ExecutorService는 독립적인 작업 여러 개를 병렬로 돌릴 때 좋다. 그런데 하나의 큰 작업을 재귀적으로 쪼개서 병렬 처리하려면? 여러 하위 작업 중 하나가 실패했을 때 나머지를 자동으로 취소하려면?

이 글에서는 Fork/Join의 분할 정복, StampedLock의 낙관적 읽기, Phaser의 동적 동기화, 그리고 Java 21의 Structured Concurrency까지 — 기본 동시성 도구로는 해결이 어려운 문제들과 그 해법을 정리해 봤습니다.

Fork/Join Framework

큰 작업을 재귀적으로 분할(fork)하고, 결과를 합치는(join) 병렬 처리 프레임워크입니다. Java 7에서 도입되었고, 핵심은 work-stealing 알고리즘이에요.

RecursiveTask vs RecursiveAction

  • RecursiveTask<V>: 결과를 반환하는 작업 (compute()가 V를 리턴)
  • RecursiveAction: 결과 없이 부수 효과만 있는 작업 (compute()가 void)

RecursiveTask<V>를 상속받아 compute() 메서드를 구현합니다. 배열의 합을 구하는 예제로 살펴볼게요.

JAVA
public class SumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10_000;
    private final long[] array;
    private final int start, end;
    // 생성자 생략

compute()에서 분할과 합산 로직을 담습니다. 작업이 충분히 작으면 직접 계산하고, 크면 반으로 나눠요.

JAVA
    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            long sum = 0;
            for (int i = start; i < end; i++) sum += array[i];
            return sum;
        }
        int mid = (start + end) / 2;
        SumTask left = new SumTask(array, start, mid);
        SumTask right = new SumTask(array, mid, end);

        left.fork();                        // 왼쪽을 다른 스레드에 넘김
        long rightResult = right.compute(); // 오른쪽은 현재 스레드에서 계산
        long leftResult = left.join();      // 왼쪽 결과 대기
        return leftResult + rightResult;
    }
}

fork()로 다른 스레드에 넘기고, compute()로 현재 스레드에서 직접 실행하는 비대칭 패턴이 핵심입니다. 양쪽 다 fork()하면 현재 스레드가 놀게 되어 비효율적이에요.

Work-Stealing 알고리즘

Fork/Join이 일반 ExecutorService보다 효율적인 이유가 여기에 있습니다.

  1. 각 스레드는 ** 자신만의 deque(양방향 큐)**를 가집니다
  2. 자기 작업은 deque의 head 에서 꺼냅니다 (LIFO — 큰 작업부터 처리)
  3. 할 일이 없는 스레드는 다른 스레드 deque의 tail 에서 훔쳐옵니다 (FIFO — 작은 작업)
  4. 이 비대칭 접근 덕분에 head/tail 경합이 거의 없어요

일반 ExecutorService는 하나의 공유 큐를 모든 스레드가 경쟁하며 꺼내갑니다. work-stealing은 스레드별 독립 큐이므로 경합이 최소화돼요.

Fork/Join vs ExecutorService 선택 기준

기준Fork/JoinExecutorService
작업 성격재귀적으로 분할 가능한 작업독립적인 작업 여러 개
내부 구조스레드별 deque + work-stealing공유 BlockingQueue
적합한 예배열 정렬, 트리 탐색, parallelStreamHTTP 요청 처리, DB 쿼리 병렬 실행
Java 버전7+5+

참고로 parallelStream()은 내부적으로 공용 ForkJoinPool(ForkJoinPool.commonPool())을 사용합니다. 이걸 모르면 의도치 않게 다른 parallelStream과 스레드 풀을 공유하게 됩니다.

StampedLock

한 줄 정의

** 낙관적 읽기(optimistic read)를 지원하는 읽기/쓰기 락으로, ReadWriteLock보다 성능이 좋은 경우가 많습니다.**

Java 8에서 도입되었습니다.

ReadWriteLock의 한계

ReentrantReadWriteLock은 읽기 락을 잡고 있으면 쓰기가 대기합니다. 읽기가 매우 빈번하면 쓰기가 계속 밀리는 ** 쓰기 기아(write starvation)** 문제가 있습니다.

낙관적 읽기가 핵심

StampedLock의 가장 큰 특징은 ** 락을 잡지 않고 읽는 것 **입니다. 쓰기는 배타적 락을 잡아요.

JAVA
public class Point {
    private double x, y;
    private final StampedLock lock = new StampedLock();

    public void move(double deltaX, double deltaY) {
        long stamp = lock.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            lock.unlockWrite(stamp);
        }
    }

읽기에서 낙관적 읽기를 먼저 시도하고, 그 사이에 쓰기가 끼어들었으면 비관적 읽기 락으로 전환합니다.

JAVA
    public double distanceFromOrigin() {
        long stamp = lock.tryOptimisticRead(); // 락 없이 stamp만 받음
        double currentX = x, currentY = y;

        if (!lock.validate(stamp)) {           // 쓰기가 끼어들었는지 확인
            stamp = lock.readLock();           // 비관적 읽기 락으로 전환
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

낙관적 읽기가 유용한 상황

  • 읽기가 대부분이고 쓰기가 드문 경우 (캐시 조회 등)
  • 읽기 시간이 짧아서 쓰기와 충돌할 확률이 낮은 경우
  • 쓰기 기아를 방지하고 싶을 때

반대로 쓰기가 빈번하면 validate가 계속 실패해서 오히려 오버헤드만 커집니다.

StampedLock 주의사항

StampedLock은 강력하지만 제약도 분명합니다.

  • ** 재진입(reentrant) 불가 **: 같은 스레드가 두 번 락을 잡으면 데드락
  • **Condition 미지원 **: ReentrantLock처럼 newCondition() 쓸 수 없음
  • **interrupt에 취약 **: writeLock() 대기 중 인터럽트 시 CPU 100%를 먹을 수 있음 → writeLockInterruptibly() 사용 권장

Phaser

한 줄 정의

** 참여자 수를 동적으로 변경할 수 있고 여러 페이즈를 반복할 수 있는 동기화 배리어입니다.**

CountDownLatch(일회용, 고정 카운트)와 CyclicBarrier(재사용 가능, 고정 참여자)의 한계를 모두 해결합니다.

기존 도구의 한계

특성CountDownLatchCyclicBarrierPhaser
재사용불가가능가능
참여자 변경불가불가** 가능**
페이즈 개념없음1 페이즈** 다중 페이즈**

사용 예제

JAVA
// 3단계 파이프라인: 각 단계마다 모든 참여자가 완료해야 다음으로
Phaser phaser = new Phaser(3); // 초기 참여자 3명

for (int i = 0; i < 3; i++) {
    final int workerId = i;
    new Thread(() -> {
        // 페이즈 0: 데이터 로딩
        System.out.println("워커 " + workerId + ": 데이터 로딩 완료");
        phaser.arriveAndAwaitAdvance(); // 모두 도착할 때까지 대기

        // 페이즈 1: 데이터 처리
        System.out.println("워커 " + workerId + ": 데이터 처리 완료");
        phaser.arriveAndAwaitAdvance();

        // 페이즈 2: 결과 저장
        System.out.println("워커 " + workerId + ": 결과 저장 완료");
        phaser.arriveAndDeregister(); // 작업 끝, 참여 해제
    }).start();
}

동적 참여자 변경

이게 Phaser만의 강점입니다.

JAVA
Phaser phaser = new Phaser(1); // 메인 스레드가 초기 참여자

// 새 참여자 등록
phaser.register(); // 참여자 수: 2
new Thread(() -> {
    // 작업 수행
    phaser.arriveAndDeregister(); // 작업 끝나면 빠지기
}).start();

// 조건에 따라 추가 참여자를 넣을 수도 있음
if (needMoreWorkers) {
    phaser.register();
    launchNewWorker(phaser);
}

phaser.arriveAndAwaitAdvance(); // 메인 스레드도 대기

Structured Concurrency (Java 21 Preview)

한 줄 정의

** 여러 동시 작업을 하나의 단위로 묶어, 코드 구조와 태스크 수명 주기를 일치시키는 패러다임입니다.**

Java 21에서 프리뷰로 도입되었고 (JEP 453), 기존 동시성 코드가 가진 근본적인 문제를 해결합니다.

기존 방식의 문제

ExecutorService로 동시 작업을 실행하면 이런 문제가 생겨요.

JAVA
// 문제가 있는 기존 코드
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Future<User> userFuture = executor.submit(() -> findUser(userId));
Future<Order> orderFuture = executor.submit(() -> findOrder(orderId));

// findUser()가 예외를 던지면?
// → findOrder()는 아직 실행 중... 스레드 누수!
User user = userFuture.get();   // 여기서 예외
Order order = orderFuture.get(); // 이 줄은 실행도 안 되지만 orderFuture는 아직 돌아가는 중

세 가지 핵심 문제가 있어요.

  1. ** 스레드 누수 **: 한 태스크가 실패해도 다른 태스크는 계속 실행됩니다
  2. ** 취소 전파 안 됨 **: 부모가 실패하거나 취소되어도 자식 태스크에 전달이 안 돼요
  3. ** 코드 구조 불일치 **: 논리적으로는 하나의 작업인데 수명 주기가 제각각입니다

StructuredTaskScope로 해결

JAVA
// Structured Concurrency 방식
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    // 하위 태스크들을 스코프 안에서 실행
    Subtask<User> userTask = scope.fork(() -> findUser(userId));
    Subtask<Order> orderTask = scope.fork(() -> findOrder(orderId));

    scope.join();           // 모든 하위 태스크 완료 대기
    scope.throwIfFailed();  // 하나라도 실패했으면 예외 발생

    // 여기 도달하면 둘 다 성공한 것
    return new Response(userTask.get(), orderTask.get());
}
// try 블록을 벗어나면 아직 실행 중인 하위 태스크는 자동 취소됨

핵심은 ** 코드 블록의 구조가 곧 태스크의 수명 주기 **라는 점이에요.

  • ShutdownOnFailure: 하나라도 실패하면 나머지 전부 취소
  • ShutdownOnSuccess: 하나라도 성공하면 나머지 전부 취소
  • try-with-resources로 스코프가 닫히면 모든 하위 태스크의 수명이 끝납니다

ShutdownOnSuccess 예제

JAVA
// 여러 미러 서버 중 가장 빨리 응답하는 결과 사용
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> fetchFromMirror1());
    scope.fork(() -> fetchFromMirror2());
    scope.fork(() -> fetchFromMirror3());

    scope.join();

    // 가장 먼저 성공한 결과 반환, 나머지는 자동 취소
    return scope.result();
}

Scoped Values (Java 21 Preview)

한 줄 정의

ThreadLocal의 현대적 대안으로, 불변이고 스코프가 명확한 스레드 로컬 값입니다.

JEP 446으로 프리뷰 도입되었습니다.

ThreadLocal의 문제점

ThreadLocal은 편리하지만 구조적 약점이 있습니다.

  1. ** 메모리 누수 **: remove() 호출을 빼먹으면 스레드 풀에서 값이 계속 남아있어요
  2. ** 가변성 **: 어디서든 set()으로 바꿀 수 있어서 추적이 어렵습니다
  3. ** 가상 스레드 비효율 **: 수백만 개의 가상 스레드가 각각 ThreadLocal 복사본을 가지면 메모리 폭발이에요
  4. ** 상속 문제 **: InheritableThreadLocal은 자식 스레드에 값을 복사하는데, 이 비용이 큽니다

ScopedValue 사용법

JAVA
// ScopedValue 선언 (static final이 일반적)
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

// 값 바인딩 — 스코프 안에서만 유효
ScopedValue.runWhere(CURRENT_USER, authenticatedUser, () -> {
    // 이 블록 안에서는 CURRENT_USER.get()으로 접근 가능
    handleRequest();
});
// 블록을 벗어나면 자동으로 바인딩 해제 — remove() 필요 없음

// 하위 메서드에서 사용
void handleRequest() {
    User user = CURRENT_USER.get(); // 현재 스코프의 값을 읽음
    processOrder(user);
}

ThreadLocal vs ScopedValue 비교

특성ThreadLocalScopedValue
가변성가변 (set/get)** 불변** (바인딩 시 고정)
수명 관리수동 remove()** 자동** (스코프 종료 시)
상속InheritableThreadLocalStructuredTaskScope과 자연스럽게 연동
가상 스레드비효율** 최적화됨**
메모리 누수위험 있음** 구조적으로 방지**

Structured Concurrency와 함께 쓰면 시너지가 큽니다. StructuredTaskScope.fork()로 만든 하위 태스크는 부모의 ScopedValue를 자동으로 물려받습니다.

JAVA
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

ScopedValue.runWhere(REQUEST_ID, "req-12345", () -> {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // fork된 하위 태스크에서도 REQUEST_ID.get() 사용 가능
        scope.fork(() -> {
            String id = REQUEST_ID.get(); // "req-12345" — 부모 스코프에서 상속
            return callServiceA(id);
        });
        scope.fork(() -> {
            String id = REQUEST_ID.get(); // 역시 "req-12345"
            return callServiceB(id);
        });

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

선택 가이드

락 선택

  • synchronized: 간단한 상호 배제, 재진입 필요할 때
  • ReentrantLock: Condition, tryLock, 공정성 제어가 필요할 때
  • StampedLock: 읽기 위주 워크로드에서 최대 성능이 필요할 때 (단, 재진입 불가 주의)

동기화 도구 선택

  • CountDownLatch: "N개 작업이 끝날 때까지 기다리기" — 일회용이면 충분
  • CyclicBarrier: "N개 스레드가 동시에 시작하기" — 재사용이 필요할 때
  • Phaser: 참여자가 동적으로 바뀌거나 다단계 파이프라인

병렬 실행 프레임워크 선택

  • ExecutorService: 독립적인 작업 N개를 병렬 실행
  • Fork/Join: 재귀적으로 분할 가능한 큰 작업
  • StructuredTaskScope: 여러 하위 태스크를 하나의 단위로 관리 (Java 21+)

주의할 점

parallelStream은 공용 ForkJoinPool을 씁니다

parallelStream()ForkJoinPool.commonPool()에서 실행됩니다. I/O 블로킹 작업에 parallelStream을 쓰면 같은 풀을 공유하는 다른 parallelStream까지 느려져요. I/O 작업은 별도 ExecutorService에서 실행해야 합니다.

StampedLock에서 재진입하면 데드락

ReentrantLock과 달리 StampedLock은 재진입을 지원하지 않습니다. 같은 스레드가 writeLock()을 두 번 호출하면 영원히 대기해요. 기존에 ReentrantLock을 쓰던 코드를 단순 교체하면 안 되는 이유입니다.

Structured Concurrency 없이 ExecutorService를 쓸 때의 스레드 누수

ExecutorService로 여러 작업을 동시에 실행하면, 한 작업이 실패해도 나머지 작업은 계속 돌아갑니다. 명시적으로 취소하지 않으면 스레드가 리소스를 점유한 채 남아있어요. StructuredTaskScope는 스코프가 닫히면 하위 태스크를 자동 취소하므로 이 문제를 구조적으로 방지합니다.

요약

  • Fork/Join: 큰 작업을 재귀적으로 쪼개는 분할 정복용. work-stealing이 핵심이고, parallelStream의 엔진이기도 합니다.
  • StampedLock: 읽기 위주일 때 낙관적 읽기로 성능을 끌어올리는 락. 단, 재진입 불가와 인터럽트 주의.
  • Phaser: 동적 참여자 + 다단계 동기화가 필요하면 CountDownLatch/CyclicBarrier 대신 이걸 쓰면 됩니다.
  • Structured Concurrency: 코드 블록 = 태스크 수명. try-with-resources처럼 자원 관리를 구조적으로 하는 동시성 버전입니다.
  • Scoped Values: ThreadLocal의 문제(메모리 누수, 가변성)를 해결하는 불변 + 자동 해제 스레드 로컬입니다.
댓글 로딩 중...