1. Producer–Consumer 패턴이란?
Producer–Consumer 패턴은
“데이터를 생산하는 주체(Producer)와 데이터를 처리하는 주체(Consumer)를 분리하고,
중간에 공유 버퍼(Queue)를 두어 두 주체가 서로 비동기로 동작하도록 만드는 디자인 패턴”
이다.
핵심 아이디어는
- Producer는 자신의 속도대로 데이터를 생성해서 큐에 넣고,
- Consumer는 자신의 속도대로 큐에서 꺼내 처리한다.
- 큐가 속도 차이를 완화해 주기 때문에, 한쪽이 느려도 다른 쪽이 바로 막히지는 않는다.
- 다만 큐가 bounded(유한 크기)라면, Consumer가 지속적으로 느리면 큐가 가득 차면서 back‑pressure가 발생할 수 있다.

2. 구성 요소
대표적인 구성은 다음과 같다.
| 구성 요소 | 역할 |
|---|---|
| Producer | 데이터/작업을 생성해서 큐에 넣는 주체. 예: 웹 요청을 처리하는 스레드, 로그 생성기 등. |
| Consumer | 큐에서 데이터를 꺼내 실제 처리(저장, 계산, 전송 등)를 수행하는 주체. |
| Shared Buffer (Queue) | Producer와 Consumer가 공유하는 큐. 보통 FIFO로 구현되며, 블로킹 큐(Bounded/Blocking Queue)를 많이 사용한다. |
이 구조는
- Producer와 Consumer가 서로를 몰라도 되고, 중간 큐만 알고 있으면 된다.
- Producer는 Consumer의 처리 완료를 직접 기다리지는 않지만, 큐가 가득 차면 Producer도 block 될 수 있다.
3. 왜 쓰는가? (장점)
Producer–Consumer 패턴을 쓰는 주요 이유는 다음과 같다.
- 부하 분리 및 비동기 처리
- Producer와 Consumer가 각자의 속도로 동작할 수 있어서, 한쪽이 느려도 다른 쪽이 바로 막히지는 않는다.
- 결합도 감소(Decoupling)
- Producer와 Consumer는 서로를 몰라도 되고, 중간 큐만 알고 있으면 된다.
- 버퍼링과 트래픽 버스트 흡수
- 순간적으로 Producer가 많이 밀려도 큐가 버퍼 역할을 해서 Consumer가 차례대로 처리할 수 있다.
- 확장성
- Producer나 Consumer를 여러 개로 늘려도, 큐만 잘 설계하면 시스템을 쉽게 확장할 수 있다.
4. 동작 방식과 규칙

