생산자-소비자 문제란?
문제 핵심
비유로 이해하기
레스토랑 모델
음료 공장 모델
예제1 구조 (BoundedQueueV1)
인터페이스
package thread.bounded;
public interface BoundedQueue {
void put(String data); // 생산자 호출
String take(); // 소비자 호출
}
구현 클래스 (V1)
package thread.bounded;
import static util.MyLogger.log;
import java.util.ArrayDeque;
import java.util.Queue;
public class BoundedQueueV1 implements BoundedQueue {
private final Queue<String> queue = new ArrayDeque<>();
private final int max;
public BoundedQueueV1(int max) {
this.max = max;
}
@Override
public synchronized void put(String data) {
if (queue.size() == max) {
log("[put] 큐가 가득 참, 버림: " + data);
return;
}
queue.offer(data);
}
@Override
public synchronized String take() {
if (queue.isEmpty()) {
return null;
}
return queue.poll();
}
@Override
public String toString() {
return queue.toString();
}
}
queue (ArrayDeque)synchronized를 이용해 임계 영역 보호합니다.생산자/소비자 스레드 구현 클래스
package thread.bounded;
import static util.MyLogger.log;
public class ConsumerTask implements Runnable {
private BoundedQueue queue;
public ConsumerTask(BoundedQueue queue) {
this.queue = queue;
}
@Override
public void run() {
log("[소비 시도] ? <- " + queue);
String data = queue.take();
log("[소비 완료] " + data + " <- " + queue);
}
}
package thread.bounded;
import static util.MyLogger.log;
public class ProducerTask implements Runnable {
private BoundedQueue queue;
private String request;
public ProducerTask(BoundedQueue queue, String request) {
this.queue = queue;
this.request = request;
}
@Override
public void run() {
log("[생산 시도] " + request + " -> " + queue);
queue.put(request);
log("[생산 완료] " + request + " -> " + queue);
}
}
실행 코드 요약 (BoundedMain)
package thread.bounded;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
import java.util.ArrayList;
import java.util.List;
public class BoundedMain {
public static void main(String[] args) {
// 1. BoundedQueue 선택
BoundedQueue queue = new BoundedQueueV1(2);
// BoundedQueue queue = new BoundedQueueV2(2);
// BoundedQueue queue = new BoundedQueueV3(2);
// BoundedQueue queue = new BoundedQueueV4(2);
// BoundedQueue queue = new BoundedQueueV5(2);
// BoundedQueue queue = new BoundedQueueV6_1(2);
// BoundedQueue queue = new BoundedQueueV6_2(2);
// BoundedQueue queue = new BoundedQueueV6_3(2);
// BoundedQueue queue = new BoundedQueueV6_4(2);
// 2. 생산자, 소비자 실행 순서 선택, 반드시 하나만 선택!
producerFirst(queue);
// consumerFirst(queue);
}
private static void producerFirst(BoundedQueue queue) {
log("== [생산자 먼저 실행] 시작, " + queue.getClass().getSimpleName() + " ==");
List<Thread> threads = new ArrayList<>();
startProducer(queue, threads);
printAllState(queue, threads);
startConsumer(queue, threads);
printAllState(queue, threads);
log("== [생산자 먼저 실행] 종료, " + queue.getClass().getSimpleName() + " ==");
}
private static void consumerFirst(BoundedQueue queue) {
log("== [소비자 먼저 실행] 시작, " + queue.getClass().getSimpleName() + " ==");
List<Thread> threads = new ArrayList<>();
startConsumer(queue, threads);
printAllState(queue, threads);
startProducer(queue, threads);
printAllState(queue, threads);
log("== [소비자 먼저 실행] 종료, " + queue.getClass().getSimpleName() + " ==");
}
private static void printAllState(BoundedQueue queue, List<Thread> threads) {
System.out.println();
log("현재 상태 출력, 큐 데이터: " + queue);
for (Thread thread : threads) {
log(thread.getName() + ": " + thread.getState());
}
}
private static void startProducer(BoundedQueue queue, List<Thread> threads) {
System.out.println();
log("생산자 시작");
for (int i = 1; i <= 3; i++) {
Thread producer = new Thread(new ProducerTask(queue, "data" + i), "producer" + i);
threads.add(producer);
producer.start();
sleep(100);
}
}
private static void startConsumer(BoundedQueue queue, List<Thread> threads) {
System.out.println();
log("소비자 시작");
for (int i = 1; i <= 3; i++) {
Thread consumer = new Thread(new ConsumerTask(queue), "consumer" + i);
threads.add(consumer);
consumer.start();
sleep(100);
}
}
}
producerFirst():consumerFirst()는 반대 순서로 동작합니다.15:08:44.306 [ main] == [생산자 먼저 실행] 시작, BoundedQueueV1 ==
15:08:44.308 [ main] 생산자 시작
15:08:44.313 [producer1] [생산 시도] data1 -> []
15:08:44.313 [producer1] [생산 완료] data1 -> [data1]
15:08:44.415 [producer2] [생산 시도] data2 -> [data1]
15:08:44.415 [producer2] [생산 완료] data2 -> [data1, data2]
15:08:44.520 [producer3] [생산 시도] data3 -> [data1, data2]
15:08:44.521 [producer3] [put] 큐가 가득 참, 버림: data3
15:08:44.521 [producer3] [생산 완료] data3 -> [data1, data2]
15:08:44.625 [ main] 현재 상태 출력, 큐 데이터: [data1, data2]
15:08:44.626 [ main] producer1: TERMINATED
15:08:44.626 [ main] producer2: TERMINATED
15:08:44.626 [ main] producer3: TERMINATED
15:08:44.626 [ main] 소비자 시작
15:08:44.627 [consumer1] [소비 시도] ? <- [data1, data2]
15:08:44.627 [consumer1] [소비 완료] data1 <- [data2]
15:08:44.730 [consumer2] [소비 시도] ? <- [data2]
15:08:44.730 [consumer2] [소비 완료] data2 <- []
15:08:44.835 [consumer3] [소비 시도] ? <- []
15:08:44.836 [consumer3] [소비 완료] null <- []
15:08:44.941 [ main] 현재 상태 출력, 큐 데이터: []
15:08:44.941 [ main] producer1: TERMINATED
15:08:44.941 [ main] producer2: TERMINATED
15:08:44.941 [ main] producer3: TERMINATED
15:08:44.941 [ main] consumer1: TERMINATED
15:08:44.941 [ main] consumer2: TERMINATED
15:08:44.942 [ main] consumer3: TERMINATED
15:08:44.942 [ main] == [생산자 먼저 실행] 종료, BoundedQueueV1 ==
결과 분석 요약
생산자 먼저 실행
producer1, 2 → 정상 저장producer3 → 큐가 가득 차 data3 버림consumer1, 2 → 정상 소비consumer3 → 큐가 비어 null 반환소비자 먼저 실행
consumer1, 2, 3 → 모두 큐가 비어 null 반환producer1, 2 → 정상 저장producer3 → 큐가 가득 차 data3 버림문제점
data3이 소비되지 못하고 버려집니다.consumer3은 데이터를 못 받아 null 처리됩니다.Thread.sleep()으로 대기 구현목적
개선 코드 요약
package thread.bounded;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
import java.util.ArrayDeque;
import java.util.Queue;
public class BoundedQueueV2 implements BoundedQueue {
private final Queue<String> queue = new ArrayDeque<>();
private final int max;
public BoundedQueueV2(int max) {
this.max = max;
}
@Override
public synchronized void put(String data) {
while (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
sleep(1000);
}
queue.offer(data);
}
@Override
public synchronized String take() {
while (queue.isEmpty()) {
log("[take] 큐에 데이터가 없음, 소비자 대기");
sleep(1000);
}
return queue.poll();
}
@Override
public String toString() {
return queue.toString();
}
}
13:45:00.496 [ main] == [생산자 먼저 실행] 시작, BoundedQueueV2 ==
13:45:00.497 [ main] 생산자 시작
13:45:00.506 [producer1] [생산 시도] data1 -> []
13:45:00.506 [producer1] [생산 완료] data1 -> [data1]
13:45:00.613 [producer2] [생산 시도] data2 -> [data1]
13:45:00.614 [producer2] [생산 완료] data2 -> [data1, data2]
13:45:00.719 [producer3] [생산 시도] data3 -> [data1, data2]
13:45:00.720 [producer3] [put] 큐가 가득 참, 생산자 대기
13:45:00.825 [ main] 현재 상태 출력, 큐 데이터: [data1, data2]
13:45:00.826 [ main] producer1: TERMINATED
13:45:00.826 [ main] producer2: TERMINATED
13:45:00.826 [ main] producer3: TIMED_WAITING
13:45:00.826 [ main] 소비자 시작
13:45:00.828 [consumer1] [소비 시도] ? <- [data1, data2]
13:45:00.930 [consumer2] [소비 시도] ? <- [data1, data2]
13:45:01.031 [consumer3] [소비 시도] ? <- [data1, data2]
13:45:01.133 [ main] 현재 상태 출력, 큐 데이터: [data1, data2]
13:45:01.133 [ main] producer1: TERMINATED
13:45:01.133 [ main] producer2: TERMINATED
13:45:01.133 [ main] producer3: TIMED_WAITING
13:45:01.133 [ main] consumer1: BLOCKED
13:45:01.133 [ main] consumer2: BLOCKED
13:45:01.134 [ main] consumer3: BLOCKED
13:45:01.134 [ main] == [생산자 먼저 실행] 종료, BoundedQueueV2 ==
13:45:01.720 [producer3] [put] 큐가 가득 참, 생산자 대기
13:45:02.724 [producer3] [put] 큐가 가득 참, 생산자 대기
13:45:03.726 [producer3] [put] 큐가 가득 참, 생산자 대기
...
13:46:30.475 [ main] == [소비자 먼저 실행] 시작, BoundedQueueV2 ==
13:46:30.476 [ main] 소비자 시작
13:46:30.479 [consumer1] [소비 시도] ? <- []
13:46:30.479 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
13:46:30.589 [consumer2] [소비 시도] ? <- []
13:46:30.695 [consumer3] [소비 시도] ? <- []
13:46:30.795 [ main] 현재 상태 출력, 큐 데이터: []
13:46:30.796 [ main] consumer1: TIMED_WAITING
13:46:30.796 [ main] consumer2: BLOCKED
13:46:30.796 [ main] consumer3: BLOCKED
13:46:30.796 [ main] 생산자 시작
13:46:30.798 [producer1] [생산 시도] data1 -> []
13:46:30.900 [producer2] [생산 시도] data2 -> []
13:46:31.001 [producer3] [생산 시도] data3 -> []
13:46:31.101 [ main] 현재 상태 출력, 큐 데이터: []
13:46:31.101 [ main] consumer1: TIMED_WAITING
13:46:31.101 [ main] consumer2: BLOCKED
13:46:31.102 [ main] consumer3: BLOCKED
13:46:31.102 [ main] producer1: BLOCKED
13:46:31.102 [ main] producer2: BLOCKED
13:46:31.102 [ main] producer3: BLOCKED
13:46:31.103 [ main] == [소비자 먼저 실행] 종료, BoundedQueueV2 ==
13:46:31.481 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
13:46:32.487 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
13:46:33.488 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
13:46:34.489 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
...
핵심 문제
synchronized + sleep()의 조합
sleep()은 스레드를 일시 정지시킬 뿐, 락을 반납하지 않습니다.queue의 락을 잠근 채로 잠들어 있습니다.실행 결과
producer1, 2 → 정상 저장합니다.producer3 → queue.size() == max로 sleep 진입합니다.take() 메서드 호출조차 못하게 됩니다. → 교착 상태 발생queue.isEmpty()로 sleep 진입합니다. → 락 보유한 채 잠듦put()에 진입을 못하게 됩니다. → 역시 교착 상태 발생요약 정리
| 항목 | 설명 |
|---|---|
| 개선 의도 | 큐가 꽉 찼거나 비어 있으면 기다렸다가 다시 시도 |
| 사용 방식 | synchronized 내부에서 Thread.sleep() 호출 |
| 문제점 | 락을 점유한 채로 잠듦 → 다른 스레드가 진입 못 함 |
| 결과 | 무한 대기 및 교착 상태 발생 가능 |
| 결론 | sleep()은 락을 놓지 않기 때문에, 동기화 문제 해결에는 부적절 |
스레드 간 협력이 필요할 때는 sleep()이 아니라 wait() / notify()처럼 락을 반납하는 방식의 동기화 메커니즘을 고려할 수 있습니다.
wait() / notify()로 스레드 간 협력 구현개선 목적
sleep() 방식은 락을 점유한 채로 대기하여 다른 스레드 진입 자체가 불가능으로 더 심각한 문제였습니다.핵심 메커니즘
wait():notify()로 깨워줄 때까지 잠듭니다.notify():wait() 중인 스레드 중 하나를 깨웁니다.개선 코드 요약 (V3)
package thread.bounded;
import static util.MyLogger.log;
import java.util.ArrayDeque;
import java.util.Queue;
public class BoundedQueueV3 implements BoundedQueue {
private final Queue<String> queue = new ArrayDeque<>();
private final int max;
public BoundedQueueV3(int max) {
this.max = max;
}
@Override
public synchronized void put(String data) {
while (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
try {
wait(); // RUNNABLE -> WAITING, 락 반납
log("[put] 생산자 깨어남");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
queue.offer(data);
log("[put] 생산자 데이터 저장, notify() 호출");
notify(); // 대기 스레드, WAIT -> BLOCKED
// notifyAll(); // 모든 대기 스레드, WAIT -> BLOCKED
}
@Override
public synchronized String take() {
while (queue.isEmpty()) {
log("[take] 큐에 데이터가 없음, 소비자 대기");
try {
wait();
log("[take] 소비자 꺠어남");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
String data = queue.poll();
log("[take] 소비자 데이터 획득, notify() 호출");
notify(); // 대기 스레드, WAIT -> BLOCKED
// notifyAll(); // 모든 대기 스레드, WAIT -> BLOCKED
return data;
}
@Override
public String toString() {
return queue.toString();
}
}
주요 변경점 요약
| 변경점 | 설명 |
|---|---|
while 사용 | 깨워져도 조건이 다시 false일 수 있음 → 조건 재검사 필수 |
wait() 사용 | 대기하면서 락을 반납 → 다른 스레드 진입 가능 |
notify() 사용 | 작업 후 다른 쪽 스레드를 깨움 |
실행 흐름: 생산자 먼저 실행
14:18:25.544 [ main] == [생산자 먼저 실행] 시작, BoundedQueueV3 ==
14:18:25.546 [ main] 생산자 시작
14:18:25.554 [producer1] [생산 시도] data1 -> []
14:18:25.554 [producer1] [put] 생산자 데이터 저장, notify() 호출
14:18:25.555 [producer1] [생산 완료] data1 -> [data1]
14:18:25.652 [producer2] [생산 시도] data2 -> [data1]
14:18:25.653 [producer2] [put] 생산자 데이터 저장, notify() 호출
14:18:25.653 [producer2] [생산 완료] data2 -> [data1, data2]
14:18:25.757 [producer3] [생산 시도] data3 -> [data1, data2]
14:18:25.757 [producer3] [put] 큐가 가득 참, 생산자 대기
14:18:25.863 [ main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:18:25.864 [ main] producer1: TERMINATED
14:18:25.864 [ main] producer2: TERMINATED
14:18:25.864 [ main] producer3: WAITING
14:18:25.864 [ main] 소비자 시작
14:18:25.865 [consumer1] [소비 시도] ? <- [data1, data2]
14:18:25.866 [consumer1] [take] 소비자 데이터 획득, notify() 호출
14:18:25.866 [producer3] [put] 생산자 깨어남
14:18:25.866 [consumer1] [소비 완료] data1 <- [data2]
14:18:25.866 [producer3] [put] 생산자 데이터 저장, notify() 호출
14:18:25.866 [producer3] [생산 완료] data3 -> [data2, data3]
14:18:25.968 [consumer2] [소비 시도] ? <- [data2, data3]
14:18:25.968 [consumer2] [take] 소비자 데이터 획득, notify() 호출
14:18:25.968 [consumer2] [소비 완료] data2 <- [data3]
14:18:26.073 [consumer3] [소비 시도] ? <- [data3]
14:18:26.073 [consumer3] [take] 소비자 데이터 획득, notify() 호출
14:18:26.074 [consumer3] [소비 완료] data3 <- []
14:18:26.180 [ main] 현재 상태 출력, 큐 데이터: []
14:18:26.180 [ main] producer1: TERMINATED
14:18:26.181 [ main] producer2: TERMINATED
14:18:26.181 [ main] producer3: TERMINATED
14:18:26.181 [ main] consumer1: TERMINATED
14:18:26.182 [ main] consumer2: TERMINATED
14:18:26.182 [ main] consumer3: TERMINATED
14:18:26.183 [ main] == [생산자 먼저 실행] 종료, BoundedQueueV3 ==
producer1, 2 → 정상 저장producer3 → 큐 가득 참 → wait()로 대기 (락 반납)consumer1, 2 → 소비 진행 → 큐 공간 생김 → notify()로 producer3 깨움producer3 → 다시 락 획득 후 정상 저장실행 흐름: 소비자 먼저 실행
14:17:09.897 [ main] == [소비자 먼저 실행] 시작, BoundedQueueV3 ==
14:17:09.899 [ main] 소비자 시작
14:17:09.903 [consumer1] [소비 시도] ? <- []
14:17:09.903 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기
14:17:10.011 [consumer2] [소비 시도] ? <- []
14:17:10.012 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:17:10.116 [consumer3] [소비 시도] ? <- []
14:17:10.117 [consumer3] [take] 큐에 데이터가 없음, 소비자 대기
14:17:10.221 [ main] 현재 상태 출력, 큐 데이터: []
14:17:10.222 [ main] consumer1: WAITING
14:17:10.222 [ main] consumer2: WAITING
14:17:10.222 [ main] consumer3: WAITING
14:17:10.222 [ main] 생산자 시작
14:17:10.225 [producer1] [생산 시도] data1 -> []
14:17:10.225 [producer1] [put] 생산자 데이터 저장, notify() 호출
14:17:10.225 [consumer1] [take] 소비자 꺠어남
14:17:10.225 [producer1] [생산 완료] data1 -> [data1]
14:17:10.225 [consumer1] [take] 소비자 데이터 획득, notify() 호출
14:17:10.226 [consumer2] [take] 소비자 꺠어남
14:17:10.226 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:17:10.226 [consumer1] [소비 완료] data1 <- []
14:17:10.327 [producer2] [생산 시도] data2 -> []
14:17:10.327 [producer2] [put] 생산자 데이터 저장, notify() 호출
14:17:10.327 [producer2] [생산 완료] data2 -> [data2]
14:17:10.327 [consumer3] [take] 소비자 꺠어남
14:17:10.328 [consumer3] [take] 소비자 데이터 획득, notify() 호출
14:17:10.328 [consumer3] [소비 완료] data2 <- []
14:17:10.328 [consumer2] [take] 소비자 꺠어남
14:17:10.328 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
14:17:10.433 [producer3] [생산 시도] data3 -> []
14:17:10.433 [producer3] [put] 생산자 데이터 저장, notify() 호출
14:17:10.433 [consumer2] [take] 소비자 꺠어남
14:17:10.433 [producer3] [생산 완료] data3 -> [data3]
14:17:10.434 [consumer2] [take] 소비자 데이터 획득, notify() 호출
14:17:10.434 [consumer2] [소비 완료] data3 <- []
14:17:10.539 [ main] 현재 상태 출력, 큐 데이터: []
14:17:10.539 [ main] consumer1: TERMINATED
14:17:10.540 [ main] consumer2: TERMINATED
14:17:10.540 [ main] consumer3: TERMINATED
14:17:10.540 [ main] producer1: TERMINATED
14:17:10.540 [ main] producer2: TERMINATED
14:17:10.540 [ main] producer3: TERMINATED
14:17:10.541 [ main] == [소비자 먼저 실행] 종료, BoundedQueueV3 ==
consumer1, 2, 3 → 큐 비어있음 → wait()로 대기producer1, 2, 3 → 생산하고 notify() → 소비자 깨어나서 소비 진행개선 효과
| 항목 | 예제2(V2) | 예제3(V3) |
|---|---|---|
| 대기 방식 | sleep() (비효율, 락 점유) | wait() (효율적, 락 반납) |
| 락 점유 | 유지 → 교착 위험 | 반납 → 진입 가능 |
| 반응성 | 느림 (시간 기반 대기) | 빠름 (상태 기반 협력) |
| 협업 구조 | 없음 | 있음 (wait ↔ notify) |
핵심 정리
wait()과 notify()는 멀티스레드 협력에서 가장 기본이 되는 메시징 방식입니다.sleep()은 단독 대기, wait()은 상호 협력 구조입니다.notify() → notifyAll() 개선 개선 필요성: notify()의 한계
notify()를 사용합니다.문제 상황 (예: 소비자만 여러 명)
if (queue.isEmpty()) {
wait(); // 여러 소비자 대기
}
// ...
notify(); // 소비자 중 하나를 깨움 → 여전히 큐는 비어 있음 → 다시 wait
해결 방법: notifyAll()
코드
package thread.bounded;
import static util.MyLogger.log;
import java.util.ArrayDeque;
import java.util.Queue;
public class BoundedQueueV3 implements BoundedQueue {
private final Queue<String> queue = new ArrayDeque<>();
private final int max;
public BoundedQueueV3(int max) {
this.max = max;
}
@Override
public synchronized void put(String data) {
while (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
try {
wait(); // RUNNABLE -> WAITING, 락 반납
log("[put] 생산자 깨어남");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
queue.offer(data);
log("[put] 생산자 데이터 저장, notify() 호출");
// notify(); // 대기 스레드, WAIT -> BLOCKED
notifyAll(); // 모든 대기 스레드, WAIT -> BLOCKED
}
@Override
public synchronized String take() {
while (queue.isEmpty()) {
log("[take] 큐에 데이터가 없음, 소비자 대기");
try {
wait();
log("[take] 소비자 꺠어남");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
String data = queue.poll();
log("[take] 소비자 데이터 획득, notify() 호출");
// notify(); // 대기 스레드, WAIT -> BLOCKED
notifyAll(); // 모든 대기 스레드, WAIT -> BLOCKED
return data;
}
@Override
public String toString() {
return queue.toString();
}
}
notifyAll() vs notify()
| 항목 | notify() | notifyAll() |
|---|---|---|
| 대상 | 대기 중인 스레드 1개 | 모든 대기 중인 스레드 |
| 위험 | 조건 미충족한 스레드가 깨어날 수 있음 → 무한 대기 위험 | 다 깨어나서 조건 확인 후 필요한 스레드만 진행 |
| 권장 | 단일 생산자/소비자 | 다중 생산자/소비자 환경에서는 필수 |
while문으로 감싸는 이유 복습
while (조건) {
wait();
}
wait()에서 깨어나도 조건이 참이라는 보장이 없습니다.notify()/notifyAll()은 단지 신호일 뿐 → 스레드는 조건 재확인은 필수입니다.예제4의 개선 요약
| 항목 | 설명 |
|---|---|
| 문제 | notify()는 조건 미충족한 스레드를 깨울 수 있음 |
| 증상 | 깨운 스레드가 다시 wait() → 진전 없음 |
| 해결 | notifyAll()로 모두 깨운 후 조건 확인 |
| 추가 장치 | while 루프로 조건 재확인 → false면 다시 wait() |
핵심 정리
wait()는 락을 반납하고 대기합니다.notify()는 랜덤하게 하나만 깨웁니다. → 다중 스레드 환경에선 불안정합니다.notifyAll()은 모든 대기 스레드를 깨워 조건에 따라 진행하게 합니다. → 안전하고 신뢰가 가능합니다.