자바 디자인 패턴 학습 노트 목차

동시성 패턴 — 여럿이 동시에 움직일 때

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 이 나은 경우가 대부분이다
  • ThreadLocalfinally 에서 remove스레드 풀에서는 값이 다음 요청으로 샌다
  • DCLvolatile 은 재배열을 막는다. 실무에서는 홀더 관용구를 쓴다
  • 조건 대기는 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)

행위 패턴 ③ — 알고리즘과 상태를 갈아 끼운다안티패턴과 헷갈리는 비교