Producer–Consumer 패턴은 “큐가 가득 찼을 때”와 “큐가 비었을 때”를 어떻게 처리하느냐에 따라 동작이 결정된다.
4‑1. 기본 규칙
- 큐가 가득 찼을 때
- Producer는 더 이상 데이터를 넣을 수 없으므로, 대기(wait) 상태가 된다.
- 큐가 비었을 때
- Consumer는 가져올 데이터가 없으므로, 대기(wait) 상태가 된다.
이렇게 하면
- Producer는 Consumer의 처리 완료를 직접 기다리지는 않지만, 큐가 가득 차면 Producer도 block 된다.
- Consumer는 Producer를 기다리지 않고, 큐에 데이터가 쌓이면 바로 처리할 수 있다.
4‑2. 동기화와 블로킹 큐
실제 구현에서는 동기화가 핵심이다.
- 큐는 공유 자원이므로, 여러 스레드가 동시에 접근하면
- race condition, 데이터 꼬임이 발생할 수 있다.
- 그래서 일반적으로
- 큐에 접근할 때 락(뮤텍스)을 걸고,
- Producer가 큐에 넣고 나면 Consumer에게 신호(notify)를 보내는 구조를 사용한다.
예를 들어 Java의 BlockingQueue를 쓰면,
put()은 큐가 가득 찼을 때 자동으로 대기하고,take()는 큐가 비었을 때 자동으로 대기한다.
전통적인 wait/notify 기반 구현에서는 Producer가 큐에 데이터를 넣은 뒤 Consumer에게 notify를 보내지만,
현대적인 구현에서는 BlockingQueue, ConcurrentLinkedQueue, Semaphore, Lock + Condition 같은 고수준 동기화 자료구조가 내부적으로 이를 처리한다.
4-3. wait/notify vs BlockingQueue: 구현 방식 비교
두 방식은 동일한 문제를 해결하지만, 구현 난이도와 안정성에서 차이가 있다.
| 구분 | wait/notify (Low-level) | BlockingQueue (High-level) |
|---|---|---|
| 구현 난이도 | 높음 (직접 동기화 로직 작성) | 낮음 (내부 구현 숨김) |
| 버그 가능성 | 높음 (spurious wakeup 등) | 낮음 (검증된 라이브러리) |
| 유연성 | 높음 (세밀한 제어 가능) | 중간 (정해진 인터페이스) |
| 유지보수 | 어려움 (디버깅 힘들 수 있음) | 쉬움 (표준 API 사용) |
wait/notify의 주요 함정
잘못된 예시 - spurious wakeup 미처리
synchronized(queue) {
if (queue.isEmpty()) { // ❌ if 사용
queue.wait();
}
return queue.remove();
}
올바른 예시
synchronized(queue) {
while (queue.isEmpty()) { // ✅ while 사용
queue.wait();
}
return queue.remove();
}
wait/notify는 다음 문제를 직접 처리해야 한다:
- Spurious wakeup: wait()가 이유 없이 깨어날 수 있어 while로 재확인 필수
- Lost notify: notify() 전에 wait()하지 않으면 신호를 놓침
- 잘못된 락 범위: 락을 너무 오래 잡으면 성능 저하, 너무 짧으면 race condition
wait/notify 완전한 구현 예시
class ProducerConsumerWithWaitNotify {
private final Queue<Integer> queue = new LinkedList<>();
private final int MAX_SIZE = 10;
// Producer 메서드
public void produce(int value) throws InterruptedException {
synchronized(queue) {
while (queue.size() == MAX_SIZE) {
queue.wait(); // 큐가 가득 차면 대기
}
queue.add(value);
queue.notifyAll(); // Consumer에게 알림
}
}
// Consumer 메서드
public int consume() throws InterruptedException {
synchronized(queue) {
while (queue.isEmpty()) {
queue.wait(); // 큐가 비면 대기
}
int value = queue.poll();
queue.notifyAll(); // Producer에게 알림
return value;
}
}
}
BlockingQueue 구현 예시
class ProducerConsumerWithBlockingQueue {
private final BlockingQueue<Integer> queue =
new ArrayBlockingQueue<>(10);
// Producer 메서드
public void produce(int value) throws InterruptedException {
queue.put(value); // 내부적으로 대기 처리
}
// Consumer 메서드
public int consume() throws InterruptedException {
return queue.take(); // 내부적으로 대기 처리
}
}
언제 어떤 것을 쓸까?
- 대부분의 경우: BlockingQueue 사용 (안전하고 명확함)
- 극한의 성능 최적화가 필요한 경우: wait/notify로 직접 구현 (예: HFT 시스템)
- 특수한 동기화 로직: Lock + Condition (wait/notify보다 유연하면서도 안전)
Lock + Condition 예시 (중간 방식)
class ProducerConsumerWithLock {
private final Queue<Integer> queue = new LinkedList<>();
private final int MAX_SIZE = 10;
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void produce(int value) throws InterruptedException {
lock.lock();
try {
while (queue.size() == MAX_SIZE) {
notFull.await(); // 큐가 가득 차면 대기
}
queue.add(value);
notEmpty.signal(); // Consumer에게 알림
} finally {
lock.unlock();
}
}
public int consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 큐가 비면 대기
}
int value = queue.poll();
notFull.signal(); // Producer에게 알림
return value;
} finally {
lock.unlock();
}
}
}
5. Producer–Consumer 패턴의 변형
Producer–Consumer 패턴은 스레드 수에 따라 여러 변형이 있다.
| 유형 | 설명 |
|---|---|
| Single Producer – Single Consumer | 하나의 Producer와 하나의 Consumer만 존재. 구현이 가장 단순. |
| Multiple Producer – Single Consumer | 여러 Producer가 하나의 Consumer에게 데이터를 보냄. 예: 여러 워커 스레드가 로그를 큐에 넣고, 하나의 로거 스레드가 파일에 기록. |
| Single Producer – Multiple Consumer | 하나의 Producer가 여러 Consumer에게 데이터를 분배. 예: 하나의 큐에서 여러 스레드가 작업을 가져와 병렬 처리. |
| Multiple Producer – Multiple Consumer | 가장 현실적인 구조. Producer와 Consumer가 여러 개이고, 큐를 공유해서 작업을 분산 처리. |
이때는
- 락 경쟁, 대기 시간, 잘못된 동기화 구현 시 데드락 가능성 등이 커지므로,
- 락 구조(스핀락/뮤텍스/세마포어)와 큐 구현(배열/링버퍼/우선순위 큐)을 잘 설계해야 한다.
6. 시스템 안정성을 위한 백프레셔(Back-pressure) 전략

