백엔드 엔지니어 이재혁
[Java] 생산자 소비자 문제 본문
생산자 소비자 문제 혹은 한정된 버퍼 문제 (둘 다 같은 의미)는 생산자, 소비자 그리고 버퍼 관계에서 발생할 수 있는 문제를 다룬다. 여러 스레드의 동시 데이터 접근 및 상태 변화 문제라고도 할 수 있다.
생산자: 데이터를 생산하는 쪽 (버거킹 알바)
소비자: 데이터를 소비하는 쪽 (버거킹 손님)
버퍼: 생산자가 생산한 데이터를 일시적으로 저장하는 공간 (버거킹 버거 대기시켜두는 곳)
소비자는 버퍼에서 데이터를 가져간다.
패스트푸드 음식점 비유
위에서 비유한대로 버거킹을 예시로 생산자 소비자 문제를 설명해보겠다.
1. 생산자가 너무 빠를 때 (버퍼 공간 부족 문제)
생산자 측에서 버거를 마구마구 만들다가 버거를 대기시켜두는 곳이 가득 차면 만든 버거를 버려야 할까?
아니다. 만든 사람(알바)이 잠깐 대기하면 된다. (손님이 버거를 사서 버퍼에 자리가 날 때까지)
정리: 버퍼에 자리가 날 때까지 생산자는 잠깐 대기하면 된다.
2. 소비자가 너무 빠를 때 (빈 버퍼 문제)
소비자 측에서 버거를 마구마구 시키다가 버거를 대기시켜두는 곳에 버거가 없다면 구입을 포기하고 빈손으로 버거킹을 떠나가야할까?
아니다. 생산자(알바)가 새 버거를 만들 때까지 잠시 대기하면 된다. (새 버거가 만들어질 것이라는 확신이 있다는 가정하에)
정리: 새 데이터가 들어올 때까지 소비자는 잠깐 대기하면 된다.
1. 대기하는 기능이 없는 경우
생산자측: 버퍼를 초과하는 새로 생산된 데이터는 즉시 버려져야 한다.
소비자측: 버퍼에 데이터가 없는 경우 null 값을 반환 받는다. (데이터를 받지 못한다.)
다음과 같은 예시를 생각해볼 수 있다.
if (queue.size() == max) {
log("[put] 큐가 가득참, 버림: " + data);
return;
}
queue.offer(data);
2. 대기하는 기능을 넣어보자
버퍼가 가득찼거나 비어있는 상태가 확인되면 다음과 같이 대기를 하도록 만들어볼 수 있다.
// 메서드에 synchronized가 걸려있는 상황을 가정
while (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
sleep(1000);
}
queue.offer(data);
하지만, 이렇게 구현하는 경우에는 대기하는 스레드가 락을 가지고 있으므로, 소비자가 데이터를 가져가기를 기다려봐야, 그 어떤 소비자도 락을 획득하지 못해 데이터를 가져가지 못한다. 결과적으로 생산자는 무한히 대기하게 되고, 데드락 상태로 빠져버린다.
뭐... 물론 이런 복잡한 방법으로 임계 구역과 대기 상태를 분리시킬 수도 있다.
(메서드 단위에서는 synchronized 키워드를 제거하고 임계 구역을 코드 단위에 설정)
while (true) {
synchronized (this) {
if (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
}
else {
queue.offer(data);
return;
}
}
sleep(1000);
}
하지만 더 좋은 방법이 있다!
3. 대기하면서 락도 넘겨주자
임계 구역 안에서도 락을 돌려놓으면서 실행 대기 상태로 들어갈 수가 있다!
Object.wait() 메서드와 Object.notify() 메서드를 활용하면 된다!
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(); // 대기 스레드, WAITING -> BLOCKED
큐가 가득 찼다면 wait() 메서드로 WAITING 상태로 바꾸면서 락도 같이 반납하고!
데이터가 잘 저장됐다면, notify()를 호출해서 혹시나 대기 중인 소비자가 있는 경우에 해당 스레드를 WAITING에서 BLOCKED 상태로 바꿔준다. 여기서 RUNNABLE 상태로 바뀌지 않는 이유는, 아직 위 메서드가 종료되지 않았기 때문에 해당 스레드가 락을 반납하지 않았기 때문이다. 아직 새로 깨어난 스레드가 락을 얻지 못했다.
참고1: wait() 메서드에 매개변수로 ms (밀리초) 값을 넘겨주면 TIMED_WAITING으로 변경된다.
참고2: IllegalMonitorStateException (임계구역 밖에서, 즉 락을 획득하지 않은 상태에서 wait() 메서드를 호출하면 발생하는 예외. RuntimeException이다.)
Java 멀티스레딩을 위한 3요소
- 모니터 락: 객체마다 모니터 락을 하나씩 보유
- 락 대기 집합(모니터 락 대기 집합): 모니터 락 획득을 위해 대기하는 곳. BLOCKED 상태
- 스레드 대기 집합: wait()와 notify() 등을 통해 대기 상태 관리
wait()과 notify()의 한계
오~ 이제 대기 상태와 락을 같이 관리할 수 있게 되었다! 이러면 생산자 소비자 문제를 말끔히 해결했을까?
물론, 로직에 문제나 오류는 없다. 하지만 이것이 완벽한 해결책이라고 볼 수는 없다.
notify() 메서드는 스레드 대기 집합에서 깨울 스레드의 종류를 구별해서 깨우는 기능이 없다. 소비자와 생산자 모두 같은 집합에서 대기 중이기 때문에 생산자가 생산자를 깨울 수 있다. 그런 경우, 다음과 같은 문제가 발생할 수 있다.

대기 중이던 생산자 스레드가 깨어나서 큐에 데이터를 넣으려고 했는데, 어라? 데이터가 가득찼네? 하면서 새로운 소비자 스레드가 등장하기를 기다린다. (스레드 대기 집합에서 새로 깨우는 것이 아니다.)
결과적으로 p0가 p1을 깨웠지만, p1은 아무 작업도 하지 않고 다시 대기 상태로 바뀐다. 의미없이 CPU 자원을 소모한 것이다.
- 스레드 기아 (Thread Starvation)
어떤 스레드를 깨울지 정해져 있지 않기 때문에 스레드 기아 문제가 발생할 수도 있다. 위 예시 이미지에서 생산자가 계속 p1, p2, p3만 깨우면 c1, c2, c3는 무한 대기 상태가 될 수도 있는 문제다. (실제 상황에서는 나중에 들어온 소비자가 먼저 깨어난다던지 하는 더 다양한 문제가 발생할 수 있음)
이 문제를 꼭 해결하고 싶다면, notifyAll()이라는 메서드를 사용할 수 있다. 이 메서드를 사용하면 스레드 대기 집합에 있는 모든 스레드를 깨운다. 하지만 역시나 깨운 스레드 중 일부만 의도한 대로 처리되고, 대부분은 다시 대기 상태로 빠지는 비효율성이 남아있다.
이런 비효율 문제는 다음 게시글에서 다뤄보았다.
'Java' 카테고리의 다른 글
| [Java] BlockingQueue (0) | 2025.05.29 |
|---|---|
| [Java] 생산자 소비자 문제 (2) (0) | 2025.05.27 |
| [Java] concurrent.locks (0) | 2025.05.23 |
| [Java] synchronized (0) | 2025.05.22 |
| [Java] 메모리 가시성 (0) | 2025.05.22 |