동시성 심화 — Fork-Join부터 Structured Concurrency까지
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() 메서드를 구현합니다. 배열의 합을 구하는 예제로 살펴볼게요.
public class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000;
private final long[] array;
private final int start, end;
// 생성자 생략
compute()에서 분할과 합산 로직을 담습니다. 작업이 충분히 작으면 직접 계산하고, 크면 반으로 나눠요.
@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보다 효율적인 이유가 여기에 있습니다.
- 각 스레드는 ** 자신만의 deque(양방향 큐)**를 가집니다
- 자기 작업은 deque의 head 에서 꺼냅니다 (LIFO — 큰 작업부터 처리)
- 할 일이 없는 스레드는 다른 스레드 deque의 tail 에서 훔쳐옵니다 (FIFO — 작은 작업)
- 이 비대칭 접근 덕분에 head/tail 경합이 거의 없어요
일반 ExecutorService는 하나의 공유 큐를 모든 스레드가 경쟁하며 꺼내갑니다. work-stealing은 스레드별 독립 큐이므로 경합이 최소화돼요.
Fork/Join vs ExecutorService 선택 기준
| 기준 | Fork/Join | ExecutorService |
|---|---|---|
| 작업 성격 | 재귀적으로 분할 가능한 작업 | 독립적인 작업 여러 개 |
| 내부 구조 | 스레드별 deque + work-stealing | 공유 BlockingQueue |
| 적합한 예 | 배열 정렬, 트리 탐색, parallelStream | HTTP 요청 처리, DB 쿼리 병렬 실행 |
| Java 버전 | 7+ | 5+ |
참고로 parallelStream()은 내부적으로 공용 ForkJoinPool(ForkJoinPool.commonPool())을 사용합니다. 이걸 모르면 의도치 않게 다른 parallelStream과 스레드 풀을 공유하게 됩니다.
StampedLock
한 줄 정의
** 낙관적 읽기(optimistic read)를 지원하는 읽기/쓰기 락으로, ReadWriteLock보다 성능이 좋은 경우가 많습니다.**
Java 8에서 도입되었습니다.
ReadWriteLock의 한계
ReentrantReadWriteLock은 읽기 락을 잡고 있으면 쓰기가 대기합니다. 읽기가 매우 빈번하면 쓰기가 계속 밀리는 ** 쓰기 기아(write starvation)** 문제가 있습니다.
낙관적 읽기가 핵심
StampedLock의 가장 큰 특징은 ** 락을 잡지 않고 읽는 것 **입니다. 쓰기는 배타적 락을 잡아요.
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);
}
}
읽기에서 낙관적 읽기를 먼저 시도하고, 그 사이에 쓰기가 끼어들었으면 비관적 읽기 락으로 전환합니다.
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(재사용 가능, 고정 참여자)의 한계를 모두 해결합니다.
기존 도구의 한계
| 특성 | CountDownLatch | CyclicBarrier | Phaser |
|---|---|---|---|
| 재사용 | 불가 | 가능 | 가능 |
| 참여자 변경 | 불가 | 불가 | ** 가능** |
| 페이즈 개념 | 없음 | 1 페이즈 | ** 다중 페이즈** |
사용 예제
// 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만의 강점입니다.
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로 동시 작업을 실행하면 이런 문제가 생겨요.
// 문제가 있는 기존 코드
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는 아직 돌아가는 중
세 가지 핵심 문제가 있어요.
- ** 스레드 누수 **: 한 태스크가 실패해도 다른 태스크는 계속 실행됩니다
- ** 취소 전파 안 됨 **: 부모가 실패하거나 취소되어도 자식 태스크에 전달이 안 돼요
- ** 코드 구조 불일치 **: 논리적으로는 하나의 작업인데 수명 주기가 제각각입니다
StructuredTaskScope로 해결
// 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 예제
// 여러 미러 서버 중 가장 빨리 응답하는 결과 사용
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은 편리하지만 구조적 약점이 있습니다.
- ** 메모리 누수 **:
remove()호출을 빼먹으면 스레드 풀에서 값이 계속 남아있어요 - ** 가변성 **: 어디서든
set()으로 바꿀 수 있어서 추적이 어렵습니다 - ** 가상 스레드 비효율 **: 수백만 개의 가상 스레드가 각각 ThreadLocal 복사본을 가지면 메모리 폭발이에요
- ** 상속 문제 **:
InheritableThreadLocal은 자식 스레드에 값을 복사하는데, 이 비용이 큽니다
ScopedValue 사용법
// 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 비교
| 특성 | ThreadLocal | ScopedValue |
|---|---|---|
| 가변성 | 가변 (set/get) | ** 불변** (바인딩 시 고정) |
| 수명 관리 | 수동 remove() | ** 자동** (스코프 종료 시) |
| 상속 | InheritableThreadLocal | StructuredTaskScope과 자연스럽게 연동 |
| 가상 스레드 | 비효율 | ** 최적화됨** |
| 메모리 누수 | 위험 있음 | ** 구조적으로 방지** |
Structured Concurrency와 함께 쓰면 시너지가 큽니다. StructuredTaskScope.fork()로 만든 하위 태스크는 부모의 ScopedValue를 자동으로 물려받습니다.
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의 문제(메모리 누수, 가변성)를 해결하는 불변 + 자동 해제 스레드 로컬입니다.