Java 동시성 패턴은 왜 이렇게 설계됐을까
스레드 풀부터 Future/Promise까지, Java 동시성 패턴 6가지의 공통 철학과 각 설계 결정의 이유를 추적한다.
- 01 Java 생성 패턴, 무엇을 왜 선택하는가
- 02 구조 패턴의 공통 문법 — 상속 대신 관계로 설계하라
- 03 Java 행위 패턴은 왜 모두 같은 문제를 푸는가
- 04 아키텍처 패턴의 공통 언어 — 관심사 분리란 무엇인가
- 05 Java 레이어드 아키텍처 패턴, 왜 이렇게 나뉘어 있는가
- 06 Java 함수형 패턴의 공통 철학은 무엇인가
- 07 Java 동시성 패턴은 왜 이렇게 설계됐을까
Java의 동시성 패턴은 저마다 독립적으로 보이지만, 모두 같은 문제를 다른 각도에서 푼다 — “스레드를 직접 만지지 말고, 그 위에 추상화를 올려라.” Thread Pool은 생성 비용을 숨기고, Producer-Consumer는 속도 차이를 흡수하고, Future는 결과를 미래로 미룬다. 이 패턴들이 왜 지금 모양을 갖게 됐는가?
스레드는 왜 직접 쓰면 안 되는가
new Thread(() -> task.run()).start()는 매력적이다. 간단하고 직관적이다. 그런데 10,000개 요청이 들어오면 10,000개 스레드가 생긴다. 스레드 하나는 스택만 1MB다. 10GB 메모리가 스택에 사라진다. 그리고 컨텍스트 스위칭 오버헤드가 CPU를 잡아먹기 시작한다.
Thread Pool이 제안하는 답은 단순하다 — 스레드를 미리 만들어두고 재사용하라. Executors.newFixedThreadPool(10)은 스레드 10개를 만들고, 작업은 큐에 쌓아뒀다가 유휴 스레드에 배분한다. 스레드 생성 비용은 시작 시 한 번만 낸다.
// 요청마다 스레드 생성 — 리소스 고갈
for (Request req : requests) {
new Thread(() -> handle(req)).start();
}
// Thread Pool — 10개 스레드로 모든 요청 처리
ExecutorService executor = Executors.newFixedThreadPool(10);
for (Request req : requests) {
executor.submit(() -> handle(req));
}
ThreadPoolExecutor의 파라미터 — corePoolSize, maximumPoolSize, workQueue, RejectedExecutionHandler — 는 이 재사용 전략의 세부 조율 손잡이다. 큐가 차면 최대 스레드까지 늘리고, 그래도 차면 거부 정책이 발동한다.
속도 불균형은 큐로 흡수한다
생산자(Producer)가 소비자(Consumer)보다 빠를 때 어떻게 되는가? 직접 연결하면 소비자가 따라올 때까지 생산자가 블록되거나, 버퍼가 무한정 불어난다.
Producer-Consumer 패턴은 BlockingQueue를 중간에 놓는다. 생산자는 put()으로 큐에 넣고, 소비자는 take()로 꺼낸다. 큐가 가득 차면 put()이 블록되고, 큐가 비면 take()가 블록된다. 동기화 코드가 없다 — BlockingQueue 자체가 동기화를 내장하고 있기 때문이다.
Producer 1 ─┐
Producer 2 ─┼──→ [ArrayBlockingQueue(100)] ──→ Consumer 1
Producer 3 ─┘ Consumer 2
큐 종류 선택이 설계 결정이다. ArrayBlockingQueue는 크기가 고정이라 백프레셔(backpressure)가 자연스럽게 걸린다. LinkedBlockingQueue는 기본적으로 무제한이라 생산자가 폭주하면 메모리가 터진다. PriorityBlockingQueue는 우선순위 순으로 꺼낸다.
new LinkedBlockingQueue<>()는 기본 용량이 Integer.MAX_VALUE다. 소비자보다 생산자가 빠른 시스템에서 이 큐를 쓰면 OutOfMemoryError가 조용히 찾아온다. 항상 용량을 명시하라.
읽기와 쓰기는 다르게 취급해야 한다
synchronized는 읽기와 쓰기를 구분하지 않는다. 100개 스레드가 동시에 읽기만 해도 순차 처리된다. 읽기는 데이터를 바꾸지 않으므로 서로 충돌하지 않는다. 그런데 왜 블록하는가?
Reader-Writer Lock은 이 직관을 코드로 옮긴다. 읽기는 동시에 허용하되, 쓰기는 독점 접근만 허용한다.
ReadWriteLock lock = new ReentrantReadWriteLock();
public V get(K key) {
lock.readLock().lock(); // 여러 스레드 동시 진입 가능
try { return cache.get(key); }
finally { lock.readLock().unlock(); }
}
public void put(K key, V value) {
lock.writeLock().lock(); // 단 하나의 스레드만 진입
try { cache.put(key, value); }
finally { lock.writeLock().unlock(); }
}
읽기가 99%, 쓰기가 1%인 설정 캐시나 상품 카탈로그에서 효과가 극적이다. 단, Writer Starvation을 주의해야 한다 — 읽기가 끊이지 않으면 쓰기가 영원히 대기한다. ReentrantReadWriteLock(true)로 공정성(fairness) 모드를 켜면 대기 순서를 보장하지만 성능은 희생된다.
Java 8 이후라면 StampedLock의 Optimistic Read를 고려하라. 낙관적으로 읽고, 충돌이 생겼을 때만 재시도하는 방식으로 Read Lock 없이도 읽기가 가능하다.
Singleton 초기화의 미묘한 함정
Double-Checked Locking은 단순한 최적화처럼 보이지만, volatile 없이는 정확하게 동작하지 않는다.
// 잘못된 예 — volatile 없음
private static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (Instance.class) {
if (instance == null) {
instance = new Instance(); // 위험!
}
}
}
return instance;
}
instance = new Instance()는 내부적으로 세 단계다 — 메모리 할당, 생성자 실행, 참조 대입. JVM과 CPU는 이 순서를 재배치(reorder)할 수 있다. 참조 대입이 생성자 실행보다 먼저 일어나면, 다른 스레드가 미완성 객체를 받아간다. volatile은 이 재배치를 막는 메모리 펜스다.
volatile은 CPU 캐시를 우회해 메인 메모리에서 읽고 쓰므로 미세한 성능 비용이 있다. 초기화 후에는 1차 체크(Lock 없음)가 항상 not null을 반환하므로 실질적 오버헤드는 무시할 수 있는 수준이다. Bill Pugh 패턴(Static Inner Class)은 클래스 로더의 초기화 보장을 이용해 volatile과 synchronized 없이도 안전한 Lazy Initialization을 달성한다.
호출과 실행을 분리하면 생기는 일
Active Object와 Future/Promise는 같은 아이디어의 두 표현이다 — 메서드를 호출한 스레드와 실제로 실행하는 스레드를 분리하라.
Active Object는 메서드 호출을 메시지로 변환해 큐에 넣고, 단일 스케줄러 스레드가 순차로 실행한다. 공유 상태에 synchronized가 필요 없다 — 항상 하나의 스레드만 상태를 건드리기 때문이다. Actor 모델의 기반 아이디어이기도 하다.
Future/Promise는 결과에 초점을 맞춘다. CompletableFuture는 비동기 작업의 결과를 나타내는 객체를 지금 당장 돌려주고, 실제 값은 나중에 채워진다.
// 3개 API를 병렬로 호출하고, 모두 완료되면 조합
CompletableFuture<User> userFuture = getUser(userId);
CompletableFuture<List<String>> orders = getOrders(userId);
CompletableFuture<List<String>> recs = getRecommendations(userId);
CompletableFuture.allOf(userFuture, orders, recs)
.thenApply(v -> new Dashboard(
userFuture.join(), orders.join(), recs.join()
));
순차로 부르면 1000ms + 1500ms + 800ms = 3300ms. 병렬로 부르면 max(1000, 1500, 800) = 1500ms. 코드 한 줄(allOf)이 2초를 돌려준다.
정리
- Thread Pool: 스레드 생성 비용을 한 번으로 줄이고, 동시 실행 수를 제어한다.
- Producer-Consumer:
BlockingQueue가 속도 불균형을 흡수하고 동기화를 대신 처리한다. - Reader-Writer Lock: 읽기는 동시에, 쓰기는 독점으로 —
synchronized보다 세밀한 제어. - Double-Checked Locking:
volatile없이는 반쪽짜리다. Bill Pugh 패턴이 더 안전한 대안이다. - Active Object / Future: 호출과 실행을 분리해 UI 블록 없는 비동기 처리를 가능하게 한다.
여섯 패턴은 각자 다른 문제를 풀지만 공통 방향을 가리킨다 — 스레드를 직접 제어하는 책임을 줄이고, 그 위에 더 높은 수준의 추상화를 올려라. 다음 글에서는 이 패턴들이 실제 Spring 애플리케이션에서 어떻게 조합되는지 추적한다.