본문 바로가기

wait와 notify로 조건 기다리기

wait가 모니터를 내려놓고 잠드는 방식과 notify 뒤에 깨어난 스레드가 다시 락을 얻는 과정을 실행 결과로 확인하고, 값 하나를 주고받는 우편함을 만들며 인터럽트 처리까지 익힙니다.

synchronized 안에서 sleep하면 멈추는 이유에서 본 생산자는 synchronized 안에서 Thread.sleep으로 빈자리를 기다렸습니다. sleep은 잠든 동안에도 모니터를 놓지 않기 때문에 빈자리를 만들어 줄 소비자는 입구에서 막혔고, 생산자는 일어나지 않을 변화를 기다리며 같은 검사를 되풀이했습니다. 이 상황에 필요한 것은 "지금은 조건이 맞지 않으니 락을 내려놓고 잠들겠다, 누군가 상태를 바꾸면 깨워 달라"고 말하는 방법입니다.

자바에서는 Object 클래스의 세 메서드 wait(), notify(), notifyAll()이 이 일을 합니다. 어떤 객체든 모니터로 쓸 수 있으므로 이 메서드도 모든 객체에 들어 있습니다. 이 글에서는 세 메서드를 쓸 때 지켜야 하는 규칙과 그 규칙이 필요한 이유를 살펴보고, 스레드가 wait에서 깨어나 다시 실행되기까지 거치는 단계를 실행 결과로 확인합니다. 그다음 값 하나를 주고받는 우편함을 만들고, 기다리는 도중에 인터럽트가 오면 어떻게 처리할지 정합니다. 실행 결과는 모두 JDK 21에서 확인했습니다.

wait와 notify가 하는 일

스레드가 synchronized (lock) 블록에 들어가면 lock 객체의 모니터를 소유합니다. 모니터와 synchronized는 가시성·원자성·임계 영역에서 다뤘습니다. 이 상태에서 lock.wait()를 부르면 스레드는 lock의 대기 집합(wait set)에 들어가면서 모니터를 내려놓고 잠듭니다. 모니터가 풀렸으니 다른 스레드가 synchronized (lock)에 들어와 공유 상태를 바꿀 수 있습니다. 상태를 바꾼 스레드가 lock.notify()를 부르면 대기 집합의 스레드 하나가, lock.notifyAll()을 부르면 기다리던 스레드 모두가 깨어납니다.

깨어난 스레드가 곧바로 다음 줄을 실행하는 것은 아닙니다. wait에서 돌아오려면 모니터를 다시 얻어야 하는데, 알림을 보낸 스레드는 아직 synchronized 블록 안에서 모니터를 쥐고 있습니다. 그래서 깨어난 스레드는 알림을 보낸 스레드가 블록을 빠져나갈 때까지 기다리고, 그 뒤에도 같은 모니터를 노리는 다른 스레드들과 경쟁해서 이겨야 합니다. 즉 wait에는 두 번의 기다림이 있습니다. 대기 집합에서 알림을 기다리는 것과, 입구에서 모니터를 다시 얻기를 기다리는 것입니다.

열쇠가 하나뿐인 방에 비유할 수 있습니다. synchronized는 열쇠를 받아 방에 들어가는 것이고, wait는 열쇠를 반납하고 옆 대기실에 가서 앉는 것입니다. notify는 대기실에서 한 사람을 불러내는 것인데, 불려 나온 사람도 열쇠를 다시 받아야 방에 들어갈 수 있습니다. 다만 이 대기실은 줄을 서는 곳이 아니라는 점에서 비유가 어긋납니다. notify가 누구를 불러낼지는 정해져 있지 않고, 불려 나온 사람이 방금 문 앞에 도착한 사람보다 먼저 열쇠를 받는다는 보장도 없습니다.

모니터 없이 wait를 부르면

세 메서드에는 공통 규칙이 있습니다. 호출하는 스레드가 그 객체의 모니터를 소유하고 있어야 한다는 것입니다. 아래 코드는 이 규칙을 일부러 어기고, gate를 잠그지 않은 채 wait를 부릅니다.

intentional-runtime/WaitWithoutOwnership.java
public final class WaitWithoutOwnership {
    public static void main(String[] args) {
        Object gate = new Object();
        try {
            gate.wait(10);
            System.out.println("unreachable");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

이 코드는 문제없이 컴파일됩니다. 컴파일러가 확인하는 것은 검사 예외인 InterruptedException을 처리했는지뿐이고, 호출하는 순간 모니터를 가졌는지는 실행해 봐야 알 수 있기 때문입니다. 실행하면 wait 호출에서 비검사 예외인 IllegalMonitorStateException이 발생합니다. 이 예외를 잡는 곳이 없으므로 JVM이 스택 트레이스를 출력하고 프로그램은 종료 코드 1로 끝나며, unreachable은 출력되지 않습니다.

Exception in thread "main" java.lang.IllegalMonitorStateException: current thread is not owner
	at java.base/java.lang.Object.wait0(Native Method)
	at java.base/java.lang.Object.wait(Object.java:366)
	at WaitWithoutOwnership.main(WaitWithoutOwnership.java:5)

Object.java:366 같은 JDK 내부의 줄 번호는 JDK 버전마다 다를 수 있습니다. notify()와 notifyAll()도 모니터를 갖지 않은 채 부르면 같은 예외가 발생합니다.

모니터를 가진 채로만 부를 수 있는 이유

이 규칙은 신호를 잃어버리지 않게 하려고 있습니다. 규칙이 없어서 소비자가 락 없이 우편함이 비었는지 확인한 뒤 wait를 부를 수 있다고 가정해 봅시다. 실행 순서가 다음과 같이 엇갈리면 문제가 생깁니다.

순서소비자생산자우편함
1우편함이 비었음을 확인비어 있음
2값을 넣고 notify() 호출값 있음
3wait()를 불러 잠듦값 있음

2단계에서 보낸 알림은 아직 아무도 기다리지 않을 때 도착했으므로 그대로 사라집니다. 알림은 어딘가에 쌓아 두었다가 나중에 전달되는 것이 아니기 때문입니다. 3단계에서 잠든 소비자는 우편함에 값이 있는데도 다음 알림이 올 때까지 깨어나지 못하고, 값을 보낼 생산자가 더 없다면 영원히 잠듭니다. 이런 문제를 신호 손실(lost wakeup)이라고 합니다.

조건 확인과 wait 호출이 같은 모니터 안에서 일어나면 이런 순서는 생길 수 없습니다. 소비자는 1단계부터 wait가 모니터를 내려놓는 순간까지 모니터를 쥐고 있으므로, 생산자는 그 사이에 값을 넣지도 알림을 보내지도 못합니다. 생산자의 상태 변경과 알림 역시 같은 모니터 안에서 일어나야 하므로, 소비자가 받을 알림은 반드시 소비자가 대기 집합에 들어간 뒤에 옵니다. 결국 이 규칙이 요구하는 것은 기다리는 조건과 그 조건을 바꾸는 코드를 모두 한 객체의 모니터로 보호하라는 것입니다.

그러니 예외를 없애려고 아무 메서드에나 synchronized를 붙이는 것은 해결책이 아닙니다. 먼저 어떤 공유 상태의 조건을 기다리는지, 그 상태를 어느 객체의 모니터가 보호하는지를 정해야 합니다. 예를 들어 큐의 크기를 this의 모니터로 보호한다면 크기 검사와 this.wait()가 같은 synchronized 안에 있어야 하고, 생산자가 값을 넣은 뒤 부르는 알림도 this.notifyAll()이어야 합니다.

wait에서 깨어나 다시 실행되기까지

이제 스레드가 wait에서 깨어나 다시 실행되기까지 어떤 단계를 거치는지 실행해서 확인해 보겠습니다. 아래 프로그램에서 waiter 스레드는 ready가 true가 될 때까지 lock에서 기다리고, main은 ready를 true로 바꾼 뒤 notifyAll()을 부릅니다. 그 사이사이에 getState()로 waiter의 스레드 상태를 출력합니다.

src/WaitReleaseTrace.java
import java.util.concurrent.CountDownLatch;

public final class WaitReleaseTrace {
    private static final Object lock = new Object();
    private static boolean ready;

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch entered = new CountDownLatch(1);
        Thread waiter = Thread.ofPlatform().name("waiter").start(() -> {
            synchronized (lock) {
                entered.countDown();
                System.out.println("waiter: ready=" + ready + ", calling wait");
                while (!ready) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        return;
                    }
                }
                System.out.println("waiter: woke up, ready=" + ready);
            }
        });

        entered.await();
        synchronized (lock) {
            System.out.println("main: got the monitor, waiter=" + waiter.getState());
            ready = true;
            lock.notifyAll();
            System.out.println("main: after notifyAll, waiter=" + waiter.getState());
        }
        waiter.join();
        System.out.println("main: after join, waiter=" + waiter.getState());
    }
}

