본문 바로가기

조건 재검사

여러 소비자가 같은 알림을 받은 뒤 벌어지는 경쟁을 재현하고 조건 술어를 while로 다시 확인하는 이유를 단일 책임 버퍼로 구현합니다.

대기에서 깨어났다는 사건과 원하는 조건이 참이라는 사실은 같지 않습니다.

알림은 “공유 상태가 바뀌었을 수 있으니 다시 보라”는 힌트입니다.

여러 소비자가 동시에 깨어나면 가장 먼저 잠금을 얻은 하나가 원소를 가져가고, 뒤늦게 진입한 소비자는 다시 빈 큐를 만납니다.

JVM은 명시적 알림이 없어도 대기에서 복귀할 가능성을 허용하므로 조건 술어를 재검사하는 코드는 선택 사항이 아닙니다.

if (empty) wait()는 대기 전에는 조건을 한 번 확인하지만 복귀 후에는 바로 다음 줄로 진행합니다.

반면 while (empty) wait()는 잠금을 다시 얻은 시점의 최신 상태를 검사합니다.

반복문은 바쁜 대기가 아닙니다.

조건이 거짓일 때 wait가 모니터를 내놓고 잠들므로 CPU를 계속 사용하지 않습니다.


단일 항목을 둘러싼 소비자 경쟁

다음 저장소는 소비 조건에 if를 씁니다.

두 작업자가 빈 큐에서 대기한 뒤 항목 하나를 넣고 notifyAll을 호출합니다.

첫 작업자가 값을 제거하고 끝난 다음 두 번째 작업자도 복귀하여 remove를 실행하므로 NoSuchElementException에 도달할 수 있습니다.

이 원문은 두 소비자가 빈 조건을 확인했음을 래치로 모은 뒤 같은 모니터에서 항목을 넣습니다. 대기 등록과 모니터 반납보다 앞서 main의 알림이 끼어들 수는 없습니다.

intentional-runtime/IfGuardedTake.java
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.CountDownLatch;

public final class IfGuardedTake {
    private final Queue<String> values = new ArrayDeque<>();
    private final CountDownLatch entered = new CountDownLatch(2);

    synchronized String take() throws InterruptedException {
        if (values.isEmpty()) {
            entered.countDown();
            wait();
        }
        return values.remove();
    }

    synchronized void put(String value) {
        values.add(value);
        notifyAll();
    }

    public static void main(String[] args) throws InterruptedException {
        IfGuardedTake queue = new IfGuardedTake();
        Runnable consumer = () -> {
            try {
                System.out.println(Thread.currentThread().getName() + "=" + queue.take());
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + " failed=" + e.getClass().getSimpleName());
            }
        };
        Thread c1 = Thread.ofPlatform().name("reader-a").start(consumer);
        Thread c2 = Thread.ofPlatform().name("reader-b").start(consumer);
        queue.entered.await();
        queue.put("only-one");
        c1.join();
        c2.join(200);
        c2.interrupt();
    }
}

이번 실행의 예외는 소비자 내부 catch가 stdout에 기록했고 프로그램은 종료 코드 0으로 끝났습니다. 알림 전 임의 반환으로 빈 큐의 제거를 시도하는 경로도 API상 가능하지만 이번 실행에서 그 원인을 관측한 것은 아닙니다.

핵심은 특정 스레드 이름이 아닙니다.

한 번 참이 된 “항목 존재” 조건을 다른 스레드가 먼저 소비할 수 있다는 점입니다.

조건은 개인에게 예약된 허가증이 아니라 공유 상태에 대한 순간적인 관찰입니다.


조건 술어를 코드의 중심에 배치

버퍼 메서드를 읽을 때 먼저 canPut = size < capacity, canTake = !isEmpty 두 술어를 적습니다.

대기 반복문의 조건은 각각 !canPut, !canTake입니다.

상태 변경이 끝난 뒤 반대 역할이 만족할 수 있는 술어가 무엇인지 확인하고 알립니다.

