안동민 개발노트

본문 시작

wait·notify와 모니터

Object 대기 집합에 들어가기 위한 선행 조건, 락 반납, 신호 뒤 재경쟁을 실행으로 확인하고 단일 슬롯 버퍼를 완성합니다.

Object.wait()는 단순히 스레드를 멈추는 메서드가 아닙니다.

호출 대상 객체의 모니터를 현재 스레드가 소유하고 있을 때만 사용할 수 있고, 대기 진입과 동시에 그 모니터를 반납합니다.

notify()도 같은 모니터를 소유한 코드에서 호출해야 합니다.

이 규칙 덕분에 조건 검사, 대기 등록, 상태 변경, 알림이 하나의 동기화 규약으로 묶입니다.

신호를 받은 스레드는 곧바로 임계 영역을 실행하지 않습니다.

객체의 대기 집합에서 나와 모니터 획득 경쟁에 참여하며, 알림을 보낸 스레드가 synchronized 블록을 끝낸 뒤에야 다시 진입할 수 있습니다.

그러므로 로그에서 notify 다음 줄에 대기자가 즉시 등장하지 않아도 오류가 아닙니다.

락 소유권의 이동을 기준으로 순서를 해석해야 합니다.


모니터를 소유하지 않은 호출의 즉시 실패

아래 코드는 gate를 잠그지 않고 wait를 부릅니다.

컴파일은 정상적으로 되지만 실행 시 IllegalMonitorStateException이 발생합니다.

검사된 예외인 InterruptedException만 처리하면 충분하다고 생각하기 쉬우나, 대기 프로토콜의 선행 조건 위반은 런타임에서 별도로 드러납니다.

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();
        }
    }
}

실패를 없애려고 메서드 전체에 무작정 synchronized를 붙이는 것도 답은 아닙니다.

어떤 공유 상태의 조건을 기다리는지, 그 상태를 어떤 객체의 모니터가 보호하는지 먼저 정해야 합니다.

큐 크기를 this 모니터로 지킨다면 크기 검사와 this.wait()가 같은 임계 영역 안에 있어야 하며, 생산자가 값을 추가한 뒤 호출하는 알림도 this.notifyAll()이어야 합니다.


대기 집합 진입과 복귀의 다섯 단계

소비자가 빈 단일 슬롯을 만났다고 가정합니다.

먼저 모니터를 가진 상태로 조건을 검사합니다.

wait 호출은 소비자를 해당 객체의 대기 집합에 등록하고 모니터를 놓습니다.

생산자가 모니터를 얻어 값을 저장한 뒤 알림을 보냅니다.

소비자는 대기 집합을 벗어나지만 생산자가 잠금을 반납할 때까지 다시 경쟁합니다.

마지막으로 모니터를 획득한 뒤 조건을 재검사하고 값을 가져갑니다.

  • 대기 조건은 반드시 모니터가 보호하는 상태만 읽어야 관찰과 등록 사이 신호 손실을 막습니다.
  • wait가 반환되었다는 사실은 상태가 원하는 값이라는 보장이 아니므로 조건식을 다시 평가합니다.
  • 알림은 상태를 먼저 변경한 뒤 보내야 깨어난 주체가 새 상태를 볼 수 있습니다.
  • notify 호출자는 잠금을 유지한 채 나머지 임계 작업을 마치고 블록 끝에서 소유권을 반납합니다.
  • 인터럽트를 받은 대기자는 InterruptedException으로 복귀하며 호출 계층의 취소 정책을 따라야 합니다.
  • 공개 API에 InterruptedException을 선언하면 호출자가 중단과 정상 결과를 혼동하지 않고 선택할 수 있습니다.

대기 집합과 락 대기열은 구별해야 합니다.

wait 중인 스레드는 조건 알림을 기다리고, synchronized 입구에서 막힌 스레드는 모니터 소유권을 기다립니다.

알림을 받은 소비자는 첫 번째 대기에서 빠져나온 뒤 두 번째 경쟁을 거칩니다.

상태 전이를 한 줄 로그로 압축하지 말고 두 관문을 따로 기록하면 디버깅이 쉬워집니다.


한 칸 우편함으로 완성하는 모니터 대기

단일 슬롯은 조건을 가장 명확하게 드러냅니다.

message == null이면 생산 가능하고, 값이 있으면 소비 가능합니다.

두 메서드는 같은 모니터로 상태를 보호합니다.

대기는 while로 감싸고, 변경 직후 notifyAll을 호출합니다.

이 구현은 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());
    }
}
조건·대기·알림은 우편함의 같은 모니터를 사용한다

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

조건·대기·알림은 우편함의 같은 모니터를 사용한다
원문의 구간message와 대기 조건this 모니터
send의 while값이 이미 있으면 wait합니다.wait하는 동안 반납하고, 복귀하려면 다시 얻어야 합니다.
receive의 while값이 없으면 wait합니다.복귀 뒤에도 같은 모니터 안에서 조건을 다시 검사합니다.
대기 조건을 통과한 뒤send는 값을 저장하고 receive는 값을 꺼내 null로 바꿉니다.둘 다 변경 뒤 notifyAll을 부릅니다. 알림 자체는 모니터를 넘기지 않습니다.
send의 while
message와 대기 조건: 값이 이미 있으면 wait합니다.
this 모니터: wait하는 동안 반납하고, 복귀하려면 다시 얻어야 합니다.
receive의 while
message와 대기 조건: 값이 없으면 wait합니다.
this 모니터: 복귀 뒤에도 같은 모니터 안에서 조건을 다시 검사합니다.
대기 조건을 통과한 뒤
message와 대기 조건: send는 값을 저장하고 receive는 값을 꺼내 null로 바꿉니다.
this 모니터: 둘 다 변경 뒤 notifyAll을 부릅니다. 알림 자체는 모니터를 넘기지 않습니다.

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

