동시성 패턴 — 여럿이 동시에 움직일 때
GoF 23개는 단일 스레드를 전제로 쓰였다. 멀티스레드 환경에서 반복해 나타나는 모양은 그 뒤에 Java Concurrency in Practice 등을 통해 따로 정리됐다.
이 편의 패턴들은 공통된 질문 하나에 답한다 — 공유 자원을 어떻게 안전하게 다루나. 가장 좋은 답부터 말하자면, 공유하지 않거나 바꾸지 않는 것이다.
| 패턴 | 의도 | Java 도구 |
|---|---|---|
| Immutable Object | 상태가 안 바뀌면 공유해도 안전 | record, final, List.of() |
| Thread Pool | 스레드를 재사용해 생성 비용·과생성을 막는다 | ExecutorService |
| Producer–Consumer | 생산·소비 속도를 버퍼로 분리 | BlockingQueue |
| Future / Promise | 결과를 나중에 받는다 | CompletableFuture |
| Read–Write Lock | 읽기는 동시에, 쓰기는 배타적으로 | ReentrantReadWriteLock |
| Thread-Local Storage | 스레드마다 독립 상태 | ThreadLocal |
| Double-Checked Locking | 지연 초기화에서 락을 최소화 | volatile + 이중 검사 |
| Guarded Suspension | 조건이 될 때까지 기다린다 | Condition, wait/notify |
Immutable Object — 가장 단순한 동시성 전략
상태가 바뀌지 않으면 동기화가 필요 없다. 여러 스레드가 동시에 읽어도 문제가 생길 여지 자체가 없다.
public record Money(long amount, Currency currency) {
public Money plus(Money other) { // 바꾸지 않고 새로 만든다
if (!currency.equals(other.currency)) throw new IllegalArgumentException();
return new Money(amount + other.amount, currency);
}
}
불변으로 만드는 조건
public final class Order { // ① 상속 금지 — 하위가 가변으로 만들 수 있다
private final String no; // ② 필드는 final
private final List<Item> items;
public Order(String no, List<Item> items) {
this.no = no;
this.items = List.copyOf(items); // ③ 방어적 복사 — 밖의 참조와 끊는다
}
public List<Item> items() { return items; } // ④ List.copyOf 는 불변이라 그냥 줘도 된다
}
③이 특히 자주 빠진다. 생성자에서 받은 리스트를 그대로 담으면 밖에서 그 리스트를 계속 바꿀 수 있다. final 은 참조만 고정하지 내용은 막지 못한다.
final 필드는 추가 보장을 받는다
class Holder {
final int value; // final 이면 생성자 완료 시점의 값이 보장된다
Holder(int v) { this.value = v; }
}
일반 필드는 객체 참조가 먼저 보이고 필드는 초기화 전 값으로 보이는 일이 가능하다. final 필드는 생성자가 끝난 뒤 다른 스레드가 반드시 제대로 본다. 불변 객체가 별도 동기화 없이 안전한 근거가 이것이다.
실무 원칙 — 값 객체·DTO·설정 스냅샷은 기본적으로 불변으로 만든다. 동시성 문제를 푸는 것이 아니라 생기지 않게 하는 접근이고, 가장 비용이 싸다.
Thread Pool — 스레드를 재사용한다
스레드 생성은 비싸고, 무제한 생성은 시스템을 죽인다. 미리 만들어 두고 작업만 던진다.
// ✗ 요청마다 스레드를 만들면 — 부하가 몰릴 때 수천 개가 생겨 OOM 이 난다
new Thread(() -> handle(req)).start();
// ✓ 풀에 던진다
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<Result> f = pool.submit(() -> handle(req));
여기서 Command 패턴과 만난다. 작업을 Runnable·Callable 객체로 만들었기 때문에 풀이 그것을 언제 어느 스레드에서 실행할지 정할 수 있다.
큐가 넘칠 때 무엇을 하나 — 여기가 Strategy 다
new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100), // 큐 크기 100
new ThreadPoolExecutor.CallerRunsPolicy()); // ← 거부 정책 = 전략
| 정책 | 동작 |
|---|---|
AbortPolicy | 예외를 던진다 (기본값) |
CallerRunsPolicy | 제출한 스레드가 직접 실행한다 — 자연스러운 역압 |
DiscardPolicy | 조용히 버린다 |
DiscardOldestPolicy | 큐에서 가장 오래된 것을 버리고 넣는다 |
CallerRunsPolicy 가 흥미롭다. 제출자가 직접 실행하면 그동안 새 작업을 제출할 수 없으므로 유입 속도가 저절로 느려진다. 별도 장치 없이 역압이 걸린다.
함정 —
Executors.newFixedThreadPool()의 큐는 무제한이다.Executors.newFixedThreadPool(8); // 내부적으로 LinkedBlockingQueue() — 크기 무한소비보다 생산이 빠르면 큐가 계속 자라 거부 정책이 발동하지 않고 OOM 으로 간다. 운영 코드에서는
ThreadPoolExecutor를 직접 만들어 큐 크기와 거부 정책을 명시하는 편이 안전하다.
Producer–Consumer — 버퍼로 속도를 분리한다
생산 속도와 소비 속도가 다를 때, 사이에 큐를 두어 서로를 기다리지 않게 한다.
BlockingQueue<LogEvent> queue = new ArrayBlockingQueue<>(1000);
// 생산자 — 큐가 가득 차면 여기서 블록된다
void log(LogEvent e) throws InterruptedException { queue.put(e); }
// 소비자 — 큐가 비면 여기서 블록된다
while (running) {
LogEvent e = queue.take();
writer.write(e);
}
put/take 가 알아서 기다린다. 우리가 wait/notify 를 쓸 일이 없다.
큐 크기가 정책이다
new ArrayBlockingQueue<>(1000); // 유한 — 가득 차면 생산자가 기다린다 (역압)
new LinkedBlockingQueue<>(); // 무한 — 생산자는 안 기다린다. 대신 메모리가 는다
new SynchronousQueue<>(); // 크기 0 — 생산자와 소비자가 직접 만난다
유한 큐를 쓰는 것이 곧 역압을 설계하는 것이다. 무한 큐는 문제를 미루기만 한다 — 결국 메모리에서 터진다.
종료를 어떻게 하나
private static final LogEvent POISON = new LogEvent("__STOP__");
queue.put(POISON); // 생산자가 마지막에 넣는다
// 소비자
LogEvent e = queue.take();
if (e == POISON) break; // 독약 알약(poison pill)
while (running) 만으로는 소비자가 take() 에서 블록된 채 플래그를 못 본다. 종료 신호를 큐에 흘려보내는 것이 관용적인 해법이다.
Future / Promise — 결과를 나중에 받는다
작업을 던지고 결과는 나중에 받는다. 그 사이에 다른 일을 한다.
Future<Report> f = pool.submit(() -> buildReport());
doSomethingElse();
Report r = f.get(); // 여기서 완료될 때까지 기다린다
CompletableFuture — 기다리지 않고 이어 붙인다
CompletableFuture<User> u = supplyAsync(() -> userClient.find(id), pool);
CompletableFuture<Order> o = supplyAsync(() -> orderClient.last(id), pool);
u.thenCombine(o, (user, order) -> new Dashboard(user, order)) // 둘 다 오면 합친다
.thenApply(Dashboard::render)
.exceptionally(e -> Dashboard.error(e)) // 실패도 흐름 안에서
.thenAccept(view::show);
Future.get() 은 기다린다(블로킹). CompletableFuture 는 "오면 이걸 해라" 를 등록한다. 외부 API 세 개를 병렬로 부르고 다 오면 합치는 일이 자연스러워진다.
CompletableFuture.allOf(a, b, c).join(); // 셋 다 완료될 때까지
CompletableFuture.anyOf(a, b).join(); // 하나라도 완료되면
함정 — 기본 실행기가 공용 풀이다.
supplyAsync(() -> blockingIoCall()); // ForkJoinPool.commonPool() 에서 돈다공용 풀은 CPU 코어 수만큼만 스레드를 갖고, 애플리케이션 전체가 공유한다. 여기서 블로킹 I/O 를 돌리면 다른 병렬 작업까지 같이 막힌다. I/O 작업에는 전용 풀을 명시적으로 넘긴다.
그리고
exceptionally를 안 붙이면 예외가 조용히 사라진다.join()하지 않는 체인의 실패는 아무 데도 안 나타난다.
Read–Write Lock — 읽기는 같이, 쓰기는 혼자
읽기가 압도적으로 많고 쓰기가 드물면, 읽기끼리는 막을 이유가 없다.
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private Map<String, Config> cache = new HashMap<>();
Config get(String key) {
lock.readLock().lock(); // 여러 스레드가 동시에 들어온다
try { return cache.get(key); }
finally { lock.readLock().unlock(); }
}
void reload(Map<String, Config> fresh) {
lock.writeLock().lock(); // 혼자만 들어온다. 읽기도 다 막힌다
try { cache = fresh; }
finally { lock.writeLock().unlock(); }
}
언제 이득인가
읽기 비중이 아주 높고 읽기 구간이 어느 정도 길 때만 이득이다. 짧으면 락 관리 비용이 이득을 넘어선다. 쓰기가 잦으면 읽기가 계속 밀려 오히려 느려진다.
// 대부분의 경우 이쪽이 더 낫다
private final Map<String, Config> cache = new ConcurrentHashMap<>();
ConcurrentHashMap 은 버킷 단위로만 잠그므로 락 경합 자체가 거의 없다. 읽기-쓰기 락을 직접 쓰기 전에 동시성 컬렉션으로 되는지 먼저 본다.
StampedLock 은 여기서 한 걸음 더 간다 — 낙관적 읽기를 지원해 읽을 때 아예 락을 잡지 않고, 읽은 뒤 그사이 변경이 있었는지 검증한다. 다만 재진입이 안 되고 사용법이 까다로워 잘못 쓰기 쉽다.
Thread-Local Storage — 스레드마다 자기 것
공유하지 않는 것도 동시성 문제를 푸는 방법이다.
private static final ThreadLocal<SimpleDateFormat> FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String format(Date d) { return FMT.get().format(d); } // 스레드마다 자기 인스턴스
스레드 안전하지 않은 객체를 스레드마다 하나씩 두어 공유를 없앤다. 요청 컨텍스트(사용자 정보·추적 ID)를 메서드 인자로 안 넘기고 전달하는 데도 쓴다.
함정 — 스레드 풀에서 안 비우면 누수다.
try { CONTEXT.set(userContext); handle(request); } finally { CONTEXT.remove(); // ← 이것이 없으면 }톰캣 워커 스레드는 재사용된다. 안 지우면 값이 다음 요청으로 새어 다른 사용자의 컨텍스트를 보게 되고, 재배포 시에는 클래스로더가 GC 되지 못해 메모리가 계속 찬다(톰캣이 경고 로그를 남기는 그 현상).
가상 스레드에서는 성격이 달라진다. 요청마다 새 스레드라 누수 위험은 줄지만,
ThreadLocal을 쓰던 이유(비싼 객체 재사용)가 사라진다 — 스레드가 수만 개면 그 수만큼 복사본이 생긴다. 자바 21 의ScopedValue가 이 자리를 대체하려는 시도다.
Double-Checked Locking — 지연 초기화의 락 최소화
앞의 생성 패턴 편 Singleton 에서 본 그 구조다. 여기서는 왜 volatile 이 필수인지만 다시 짚는다.
private static volatile Config instance; // volatile 이 없으면 깨진다
static Config getInstance() {
if (instance == null) { // ① 락 없이 확인 (빠른 경로)
synchronized (Config.class) {
if (instance == null) // ② 락 안에서 다시 확인
instance = new Config();
}
}
return instance;
}
instance = new Config() 는 한 단계가 아니다 — 메모리 할당 → 생성자 실행 → 참조 대입 셋인데, 컴파일러·CPU 가 순서를 바꿀 수 있다. 참조 대입이 생성자보다 먼저 보이면, ①에서 null 이 아니라고 판단한 다른 스레드가 아직 초기화되지 않은 객체를 잡는다.
volatile 이 그 재배열을 막고 다른 스레드에 대한 가시성도 보장한다.
실무에서는 홀더 관용구를 쓴다. 같은 효과를 얻으면서 이런 미묘한 부분을 신경 쓸 필요가 없다.
Guarded Suspension — 조건이 될 때까지 기다린다
private final Lock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Queue<Task> queue = new ArrayDeque<>();
Task take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) // ← if 가 아니라 while
notEmpty.await();
return queue.poll();
} finally { lock.unlock(); }
}
void put(Task t) {
lock.lock();
try { queue.add(t); notEmpty.signal(); }
finally { lock.unlock(); }
}
if 가 아니라 while 인 이유가 핵심이다. 깨어났다고 조건이 참이라는 보장이 없다 — 다른 스레드가 먼저 가져갔을 수도 있고, 아무도 안 깨웠는데 깨어나는 일(spurious wakeup)도 명세상 허용된다. 그래서 깨어나면 조건을 다시 확인해야 한다.
관련해 Balking 은 반대 전략이다 — 조건이 아니면 기다리지 않고 즉시 포기한다. tryLock() 이나 "이미 저장 중이면 아무것도 안 함" 같은 처리가 그것이다.
실무에서 이 둘을 직접 구현할 일은 드물다. BlockingQueue 가 이미 정확히 이 구조로 만들어져 있기 때문이다.
모던 자바가 바꾼 것
// 가상 스레드 — 스레드를 아끼지 않아도 된다
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var req : requests) executor.submit(() -> handle(req)); // 수만 개도 괜찮다
}
Thread Pool 패턴은 스레드가 비싸다는 전제 위에 있었다. 가상 스레드는 그 전제를 바꾼다 — 블로킹해도 OS 스레드를 놓아 주므로, 요청마다 하나씩 만들어도 된다. 다만 풀이 필요 없어졌을 뿐 동시성 제어가 필요 없어진 것은 아니다. 외부 시스템을 보호하려면 세마포어 같은 별도 장치로 동시 호출 수를 제한해야 한다.
// 구조적 동시성 — 연관 작업을 한 단위로 묶는다
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> userClient.find(id));
var order = scope.fork(() -> orderClient.last(id));
scope.join().throwIfFailed(); // 하나 실패하면 나머지도 취소된다
return new Dashboard(user.get(), order.get());
}
CompletableFuture 의 문제는 작업이 흩어져 취소·예외 전파가 어렵다는 것이었다. 구조적 동시성은 작업의 수명을 블록 범위에 묶어, 하나가 실패하면 형제도 같이 정리한다.
한눈에 정리
- 가장 좋은 전략은 공유하지 않거나 바꾸지 않는 것 — 불변 객체가 첫 번째 선택지다
- 불변의 조건은
final클래스 +final필드 + 방어적 복사.final은 참조만 고정한다 - Thread Pool — 작업을 객체로 만들었기에(Command) 풀이 스케줄링할 수 있다. 거부 정책은 Strategy
Executors.newFixedThreadPool()의 큐는 무제한 — 거부 정책 전에 OOM 이 온다- Producer–Consumer — 유한 큐를 쓰는 것이 곧 역압 설계다. 종료는 poison pill 로
CompletableFuture— 기다리지 않고 이어 붙인다. 기본 공용 풀에서 블로킹 I/O 를 돌리지 않는다- Read–Write Lock 보다
ConcurrentHashMap이 나은 경우가 대부분이다 ThreadLocal은finally에서remove— 스레드 풀에서는 값이 다음 요청으로 샌다- DCL 의
volatile은 재배열을 막는다. 실무에서는 홀더 관용구를 쓴다 - 조건 대기는
if가 아니라while— 깨어났다고 조건이 참이라는 보장이 없다 - **가상 스레드**는 "스레드가 비싸다" 는 전제를 바꿨다. 풀은 덜 필요해졌지만 동시성 제한은 여전히 필요하다
출처 — Goetz et al., Java Concurrency in Practice(2006) §3·§5·§8 · JLS §17.4(메모리 모델)·§17.5(final 필드 의미론) · Java SE 21 API Docs(
ThreadPoolExecutor·BlockingQueue·CompletableFuture·StampedLock) · JEP 444(Virtual Threads) · JEP 453(Structured Concurrency)