이렇게 표현하면 잠금 도구가 synchronized에서 Condition으로 바뀌어도 논리는 유지됩니다.

  • 알림·허용된 임의 복귀·시간 제한 형태의 시간 만료로 정상 반환해도 상태를 재검사합니다. InterruptedException이 발생하면 정상 반환 뒤의 조건 재검사로 이어지지 않고 메서드를 빠져나갑니다.
  • while의 본문에는 상태 변경을 넣지 않고 오직 대기만 두어 술어와 전이를 분리합니다.
  • 큐 조작은 조건 검사를 수행한 동일한 잠금 구간에 있어야 다른 참여자가 사이에 상태를 바꾸지 못합니다.
  • 값을 제거한 소비자는 빈자리를 만들었고, 값을 추가한 생산자는 소비 가능 항목을 만들었다는 사건을 알립니다.
  • 대기에서 인터럽트되면 원소를 추가하거나 제거하지 않은 채 메서드를 빠져나가 상태 불변식을 보존합니다.
  • 테스트는 두 소비자·한 값처럼 경쟁을 의도적으로 만들어 재검사 경로가 실제로 실행되는지 확인합니다.

조건을 메서드 이름과 맞추는 것도 중요합니다.

take는 값이 생길 때까지 기다리는 동작이고 poll은 현재 없으면 즉시 빈 결과를 주는 동작입니다.

같은 구현에서 두 규칙을 뒤섞으면 호출자가 지연 특성을 예측할 수 없습니다.

메서드 이름, 예외 선언, 시간 제한을 통해 대기 성격을 드러냅니다.


경쟁 후에도 안전한 다중 소비 버퍼

완성 버전은 여러 생산자와 소비자를 받아들입니다.

put과 take의 대기 조건을 반복 검사하고 상태 전이 뒤 전체 알림을 보냅니다.

ArrayDeque는 null을 허용하지 않으므로 빈 상태 표현이 값과 겹치지 않습니다.

스냅샷 메서드는 확인용으로만 두고 실제 업무 흐름은 대기 메서드를 사용합니다.

src/WhileGuardedBuffer.java
import java.util.ArrayDeque;
import java.util.List;
import java.util.Objects;
import java.util.Queue;

public final class WhileGuardedBuffer<E> {
    private final Queue<E> queue = new ArrayDeque<>();
    private final int capacity;

    public WhileGuardedBuffer(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException("capacity=" + capacity);
        this.capacity = capacity;
    }

    public synchronized void put(E value) throws InterruptedException {
        Objects.requireNonNull(value, "value");
        while (queue.size() >= capacity) {
            wait();
        }
        queue.add(value);
        notifyAll();
    }