빈 슬롯에서 먼저 실행된 receive는 모니터를 반납하고 기다립니다. 생산자가 먼저 채웠다면 대기를 건너뜁니다.

생산자는 같은 모니터에 들어와 메시지를 저장하고 모두에게 상태 변화를 알립니다.

최종 출력은 received=post-20 다음 empty=true입니다. main이 수신 스레드의 join을 마친 뒤 빈 상태를 출력하므로 이 순서는 고정입니다.

중요한 회귀 조건은 두 스레드가 끝나고 슬롯이 비었다는 사실입니다.


취소를 정상 데이터로 위장하지 않는 이유

대기 중 인터럽트를 잡아 null을 반환하면 호출자는 실제 빈 결과와 취소를 구별하지 못합니다.

다음 서비스는 InterruptedException을 상위로 전달하고, 작업 스레드의 가장 바깥 접점에서 인터럽트 상태를 복구합니다.

성공 목록과 취소 여부를 별도로 기록하여 처리된 메시지 수를 검사할 수 있습니다.

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());
    }
}
시작 신호·상태 관측·취소 결과는 서로 다른 경계다

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

시작 신호·상태 관측·취소 결과는 서로 다른 경계다
main의 경계알 수 있는 사실한계
waiting.await() 뒤작업자가 receive 호출 직전의 신호를 보냈습니다.wait 진입 완료를 뜻하지 않습니다.
WAITING 조회 뒤이 상태를 관측한 뒤 작업자에게 interrupt를 보냅니다.상태 폴링에는 시간 제한이 없습니다.
worker.join() 뒤이번 실행은 handled=0 다음 interrupted=true입니다.receive의 예외로 add를 건너뛰고 바깥 catch가 플래그를 복구합니다.
waiting.await() 뒤
알 수 있는 사실: 작업자가 receive 호출 직전의 신호를 보냈습니다.
한계: wait 진입 완료를 뜻하지 않습니다.
WAITING 조회 뒤
알 수 있는 사실: 이 상태를 관측한 뒤 작업자에게 interrupt를 보냅니다.
한계: 상태 폴링에는 시간 제한이 없습니다.
worker.join() 뒤
알 수 있는 사실: 이번 실행은 handled=0 다음 interrupted=true입니다.
한계: receive의 예외로 add를 건너뛰고 바깥 catch가 플래그를 복구합니다.

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

운영 코드가 스레드 상태를 바쁜 반복으로 기다리면 안 되며, 종료 프로토콜에는 별도의 시작·종료 신호나 구조화된 실행기를 사용합니다.


모니터 대기를 적용할 수 있는 경계

설계 질문확인 대상결정
조건 상태의 소유자는 누구인가필드와 잠금 객체한 모니터로 일치시킴
취소가 호출자에게 중요한가API 예외 규칙InterruptedException 전파
조건 종류가 하나뿐인가생산 가능·소비 가능 수다수면 Condition 검토
알림 비용을 감당할 수 있는가대기자 수와 재대기 횟수notifyAll 우선 안전성 확보

저수준 모니터는 규칙을 이해하는 데 유용하지만 공개 라이브러리를 새로 만들 때 기본 선택은 아닙니다.

이미 검증된 BlockingQueue가 용량, 시간 제한, 인터럽트 처리를 제공합니다.

직접 구현은 상태 조건이 큐 표준 연산으로 표현되지 않거나 학습 목적일 때만 유지 비용을 감수할 근거가 생깁니다.


연습 문제

두 스레드가 한 슬롯을 공유합니다.

클라이언트는 요청 값을 보내고 그 값의 두 배를 응답으로 받을 때까지 기다리며, 서버는 요청을 받은 뒤 응답을 기록합니다.

요청을 연속 두 번 덮어쓰지 못하고 응답을 소비하기 전에 다음 라운드가 시작되지 않도록 모니터 조건을 작성하세요.

정답과 해설

상태를 EMPTY, REQUEST_READY, RESPONSE_READY 세 단계로 나누면 조건이 명확해집니다.

request·awaitRequest·awaitResponse는 필요한 선행 상태까지 while로 기다립니다. request·respond·awaitResponse가 단계를 바꾸고, awaitRequest는 현재 값만 읽습니다. respond는 기다리지 않고 REQUEST_READY가 아니면 예외를 던집니다.

전이가 끝날 때 notifyAll을 보내 다른 역할이 새 단계를 확인하게 합니다.

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();
    }
}
응답을 소비해야 다음 요청을 넣을 수 있다

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

응답을 소비해야 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
호출과 값: 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는 요청을 독점하지 않으므로 여러 서버가 같은 요청을 읽지 못하게 하는 규약은 아닙니다.

더 많은 클라이언트를 지원하려면 요청별 식별자와 큐가 필요하므로 이 한 슬롯 프로토콜을 무리하게 확장하지 말고 적절한 동시성 자료구조로 모델을 바꿔야 합니다.


모니터 대기에서 기억할 경계선

wait는 락을 가진 채 호출하고, 잠드는 순간 그 락을 반납합니다.

notifyAll은 조건을 바꾼 뒤 같은 락 안에서 호출하며, 깨운 스레드는 알림 직후가 아니라 모니터를 다시 얻은 뒤 실행합니다.

이 세 문장을 실제 상태 전이로 설명할 수 있으면 모니터 프로토콜의 뼈대가 잡힌 것입니다.

정상 데이터, 빈 상태, 취소는 서로 다른 결과입니다.

하나를 null로 뭉개면 호출 계층이 올바른 복구를 선택할 수 없습니다.

저수준 대기 코드는 상태 모델과 예외 규칙이 분명할 때만 작게 유지해야 합니다.