F-LAB JAVA · 4주차 · Phase 6 · 스레드 간 협력
🚀 Phase 6 시작 — 스레드 협력 진입
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
생산자-소비자 문제 (Producer-Consumer Problem) 는 데이터를 생성하는 생산자 스레드와 소비하는 소비자 스레드가 공유 버퍼 (큐) 를 통해 협력하는 고전적인 동시성 문제다.
생산자 는 데이터를 버퍼에 넣고 (produce), 소비자 는 버퍼에서 꺼내 처리한다 (consume) — 둘은 서로의 속도를 모른 채 버퍼를 매개로 느슨하게 연결된다.
핵심 제약은 버퍼가 꽉 차면 생산자가 대기 (넣을 공간 없음), 버퍼가 비면 소비자가 대기 (꺼낼 것 없음) 해야 한다는 점이며, 이 대기·통지를 어떻게 구현하느냐가 문제의 본질이다.
실제 사례로 메시지 큐 (Kafka, RabbitMQ), 비동기 로깅, 작업 큐 (스레드 풀의 task queue) 등이 있다.
일반 락 (synchronized) 만으로는 "버퍼 빌 때까지 반복 확인" 하는 바쁜 대기 (busy waiting) 가 되어 CPU 를 낭비하므로, 효율적 대기·통지를 위해 wait/notify (다음 Unit) 또는 BlockingQueue 가 필요하다.
생산자-소비자 = 빵집:
생산자 (제빵사):
- 빵을 만들어 진열대에 놓음
- 진열대 가득 차면 → 대기 (놓을 곳 없음)
소비자 (손님):
- 진열대에서 빵을 가져감
- 진열대 비면 → 대기 (가져갈 빵 없음)
버퍼 (진열대):
- 빵을 임시 보관
- 생산/소비 속도 차이 흡수
협력:
- 제빵사와 손님은 서로 속도 모름
- 진열대로 느슨하게 연결
- 진열대 차면 제빵사 쉼
- 진열대 비면 손님 기다림
나쁜 방법 (바쁜 대기):
- 손님이 "빵 있나?" 1초에 1000번 확인
- 에너지 낭비
좋은 방법 (wait/notify):
- 손님은 자고 (wait)
- 빵 나오면 제빵사가 깨움 (notify)
→ 생산자-소비자 = 버퍼 매개 협력, 차면/비면 대기, 효율적 통지 필요.
1. 생산자-소비자 문제의 정의
2. 생산자와 소비자의 역할
3. 버퍼가 꽉 차거나 빌 때
4. 실제 사례
5. 일반 락만으로 처리
6. 바쁜 대기의 문제
7. 멀티스레드 핵심 문제인 이유
8. BlockingQueue 해결책
9. 면접 + 자기 점검
생산자-소비자 문제:
데이터를 생성하는 생산자와
소비하는 소비자가
공유 버퍼를 통해 협력하는 문제.
구성:
- 생산자 (Producer)
- 소비자 (Consumer)
- 버퍼 (Buffer/Queue)
생산자-소비자 구조:
생산자 ──put──→ [버퍼] ──take──→ 소비자
(큐)
- 생산자: 데이터 생성 → 버퍼
- 소비자: 버퍼 → 데이터 처리
- 버퍼: 중간 저장소
느슨한 결합 (Decoupling):
생산자와 소비자가 직접 X.
버퍼를 매개로 연결.
효과:
- 속도 차이 흡수
- 독립적 동작
- 확장성 (생산자/소비자 수 조절)
// 생산자-소비자 (개념)
public class ProducerConsumer {
private final Queue<Task> buffer = new LinkedList<>();
private final int capacity = 10;
// 생산자
public void produce(Task task) {
// 버퍼에 추가
buffer.offer(task);
}
// 소비자
public Task consume() {
// 버퍼에서 꺼냄
return buffer.poll();
}
// 동기화 필요 (다음 섹션)
}
@Service
public class ShipmentProcessingPipeline {
// 생산자: 주문 → 처리 큐
// 소비자: 처리 큐 → 실제 처리
private final Queue<Shipment> processingQueue = new LinkedList<>();
// 생산자 (주문 접수)
public void submitForProcessing(Shipment shipment) {
processingQueue.offer(shipment); // 큐에 추가
// 동기화 필요
}
// 소비자 (백그라운드 처리)
public Shipment takeForProcessing() {
return processingQueue.poll(); // 큐에서 꺼냄
}
// 생산 (주문) 과 소비 (처리) 가 느슨하게 연결
// 큐로 속도 차이 흡수
}
생산자-소비자 문제의 정의는?
답:
1. 정의:
구조:
느슨한 결합:
효과:
생산자 (Producer):
데이터를 생성하여 버퍼에 넣음.
동작:
1. 데이터 생성
2. 버퍼에 추가 (put/offer)
3. (버퍼 가득 차면 대기)
예:
- 주문 접수
- 로그 생성
- 이벤트 발생
소비자 (Consumer):
버퍼에서 데이터를 꺼내 처리.
동작:
1. 버퍼에서 꺼냄 (take/poll)
2. 데이터 처리
3. (버퍼 비면 대기)
예:
- 주문 처리
- 로그 기록
- 이벤트 핸들링
N 생산자 - M 소비자:
- 여러 생산자가 동시 생성
- 여러 소비자가 동시 처리
- 버퍼는 공유
장점:
- 병렬 처리
- 부하 분산
- 확장성
도전:
- 버퍼 동기화 (경쟁)
N-M 생산자-소비자:
생산자1 ─┐
생산자2 ─┼─put──→ [버퍼] ──take─┬─→ 소비자1
생산자3 ─┘ ├─→ 소비자2
└─→ 소비자3
버퍼 동기화 필요 (여러 접근)
속도 불균형:
생산 > 소비:
- 버퍼 점점 참
- 생산자 대기 증가
- 소비자 증설 필요
생산 < 소비:
- 버퍼 점점 빔
- 소비자 대기 증가
- 생산자 증설 또는 소비자 감소
버퍼:
- 일시적 불균형 흡수
@Service
public class ShipmentProducerConsumer {
private final BlockingQueue<Shipment> queue = new LinkedBlockingQueue<>(100);
// 생산자 (여러 API 핸들러)
public void produce(Shipment shipment) throws InterruptedException {
queue.put(shipment); // 큐 가득 차면 대기
log.info("생산: {}", shipment.getId());
}
// 소비자 (여러 워커 스레드)
public void startConsumers(int count) {
for (int i = 0; i < count; i++) {
Thread consumer = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Shipment shipment = queue.take(); // 비면 대기
process(shipment);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}, "consumer-" + i);
consumer.start();
}
}
private void process(Shipment s) { }
}
생산자와 소비자의 역할은?
답:
1. 생산자:
소비자:
N-M:
속도 불균형:
버퍼 경계 조건:
1. 버퍼 가득 참 (Full):
- 생산자가 넣을 수 없음
- → 생산자 대기
2. 버퍼 빔 (Empty):
- 소비자가 꺼낼 수 없음
- → 소비자 대기
버퍼 가득 참:
생산자:
- 버퍼 크기 한계 (예: 100)
- 가득 차면 더 못 넣음
처리:
- 생산자 대기 (소비자가 비울 때까지)
- 또는 버린다 (drop)
- 또는 블록
버퍼 빔:
소비자:
- 꺼낼 데이터 없음
처리:
- 소비자 대기 (생산자가 채울 때까지)
- 또는 null 반환
- 또는 블록
대기와 통지:
생산자가 채움:
→ 소비자에게 통지 ("데이터 있어!")
→ 대기 중 소비자 깨움
소비자가 비움:
→ 생산자에게 통지 ("공간 있어!")
→ 대기 중 생산자 깨움
핵심:
- 조건 충족 시 통지
- 효율적 협력
버퍼 경계:
가득 참:
생산자: [대기 (공간 없음)]
소비자: 꺼냄 → 공간 생김 → 생산자 깨움
빔:
소비자: [대기 (데이터 없음)]
생산자: 넣음 → 데이터 생김 → 소비자 깨움
@Service
public class BufferBoundaryExample {
// 유한 버퍼 (크기 100)
private final BlockingQueue<Shipment> queue = new ArrayBlockingQueue<>(100);
// 생산자 — 가득 차면 대기
public void produce(Shipment shipment) throws InterruptedException {
queue.put(shipment); // 가득 차면 자동 대기
// 소비자가 비우면 자동 진행
}
// 소비자 — 비면 대기
public Shipment consume() throws InterruptedException {
return queue.take(); // 비면 자동 대기
// 생산자가 채우면 자동 진행
}
// 비대기 버전 (offer/poll)
public boolean tryProduce(Shipment shipment) {
return queue.offer(shipment); // 가득 차면 false (대기 X)
}
public Shipment tryConsume() {
return queue.poll(); // 비면 null (대기 X)
}
}
버퍼가 꽉 차거나 빌 때의 처리는?
답:
1. 가득 참:
빔:
통지:
효율:
메시지 큐 (Message Queue):
Kafka, RabbitMQ, SQS 등.
생산자:
- 메시지 발행 (publish)
소비자:
- 메시지 구독/소비 (subscribe/consume)
큐:
- 메시지 저장
- 비동기 통신
비동기 로깅:
Logback AsyncAppender 등.
생산자 (앱 스레드):
- 로그 이벤트 생성
- 큐에 추가 (빠름)
소비자 (로깅 스레드):
- 큐에서 꺼냄
- 파일/DB 기록 (느림)
효과:
- 앱 스레드 블록 X
- 로깅 비동기
작업 큐 (Thread Pool):
ExecutorService 의 내부.
생산자 (submit):
- 작업 제출
- 작업 큐에 추가
소비자 (워커 스레드):
- 큐에서 작업 꺼냄
- 실행
→ 스레드 풀의 핵심 (Phase 7)
기타 사례:
- 이벤트 루프 (이벤트 큐)
- 배치 처리 (입력 큐)
- 스트림 처리
- 다운로드 매니저 (작업 큐)
- 프린터 스풀러
공통 패턴:
생산 속도 ≠ 소비 속도:
- 버퍼로 흡수
비동기:
- 생산자 블록 X
- 소비자 독립
확장:
- 소비자 증설 (병렬)
@Service
public class ShipmentRealWorldExamples {
// 1. 비동기 알림 (메시지 큐 패턴)
private final BlockingQueue<Notification> notificationQueue =
new LinkedBlockingQueue<>();
public void sendNotificationAsync(Notification notification) throws InterruptedException {
notificationQueue.put(notification); // 생산 (빠름)
// 별도 스레드가 소비 (실제 발송, 느림)
}
// 2. 비동기 로깅 (감사 로그)
private final BlockingQueue<AuditLog> auditQueue = new LinkedBlockingQueue<>();
public void logAudit(AuditLog log) {
auditQueue.offer(log); // 비동기 (앱 블록 X)
}
// 3. 작업 큐 (배송 처리)
private final ExecutorService processingPool =
Executors.newFixedThreadPool(4); // 내부 작업 큐
public void processShipment(Shipment shipment) {
processingPool.submit(() -> doProcess(shipment)); // 생산
// 워커 스레드가 소비 (Phase 7)
}
private void doProcess(Shipment s) { }
record Notification(String to, String message) {}
record AuditLog(String action, long timestamp) {}
}
실제 사례는?
답:
1. 메시지 큐:
비동기 로깅:
작업 큐:
공통:
// 일반 락만으로 (한계 있음)
public class SimpleBuffer {
private final Queue<Task> buffer = new LinkedList<>();
private final int capacity = 10;
private final Object lock = new Object();
public void produce(Task task) {
synchronized (lock) {
while (buffer.size() >= capacity) {
// 가득 참 → 어떻게 대기?
// 그냥 반복? (바쁜 대기)
}
buffer.offer(task);
}
}
public Task consume() {
synchronized (lock) {
while (buffer.isEmpty()) {
// 빔 → 어떻게 대기?
}
return buffer.poll();
}
}
}
// ❌ 바쁜 대기 (busy waiting)
public Task consume() {
while (true) {
synchronized (lock) {
if (!buffer.isEmpty()) {
return buffer.poll();
}
}
// 비어있으면 반복 확인 (CPU 낭비)
}
}
// 계속 확인 → CPU 100%
// ❌ 락 안에서 무한 루프 (데드락)
public Task consumeBroken() {
synchronized (lock) {
while (buffer.isEmpty()) {
// 락 잡은 채 대기
// → 생산자가 락 못 얻음
// → 영원히 빔 (데드락)
}
return buffer.poll();
}
}
// 락 잡고 대기 → 생산자 진입 불가
// sleep 으로 바쁜 대기 완화 (불완전)
public Task consumeWithSleep() throws InterruptedException {
while (true) {
synchronized (lock) {
if (!buffer.isEmpty()) {
return buffer.poll();
}
}
Thread.sleep(10); // 잠시 대기 (CPU 절약)
// 하지만:
// - 지연 (10ms)
// - 여전히 폴링
// - 효율 X
}
}
일반 락의 한계:
- 바쁜 대기 (CPU 낭비)
- 또는 sleep (지연, 폴링)
- 락 안 대기 (데드락)
근본 해결:
- wait/notify (다음 Unit)
- 락 반납 + 대기
- 통지로 깨움
- BlockingQueue
- 이미 구현됨
@Service
public class LockOnlyLimitation {
private final Queue<Shipment> buffer = new LinkedList<>();
private final int capacity = 100;
private final Object lock = new Object();
// ❌ 바쁜 대기 (CPU 낭비)
public Shipment consumeBusy() {
while (true) {
synchronized (lock) {
if (!buffer.isEmpty()) {
return buffer.poll();
}
}
// 비어있으면 계속 반복 (CPU 100%)
}
}
// △ sleep (완화, 여전히 폴링)
public Shipment consumeSleep() throws InterruptedException {
while (true) {
synchronized (lock) {
if (!buffer.isEmpty()) {
return buffer.poll();
}
}
Thread.sleep(10); // 지연 + 폴링
}
}
// ✓ 근본 해결은 wait/notify 또는 BlockingQueue (다음)
}
일반 락만으로 처리의 한계는?
답:
1. synchronized 만:
바쁜 대기:
데드락:
sleep:
바쁜 대기 (Busy Waiting / Spin Waiting):
조건을 만족할 때까지
계속 반복 확인하며 CPU 점유.
while (!condition) { } // 계속 확인
CPU 낭비:
바쁜 대기:
- CPU 100% 사용
- 의미 없는 반복
- 다른 작업 방해
- 전력 낭비
예:
while (buffer.isEmpty()) { }
→ 비어있는 동안 CPU 풀가동
바쁜 대기 vs 효율적 대기:
바쁜 대기:
소비자: [확인][확인][확인]...[확인][꺼냄]
CPU 100% (의미 없음)
효율적 대기 (wait):
소비자: [wait (CPU 0%)]......[깨어남][꺼냄]
CPU 0% (대기)
생산자 notify 로 깨움
바쁜 대기가 OK인 경우:
- 매우 짧은 대기 (마이크로초)
- 컨텍스트 스위칭이 더 비쌀 때
- spin lock (짧은 임계 영역)
대부분:
- 바쁜 대기 피해야
- wait/notify, BlockingQueue
효율적 대기:
wait/notify:
- 조건 안 되면 wait (CPU 0%)
- 조건 되면 notify (깨움)
BlockingQueue:
- take() 가 자동 대기
- put() 이 자동 통지
LockSupport:
- park/unpark
→ CPU 낭비 X
@Service
public class BusyWaitingProblem {
private final Queue<Shipment> buffer = new ConcurrentLinkedQueue<>();
// ❌ 바쁜 대기 (CPU 낭비)
public Shipment consumeBusy() {
Shipment shipment;
while ((shipment = buffer.poll()) == null) {
// 계속 확인 (CPU 100%)
}
return shipment;
}
// ✓ 효율적 — BlockingQueue
private final BlockingQueue<Shipment> blockingBuffer =
new LinkedBlockingQueue<>();
public Shipment consumeEfficient() throws InterruptedException {
return blockingBuffer.take(); // 비면 효율적 대기 (CPU 0%)
// 내부적으로 wait/notify (또는 park/unpark)
}
}
바쁜 대기의 문제는?
답:
1. 바쁜 대기:
문제:
OK 경우:
효율적:
생산자-소비자가 핵심인 이유:
멀티스레드 협력의 모든 요소 포함:
- 공유 자원 (버퍼)
- 동기화 (경쟁)
- 조건 대기 (차/빔)
- 통지 (협력)
- 효율 (바쁜 대기 회피)
포함된 동시성 개념:
1. 상호 배제
- 버퍼 동기화
2. 조건 동기화
- 차면/비면 대기
3. 협력
- 생산자-소비자 통지
4. 효율
- 바쁜 대기 회피
다른 동시성 문제와 연결:
- 스레드 풀 (작업 큐)
- 이벤트 루프 (이벤트 큐)
- 파이프라인 (단계별 큐)
- 백프레셔 (버퍼 제어)
→ 생산자-소비자가 기반
실무 보편성:
거의 모든 비동기 시스템:
- 메시지 큐
- 작업 큐
- 로깅
- 스트림
→ 생산자-소비자 패턴
→ 이해 필수
학습 가치:
생산자-소비자 이해 =
- wait/notify 이해
- BlockingQueue 이해
- 스레드 풀 이해
- 비동기 시스템 이해
→ 동시성의 핵심
@Service
public class ProducerConsumerCore {
// ILIC 의 여러 곳에 생산자-소비자
// 1. 배송 처리 파이프라인
private final BlockingQueue<Shipment> processingQueue =
new LinkedBlockingQueue<>();
// 2. 알림 발송
private final BlockingQueue<Notification> notificationQueue =
new LinkedBlockingQueue<>();
// 3. 감사 로그
private final BlockingQueue<AuditEvent> auditQueue =
new LinkedBlockingQueue<>();
// 4. 외부 API 호출 (재시도 큐)
private final BlockingQueue<ApiCall> retryQueue =
new LinkedBlockingQueue<>();
// 모두 생산자-소비자 패턴
// 이 패턴 이해 = ILIC 비동기 시스템 이해
record Notification(String message) {}
record AuditEvent(String action) {}
record ApiCall(String endpoint) {}
}
멀티스레드 핵심 문제인 이유는?
답:
1. 모든 요소 포함:
개념:
연결:
보편성:
BlockingQueue:
생산자-소비자를 위한 동시성 큐.
java.util.concurrent
특징:
- put: 가득 차면 대기
- take: 비면 대기
- 내부 동기화 (자동)
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
// 블로킹 (대기)
queue.put(task); // 가득 차면 대기
Task task = queue.take(); // 비면 대기
// 논블로킹
queue.offer(task); // 가득 차면 false
Task t = queue.poll(); // 비면 null
// 타임아웃
queue.offer(task, 1, SECONDS); // 1초 시도
queue.poll(1, SECONDS); // 1초 대기
BlockingQueue 구현체:
ArrayBlockingQueue:
- 고정 크기 배열
- 유한 버퍼
LinkedBlockingQueue:
- 링크드 노드
- 선택적 크기
PriorityBlockingQueue:
- 우선순위
SynchronousQueue:
- 크기 0 (직접 전달)
DelayQueue:
- 지연 후 꺼냄
// BlockingQueue 로 생산자-소비자 (간단)
public class ProducerConsumerWithBQ {
private final BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
// 생산자
public void producer() throws InterruptedException {
while (true) {
Task task = createTask();
queue.put(task); // 가득 차면 자동 대기
}
}
// 소비자
public void consumer() throws InterruptedException {
while (true) {
Task task = queue.take(); // 비면 자동 대기
process(task);
}
}
// wait/notify 직접 안 써도 됨 (내부 구현)
private Task createTask() { return null; }
private void process(Task t) { }
}
BlockingQueue 권장 이유:
- 검증된 구현 (버그 없음)
- wait/notify 직접 X
- 효율적 (바쁜 대기 X)
- 다양한 구현체
- 타임아웃, 논블로킹
→ 직접 wait/notify 보다 권장
@Service
public class ShipmentBlockingQueue {
// 유한 버퍼 (백프레셔)
private final BlockingQueue<Shipment> queue = new ArrayBlockingQueue<>(1000);
// 생산자 (API 핸들러)
public void submit(Shipment shipment) throws InterruptedException {
queue.put(shipment); // 1000 가득 차면 대기 (백프레셔)
}
// 논블로킹 버전 (거부)
public boolean trySubmit(Shipment shipment) {
boolean accepted = queue.offer(shipment);
if (!accepted) {
log.warn("큐 가득 참, 거부: {}", shipment.getId());
}
return accepted;
}
// 소비자 (워커 풀)
@PostConstruct
public void startWorkers() {
for (int i = 0; i < 4; i++) {
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Shipment shipment = queue.take(); // 효율적 대기
process(shipment);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}, "worker-" + i);
worker.start();
}
}
private void process(Shipment s) { }
}
BlockingQueue가 해결책인 이유는?
답:
1. BlockingQueue:
메서드:
구현체:
권장:
| Q | 핵심 답변 |
|---|---|
| 생산자-소비자? | 버퍼 매개 협력 |
| 생산자 역할? | 데이터 생성 → 버퍼 |
| 소비자 역할? | 버퍼 → 처리 |
| 버퍼 가득? | 생산자 대기 |
| 버퍼 빔? | 소비자 대기 |
| 실제 사례? | MQ, 로깅, 작업 큐 |
| 일반 락 한계? | 바쁜 대기, 데드락 |
| 바쁜 대기? | CPU 낭비 |
| 핵심 문제 이유? | 모든 협력 요소 |
| 해결책? | wait/notify, BlockingQueue |
답:
답:
답:
답:
답:
1. 생산자-소비자
2. 일반 락 한계
3. 해결
이번 Unit에서 생산자-소비자 문제를 봤다면, 다음은 wait/notify (효율적 대기/통지).
🚀 Phase 6 — 스레드 간 협력
✅ Unit 6.1 생산자-소비자 문제 ← 여기
⏭ Unit 6.2 wait()과 notify()
⏭ Unit 6.3 인터럽트 메커니즘
⏭ Unit 6.4 yield()
✅ Phase 1~5 (21 Unit, 1차 정점 완료)
🚀 Phase 6 — 스레드 협력 (1/4 진행)
총: 22/35 Unit
F-LAB JAVA · 4주차 · Phase 6 · Unit 6.1 · 끝
🚀 Phase 6 시작 — 스레드 협력 진입