    public synchronized E take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }
        E value = queue.remove();
        notifyAll();
        return value;
    }

    public synchronized List<E> snapshot() {
        return List.copyOf(queue);
    }

    public static void main(String[] args) throws InterruptedException {
        WhileGuardedBuffer<String> buffer = new WhileGuardedBuffer<>(1);
        Thread first = consumer("first", buffer);
        Thread second = consumer("second", buffer);
        Thread.sleep(30);
        buffer.put("alpha");
        buffer.put("beta");
        first.join();
        second.join();
        System.out.println("remaining=" + buffer.snapshot().size());
    }

    private static Thread consumer(String name, WhileGuardedBuffer<String> buffer) {
        return Thread.ofPlatform().name(name).start(() -> {
            try {
                System.out.println(name + " consumed=" + buffer.take());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다

if 복귀 후 재검사 없는 제거와 while 재검사의 서로 다른 소비 경로입니다. 입력 수·스케줄 차이를 유지해 복귀→조건→제거/재대기 시점을 분리합니다.

정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다

IfGuardedTake와 WhileGuardedBuffer의 다른 입력, 실제 결과와 소스의 재검사 경로를 구별합니다.

if와 while · 복귀 뒤 책임

정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다

wait의 정상 반환은 값이 남아 있다는 보장이 아닙니다. while은 모니터를 다시 얻은 뒤 조건을 재검사합니다.

정상 wait 복귀 뒤의 두 경로정상 wait복귀가값보존을보장하지않습니다. if는재검사없이remove해예외가가능하고while은비면재대기하며값이있을때만제거합니다. 두main의입력수는다릅니다.정상 wait 복귀모니터 재획득if재검사 없음while조건 재검사비면 wait바로 remove값 또는 예외값 있을 때remove
정상 wait 복귀 뒤의 두 경로정상 wait복귀가값보존을보장하지않습니다. if는재검사없이remove해예외가가능하고while은비면재대기하며값이있을때만제거합니다. 두main의입력수는다릅니다.정상 wait 복귀모니터 재획득if재검사없음while조건재검사비면 wait바로remove값 또는예외값 있을 때remove
설명과 원래 예시 값

if 예제

빈 큐의 대기 조건
if (values.isEmpty())
정상 wait 복귀 뒤
조건 재검사 없이 remove
main이 넣은 값
only-one 한 개
이번 실행
값 한 개와 잡힌 NoSuchElementException 한 건

while 예제

빈 큐의 대기 조건
while (queue.isEmpty())
정상 wait 복귀 뒤
같은 모니터에서 조건 재검사 · 비면 다시 wait
main이 넣은 값
alpha, beta 두 개
이번 실행
두 값이 한 번씩 소비됨 · remaining=0

두 예제의 입력 개수는 다릅니다. 소비자별 값과 출력 순서는 보장하지 않으며, while 예제의 실제 재대기 여부는 출력하지 않습니다.

이 예의 조건

  • if 예제 입력 1개, while 예제 입력 2개이며 소비자별 값·순서는 가변입니다.
  • sleep 30ms만으로 wait 진입을 보장하지 않습니다. 빈 큐를 검사해 wait에 들어간 조건에서 복귀 뒤의 두 구현을 비교합니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다 정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다 IfGuardedTake와 WhileGuardedBuffer의 다른 입력, 실제 결과와 소스의 재검사 경로를 구별합니다. if와 while · 복귀 뒤 책임 정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다 wait의 정상 반환은 값이 남아 있다는 보장이 아닙니다. while은 모니터를 다시 얻은 뒤 조건을 재검사합니다. if 예제 빈 큐의 대기 조건 if (values.isEmpty()) 정상 wait 복귀 뒤 조건 재검사 없이 remove main이 넣은 값 only-one 한 개 이번 실행 값 한 개와 잡힌 NoSuchElementException 한 건 while 예제 빈 큐의 대기 조건 while (queue.isEmpty()) 정상 wait 복귀 뒤 같은 모니터에서 조건 재검사 · 비면 다시 wait main이 넣은 값 alpha , beta 두 개 이번 실행 두 값이 한 번씩 소비됨 · remaining=0 두 예제의 입력 개수는 다릅니다. 소비자별 값과 출력 순서는 보장하지 않으며, while 예제의 실제 재대기 여부는 출력하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
빈 큐를 확인한 경로

빈 큐를 검사한 경로에서는 두 구현이 각각 wait에 들어갑니다.

  • if: values.isEmpty
  • while: queue.isEmpty 반복
  • 정상 wait 복귀는 값이 남아 있다는 증거가 아닙니다.
복귀 뒤 책임

wait에서 정상 복귀한 조건이면 if는 바로 remove하고 while은 같은 모니터에서 재검사합니다.

  • if: 재검사 없음
  • while: 비면 다시 wait
  • 조건 재검사가 다른 소비자의 인출 뒤 빈 큐 제거를 막습니다.
각 실행 결과

서로 다른 입력의 실제 관측 결과를 읽습니다.

  • if only-one: 한 값과 잡힌 NoSuchElementException
  • while alpha,beta: 각 1번 소비, remaining 0
  • while의 실제 재대기 여부를 출력으로 추론하지 않습니다.

정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다

if 복귀 후 재검사 없는 제거와 while 재검사의 서로 다른 소비 경로입니다. 입력 수·스케줄 차이를 유지해 복귀→조건→제거/재대기 시점을 분리합니다.

정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다

IfGuardedTake와 WhileGuardedBuffer의 다른 입력, 실제 결과와 소스의 재검사 경로를 구별합니다.

if와 while · 복귀 뒤 책임

정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다

wait의 정상 반환은 값이 남아 있다는 보장이 아닙니다. while은 모니터를 다시 얻은 뒤 조건을 재검사합니다.

정상 wait 복귀 뒤의 두 경로정상 wait복귀가값보존을보장하지않습니다. if는재검사없이remove해예외가가능하고while은비면재대기하며값이있을때만제거합니다. 두main의입력수는다릅니다.정상 wait 복귀모니터 재획득if재검사 없음while조건 재검사비면 wait바로 remove값 또는 예외값 있을 때remove
정상 wait 복귀 뒤의 두 경로정상 wait복귀가값보존을보장하지않습니다. if는재검사없이remove해예외가가능하고while은비면재대기하며값이있을때만제거합니다. 두main의입력수는다릅니다.정상 wait 복귀모니터 재획득if재검사없음while조건재검사비면 wait바로remove값 또는예외값 있을 때remove
설명과 원래 예시 값

if 예제

빈 큐의 대기 조건
if (values.isEmpty())
정상 wait 복귀 뒤
조건 재검사 없이 remove
main이 넣은 값
only-one 한 개
이번 실행
값 한 개와 잡힌 NoSuchElementException 한 건

while 예제

빈 큐의 대기 조건
while (queue.isEmpty())
정상 wait 복귀 뒤
같은 모니터에서 조건 재검사 · 비면 다시 wait
main이 넣은 값
alpha, beta 두 개
이번 실행
두 값이 한 번씩 소비됨 · remaining=0

두 예제의 입력 개수는 다릅니다. 소비자별 값과 출력 순서는 보장하지 않으며, while 예제의 실제 재대기 여부는 출력하지 않습니다.

이 예의 조건

  • if 예제 입력 1개, while 예제 입력 2개이며 소비자별 값·순서는 가변입니다.
  • sleep 30ms만으로 wait 진입을 보장하지 않습니다. 빈 큐를 검사해 wait에 들어간 조건에서 복귀 뒤의 두 구현을 비교합니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다 정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다 IfGuardedTake와 WhileGuardedBuffer의 다른 입력, 실제 결과와 소스의 재검사 경로를 구별합니다. if와 while · 복귀 뒤 책임 정상 wait 복귀 뒤의 재검사가 빈 큐 제거를 막는다 wait의 정상 반환은 값이 남아 있다는 보장이 아닙니다. while은 모니터를 다시 얻은 뒤 조건을 재검사합니다. if 예제 빈 큐의 대기 조건 if (values.isEmpty()) 정상 wait 복귀 뒤 조건 재검사 없이 remove main이 넣은 값 only-one 한 개 이번 실행 값 한 개와 잡힌 NoSuchElementException 한 건 while 예제 빈 큐의 대기 조건 while (queue.isEmpty()) 정상 wait 복귀 뒤 같은 모니터에서 조건 재검사 · 비면 다시 wait main이 넣은 값 alpha , beta 두 개 이번 실행 두 값이 한 번씩 소비됨 · remaining=0 두 예제의 입력 개수는 다릅니다. 소비자별 값과 출력 순서는 보장하지 않으며, while 예제의 실제 재대기 여부는 출력하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
빈 큐를 확인한 경로

빈 큐를 검사한 경로에서는 두 구현이 각각 wait에 들어갑니다.

  • if: values.isEmpty
  • while: queue.isEmpty 반복
  • 정상 wait 복귀는 값이 남아 있다는 증거가 아닙니다.
복귀 뒤 책임

wait에서 정상 복귀한 조건이면 if는 바로 remove하고 while은 같은 모니터에서 재검사합니다.

  • if: 재검사 없음
  • while: 비면 다시 wait
  • 조건 재검사가 다른 소비자의 인출 뒤 빈 큐 제거를 막습니다.
각 실행 결과

서로 다른 입력의 실제 관측 결과를 읽습니다.

  • if only-one: 한 값과 잡힌 NoSuchElementException
  • while alpha,beta: 각 1번 소비, remaining 0
  • while의 실제 재대기 여부를 출력으로 추론하지 않습니다.

동시성 테스트의 단언은 스케줄 순서보다 보존 성질을 겨냥해야 합니다.

특정 이름의 스레드가 첫 값을 받는다고 가정하면 정상적인 스케줄 변화에도 테스트가 흔들립니다.


술어를 이름 붙여 복잡한 상태를 다루기

현실의 작업 큐에는 일시 중지, 종료 요청, 우선순위 같은 조건이 더 붙습니다.

긴 논리식을 반복문에 직접 넣으면 어떤 상태에서 깨어나야 하는지 읽기 어렵습니다.

아래 예제는 canTake와 isDrained를 분리합니다.

닫힌 큐에 남은 항목은 계속 처리하고, 닫힌 뒤 완전히 비면 전용 결과로 종료합니다.

src/CloseableWorkQueue.java
import java.util.ArrayDeque;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;

public final class CloseableWorkQueue<E> {
    private final Queue<E> jobs = new ArrayDeque<>();
    private boolean closed;

    public synchronized void submit(E job) {
        Objects.requireNonNull(job, "job");
        if (closed) throw new IllegalStateException("queue closed");
        jobs.add(job);
        notifyAll();
    }

    public synchronized Optional<E> takeUntilClosed() throws InterruptedException {
        while (!canTake() && !isDrained()) {
            wait();
        }
        if (isDrained()) return Optional.empty();
        return Optional.of(jobs.remove());
    }

    public synchronized void close() {
        closed = true;
        notifyAll();
    }

    private boolean canTake() {
        return !jobs.isEmpty();
    }

    private boolean isDrained() {
        return closed && jobs.isEmpty();
    }

    public static void main(String[] args) throws InterruptedException {
        CloseableWorkQueue<Integer> queue = new CloseableWorkQueue<>();
        queue.submit(10);
        queue.submit(20);
        queue.close();
        int sum = 0;
        Optional<Integer> next;
        while ((next = queue.takeUntilClosed()).isPresent()) sum += next.orElseThrow();
        System.out.println("sum=" + sum);
    }
}

닫힘과 비어 있음을 나누어 남은 작업부터 배출한다

10·20 제출 뒤 close해도 잔여부터 배출하고 닫힌 빈 큐에서 종료합니다. closed/size와 반환값 변화가 단계형에 적합하며 원래 main의 미실행 wait는 조건으로만 남깁니다.

닫힘과 비어 있음을 나누어 남은 작업부터 배출한다

CloseableWorkQueue의 canTake와 isDrained가 결정하는 wait와 Optional 반환을 비교합니다.

배출과 종료 · 두 술어

닫힘과 비어 있음을 나누어 남은 작업부터 배출한다

닫혔어도 값이 남으면 먼저 꺼냅니다. 닫혔고 비었을 때만 빈 결과로 종료합니다.

남은 항목과 닫힌 빈 큐의 분기항목이남으면closed와무관하게먼저Optional.of로꺼냅니다. 비어있을때open이면wait후재검사하고closed이면Optional.empty로끝냅니다.항목 있음?있음제거 · of비었음닫힘?열림wait·재검사닫힘empty 종료
남은 항목과 닫힌 빈 큐의 분기항목이남으면closed와무관하게먼저Optional.of로꺼냅니다. 비어있을때open이면wait후재검사하고closed이면Optional.empty로끝냅니다.항목 있음?있음제거 · of비었음닫힘?열림wait·재검사닫힘empty종료
설명과 원래 예시 값

열려 있고 비어 있음

두 술어
canTake=false · isDrained=false
takeUntilClosed 동작
wait 뒤 다시 검사

항목이 남아 있음

두 술어
canTake=true · isDrained=false
takeUntilClosed 동작
항목 제거 · Optional.of로 반환

닫혀 있고 비어 있음

두 술어
canTake=false · isDrained=true
takeUntilClosed 동작
기다리지 않고 Optional.empty() 반환

이번 main은 10과 20을 넣고 닫은 뒤 같은 스레드에서 모두 꺼내 sum=30을 출력했습니다. 대기·깨움은 실행하지 않았으며, 이 큐에는 용량 제한이 없습니다.

이 예의 조건

  • 원 main은 같은 스레드에서 10과 20을 넣고 close한 뒤 인출합니다. 이 실행은 대기·깨움을 수행하지 않으며 용량 제한도 없습니다.
  • 열린 빈 큐의 wait·재검사 조건은 별도 조건으로 원문 전체 표에 보존합니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보닫힘과 비어 있음을 나누어 남은 작업부터 배출한다 닫힘과 비어 있음을 나누어 남은 작업부터 배출한다 CloseableWorkQueue의 canTake와 isDrained가 결정하는 wait와 Optional 반환을 비교합니다. 배출과 종료 · 두 술어 닫힘과 비어 있음을 나누어 남은 작업부터 배출한다 닫혔어도 값이 남으면 먼저 꺼냅니다. 닫혔고 비었을 때만 빈 결과로 종료합니다. 열려 있고 비어 있음 두 술어 canTake=false · isDrained=false takeUntilClosed 동작 wait 뒤 다시 검사 항목이 남아 있음 두 술어 canTake=true · isDrained=false takeUntilClosed 동작 항목 제거 · Optional.of 로 반환 닫혀 있고 비어 있음 두 술어 canTake=false · isDrained=true takeUntilClosed 동작 기다리지 않고 Optional.empty() 반환 이번 main은 10과 20을 넣고 닫은 뒤 같은 스레드에서 모두 꺼내 sum=30 을 출력했습니다. 대기·깨움은 실행하지 않았으며, 이 큐에는 용량 제한이 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
닫힌 큐의 남은 값

10과 20을 접수한 뒤 close해도 두 항목은 배출 대상으로 남습니다.

  • 큐: closed=true · [10,20]
  • 술어: canTake=true · isDrained=false
  • main sum: 0
  • 닫힘은 새 작업 접수를 막지만 이미 접수한 항목을 버리는 조건이 아닙니다.
두 항목 인출

같은 main이 남은 10과 20을 차례로 꺼내 합계에 더합니다.

  • 첫 인출: Optional.of(10) · 남은 [20] · sum=10
  • 둘째 인출: Optional.of(20) · 남은 [] · sum=30
  • 대기: 실행 없음
  • 항목 제거가 큐를 비우며 각 값을 더해 원 main의 합계가 30이 됩니다.
배출 완료로 종료

닫혔고 빈 큐의 다음 인출은 기다리지 않고 Optional.empty를 반환합니다.

  • 큐: closed=true · []
  • 술어: canTake=false · isDrained=true
  • 반환·출력: Optional.empty · sum=30
  • 닫힘과 비어 있음이 함께 성립해 반복을 끝내고 최종 합계를 출력합니다.

닫힘과 비어 있음을 나누어 남은 작업부터 배출한다

10·20 제출 뒤 close해도 잔여부터 배출하고 닫힌 빈 큐에서 종료합니다. closed/size와 반환값 변화가 단계형에 적합하며 원래 main의 미실행 wait는 조건으로만 남깁니다.

닫힘과 비어 있음을 나누어 남은 작업부터 배출한다

CloseableWorkQueue의 canTake와 isDrained가 결정하는 wait와 Optional 반환을 비교합니다.

배출과 종료 · 두 술어

닫힘과 비어 있음을 나누어 남은 작업부터 배출한다

닫혔어도 값이 남으면 먼저 꺼냅니다. 닫혔고 비었을 때만 빈 결과로 종료합니다.

남은 항목과 닫힌 빈 큐의 분기항목이남으면closed와무관하게먼저Optional.of로꺼냅니다. 비어있을때open이면wait후재검사하고closed이면Optional.empty로끝냅니다.항목 있음?있음제거 · of비었음닫힘?열림wait·재검사닫힘empty 종료
남은 항목과 닫힌 빈 큐의 분기항목이남으면closed와무관하게먼저Optional.of로꺼냅니다. 비어있을때open이면wait후재검사하고closed이면Optional.empty로끝냅니다.항목 있음?있음제거 · of비었음닫힘?열림wait·재검사닫힘empty종료
설명과 원래 예시 값

열려 있고 비어 있음

두 술어
canTake=false · isDrained=false
takeUntilClosed 동작
wait 뒤 다시 검사

항목이 남아 있음

두 술어
canTake=true · isDrained=false
takeUntilClosed 동작
항목 제거 · Optional.of로 반환

닫혀 있고 비어 있음

두 술어
canTake=false · isDrained=true
takeUntilClosed 동작
기다리지 않고 Optional.empty() 반환

이번 main은 10과 20을 넣고 닫은 뒤 같은 스레드에서 모두 꺼내 sum=30을 출력했습니다. 대기·깨움은 실행하지 않았으며, 이 큐에는 용량 제한이 없습니다.

이 예의 조건

  • 원 main은 같은 스레드에서 10과 20을 넣고 close한 뒤 인출합니다. 이 실행은 대기·깨움을 수행하지 않으며 용량 제한도 없습니다.
  • 열린 빈 큐의 wait·재검사 조건은 별도 조건으로 원문 전체 표에 보존합니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보닫힘과 비어 있음을 나누어 남은 작업부터 배출한다 닫힘과 비어 있음을 나누어 남은 작업부터 배출한다 CloseableWorkQueue의 canTake와 isDrained가 결정하는 wait와 Optional 반환을 비교합니다. 배출과 종료 · 두 술어 닫힘과 비어 있음을 나누어 남은 작업부터 배출한다 닫혔어도 값이 남으면 먼저 꺼냅니다. 닫혔고 비었을 때만 빈 결과로 종료합니다. 열려 있고 비어 있음 두 술어 canTake=false · isDrained=false takeUntilClosed 동작 wait 뒤 다시 검사 항목이 남아 있음 두 술어 canTake=true · isDrained=false takeUntilClosed 동작 항목 제거 · Optional.of 로 반환 닫혀 있고 비어 있음 두 술어 canTake=false · isDrained=true takeUntilClosed 동작 기다리지 않고 Optional.empty() 반환 이번 main은 10과 20을 넣고 닫은 뒤 같은 스레드에서 모두 꺼내 sum=30 을 출력했습니다. 대기·깨움은 실행하지 않았으며, 이 큐에는 용량 제한이 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
닫힌 큐의 남은 값

10과 20을 접수한 뒤 close해도 두 항목은 배출 대상으로 남습니다.

  • 큐: closed=true · [10,20]
  • 술어: canTake=true · isDrained=false
  • main sum: 0
  • 닫힘은 새 작업 접수를 막지만 이미 접수한 항목을 버리는 조건이 아닙니다.
두 항목 인출

같은 main이 남은 10과 20을 차례로 꺼내 합계에 더합니다.

  • 첫 인출: Optional.of(10) · 남은 [20] · sum=10
  • 둘째 인출: Optional.of(20) · 남은 [] · sum=30
  • 대기: 실행 없음
  • 항목 제거가 큐를 비우며 각 값을 더해 원 main의 합계가 30이 됩니다.
배출 완료로 종료

닫혔고 빈 큐의 다음 인출은 기다리지 않고 Optional.empty를 반환합니다.

  • 큐: closed=true · []
  • 술어: canTake=false · isDrained=true
  • 반환·출력: Optional.empty · sum=30
  • 닫힘과 비어 있음이 함께 성립해 반복을 끝내고 최종 합계를 출력합니다.

종료 플래그만 보고 즉시 빠져나가면 이미 접수된 작업이 남습니다.

반대로 빈 상태만 기다리면 더 이상 생산자가 없는 닫힌 큐에서 영원히 잠듭니다.

두 술어를 구별하면 “남은 작업 배출 후 종료”라는 제품 규칙이 코드에 직접 나타납니다.


조건 대기의 if·while 선택 기준

검토 항목위험 신호수정 방향
깨어난 뒤 조건을 다시 읽는가wait 다음 줄이 removewhile 술어로 감싸기
다른 스레드가 자원을 선점할 수 있는가대기자가 둘 이상재경쟁을 전제로 설계
종료 상태가 별도로 있는가빈 상태와 end 혼용종료 술어 분리
테스트가 순서를 단정하는가스레드 이름별 값 고정총량과 종료를 검증

if가 허용되는 드문 경우를 찾는 것보다 조건 대기의 기본 형식으로 while을 고정하는 편이 안전합니다.

대기자가 하나라고 지금 확신해도 유지보수 중 역할 수가 늘 수 있습니다.

표준 라이브러리의 문서와 관례도 반복 검사를 전제로 하므로 팀 코드 리뷰에서 빠르게 확인할 수 있습니다.


연습 문제

작업 큐에 pause, resume, close 기능을 추가하세요.

소비자는 일시 중지 중에는 항목이 있어도 가져가면 안 됩니다.

닫을 때는 남은 작업을 모두 처리한 뒤 빈 결과로 종료하되, 닫힌 상태에서 일시 중지가 계속되어 종료를 막아서는 안 됩니다.

조건 술어를 메서드로 이름 붙이세요.

정답과 해설

원문의 소비 가능 조건은 !queue.isEmpty() && !paused입니다.

배출 완료는 “닫혔고 비어 있음”입니다.

닫기는 더 이상의 제출을 금지하고, 남은 작업이 있다면 pause를 무시해 종료가 진행되도록 합니다.

submit, resume, close는 전체 알림을 보내고, pause는 플래그만 바꿉니다.

exercise/PausableBatchQueueSolution.java
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;

public final class PausableBatchQueueSolution<E> {
    private final Queue<E> queue = new ArrayDeque<>();
    private boolean paused;
    private boolean closed;

    synchronized void submit(E value) {
        if (closed) throw new IllegalStateException("closed");
        if (value == null) throw new NullPointerException("value");
        queue.add(value);
        notifyAll();
    }

    synchronized void pause() {
        if (!closed) paused = true;
    }

    synchronized void resume() {
        paused = false;
        notifyAll();
    }

    synchronized void close() {
        closed = true;
        paused = false;
        notifyAll();
    }

    synchronized Optional<E> take() throws InterruptedException {
        while (!mayConsume() && !drained()) wait();
        if (drained()) return Optional.empty();
        return Optional.of(queue.remove());
    }

    private boolean mayConsume() {
        return !queue.isEmpty() && !paused;
    }

    private boolean drained() {
        return closed && queue.isEmpty();
    }

    public static void main(String[] args) throws InterruptedException {
        PausableBatchQueueSolution<String> jobs = new PausableBatchQueueSolution<>();
        jobs.pause();
        jobs.submit("index");
        Thread worker = Thread.ofPlatform().start(() -> {
            try {
                System.out.println(jobs.take().orElseThrow());
                System.out.println("ended=" + jobs.take().isEmpty());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        jobs.close();
        worker.join();
    }
}

close가 paused=false로 바꾸므로 이번 실행은 index, ended=true를 출력합니다. 작업자가 실제 일시 중지 대기를 했는지는 출력하지 않으며, resume과 닫힌 뒤 제출 거부도 실행하지 않습니다.

제품 요구가 “일시 중지 상태에서는 닫아도 처리하지 않고 반환”이라면 술어가 달라져야 합니다.

핵심은 숨은 우선순위를 만들지 않고 상태 전이와 코드가 같은 정책을 말하게 하는 것입니다.


알림 이후의 책임 정리

깨어난 작업자는 행동할 권리를 받은 것이 아니라 조건을 다시 검사할 기회를 얻었습니다.

잠금을 획득하고 현재 술어가 참임을 확인한 뒤에만 큐를 바꿉니다.

이 원칙이 있으면 임의 복귀와 다른 작업자의 선점을 같은 코드 경로로 안전하게 처리할 수 있습니다.

조건이 늘어나면 긴 표현을 반복하지 말고 의미 있는 이름으로 분리합니다.

대기 반복문을 읽는 사람이 “무엇을 기다리고 언제 끝나는지” 바로 알 수 있어야 합니다.

반복 검사는 동시성 기법인 동시에 상태 모델을 문서화하는 장치입니다.