Producer-Consumer 패턴에서 가장 경계해야 할 상황은 생산 속도가 소비 속도를 압도할 때이다. 이를 제어하지 못하면 시스템은 다음과 같은 임계점에 도달하게 된다.
- 자원 고갈: 큐에 데이터가 무한정 쌓이면서 메모리 사용량이 급증하고, 결국 OOM(Out Of Memory)으로 프로세스가 강제 종료될 수 있다.
- 성능 저하: 메모리 압박으로 인해 GC(Garbage Collection)가 빈번하게 발생하며 시스템 전체의 처리 성능(Throughput)이 급격히 떨어진다.
이러한 과부하 상태를 생산자에게 알려 데이터 흐름을 제어하는 메커니즘을 Back-pressure라고 한다. 단순히 큐의 크기를 늘리는 것은 임시방편일 뿐이며, 시스템의 성격에 따라 다음과 같은 명확한 거절 전략(Rejection Policy)을 설계해야 한다.
- Blocking(차단): 큐가 가득 차면 생산자를 대기 상태로 만든다. 데이터 유실이 없어야 하는 시스템에서 가장 기본적으로 사용하는 전략이다.
- Drop(유실): 큐가 포화 상태일 때 새로 들어오는 데이터(Drop New) 혹은 가장 오래된 데이터(Drop Old)를 과감히 버린다. 실시간성이 중요한 로그 수집이나 센서 데이터 처리에 적합하다.
- Rate Limiting(속도 제한): 생산자가 생성하는 데이터 양 자체를 시간당 일정 수치 이하로 제한하여 과부하의 원인을 사전 차단한다.
- Circuit Breaker(회로 차단): 소비 측의 장애가 감지되면 생산자와의 연결을 즉시 차단하여 시스템 전체로 장애가 전파되는 것을 방지한다.
7. Producer–Consumer 패턴과 메시지 큐의 관계

Producer–Consumer 패턴은 “멀티스레드 환경에서의 큐 기반 비동기 처리”이고,
메시지 큐는 그걸 네트워크/분산 환경으로 확장한 구조라고 보면 된다.
| 항목 | Producer–Consumer (OS/스레드) | 메시지 큐 (분산) |
|---|---|---|
| 위치 | 같은 프로세스/서버 내 | 여러 서버 간 |
| 통신 | 공유 메모리/큐 | 네트워크 + 브로커 |
| 동기화 | 락/세마포어 | ack, commit, 리플리카 |
| 장애 | 프로세스 종료 시 큐 유실 가능 | 디스크/리플리카로 내구성 제공 |
즉, Producer–Consumer 패턴은 “동기화 + 버퍼 + 비동기 처리”라는 CS 개념을 이해하는 핵심이고,
메시지 큐는 그걸 분산 시스템에서 네트워크와 내구성을 붙여서 확장한 형태다.
'공부 > CS' 카테고리의 다른 글
| REST, REST API, 그리고 RESTful API (0) | 2025.11.21 |
|---|---|
| [Python] 파이썬으로 TTS 구현하기: gTTS (Google Text-to-Speech) (0) | 2025.11.13 |
| [Celery] 파이썬 비동기 태스크 큐 Celery란 무엇인가 (0) | 2025.11.11 |