entered 래치는 main이 waiter보다 먼저 lock을 잡지 않도록 순서를 맞춥니다. waiter는 모니터를 쥔 채로 래치를 열고, wait()에 들어가서야 모니터를 놓습니다. 따라서 래치를 통과한 main이 synchronized (lock)에 들어갈 수 있는 것은 waiter가 wait()로 모니터를 내려놓은 뒤입니다. 프로그램을 실행하면 다음과 같이 출력됩니다.

waiter: ready=false, calling wait
main: got the monitor, waiter=WAITING
main: after notifyAll, waiter=BLOCKED
waiter: woke up, ready=true
main: after join, waiter=TERMINATED

첫 줄은 waiter가 모니터를 쥔 채 ready가 false인 것을 확인하고 wait()를 부르기 직전에 출력됩니다. 둘째 줄에서 main이 모니터를 얻었을 때 waiter는 WAITING, 즉 대기 집합에서 알림을 기다리는 상태입니다. wait()가 모니터를 내려놓았기 때문에 main이 들어올 수 있었던 것입니다. 셋째 줄이 이 실험의 핵심입니다. notifyAll()을 부른 직후에도 waiter는 실행되지 못하고 BLOCKED 상태에 머뭅니다.

Thread.State 문서는 BLOCKED를 synchronized 블록에 들어가려고, 또는 Object.wait를 부른 뒤 다시 들어가려고 모니터를 기다리는 상태라고 설명합니다. 셋째 줄의 waiter는 뒤쪽 경우로, 대기 집합에서는 나왔지만 main이 아직 모니터를 쥐고 있어서 입구에서 기다리는 중입니다. 알림은 기다리던 스레드를 대기 집합에서 꺼내 줄 뿐 모니터를 넘겨주지 않습니다. 전체 과정을 단계별로 정리하면 다음과 같습니다.

단계일어나는 일모니터 소유자waiter 상태
1waiter가 모니터를 얻고 ready가 false임을 확인waiterRUNNABLE
2waiter가 lock.wait()로 대기 집합에 들어가며 모니터를 놓음없음WAITING
3main이 모니터를 얻어 ready를 true로 바꾸고 notifyAll() 호출mainWAITING에서 BLOCKED로
4main이 블록을 빠져나가며 모니터를 놓고, waiter가 모니터를 다시 얻음waiterRUNNABLE
5wait()에서 돌아온 waiter가 while에서 ready를 다시 확인하고 통과waiterRUNNABLE
6waiter가 블록을 나와 끝남없음TERMINATED

넷째 줄 waiter: woke up은 main이 synchronized 블록을 끝낸 뒤에야 출력됩니다. 그 전에는 waiter가 모니터를 얻을 수 없기 때문입니다. 그러니 로그에서 notify 바로 다음 줄에 깨어난 스레드의 출력이 나오지 않더라도 잘못된 것이 아닙니다. 순서를 해석할 때는 알림을 보낸 시점이 아니라 모니터가 풀리는 시점을 기준으로 삼아야 합니다.

출력 줄의 순서는 모니터가 정해 주므로 몇 번을 실행해도 바뀌지 않습니다. 반면 상태 이름은 getState()가 그 순간의 값을 읽은 것이라 원칙적으로는 실행 환경에 따라 다를 수 있는데, JDK 21에서 300번 실행했을 때는 모두 위와 같았습니다. getState()는 이렇게 상황을 들여다보는 데만 쓰고 스레드 사이의 순서를 맞추는 데는 쓰지 않습니다. 이 구분은 BLOCKED·WAITING 상태로 스레드 덤프 읽기에서 자세히 다룹니다.

앞 글의 sleep과 비교하면 차이가 분명합니다. sleep은 모니터를 쥔 채로 잠들어 다른 스레드가 상태를 바꿀 기회를 막았지만, wait는 잠드는 순간 모니터를 내려놓아 그 기회를 열어 줍니다.

깨어났다고 조건이 맞는 것은 아니다

다섯째 단계에서 waiter가 ready를 한 번 더 확인한 데는 이유가 있습니다. wait에서 돌아왔다는 것은 누군가 상태를 바꿨을 수도 있다는 뜻일 뿐, 기다리던 조건이 지금 참이라는 뜻은 아닙니다. 깨어난 스레드가 모니터를 다시 얻기 전에 다른 스레드가 먼저 들어와 조건을 다시 거짓으로 만들 수 있고, notifyAll()은 다른 조건을 기다리던 스레드까지 함께 깨웁니다. 게다가 Object.wait 문서에 따르면 스레드는 알림도, 인터럽트도, 시간 만료도 없이 깨어날 수 있는데, 이를 가짜 깨움(spurious wakeup)이라고 합니다.

그래서 wait는 언제나 조건을 검사하는 while 반복문 안에서 부릅니다. 깨어날 때마다 조건을 다시 확인하고, 아직 거짓이면 다시 기다리는 모양입니다. 이 규칙이 왜 선택 사항이 아닌지는 다음 글에서 소비자 둘이 값 하나를 두고 다투는 실험으로 확인합니다.

synchronized (lock) {
    while (!conditionHolds()) {  // 깨어날 때마다 다시 확인한다
        lock.wait();
    }
    // 여기에 왔다면 조건이 참이다
}

값 하나를 주고받는 우편함

이제 이 규칙들로 값 하나를 주고받는 우편함을 만들어 보겠습니다. 상태는 message 필드 하나입니다. message가 null이면 우편함이 비어 있어 보낼 수 있고, 값이 들어 있으면 받을 수 있습니다. 이렇게 null을 '비어 있음'의 표시로 쓰기 때문에 null을 보내는 것은 금지합니다. null을 허용하면 비어 있는 우편함과 null이 들어 있는 우편함을 구별할 수 없습니다.

src/MonitorMailbox.java
import java.util.Objects;

public final class MonitorMailbox<E> {
    private E message;

    public synchronized void send(E value) throws InterruptedException {
        Objects.requireNonNull(value, "value");
        while (message != null) {
            wait();
        }
        message = value;
        notifyAll();
    }

    public synchronized E receive() throws InterruptedException {
        while (message == null) {
            wait();
        }
        E result = message;
        message = null;
        notifyAll();
        return result;
    }

    public synchronized boolean isEmpty() {
        return message == null;
    }

    public static void main(String[] args) throws InterruptedException {
        MonitorMailbox<String> mailbox = new MonitorMailbox<>();
        Thread receiver = Thread.ofPlatform().name("receiver").start(() -> {
            try {
                System.out.println("received=" + mailbox.receive());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Thread sender = Thread.ofPlatform().name("sender").start(() -> {
            try {
                mailbox.send("post-20");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        sender.join();
        receiver.join();
        System.out.println("empty=" + mailbox.isEmpty());
    }
}

send와 receive는 둘 다 synchronized 인스턴스 메서드이므로 같은 this의 모니터를 씁니다. 메서드 안의 wait()와 notifyAll()은 this.wait(), this.notifyAll()과 같으므로 조건 검사, 대기, 상태 변경, 알림이 모두 한 모니터 안에서 일어납니다. send는 우편함이 차 있는 동안, receive는 비어 있는 동안 while로 기다리고, 조건을 통과하면 상태를 바꾼 뒤 notifyAll()로 기다리는 스레드들에게 알립니다. isEmpty()까지 synchronized로 만든 것은 다른 스레드가 바꾼 message를 올바르게 읽으려면 같은 모니터가 필요하기 때문입니다.

알림에 notify() 대신 notifyAll()을 쓴 이유는 보내는 쪽과 받는 쪽이 같은 대기 집합에서 기다리기 때문입니다. notify()는 그중 아무나 하나만 깨우므로, 값을 넣은 뒤 받는 쪽이 아니라 다른 보내는 쪽을 깨울 수도 있습니다. 이 문제는 notify와 잘못된 깨움에서 다루고, 여기서는 안전한 notifyAll()을 기본으로 씁니다.

main은 받는 스레드와 보내는 스레드를 하나씩 시작한 뒤 둘 다 끝나기를 기다립니다. 실행 결과는 다음과 같고, 100번 실행해도 모두 같았습니다.

received=post-20
empty=true

두 스레드 가운데 누가 먼저 실행될지는 정해져 있지 않지만 출력은 언제나 같습니다. receiver가 먼저 receive()에 들어가면 빈 우편함을 보고 모니터를 내려놓은 채 기다리다가, sender의 알림을 받고 모니터를 다시 얻은 뒤 값을 꺼냅니다. sender가 먼저 값을 넣었다면 receiver는 while 조건이 처음부터 거짓이므로 기다리지 않고 바로 꺼냅니다. 어느 쪽이든 received=post-20이 출력되고, main은 두 스레드의 join이 끝난 뒤에 empty=true를 출력하므로 두 줄의 순서도 바뀌지 않습니다. 기다림이 실제로 일어나는 쪽, 즉 receiver가 먼저 들어간 경우를 단계별로 따라가면 다음과 같습니다.

단계스레드하는 일message모니터
1receivermessage가 null이므로 wait()null반납
2sender우편함이 비었으므로 기다리지 않고 값을 저장"post-20"sender
3sendernotifyAll()로 receiver를 대기 집합에서 꺼냄"post-20"sender
4sendersend()를 마치며 모니터 반납"post-20"없음
5receiver모니터를 다시 얻고 조건을 재확인한 뒤 값을 꺼내 null로 바꾸고 notifyAll()nullreceiver

조건·대기·알림은 우편함의 같은 모니터를 사용한다

같은 message 슬롯의 조건 검사→wait 모니터 반납→재획득/재검사→저장·제거·알림입니다. 알림이 소유권을 즉시 넘기지 않는 과정을 추적합니다.

조건·대기·알림은 우편함의 같은 모니터를 사용한다

MonitorMailbox의 send와 receive에서 조건 검사, wait, 상태 변경, notifyAll이 같은 this 모니터를 사용하는 범위를 비교합니다.

한 모니터 · 조건 재검사

조건·대기·알림은 우편함의 같은 모니터를 사용한다

send와 receive의 조건 검사·대기·변경·알림은 같은 this 모니터 안에 있습니다. 알림 자체는 소유권을 넘기지 않습니다.

같은 모니터의 두 술어와 재획득send는값있음을receive는값없음을기다립니다. wait는같은this모니터만반납하고복귀하려면재획득·조건재검사가필요합니다. 저장·제거후notifyAll도같은모니터보유중입니다.this 모니터 보유message 조건 검사send값 있음?receive값 없음?값 없을 때저장·알림값 있을 때제거·null알림조건 불충족 · wait모니터 반납복귀는 재획득·재검사
같은 모니터의 두 술어와 재획득send는값있음을receive는값없음을기다립니다. wait는같은this모니터만반납하고복귀하려면재획득·조건재검사가필요합니다. 저장·제거후notifyAll도같은모니터보유중입니다.this 모니터 보유message 조건 검사send값 있음?receive값 없음?값 없을 때저장·알림값 있을 때제거·null알림조건 불충족 · wait모니터 반납복귀는 재획득·재검사
설명과 원래 예시 값

send의 while

message와 대기 조건
값이 이미 있으면 wait합니다.
this 모니터
wait하는 동안 반납하고, 복귀하려면 다시 얻어야 합니다.

receive의 while

message와 대기 조건
값이 없으면 wait합니다.
this 모니터
복귀 뒤에도 같은 모니터 안에서 조건을 다시 검사합니다.

대기 조건을 통과한 뒤

message와 대기 조건
send는 값을 저장하고 receive는 값을 꺼내 null로 바꿉니다.
this 모니터
둘 다 변경 뒤 notifyAll을 부릅니다. 알림 자체는 모니터를 넘기지 않습니다.

wait는 호출 대상의 모니터만 반납합니다. 이 우편함에는 종료 상태가 없으며, 빈 슬롯은 receive가 기다리는 상태입니다.

이 예의 조건

  • send와 receive는 같은 this 모니터를 사용하며 종료 상태는 없습니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보조건·대기·알림은 우편함의 같은 모니터를 사용한다 조건·대기·알림은 우편함의 같은 모니터를 사용한다 MonitorMailbox의 send와 receive에서 조건 검사, wait, 상태 변경, notifyAll이 같은 this 모니터를 사용하는 범위를 비교합니다. 한 모니터 · 조건 재검사 조건·대기·알림은 우편함의 같은 모니터를 사용한다 send와 receive의 조건 검사·대기·변경·알림은 같은 this 모니터 안에 있습니다. 알림 자체는 소유권을 넘기지 않습니다. send 의 while message와 대기 조건 값이 이미 있으면 wait합니다. this 모니터 wait하는 동안 반납하고, 복귀하려면 다시 얻어야 합니다. receive 의 while message와 대기 조건 값이 없으면 wait합니다. this 모니터 복귀 뒤에도 같은 모니터 안에서 조건을 다시 검사합니다. 대기 조건을 통과한 뒤 message와 대기 조건 send는 값을 저장하고 receive는 값을 꺼내 null로 바꿉니다. this 모니터 둘 다 변경 뒤 notifyAll을 부릅니다. 알림 자체는 모니터를 넘기지 않습니다. wait는 호출 대상의 모니터만 반납합니다. 이 우편함에는 종료 상태가 없으며, 빈 슬롯은 receive가 기다리는 상태입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
대기 조건 검사

send는 값 있음, receive는 값 없음에서 while로 기다립니다.

  • send 대기: 슬롯 점유
  • receive 대기: 슬롯 비어 있음
  • wait는 호출 대상 this의 모니터를 반납합니다.
복귀와 재검사

wait에서 복귀하려면 this의 모니터를 다시 획득합니다.

  • 소유: 재획득 필요
  • 조건: while에서 재검사
  • 알림이 슬롯 상태나 모니터 소유권을 보장하지 않습니다.
상태 변경과 알림

send는 저장하고 receive는 꺼내 null로 바꿉니다.

  • 보호: this 안의 변경
  • 알림: 변경 후 notifyAll
  • 다른 대기자는 모니터를 얻은 뒤 자기 조건을 다시 확인합니다.

조건·대기·알림은 우편함의 같은 모니터를 사용한다

같은 message 슬롯의 조건 검사→wait 모니터 반납→재획득/재검사→저장·제거·알림입니다. 알림이 소유권을 즉시 넘기지 않는 과정을 추적합니다.

조건·대기·알림은 우편함의 같은 모니터를 사용한다

MonitorMailbox의 send와 receive에서 조건 검사, wait, 상태 변경, notifyAll이 같은 this 모니터를 사용하는 범위를 비교합니다.

한 모니터 · 조건 재검사

조건·대기·알림은 우편함의 같은 모니터를 사용한다

send와 receive의 조건 검사·대기·변경·알림은 같은 this 모니터 안에 있습니다. 알림 자체는 소유권을 넘기지 않습니다.

같은 모니터의 두 술어와 재획득send는값있음을receive는값없음을기다립니다. wait는같은this모니터만반납하고복귀하려면재획득·조건재검사가필요합니다. 저장·제거후notifyAll도같은모니터보유중입니다.this 모니터 보유message 조건 검사send값 있음?receive값 없음?값 없을 때저장·알림값 있을 때제거·null알림조건 불충족 · wait모니터 반납복귀는 재획득·재검사
같은 모니터의 두 술어와 재획득send는값있음을receive는값없음을기다립니다. wait는같은this모니터만반납하고복귀하려면재획득·조건재검사가필요합니다. 저장·제거후notifyAll도같은모니터보유중입니다.this 모니터 보유message 조건 검사send값 있음?receive값 없음?값 없을 때저장·알림값 있을 때제거·null알림조건 불충족 · wait모니터 반납복귀는 재획득·재검사
설명과 원래 예시 값

send의 while

message와 대기 조건
값이 이미 있으면 wait합니다.
this 모니터
wait하는 동안 반납하고, 복귀하려면 다시 얻어야 합니다.

receive의 while

message와 대기 조건
값이 없으면 wait합니다.
this 모니터
복귀 뒤에도 같은 모니터 안에서 조건을 다시 검사합니다.

대기 조건을 통과한 뒤

message와 대기 조건
send는 값을 저장하고 receive는 값을 꺼내 null로 바꿉니다.
this 모니터
둘 다 변경 뒤 notifyAll을 부릅니다. 알림 자체는 모니터를 넘기지 않습니다.

wait는 호출 대상의 모니터만 반납합니다. 이 우편함에는 종료 상태가 없으며, 빈 슬롯은 receive가 기다리는 상태입니다.

이 예의 조건

  • send와 receive는 같은 this 모니터를 사용하며 종료 상태는 없습니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보조건·대기·알림은 우편함의 같은 모니터를 사용한다 조건·대기·알림은 우편함의 같은 모니터를 사용한다 MonitorMailbox의 send와 receive에서 조건 검사, wait, 상태 변경, notifyAll이 같은 this 모니터를 사용하는 범위를 비교합니다. 한 모니터 · 조건 재검사 조건·대기·알림은 우편함의 같은 모니터를 사용한다 send와 receive의 조건 검사·대기·변경·알림은 같은 this 모니터 안에 있습니다. 알림 자체는 소유권을 넘기지 않습니다. send 의 while message와 대기 조건 값이 이미 있으면 wait합니다. this 모니터 wait하는 동안 반납하고, 복귀하려면 다시 얻어야 합니다. receive 의 while message와 대기 조건 값이 없으면 wait합니다. this 모니터 복귀 뒤에도 같은 모니터 안에서 조건을 다시 검사합니다. 대기 조건을 통과한 뒤 message와 대기 조건 send는 값을 저장하고 receive는 값을 꺼내 null로 바꿉니다. this 모니터 둘 다 변경 뒤 notifyAll을 부릅니다. 알림 자체는 모니터를 넘기지 않습니다. wait는 호출 대상의 모니터만 반납합니다. 이 우편함에는 종료 상태가 없으며, 빈 슬롯은 receive가 기다리는 상태입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
대기 조건 검사

send는 값 있음, receive는 값 없음에서 while로 기다립니다.

  • send 대기: 슬롯 점유
  • receive 대기: 슬롯 비어 있음
  • wait는 호출 대상 this의 모니터를 반납합니다.
복귀와 재검사

wait에서 복귀하려면 this의 모니터를 다시 획득합니다.

  • 소유: 재획득 필요
  • 조건: while에서 재검사
  • 알림이 슬롯 상태나 모니터 소유권을 보장하지 않습니다.
상태 변경과 알림

send는 저장하고 receive는 꺼내 null로 바꿉니다.

  • 보호: this 안의 변경
  • 알림: 변경 후 notifyAll
  • 다른 대기자는 모니터를 얻은 뒤 자기 조건을 다시 확인합니다.

5단계에서 receiver도 notifyAll()을 부르는 것은, 우편함이 비었다는 사실을 차 있는 우편함 앞에서 기다리는 send 호출들에게 알리기 위해서입니다. 이 예제에서는 보내는 스레드가 하나뿐이라 깨울 대상이 없지만, 보내는 쪽이 여럿이면 이 알림 없이는 그들이 영원히 기다릴 수 있습니다.

notifyAll()을 상태 변경보다 먼저 부르면 어떻게 될까요? 같은 synchronized 메서드 안이라면 결과는 같습니다. 깨어난 스레드는 이 메서드가 끝나 모니터가 풀릴 때까지 진행하지 못하므로, 실제로 조건을 확인할 때는 상태가 이미 바뀌어 있습니다. 그래도 '바꾼 다음 알린다'는 순서가 의도를 더 잘 드러내므로 상태 변경 뒤에 두는 것이 관례입니다. 순서보다 중요한 것은 변경과 알림이 모두 같은 모니터 안에서 일어나는 것입니다.

기다리는 중에 인터럽트되면

wait()가 검사 예외 InterruptedException을 던지도록 선언된 것은, 기다리는 스레드를 밖에서 멈출 수 있게 하기 위해서입니다. 기다리기 전이나 기다리는 동안 다른 스레드가 interrupt()를 부르면 wait()는 InterruptedException을 던지며 돌아옵니다. 이때 스레드의 인터럽트 상태는 지워지고, 예외는 모니터를 다시 얻은 뒤에 던져집니다. 인터럽트 상태와 취소 처리의 기본은 interrupt 플래그와 InterruptedException 처리에서 다뤘습니다.

MonitorMailbox의 receive()는 이 예외를 잡지 않고 throws InterruptedException으로 호출자에게 넘깁니다. 만약 receive() 안에서 예외를 잡고 null을 돌려준다면, 호출자는 '취소되었다'와 '받은 값이 없다'를 구별할 수 없습니다. 예외를 넘기면 receive()는 message를 건드리지 않은 채 빠져나가므로 우편함의 상태도 그대로 남습니다. 아래 프로그램은 메시지를 보내지 않고 기다리는 작업자 스레드를 인터럽트해서 이 흐름을 확인합니다.

src/InterruptibleMailboxWorker.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

public final class InterruptibleMailboxWorker {
    public static void main(String[] args) throws InterruptedException {
        MonitorMailbox<String> mailbox = new MonitorMailbox<>();
        List<String> handled = new ArrayList<>();
        CountDownLatch waiting = new CountDownLatch(1);

        Thread worker = Thread.ofPlatform().name("mail-worker").start(() -> {
            try {
                waiting.countDown();
                handled.add(mailbox.receive());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        waiting.await();
        while (worker.getState() != Thread.State.WAITING) {
            Thread.onSpinWait();
        }
        worker.interrupt();
        worker.join();
        System.out.println("handled=" + handled.size());
        System.out.println("interrupted=" + worker.isInterrupted());
    }
}

이 프로그램은 앞의 MonitorMailbox를 쓰므로 두 파일을 같은 src 폴더에 두고 함께 컴파일해야 합니다. 실행 결과는 100번 모두 같았습니다.

javac -d out src/MonitorMailbox.java src/InterruptibleMailboxWorker.java
java -cp out InterruptibleMailboxWorker
handled=0
interrupted=true

main은 작업자 스레드가 receive() 안에서 기다리기 시작한 것을 확인한 뒤 interrupt()를 부릅니다. 그러면 wait()가 InterruptedException을 던지고, 예외가 receive()를 거쳐 람다의 catch까지 올라가므로 handled.add(...)는 실행되지 않습니다. 그래서 handled=0입니다. catch에서는 Thread.currentThread().interrupt()로 지워진 인터럽트 상태를 되살린 뒤 스레드를 끝내고, main은 join()으로 종료를 기다렸다가 그 상태를 읽어 interrupted=true를 출력합니다.

마지막 줄에는 JDK 구현의 성질도 관여합니다. JDK 21은 스레드가 끝난 뒤에도 마지막 인터럽트 상태를 기억해 isInterrupted()로 알려 주지만, JDK 8 같은 예전 버전은 끝난 스레드에 대해 항상 false를 돌려주었습니다. 취소 여부를 스레드 밖에서 확실히 알아야 한다면 이 값에 기대기보다 결과를 따로 기록하는 편이 안전합니다.

작업자 스레드가 기다리기 시작했는지는 두 단계로 확인합니다. waiting 래치는 작업자 스레드가 receive()를 부르기 직전에 열리므로, 래치를 통과했다고 해서 작업자 스레드가 이미 wait() 안에 있다는 뜻은 아닙니다. 그래서 main은 이어서 getState()가 WAITING이 될 때까지 Thread.onSpinWait()로 바쁘게 기다립니다. 사실 인터럽트가 wait() 호출보다 먼저 도착해도 wait()는 곧바로 InterruptedException을 던지므로 출력은 같습니다. 이 확인은 기다리는 도중에 인터럽트가 도착하는 상황을 보여 주기 위한 실험 장치입니다.

시작 신호·상태 관측·취소 결과는 서로 다른 경계다

호출 직전 래치·WAITING 순간 관측·interrupt·join 뒤 취소 결과가 실제로 이어집니다. 준비 신호와 완료 확인을 단계로 구분할 수 있습니다.

시작 신호·상태 관측·취소 결과는 서로 다른 경계다

InterruptibleMailboxWorker가 메시지 없이 기다리는 작업자에게 인터럽트를 보내고 join 뒤 두 결과를 읽는 경계를 구분합니다.

시작 · 관측 · 취소

시작 신호·상태 관측·취소 결과는 서로 다른 경계다

래치는 호출 직전 신호이고 WAITING은 순간 관측입니다. 취소 결과는 작업자를 join한 뒤 읽습니다.

래치·순간 관측·join의 경계메시지를보내지않는 실행에서 호출직전래치와receive wait는 구분됩니다. main의WAITING관측뒤interrupt가receive예외와add건너뜀을만들며 join뒤에만handled0과interruptedtrue를읽습니다.receive 호출 직전작업자 래치 신호receive빈 슬롯 waitmain의래치 awaitreceive 예외add 건너뜀WAITING 조회interruptworker.join 뒤handled=0interrupted=true
래치·순간 관측·join의 경계메시지를보내지않는 실행에서 호출직전래치와receive wait는 구분됩니다. main의WAITING관측뒤interrupt가receive예외와add건너뜀을만들며 join뒤에만handled0과interruptedtrue를읽습니다.receive 호출 직전작업자 래치 신호receive빈 슬롯waitmain의래치 awaitreceive예외add건너뜀WAITING조회interruptworker.join 뒤handled=0interrupted=true
설명과 원래 예시 값
  1. waiting.await() 뒤

    알 수 있는 사실
    작업자가 receive 호출 직전의 신호를 보냈습니다.
    한계
    wait 진입 완료를 뜻하지 않습니다.
  2. WAITING 조회 뒤

    알 수 있는 사실
    이 상태를 관측한 뒤 작업자에게 interrupt를 보냅니다.
    한계
    상태 폴링에는 시간 제한이 없습니다.
  3. worker.join() 뒤

    알 수 있는 사실
    이번 실행은 handled=0 다음 interrupted=true입니다.
    한계
    receive의 예외로 add를 건너뛰고 바깥 catch가 플래그를 복구합니다.

빈 값인 null을 정상 반환해 취소를 숨기지 않습니다. 이 실행은 메시지를 보내지 않았으며 처리 성공 경로나 일정 시간 내 종료를 입증하지 않습니다.

이 예의 조건

  • 메시지를 보내지 않은 실행이며 상태 폴링에는 시간 제한이 없습니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보시작 신호·상태 관측·취소 결과는 서로 다른 경계다 시작 신호·상태 관측·취소 결과는 서로 다른 경계다 InterruptibleMailboxWorker가 메시지 없이 기다리는 작업자에게 인터럽트를 보내고 join 뒤 두 결과를 읽는 경계를 구분합니다. 시작 · 관측 · 취소 시작 신호·상태 관측·취소 결과는 서로 다른 경계다 래치는 호출 직전 신호이고 WAITING은 순간 관측입니다. 취소 결과는 작업자를 join한 뒤 읽습니다. waiting.await() 뒤 알 수 있는 사실 작업자가 receive 호출 직전의 신호를 보냈습니다. 한계 wait 진입 완료를 뜻하지 않습니다. WAITING 조회 뒤 알 수 있는 사실 이 상태를 관측한 뒤 작업자에게 interrupt를 보냅니다. 한계 상태 폴링에는 시간 제한이 없습니다. worker.join() 뒤 알 수 있는 사실 이번 실행은 handled=0 다음 interrupted=true 입니다. 한계 receive의 예외로 add를 건너뛰고 바깥 catch가 플래그를 복구합니다. 빈 값인 null을 정상 반환해 취소를 숨기지 않습니다. 이 실행은 메시지를 보내지 않았으며 처리 성공 경로나 일정 시간 내 종료를 입증하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
호출 직전 신호

작업자가 receive 직전 waiting 래치를 해제합니다.

  • main: waiting.await 반환
  • 확인 범위: 호출 직전, wait 진입 전일 수 있음
  • 시작 신호는 대기 진입 완료가 아닙니다.
상태 관측과 요청

main이 WAITING을 관측한 뒤 interrupt합니다.

  • 관측: 한 순간 WAITING
  • receive: 중단 예외 경로
  • 예외가 정상 add를 건너뛰게 합니다.
종료 후 결과

바깥 catch가 플래그를 복원하고 worker.join 뒤 조회합니다.

  • handled: 0
  • interrupted: true
  • null을 정상 반환해 취소를 숨기지 않습니다.

시작 신호·상태 관측·취소 결과는 서로 다른 경계다

호출 직전 래치·WAITING 순간 관측·interrupt·join 뒤 취소 결과가 실제로 이어집니다. 준비 신호와 완료 확인을 단계로 구분할 수 있습니다.

시작 신호·상태 관측·취소 결과는 서로 다른 경계다

InterruptibleMailboxWorker가 메시지 없이 기다리는 작업자에게 인터럽트를 보내고 join 뒤 두 결과를 읽는 경계를 구분합니다.

시작 · 관측 · 취소

시작 신호·상태 관측·취소 결과는 서로 다른 경계다

래치는 호출 직전 신호이고 WAITING은 순간 관측입니다. 취소 결과는 작업자를 join한 뒤 읽습니다.

래치·순간 관측·join의 경계메시지를보내지않는 실행에서 호출직전래치와receive wait는 구분됩니다. main의WAITING관측뒤interrupt가receive예외와add건너뜀을만들며 join뒤에만handled0과interruptedtrue를읽습니다.receive 호출 직전작업자 래치 신호receive빈 슬롯 waitmain의래치 awaitreceive 예외add 건너뜀WAITING 조회interruptworker.join 뒤handled=0interrupted=true
래치·순간 관측·join의 경계메시지를보내지않는 실행에서 호출직전래치와receive wait는 구분됩니다. main의WAITING관측뒤interrupt가receive예외와add건너뜀을만들며 join뒤에만handled0과interruptedtrue를읽습니다.receive 호출 직전작업자 래치 신호receive빈 슬롯waitmain의래치 awaitreceive예외add건너뜀WAITING조회interruptworker.join 뒤handled=0interrupted=true
설명과 원래 예시 값
  1. waiting.await() 뒤

    알 수 있는 사실
    작업자가 receive 호출 직전의 신호를 보냈습니다.
    한계
    wait 진입 완료를 뜻하지 않습니다.
  2. WAITING 조회 뒤

    알 수 있는 사실
    이 상태를 관측한 뒤 작업자에게 interrupt를 보냅니다.
    한계
    상태 폴링에는 시간 제한이 없습니다.
  3. worker.join() 뒤

    알 수 있는 사실
    이번 실행은 handled=0 다음 interrupted=true입니다.
    한계
    receive의 예외로 add를 건너뛰고 바깥 catch가 플래그를 복구합니다.

빈 값인 null을 정상 반환해 취소를 숨기지 않습니다. 이 실행은 메시지를 보내지 않았으며 처리 성공 경로나 일정 시간 내 종료를 입증하지 않습니다.

이 예의 조건

  • 메시지를 보내지 않은 실행이며 상태 폴링에는 시간 제한이 없습니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보시작 신호·상태 관측·취소 결과는 서로 다른 경계다 시작 신호·상태 관측·취소 결과는 서로 다른 경계다 InterruptibleMailboxWorker가 메시지 없이 기다리는 작업자에게 인터럽트를 보내고 join 뒤 두 결과를 읽는 경계를 구분합니다. 시작 · 관측 · 취소 시작 신호·상태 관측·취소 결과는 서로 다른 경계다 래치는 호출 직전 신호이고 WAITING은 순간 관측입니다. 취소 결과는 작업자를 join한 뒤 읽습니다. waiting.await() 뒤 알 수 있는 사실 작업자가 receive 호출 직전의 신호를 보냈습니다. 한계 wait 진입 완료를 뜻하지 않습니다. WAITING 조회 뒤 알 수 있는 사실 이 상태를 관측한 뒤 작업자에게 interrupt를 보냅니다. 한계 상태 폴링에는 시간 제한이 없습니다. worker.join() 뒤 알 수 있는 사실 이번 실행은 handled=0 다음 interrupted=true 입니다. 한계 receive의 예외로 add를 건너뛰고 바깥 catch가 플래그를 복구합니다. 빈 값인 null을 정상 반환해 취소를 숨기지 않습니다. 이 실행은 메시지를 보내지 않았으며 처리 성공 경로나 일정 시간 내 종료를 입증하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
호출 직전 신호

작업자가 receive 직전 waiting 래치를 해제합니다.

  • main: waiting.await 반환
  • 확인 범위: 호출 직전, wait 진입 전일 수 있음
  • 시작 신호는 대기 진입 완료가 아닙니다.
상태 관측과 요청

main이 WAITING을 관측한 뒤 interrupt합니다.

  • 관측: 한 순간 WAITING
  • receive: 중단 예외 경로
  • 예외가 정상 add를 건너뛰게 합니다.
종료 후 결과

바깥 catch가 플래그를 복원하고 worker.join 뒤 조회합니다.

  • handled: 0
  • interrupted: true
  • null을 정상 반환해 취소를 숨기지 않습니다.

이 실험 장치에는 시간 제한이 없다는 점도 알아 두어야 합니다. 상태를 확인하는 반복문과 join()이 제한 없이 기다리므로, 코드를 바꿔 실험하다가 작업자 스레드가 WAITING에 들어가지 못하게 되면 main도 끝나지 않습니다. 실험을 변형할 때는 join(1000)처럼 시간 제한을 두는 편이 좋습니다. 실제 서비스 코드는 이렇게 스레드 상태를 바쁘게 확인하며 순서를 맞추지 않고, 래치나 큐 같은 동기화 도구로 시작과 종료를 주고받습니다.

더 깊이: wait는 그 객체의 모니터만 내려놓는다

wait()가 내려놓는 것은 호출 대상 객체의 모니터뿐입니다. Object.wait API 문서는 이 객체의 락만 풀리고, 현재 스레드가 쥐고 있는 다른 객체의 락은 기다리는 동안에도 그대로 유지된다고 밝힙니다. 예를 들어 synchronized (registry) 블록 안에서 mailbox.receive()를 부르면 작업자 스레드는 mailbox의 모니터만 놓고 registry의 모니터는 쥔 채 잠듭니다. 이때 메시지를 보낼 스레드가 send()를 부르기 전에 registry의 모니터를 먼저 얻어야 한다면, 그 스레드는 들어오지 못하고 두 스레드가 함께 멈춥니다.

같은 객체의 모니터를 여러 번 얻은 경우는 반대입니다. synchronized 메서드 안에서 같은 객체의 다른 synchronized 메서드를 불러 모니터를 두 겹으로 얻은 상태(재진입)라도, wait()는 그 객체에 대한 소유를 모두 내려놓았다가 돌아올 때 원래 상태 그대로 되돌립니다. 그러니 기다리는 동안 문제가 되는 것은 다른 객체의 락입니다. wait()를 부르기 전에 다른 락을 쥐고 있지 않은지 확인해야, 그 락이 필요한 스레드들까지 함께 멈추는 일을 막을 수 있습니다.

wait·notify를 직접 쓸지 판단하기

wait와 notify는 자바의 동기화 규칙을 이해하는 데 꼭 필요하지만, 새 코드를 쓸 때 첫 번째 선택은 아닙니다. 스레드 사이에 값을 주고받는 일이라면 java.util.concurrent 패키지의 BlockingQueue가 용량 제한, 시간 제한, 인터럽트 처리를 이미 갖추고 있습니다. 직접 구현할 만한 것은 기다리는 조건이 큐의 표준 연산으로 표현되지 않을 때나, 이 글처럼 원리를 배울 때입니다. 직접 구현한다면 다음 질문에 먼저 답해 보세요.

확인할 질문정하는 방법
기다리는 조건의 상태를 어느 객체가 보호하는가조건 검사, wait, 상태 변경, 알림을 모두 그 객체의 모니터 안에 둡니다
기다리다 취소될 수 있는가InterruptedException을 호출자에게 넘기고, null 같은 정상 값으로 바꾸지 않습니다
기다리는 조건이 여러 종류인가한 대기 집합에서는 notifyAll을 쓰고, 조건별로 대기실을 나누려면 Condition을 검토합니다
표준 라이브러리로 해결되는가용량, 시간 제한, 인터럽트 처리를 갖춘 BlockingQueue를 먼저 검토합니다

표의 셋째 줄에 나온 Condition은 ReentrantLock과 Condition에서, 넷째 줄의 표준 큐에 맡기는 방법은 ArrayBlockingQueue로 생산자·소비자 만들기에서 다룹니다. Condition도 이 글의 규칙, 즉 락을 쥔 채 조건을 확인하고 반복문 안에서 기다리며 상태를 바꾼 뒤 알리는 방식을 그대로 따릅니다. BlockingQueue는 그 규칙을 라이브러리 안에서 대신 지켜 줍니다.

자주 하는 실수

첫째, 대상 객체를 잘못 고르는 실수입니다. synchronized (lock) 안에서 그냥 wait()라고 쓰면 this.wait()가 되어 lock이 아니라 this의 모니터를 요구합니다. 두 객체가 다르면 모니터를 쥐고 있는데도 IllegalMonitorStateException이 납니다. '모니터를 가졌다'는 말은 언제나 특정 객체의 모니터를 가졌다는 뜻이므로 lock.wait()처럼 대상을 적어야 합니다.

synchronized (lock) {
    while (!ready) {
        wait();  // this.wait(): lock이 아니라 this의 모니터가 필요하다
    }
}

둘째, 인터럽트를 삼키는 실수입니다. 아래처럼 wait()의 예외를 잡고 아무것도 하지 않으면 반복문이 곧바로 다시 wait()를 부르므로, 이 스레드는 인터럽트로 멈출 수 없게 됩니다. 예외를 호출자에게 넘기거나, 넘길 수 없는 자리라면 Thread.currentThread().interrupt()로 상태를 되살리고 빠져나와야 합니다.

while (message == null) {
    try {
        wait();
    } catch (InterruptedException ignored) {
        // 취소 요청이 사라지고 반복문이 다시 기다린다
    }
}

셋째, 이미 본 실수들입니다. 락 밖에서 조건을 확인하면 신호 손실이 생기고, wait 대신 sleep으로 기다리면 모니터를 쥔 채 잠듭니다. while 대신 if로 기다리면 깨어난 뒤 조건을 다시 확인하지 못하는데, 이것이 다음 글의 주제입니다.

정리

  • wait()는 그 객체의 모니터를 내려놓고 대기 집합에서 잠들고, notify()는 그중 하나를, notifyAll()은 모두를 깨웁니다. 세 메서드 모두 그 객체의 모니터를 가진 스레드만 부를 수 있으며, 어기면 IllegalMonitorStateException이 납니다.
  • 모니터를 요구하는 이유는 조건 확인과 대기 사이에 알림이 끼어들어 사라지는 신호 손실을 막기 위해서입니다. 기다리는 조건과 그 조건을 바꾸는 코드는 모두 한 객체의 모니터로 보호합니다.
  • 깨어난 스레드는 알림을 보낸 스레드가 블록을 끝내 모니터를 놓은 뒤, 다른 스레드와 경쟁해 모니터를 다시 얻어야 실행됩니다. 그동안 상태는 BLOCKED입니다.
  • 깨어났다고 조건이 참인 것은 아니므로 wait()는 언제나 while 안에서 부릅니다.
  • 기다리다 인터럽트되면 InterruptedException이 발생하고 인터럽트 상태는 지워집니다. 예외를 호출자에게 넘기거나 상태를 되살리고, null 같은 정상 값으로 바꾸지 않습니다.

연습 문제

  1. WaitReleaseTrace의 main에서 after notifyAll 줄을 출력한 바로 뒤, 즉 synchronized 블록 안에 아래 두 줄을 추가하면 출력이 어떻게 바뀔지 예측하세요. waiter: woke up 줄은 언제 나오나요?
Thread.sleep(1000);
System.out.println("main: 1s later, waiter=" + waiter.getState());
  1. MonitorMailbox의 receive()에서 synchronized를 빼면 실행할 때 무슨 일이 생기나요? 또 자바가 그런 호출을 허용했다면 어떤 실행 순서에서 메시지를 영영 받지 못하게 되는지 설명하세요.
  2. 두 스레드가 한 칸을 공유합니다. 클라이언트는 정수 요청을 보내고 그 값의 두 배가 응답으로 올 때까지 기다리며, 서버는 요청을 읽은 뒤 응답을 기록합니다. 요청을 연달아 두 번 덮어쓰지 못하고, 응답을 꺼내 가기 전에는 다음 요청을 넣을 수 없도록 모니터 조건을 작성하세요.

힌트

  1. sleep은 모니터를 놓지 않습니다.
  2. receive() 안의 wait()는 this.wait()입니다. 신호 손실을 설명한 표를 떠올리세요.
  3. 한 칸의 상태를 EMPTY, REQUEST_READY, RESPONSE_READY 세 단계로 나눠 보세요.
1번 정답과 해설

main이 모니터를 쥔 채 1초 동안 잠들기 때문에, waiter는 그동안 내내 BLOCKED로 남습니다. notifyAll()로 대기 집합에서는 나왔지만, 모니터는 main이 synchronized 블록을 빠져나가야 풀립니다. 실제로 실행하면 다음과 같이 출력되고, waiter: woke up 줄은 1초가 지나 main이 블록을 나온 뒤에야 나옵니다.

waiter: ready=false, calling wait
main: got the monitor, waiter=WAITING
main: after notifyAll, waiter=BLOCKED
main: 1s later, waiter=BLOCKED
waiter: woke up, ready=true
main: after join, waiter=TERMINATED

알림은 깨울 스레드를 대기 집합에서 꺼낼 뿐 모니터를 넘기지 않는다는 것, 그리고 sleep은 모니터를 놓지 않는다는 것을 함께 보여 주는 결과입니다. 알림을 보낸 뒤 오래 걸리는 작업을 블록 안에서 하면 깨운 스레드들이 그만큼 입구에서 기다리게 되므로, 알림 뒤의 작업은 짧게 끝내는 것이 좋습니다.

2번 정답과 해설

receive()가 synchronized가 아니면 그 안의 wait()는 this의 모니터 없이 호출되므로, 실행 중에 IllegalMonitorStateException이 발생합니다. 컴파일은 그대로 됩니다.

자바가 이 호출을 허용했다고 가정하면 신호 손실이 생길 수 있습니다. 받는 스레드가 message == null을 확인한 직후, wait()를 부르기 전에 보내는 스레드가 값을 넣고 notifyAll()을 부르면 그 알림은 아직 아무도 기다리지 않을 때 도착해 사라집니다. 이어서 받는 스레드가 wait()로 잠들면 우편함에 값이 있는데도 깨워 줄 알림이 다시 오지 않습니다. 보내는 쪽이 더 없다면 받는 스레드는 영원히 기다리고, 우편함이 차 있으니 다음 send()를 부르는 스레드도 영원히 기다립니다.

3번 정답과 해설

상태를 세 단계로 나누면 각 메서드가 기다릴 조건이 분명해집니다. request는 EMPTY가 될 때까지, awaitRequest는 REQUEST_READY가 될 때까지, awaitResponse는 RESPONSE_READY가 될 때까지 while로 기다립니다. 단계를 바꾸는 메서드는 request, respond, awaitResponse 세 개이고, 각각 단계를 바꾼 뒤 notifyAll()로 다른 역할에게 알립니다. awaitRequest는 값을 읽기만 하고 단계는 바꾸지 않습니다. respond는 기다리지 않는 대신, 요청이 없는 단계에서 불리면 잘못 쓴 것이므로 IllegalStateException을 던집니다.

exercise/RequestResponseHandshakeSolution.java
public final class RequestResponseHandshakeSolution {
    private enum Phase { EMPTY, REQUEST_READY, RESPONSE_READY }

    private Phase phase = Phase.EMPTY;
    private int payload;

    synchronized void request(int value) throws InterruptedException {
        while (phase != Phase.EMPTY) wait();
        payload = value;
        phase = Phase.REQUEST_READY;
        notifyAll();
    }

    synchronized int awaitRequest() throws InterruptedException {
        while (phase != Phase.REQUEST_READY) wait();
        return payload;
    }

    synchronized void respond(int value) {
        if (phase != Phase.REQUEST_READY) throw new IllegalStateException("phase=" + phase);
        payload = value;
        phase = Phase.RESPONSE_READY;
        notifyAll();
    }

    synchronized int awaitResponse() throws InterruptedException {
        while (phase != Phase.RESPONSE_READY) wait();
        int response = payload;
        phase = Phase.EMPTY;
        notifyAll();
        return response;
    }

    public static void main(String[] args) throws InterruptedException {
        RequestResponseHandshakeSolution channel = new RequestResponseHandshakeSolution();
        Thread server = Thread.ofPlatform().start(() -> {
            try {
                int request = channel.awaitRequest();
                channel.respond(request * 2);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        channel.request(21);
        System.out.println("response=" + channel.awaitResponse());
        server.join();
    }
}

실행하면 response=42가 출력되고, 100번 실행해도 모두 같았습니다. 클라이언트의 request(21)과 서버의 awaitRequest() 가운데 어느 쪽이 먼저 실행되든, 서버는 REQUEST_READY가 된 뒤에야 21을 읽고 클라이언트는 RESPONSE_READY가 된 뒤에야 42를 꺼내기 때문입니다.

응답을 소비해야 다음 요청을 넣을 수 있다

EMPTY→REQUEST_READY 21→RESPONSE_READY 42→응답 회수/EMPTY의 한 슬롯 프로토콜입니다. awaitRequest는 단계 유지라는 점을 같은 상태판에서 보여 줍니다.

응답을 소비해야 다음 요청을 넣을 수 있다

RequestResponseHandshakeSolution에서 request, respond, awaitResponse가 EMPTY, REQUEST_READY, RESPONSE_READY 사이를 바꾸며 awaitRequest는 상태를 바꾸지 않습니다.

한 슬롯 · 상태 순환

응답을 소비해야 다음 요청을 넣을 수 있다

request는 요청 상태를 만들고 respond는 응답 상태를 만듭니다. 응답을 소비한 뒤에야 다음 요청을 받을 수 있습니다.

응답을 소비해야 EMPTY로 돌아가는 한 슬롯EMPTY에서 request가 21을 기록해 REQUEST_READY로 바꿉니다. awaitRequest는 값을 읽지만 단계를 유지합니다. respond가 42를 기록해 RESPONSE_READY로 바꾸고 awaitResponse가 42를 반환하며 EMPTY로 바꿉니다.request()respond()awaitResponse(): 42 반환EMPTY다음 요청 가능REQUEST_READY요청 값 21awaitRequest()값만 읽고 단계 유지RESPONSE_READY응답 값 42
응답을 소비해야 다음 요청이 가능한 한 슬롯EMPTY에서request21로REQUEST_READY가된다. awaitRequest는읽기만한다. respond42로RESPONSE_READY가되고awaitResponse가42를반환하며EMPTY로돌아간다. 한클라이언트·한서버·한라운드이며복수서버의독점규약은없다.EMPTY다음 요청 가능request()REQUEST_READY요청 값 21awaitRequest()값만 읽고 단계 유지respond()RESPONSE_READY응답 값 42awaitResponse()42 반환 · EMPTY로
EMPTY
호출과 값: request(21) — payload에 21을 기록
다음 단계: REQUEST_READY
REQUEST_READY
호출과 값: awaitRequest() — 값만 읽음
다음 단계: REQUEST_READY 유지
REQUEST_READY
호출과 값: respond(42) — 응답 값을 기록
다음 단계: RESPONSE_READY
RESPONSE_READY
호출과 값: awaitResponse() — 42를 반환하고 단계를 EMPTY로 바꿈
다음 단계: EMPTY

이번 main은 클라이언트 1개·서버 1개·한 라운드이며 response=42를 출력합니다. awaitRequest는 요청을 독점하지 않으므로 여러 서버가 같은 요청을 읽지 못하게 하는 규약은 아닙니다.

이 예의 조건

  • 한 슬롯의 단계와 값 21→42를 추적합니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보응답을 소비해야 다음 요청을 넣을 수 있다 응답을 소비해야 다음 요청을 넣을 수 있다 RequestResponseHandshakeSolution에서 request, respond, awaitResponse가 EMPTY, REQUEST_READY, RESPONSE_READY 사이를 바꾸며 awaitRequest는 상태를 바꾸지 않습니다. 한 슬롯 · 상태 순환 응답을 소비해야 다음 요청을 넣을 수 있다 request는 요청 상태를 만들고 respond는 응답 상태를 만듭니다. 응답을 소비한 뒤에야 다음 요청을 받을 수 있습니다. EMPTY 호출과 값: request(21) — payload에 21을 기록 다음 단계: REQUEST_READY REQUEST_READY 호출과 값: awaitRequest() — 값만 읽음 다음 단계: REQUEST_READY 유지 REQUEST_READY 호출과 값: respond(42) — 응답 값을 기록 다음 단계: RESPONSE_READY RESPONSE_READY 호출과 값: awaitResponse() — 42를 반환하고 단계를 EMPTY로 바꿈 다음 단계: EMPTY 이번 main은 클라이언트 1개·서버 1개·한 라운드이며 response=42를 출력합니다. awaitRequest는 요청을 독점하지 않으므로 여러 서버가 같은 요청을 읽지 못하게 하는 규약은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
요청 등록

EMPTY에서 request가 21을 기록합니다.

  • 단계: REQUEST_READY
  • 요청값: 21
  • 요청 등록이 다음 요청 가능 상태를 끝냅니다.
요청 읽기와 응답

awaitRequest는 단계 유지, respond가 42를 기록합니다.

  • 읽기 직후: REQUEST_READY 유지
  • 응답 직후: RESPONSE_READY,42
  • 요청을 읽는 것만으로 슬롯을 비우지 않습니다.
응답 소비

awaitResponse가 42를 반환하고 EMPTY로 바꿉니다.

  • 반환: 42
  • 단계: EMPTY
  • 응답을 소비해야 다음 요청이 가능합니다.

응답을 소비해야 다음 요청을 넣을 수 있다

EMPTY→REQUEST_READY 21→RESPONSE_READY 42→응답 회수/EMPTY의 한 슬롯 프로토콜입니다. awaitRequest는 단계 유지라는 점을 같은 상태판에서 보여 줍니다.

응답을 소비해야 다음 요청을 넣을 수 있다

RequestResponseHandshakeSolution에서 request, respond, awaitResponse가 EMPTY, REQUEST_READY, RESPONSE_READY 사이를 바꾸며 awaitRequest는 상태를 바꾸지 않습니다.

한 슬롯 · 상태 순환

응답을 소비해야 다음 요청을 넣을 수 있다

request는 요청 상태를 만들고 respond는 응답 상태를 만듭니다. 응답을 소비한 뒤에야 다음 요청을 받을 수 있습니다.

응답을 소비해야 EMPTY로 돌아가는 한 슬롯EMPTY에서 request가 21을 기록해 REQUEST_READY로 바꿉니다. awaitRequest는 값을 읽지만 단계를 유지합니다. respond가 42를 기록해 RESPONSE_READY로 바꾸고 awaitResponse가 42를 반환하며 EMPTY로 바꿉니다.request()respond()awaitResponse(): 42 반환EMPTY다음 요청 가능REQUEST_READY요청 값 21awaitRequest()값만 읽고 단계 유지RESPONSE_READY응답 값 42
응답을 소비해야 다음 요청이 가능한 한 슬롯EMPTY에서request21로REQUEST_READY가된다. awaitRequest는읽기만한다. respond42로RESPONSE_READY가되고awaitResponse가42를반환하며EMPTY로돌아간다. 한클라이언트·한서버·한라운드이며복수서버의독점규약은없다.EMPTY다음 요청 가능request()REQUEST_READY요청 값 21awaitRequest()값만 읽고 단계 유지respond()RESPONSE_READY응답 값 42awaitResponse()42 반환 · EMPTY로
EMPTY
호출과 값: request(21) — payload에 21을 기록
다음 단계: REQUEST_READY
REQUEST_READY
호출과 값: awaitRequest() — 값만 읽음
다음 단계: REQUEST_READY 유지
REQUEST_READY
호출과 값: respond(42) — 응답 값을 기록
다음 단계: RESPONSE_READY
RESPONSE_READY
호출과 값: awaitResponse() — 42를 반환하고 단계를 EMPTY로 바꿈
다음 단계: EMPTY

이번 main은 클라이언트 1개·서버 1개·한 라운드이며 response=42를 출력합니다. awaitRequest는 요청을 독점하지 않으므로 여러 서버가 같은 요청을 읽지 못하게 하는 규약은 아닙니다.

이 예의 조건

  • 한 슬롯의 단계와 값 21→42를 추적합니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보응답을 소비해야 다음 요청을 넣을 수 있다 응답을 소비해야 다음 요청을 넣을 수 있다 RequestResponseHandshakeSolution에서 request, respond, awaitResponse가 EMPTY, REQUEST_READY, RESPONSE_READY 사이를 바꾸며 awaitRequest는 상태를 바꾸지 않습니다. 한 슬롯 · 상태 순환 응답을 소비해야 다음 요청을 넣을 수 있다 request는 요청 상태를 만들고 respond는 응답 상태를 만듭니다. 응답을 소비한 뒤에야 다음 요청을 받을 수 있습니다. EMPTY 호출과 값: request(21) — payload에 21을 기록 다음 단계: REQUEST_READY REQUEST_READY 호출과 값: awaitRequest() — 값만 읽음 다음 단계: REQUEST_READY 유지 REQUEST_READY 호출과 값: respond(42) — 응답 값을 기록 다음 단계: RESPONSE_READY RESPONSE_READY 호출과 값: awaitResponse() — 42를 반환하고 단계를 EMPTY로 바꿈 다음 단계: EMPTY 이번 main은 클라이언트 1개·서버 1개·한 라운드이며 response=42를 출력합니다. awaitRequest는 요청을 독점하지 않으므로 여러 서버가 같은 요청을 읽지 못하게 하는 규약은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
요청 등록

EMPTY에서 request가 21을 기록합니다.

  • 단계: REQUEST_READY
  • 요청값: 21
  • 요청 등록이 다음 요청 가능 상태를 끝냅니다.
요청 읽기와 응답

awaitRequest는 단계 유지, respond가 42를 기록합니다.

  • 읽기 직후: REQUEST_READY 유지
  • 응답 직후: RESPONSE_READY,42
  • 요청을 읽는 것만으로 슬롯을 비우지 않습니다.
응답 소비

awaitResponse가 42를 반환하고 EMPTY로 바꿉니다.

  • 반환: 42
  • 단계: EMPTY
  • 응답을 소비해야 다음 요청이 가능합니다.

awaitRequest가 단계를 바꾸지 않기 때문에 이 방식은 서버가 하나일 때만 맞습니다. 서버가 둘이면 두 서버가 같은 요청을 함께 읽을 수 있습니다. 그러면 늦게 respond를 부른 서버는 단계가 이미 바뀐 것을 보고 IllegalStateException을 던지거나, 그사이 클라이언트가 다음 요청을 넣었다면 그 요청에 엉뚱한 응답을 기록합니다. 클라이언트나 서버가 여럿이라면 요청마다 식별자를 붙이고 큐를 쓰는 식으로 구조를 바꿔야 하며, 한 칸짜리 방식을 억지로 늘리는 것은 좋은 방법이 아닙니다.

다음 글

wait를 while 안에서 부르는 이유에서는 wait를 if로 감쌌을 때 소비자 둘이 값 하나를 두고 다투면 어떤 예외가 나는지 실행으로 확인합니다. 이어서 가짜 깨움까지 같은 코드로 처리하는 while 반복과, 종료 조건을 갖춘 작업 큐를 만듭니다.