본문 바로가기

유한 버퍼 계약

생산 속도와 소비 속도가 어긋날 때 값이 사라지는 큐를 재현하고, 용량·결과·종료를 호출자가 판단할 수 있는 규칙으로 바꿉니다.

생산자와 소비자는 같은 큐를 보지만 서로 다른 속도로 진행합니다.

수집기는 짧은 순간에 이벤트를 몰아서 넣고, 저장기는 디스크 속도만큼만 꺼냅니다.

이 차이를 흡수하는 유한 버퍼는 단순한 Queue 포장이 아닙니다.

가득 찼을 때 누가 기다리는지, 어떤 값을 버리는지, 호출자가 실패를 어떻게 알아채는지를 결정하는 작은 운영 정책입니다.

첫 실험의 목표는 스레드를 많이 만드는 것이 아니라 손실을 관찰 가능한 값으로 바꾸는 것입니다.

put이라는 이름을 붙이고도 내부에서 조용히 버리면 생산자는 저장되었다고 믿습니다.

반대로 빈 큐에서 null을 돌려주면서 실제 데이터로 null도 허용하면 소비자는 종료와 값 부재를 구별하지 못합니다.

API의 반환형과 허용 값부터 정해야 이후 대기 전략을 올바르게 논의할 수 있습니다.


먼저 Queue의 추가·제거, Optional의 값/부재, synchronized가 같은 객체의 모니터를 사용한다는 의미를 확인하세요. 여기서는 파일·DB 저장이 아니라 메모리 큐에 넣고 꺼내는 동작을 구현합니다. 예제 이름의 saved나 consumed만 보고 내구 저장 완료로 해석하지 않습니다.

따라 실행할 때는 각 코드의 title에 적힌 파일명과 public 클래스명을 맞춥니다. ObservableBoundedBuffer.java와 이를 사용하는 PollingDrainDemo.java는 같은 src 폴더에 두고 프로젝트 루트에서 javac -d out src/ObservableBoundedBuffer.java src/PollingDrainDemo.java로 함께 컴파일한 뒤, java -cp out ObservableBoundedBuffer와 java -cp out PollingDrainDemo를 각각 실행합니다. Thread.ofPlatform()을 쓰는 부분은 JDK 21 이상이 필요합니다. 아래 실패 클래스와 연습 해설은 각 파일을 따로 준비해 실행하며, 여러 public 클래스를 한 파일에 붙이지 않습니다.

거짓 저장 로그 재현

아래 클래스는 임계 영역 자체는 synchronized로 보호합니다.

따라서 ArrayDeque의 내부 구조가 동시에 깨지지는 않습니다.

그러나 용량이 찼을 때 새 항목을 버리고도 호출자에게 결과를 알리지 않습니다.

실행하면 생산 측 로그에는 세 건이 모두 완료된 것처럼 보이지만 소비 측 집계는 두 건뿐입니다.

데이터 경쟁은 없는데 업무 데이터가 사라지는 전형적인 규칙 실패입니다.

bad/SilentDropBuffer.java
import java.util.ArrayDeque;
import java.util.Queue;

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

    public SilentDropBuffer(int capacity) {
        this.capacity = capacity;
    }

    public synchronized void put(String value) {
        if (queue.size() == capacity) {
            return;
        }
        queue.add(value);
    }

    public synchronized String take() {
        return queue.poll();
    }

    public static void main(String[] args) {
        SilentDropBuffer buffer = new SilentDropBuffer(2);
        for (String event : new String[]{"login", "search", "purchase"}) {
            buffer.put(event);
            System.out.println("accepted=" + event);
        }
        System.out.println("saved=" + buffer.take());
        System.out.println("saved=" + buffer.take());
        System.out.println("saved=" + buffer.take());
    }
}

호출 로그 세 줄과 실제 수용 두 건을 구분한다

세 accepted 로그와 실제 두 건 큐 수용이 세 번째에서 어긋납니다. 입력별 큐 상태와 뒤 take 결과를 단계로 추적하면 조용한 유실 지점이 명확합니다.

호출 로그 세 줄과 실제 수용 두 건을 구분한다

SilentDropBuffer의 용량 2와 세 put 호출을 로그 및 실제 큐 상태로 비교합니다.

로그와 수용 · 조용한 유실

호출 로그 세 줄과 실제 수용 두 건을 구분한다

호출자의 accepted 로그는 수용 확인값이 아닙니다. 세 번째 입력은 큐에 들어가지 않습니다.

호출 로그와 실제 큐의 갈림put 세 호출은 accepted 로그 세 줄을 남기지만 용량2 큐는 login과 search만 보관하며 take의 세번째 반환은 null입니다.put 3회큐 용량 2실제 수용loginsearch호출 로그accepted3줄take 3회login,searchnull
호출 로그와 실제 큐의 갈림put 세 호출은 accepted 로그 세 줄을 남기지만 용량2 큐는 login과 search만 보관하며 take의 세번째 반환은 null입니다.put 3회큐 용량 2실제 수용loginsearch호출 로그accepted3줄take 3회login,searchnull
설명과 원래 예시 값
  1. login

    호출자가 남기는 로그
    accepted=login
    put 뒤 큐
    login
  2. search

    호출자가 남기는 로그
    accepted=search
    put 뒤 큐
    login, search
  3. purchase

    호출자가 남기는 로그
    accepted=purchase
    put 뒤 큐
    login, search — 가득 차서 추가하지 않음

세 번의 take는 login, search, null을 반환합니다. accepted와 saved는 main의 로그 이름이며 실제 저장 성공을 확인한 신호가 아닙니다.

이 예의 조건

  • 용량 2이며 accepted는 호출자가 붙인 로그 이름입니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보호출 로그 세 줄과 실제 수용 두 건을 구분한다 호출 로그 세 줄과 실제 수용 두 건을 구분한다 SilentDropBuffer의 용량 2와 세 put 호출을 로그 및 실제 큐 상태로 비교합니다. 로그와 수용 · 조용한 유실 호출 로그 세 줄과 실제 수용 두 건을 구분한다 호출자의 accepted 로그는 수용 확인값이 아닙니다. 세 번째 입력은 큐에 들어가지 않습니다. login 호출자가 남기는 로그 accepted=login put 뒤 큐 login search 호출자가 남기는 로그 accepted=search put 뒤 큐 login, search purchase 호출자가 남기는 로그 accepted=purchase put 뒤 큐 login, search — 가득 차서 추가하지 않음 세 번의 take는 login, search, null을 반환합니다. accepted와 saved는 main의 로그 이름이며 실제 저장 성공을 확인한 신호가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 두 호출

login과 search를 put하고 accepted 로그를 남깁니다.

  • 로그: accepted=login,search
  • 큐: login,search
  • 빈 용량이 있어 실제 항목도 두 건 추가됩니다.
세 번째 호출

purchase 호출도 accepted 로그를 남깁니다.

  • 로그: accepted=purchase
  • 큐: login,search 그대로
  • 가득 찬 큐는 void put에서 조용히 추가를 생략합니다.
인출 결과

세 번 take를 호출합니다.

  • 반환: login,search,null
  • 실제 수용: 2건
  • 로그 세 줄은 수용 세 건의 증거가 아닙니다.

호출 로그 세 줄과 실제 수용 두 건을 구분한다

세 accepted 로그와 실제 두 건 큐 수용이 세 번째에서 어긋납니다. 입력별 큐 상태와 뒤 take 결과를 단계로 추적하면 조용한 유실 지점이 명확합니다.

호출 로그 세 줄과 실제 수용 두 건을 구분한다

SilentDropBuffer의 용량 2와 세 put 호출을 로그 및 실제 큐 상태로 비교합니다.

로그와 수용 · 조용한 유실

호출 로그 세 줄과 실제 수용 두 건을 구분한다

호출자의 accepted 로그는 수용 확인값이 아닙니다. 세 번째 입력은 큐에 들어가지 않습니다.

호출 로그와 실제 큐의 갈림put 세 호출은 accepted 로그 세 줄을 남기지만 용량2 큐는 login과 search만 보관하며 take의 세번째 반환은 null입니다.put 3회큐 용량 2실제 수용loginsearch호출 로그accepted3줄take 3회login,searchnull
호출 로그와 실제 큐의 갈림put 세 호출은 accepted 로그 세 줄을 남기지만 용량2 큐는 login과 search만 보관하며 take의 세번째 반환은 null입니다.put 3회큐 용량 2실제 수용loginsearch호출 로그accepted3줄take 3회login,searchnull
설명과 원래 예시 값
  1. login

    호출자가 남기는 로그
    accepted=login
    put 뒤 큐
    login
  2. search

    호출자가 남기는 로그
    accepted=search
    put 뒤 큐
    login, search
  3. purchase

    호출자가 남기는 로그
    accepted=purchase
    put 뒤 큐
    login, search — 가득 차서 추가하지 않음

세 번의 take는 login, search, null을 반환합니다. accepted와 saved는 main의 로그 이름이며 실제 저장 성공을 확인한 신호가 아닙니다.

이 예의 조건

  • 용량 2이며 accepted는 호출자가 붙인 로그 이름입니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보호출 로그 세 줄과 실제 수용 두 건을 구분한다 호출 로그 세 줄과 실제 수용 두 건을 구분한다 SilentDropBuffer의 용량 2와 세 put 호출을 로그 및 실제 큐 상태로 비교합니다. 로그와 수용 · 조용한 유실 호출 로그 세 줄과 실제 수용 두 건을 구분한다 호출자의 accepted 로그는 수용 확인값이 아닙니다. 세 번째 입력은 큐에 들어가지 않습니다. login 호출자가 남기는 로그 accepted=login put 뒤 큐 login search 호출자가 남기는 로그 accepted=search put 뒤 큐 login, search purchase 호출자가 남기는 로그 accepted=purchase put 뒤 큐 login, search — 가득 차서 추가하지 않음 세 번의 take는 login, search, null을 반환합니다. accepted와 saved는 main의 로그 이름이며 실제 저장 성공을 확인한 신호가 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 두 호출

login과 search를 put하고 accepted 로그를 남깁니다.

  • 로그: accepted=login,search
  • 큐: login,search
  • 빈 용량이 있어 실제 항목도 두 건 추가됩니다.
세 번째 호출

purchase 호출도 accepted 로그를 남깁니다.

  • 로그: accepted=purchase
  • 큐: login,search 그대로
  • 가득 찬 큐는 void put에서 조용히 추가를 생략합니다.
인출 결과

세 번 take를 호출합니다.

  • 반환: login,search,null
  • 실제 수용: 2건
  • 로그 세 줄은 수용 세 건의 증거가 아닙니다.

여기서 잘못은 큐가 유한하다는 사실이 아니라, 생산 완료와 실제 수용을 같은 사건처럼 기록한 데 있습니다.

버퍼가 넘치면 즉시 거부하는 정책도 충분히 타당할 수 있습니다.

다만 호출자가 false나 예외를 받아 재시도·별도 보관·폐기 계수 증가 중 하나를 선택할 수 있어야 합니다.

용량 초과를 숨기는 순간 시스템의 손실률은 측정할 수 없는 값이 됩니다.


용량 불변식과 결과 타입

유한 버퍼의 핵심 불변식은 언제나 0 <= size && size <= capacity입니다.

이 식만으로는 충분하지 않습니다.

삽입 결과에는 수용 여부가 있어야 하고, 꺼내기 결과에는 현재 비어 있다는 의미가 있어야 합니다.

이 예제에서는 null 저장을 금지하고 Optional<String>으로 부재를 표현합니다.

그러면 소비자는 실제 문자열과 비어 있는 순간을 혼동하지 않습니다.

  • 생성 시 용량이 1보다 작으면 즉시 거부하여 영원히 수용할 수 없는 객체를 만들지 않습니다.
  • 삽입은 잠금 안에서 크기를 검사하고 같은 임계 영역에서 원소를 추가해야 검사와 변경 사이 틈이 없습니다.
  • 즉시 수용할 수 없으면 OfferResult.FULL을 반환하여 호출자가 과부하 정책을 결정하게 합니다.
  • 제거는 값이 없을 때 Optional.empty()를 사용하고 큐 원소에는 null을 허용하지 않습니다.
  • 진단용 스냅샷은 내부 컬렉션 구조를 노출하지 않는 복사본을 반환합니다. 일반 원소의 내부 가변성까지 복사되지는 않습니다.
  • 생산 개수와 소비 개수뿐 아니라 거부 개수도 따로 세어야 총량 보존식을 확인할 수 있습니다.

총량 보존식은 attempted = accepted + rejected입니다.

소비가 모두 끝난 시점에는 accepted = consumed + remaining도 만족해야 합니다.

두 식은 멀티스레드 실행 순서와 무관하게 확인할 수 있는 회귀 기준입니다.

로그 한 줄의 순서를 정답으로 삼으면 스케줄러가 달라질 때 정상 실행도 실패로 오판합니다.


즉시 거부형 버퍼의 완전 구현

다음 구현은 기다리지 않는 정책을 의도적으로 선택합니다.

offer라는 이름, 열거형 결과, 불변 스냅샷이 같은 방향을 가리킵니다.

여러 생산자가 동시에 들어와도 크기 검사와 추가가 하나의 모니터 안에서 수행되므로 용량을 넘지 않습니다.

이 절의 관심사는 대기시키는 법이 아니라, 대기하지 않는 선택도 정직한 API로 만들 수 있다는 점입니다.

src/ObservableBoundedBuffer.java
import java.util.ArrayDeque;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;

public final class ObservableBoundedBuffer<E> {
    public enum OfferResult { ACCEPTED, FULL }

    private final Queue<E> elements = new ArrayDeque<>();
    private final int capacity;

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

    public synchronized OfferResult offer(E element) {
        Objects.requireNonNull(element, "element");
        if (elements.size() >= capacity) {
            return OfferResult.FULL;
        }
        elements.add(element);
        return OfferResult.ACCEPTED;
    }

    public synchronized Optional<E> poll() {
        return Optional.ofNullable(elements.poll());
    }

    public synchronized int size() {
        return elements.size();
    }

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

    public static void main(String[] args) {
        ObservableBoundedBuffer<String> buffer = new ObservableBoundedBuffer<>(2);
        int accepted = 0;
        int rejected = 0;
        for (String event : List.of("login", "search", "purchase")) {
            if (buffer.offer(event) == OfferResult.ACCEPTED) accepted++;
            else rejected++;
        }
        int consumed = 0;
        while (buffer.poll().isPresent()) consumed++;
        System.out.printf("accepted=%d rejected=%d consumed=%d remaining=%d%n",
                accepted, rejected, consumed, buffer.size());
    }
}
거부와 제거를 각각 세어 보존식을 확인한다

ObservableBoundedBuffer의 정상 main이 출력한 수용·거부·제거·잔여 집계의 의미를 구분합니다.

수용 · 제거 · 보존식

거부와 제거를 각각 세어 보존식을 확인한다

수용과 거부의 합은 시도 수이고, 제거와 잔여의 합은 수용 수입니다. 여기서 제거는 저장 완료가 아닙니다.

세 번의 offer

이번 main의 값
수용 2 + 거부 1 = 시도 3
확인할 수 있는 범위
거부 횟수는 남지만 이벤트 ID는 출력하지 않습니다.

poll로 모두 꺼낸 뒤

이번 main의 값
제거 2 + 잔여 0 = 수용 2
확인할 수 있는 범위
consumed는 값이 나온 poll 횟수입니다.

용량 검사

이번 main의 값
capacity=2, 마지막 size=0
확인할 수 있는 범위
검사와 삽입은 같은 모니터 안입니다. 이 main은 단일 스레드입니다.

출력의 consumed는 큐에서 제거한 수이며 파일이나 데이터베이스의 저장 완료를 측정하지 않습니다.

출력은 accepted=2 rejected=1 consumed=2 remaining=0입니다.

이 정책에서 재시도는 버퍼 내부 책임이 아닙니다.

수집 지연을 허용할 수 없는 호출자는 거부 항목을 디스크 스풀로 넘길 수 있고, 최신 상태만 중요하다면 오래된 값을 제거하는 별도 버퍼를 선택할 수 있습니다.


종료 토큰을 데이터와 분리하는 소비 루프

빈 큐는 작업 종료를 뜻하지 않습니다.

생산자가 잠시 느린 것일 수 있기 때문입니다.

기다리지 않는 버퍼를 폴링하는 소비자는 poll()의 빈 결과를 보고 즉시 종료해서는 안 됩니다.

아래 예제는 생산 완료 플래그와 버퍼의 빈 상태를 함께 확인합니다.

volatile 플래그가 생산 스레드가 쓴 값을 소비 스레드에 공개하고, 처리 건수는 소비 스레드 하나만 변경합니다.

src/PollingDrainDemo.java
import java.util.Optional;

public final class PollingDrainDemo {
    private static final class RunState {
        private volatile boolean productionFinished;
        private int handled;
    }

    public static void main(String[] args) throws InterruptedException {
        ObservableBoundedBuffer<String> buffer = new ObservableBoundedBuffer<>(4);
        RunState state = new RunState();

        Thread producer = Thread.ofPlatform().start(() -> {
            for (String value : new String[]{"A", "B", "C", "D"}) {
                ObservableBoundedBuffer.OfferResult result = buffer.offer(value);
                if (result != ObservableBoundedBuffer.OfferResult.ACCEPTED) {
                    throw new IllegalStateException("unexpected overflow: " + value);
                }
            }
            state.productionFinished = true;
        });

        Thread consumer = Thread.ofPlatform().start(() -> {
            while (!state.productionFinished || buffer.size() > 0) {
                Optional<String> next = buffer.poll();
                if (next.isPresent()) {
                    state.handled++;
                } else {
                    Thread.onSpinWait();
                }
            }
        });

        producer.join();
        consumer.join();
        System.out.println("handled=" + state.handled);
    }
}

이 구현은 짧은 실험에는 맞지만 실제 서비스에서 빈 큐를 계속 확인하면 CPU를 낭비합니다.

뒤 절에서 wait, Condition, BlockingQueue로 잠들게 만드는 이유가 여기에 있습니다.

먼저 종료 조건과 손실 규칙을 고정했기 때문에 대기 기술을 바꾸더라도 사용자 관점의 보존식은 그대로 재사용할 수 있습니다.


즉시 반환 정책을 선택할 때 묻는 질문

운영 질문관측 지표적합한 대응
한 건도 잃으면 안 되는가거부 건수와 재처리 성공률호출자를 막거나 내구 스풀 사용
최신 값만 의미가 있는가같은 키의 갱신 빈도오래된 항목 교체 정책 검토
요청 지연 상한이 짧은가offer 지연 백분위즉시 실패와 명시적 폴백
생산 폭주가 일시적인가버퍼 점유율 지속 시간제한된 시간 대기 고려

capacity를 크게 잡는 것만으로 정책 결정이 사라지지 않습니다.

큰 큐는 손실 시점을 늦추는 대신 오래된 작업의 지연과 메모리 점유를 늘립니다.

처리율이 장기적으로 생산율보다 낮다면 어떤 크기도 결국 찹니다.

용량은 정상적인 순간 폭주를 흡수할 만큼만 두고, 과부하 때의 반환값과 호출자 행동을 별도로 설계해야 합니다.


연습 문제

온도 센서는 같은 장치의 측정치를 빠르게 보냅니다.

용량이 찼을 때 무조건 새 값을 거부하지 말고, 동일한 센서 ID가 이미 있으면 그 항목만 최신 측정으로 교체하세요.

다른 ID만 가득 차 있다면 FULL을 반환해야 합니다.

교체와 신규 수용을 구별할 수 있는 결과 타입도 설계합니다.

정답과 해설

배열이나 덱만 사용하면 ID 검색이 선형 시간이지만 작은 상태 버퍼에서는 정책의 명료성이 먼저입니다.

잠금 안에서 기존 위치 탐색, 교체, 용량 검사를 모두 수행해야 두 생산자가 같은 ID를 동시에 추가하는 일을 막을 수 있습니다.

결과를 세 종류로 나누면 운영 지표에서 신규 수용과 덮어쓰기를 분리할 수 있습니다.

exercise/LatestReadingBufferSolution.java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

public final class LatestReadingBufferSolution {
    record Reading(String sensorId, double celsius) {
        Reading {
            if (sensorId == null || sensorId.isBlank()) throw new IllegalArgumentException("sensorId");
        }
    }

    enum StoreResult { INSERTED, REPLACED, FULL }

    private final int capacity;
    private final LinkedHashMap<String, Reading> readings = new LinkedHashMap<>();

    LatestReadingBufferSolution(int capacity) {
        if (capacity < 1) throw new IllegalArgumentException("capacity");
        this.capacity = capacity;
    }

    synchronized StoreResult store(Reading reading) {
        Objects.requireNonNull(reading, "reading");
        if (readings.containsKey(reading.sensorId())) {
            readings.put(reading.sensorId(), reading);
            return StoreResult.REPLACED;
        }
        if (readings.size() == capacity) return StoreResult.FULL;
        readings.put(reading.sensorId(), reading);
        return StoreResult.INSERTED;
    }

    synchronized Optional<Reading> takeOldest() {
        var iterator = readings.entrySet().iterator();
        if (!iterator.hasNext()) return Optional.empty();
        Reading oldest = iterator.next().getValue();
        iterator.remove();
        return Optional.of(oldest);
    }

    public static void main(String[] args) {
        LatestReadingBufferSolution buffer = new LatestReadingBufferSolution(2);
        System.out.println(buffer.store(new Reading("A", 20.1)));
        System.out.println(buffer.store(new Reading("B", 21.0)));
        System.out.println(buffer.store(new Reading("A", 20.8)));
        System.out.println(buffer.store(new Reading("C", 19.7)));
        System.out.println(buffer.takeOldest().orElseThrow());
    }
}

동일 센서의 값을 교체해도 기존 순서는 남는다

A/B 삽입→기존 A 값 교체→C FULL→takeOldest의 순서와 값이 변합니다. 삽입 순서는 유지되고 값만 바뀌는 상태를 따라가기 좋습니다.

동일 센서의 값을 교체해도 기존 순서는 남는다

LatestReadingBufferSolution의 용량 2와 A, B, A, C 입력에서 반환값과 삽입 순서를 추적합니다.

같은 키 · 삽입 순서

동일 센서의 값을 교체해도 기존 순서는 남는다

A 값을 교체해도 A의 위치는 그대로입니다. 뒤의 takeOldest는 갱신된 A 값을 가져옵니다.

삽입 위치와 값 교체의 관계용량2의 삽입순서 A→B에서 기존 A 갱신은 A위치를 유지하고 takeOldest는 20.8을 가져옵니다. C는 FULL로 거부됩니다.삽입 순서 · 용량 2put A20.8℃put C19.7℃앞 A20.1→20.8REPLACED뒤 B21.0℃takeOldestA:20.8용량 2FULL
삽입 위치와 값 교체의 관계용량2의 삽입순서 A→B에서 기존 A 갱신은 A위치를 유지하고 takeOldest는 20.8을 가져옵니다. C는 FULL로 거부됩니다.삽입 순서 · 용량 2put A20.8℃put C19.7℃앞 A20.1→20.8REPLACED뒤 B21.0℃앞에서인출A:20.8용량 2FULL
설명과 원래 예시 값
  1. A · 20.1℃

    반환값
    INSERTED
    저장된 순서·값
    A:20.1
  2. B · 21.0℃

    반환값
    INSERTED
    저장된 순서·값
    A:20.1, B:21.0
  3. A · 20.8℃

    반환값
    REPLACED
    저장된 순서·값
    A:20.8, B:21.0
  4. C · 19.7℃

    반환값
    FULL
    저장된 순서·값
    A:20.8, B:21.0

뒤의 takeOldest는 A의 20.8℃ 값을 꺼냅니다. 기존 키에 대한 put은 A를 뒤로 옮기지 않으므로 최신 갱신 순서가 아니라 최초 삽입 순서입니다.

이 예의 조건

  • 용량 2인 삽입 순서 버퍼이며 갱신 순서 버퍼가 아닙니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보동일 센서의 값을 교체해도 기존 순서는 남는다 동일 센서의 값을 교체해도 기존 순서는 남는다 LatestReadingBufferSolution의 용량 2와 A, B, A, C 입력에서 반환값과 삽입 순서를 추적합니다. 같은 키 · 삽입 순서 동일 센서의 값을 교체해도 기존 순서는 남는다 A 값을 교체해도 A의 위치는 그대로입니다. 뒤의 takeOldest는 갱신된 A 값을 가져옵니다. A · 20.1℃ 반환값 INSERTED 저장된 순서·값 A:20.1 B · 21.0℃ 반환값 INSERTED 저장된 순서·값 A:20.1, B:21.0 A · 20.8℃ 반환값 REPLACED 저장된 순서·값 A:20.8, B:21.0 C · 19.7℃ 반환값 FULL 저장된 순서·값 A:20.8, B:21.0 뒤의 takeOldest는 A의 20.8℃ 값을 꺼냅니다. 기존 키에 대한 put은 A를 뒤로 옮기지 않으므로 최신 갱신 순서가 아니라 최초 삽입 순서입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A와 B 삽입

두 새 센서를 용량 안에 등록합니다.

  • 반환: INSERTED 두 번
  • 순서·값: A:20.1,B:21.0
  • 처음 삽입한 A가 가장 오래된 위치를 갖습니다.
같은 키 교체

A의 값을 20.8로 바꾸되 위치는 유지합니다.

  • 반환: REPLACED
  • 순서·값: A:20.8,B:21.0
  • 기존 키 put은 A를 뒤로 옮기지 않습니다.
새 키 거절과 인출

C:19.7은 FULL이며 takeOldest는 갱신된 A를 꺼냅니다.

  • C 후: A:20.8,B:21.0 유지
  • 인출값: A:20.8
  • 용량 거절이 기존 값을 지우지 않으며 위치와 최신 값은 별개입니다.

동일 센서의 값을 교체해도 기존 순서는 남는다

A/B 삽입→기존 A 값 교체→C FULL→takeOldest의 순서와 값이 변합니다. 삽입 순서는 유지되고 값만 바뀌는 상태를 따라가기 좋습니다.

동일 센서의 값을 교체해도 기존 순서는 남는다

LatestReadingBufferSolution의 용량 2와 A, B, A, C 입력에서 반환값과 삽입 순서를 추적합니다.

같은 키 · 삽입 순서

동일 센서의 값을 교체해도 기존 순서는 남는다

A 값을 교체해도 A의 위치는 그대로입니다. 뒤의 takeOldest는 갱신된 A 값을 가져옵니다.

삽입 위치와 값 교체의 관계용량2의 삽입순서 A→B에서 기존 A 갱신은 A위치를 유지하고 takeOldest는 20.8을 가져옵니다. C는 FULL로 거부됩니다.삽입 순서 · 용량 2put A20.8℃put C19.7℃앞 A20.1→20.8REPLACED뒤 B21.0℃takeOldestA:20.8용량 2FULL
삽입 위치와 값 교체의 관계용량2의 삽입순서 A→B에서 기존 A 갱신은 A위치를 유지하고 takeOldest는 20.8을 가져옵니다. C는 FULL로 거부됩니다.삽입 순서 · 용량 2put A20.8℃put C19.7℃앞 A20.1→20.8REPLACED뒤 B21.0℃앞에서인출A:20.8용량 2FULL
설명과 원래 예시 값
  1. A · 20.1℃

    반환값
    INSERTED
    저장된 순서·값
    A:20.1
  2. B · 21.0℃

    반환값
    INSERTED
    저장된 순서·값
    A:20.1, B:21.0
  3. A · 20.8℃

    반환값
    REPLACED
    저장된 순서·값
    A:20.8, B:21.0
  4. C · 19.7℃

    반환값
    FULL
    저장된 순서·값
    A:20.8, B:21.0

뒤의 takeOldest는 A의 20.8℃ 값을 꺼냅니다. 기존 키에 대한 put은 A를 뒤로 옮기지 않으므로 최신 갱신 순서가 아니라 최초 삽입 순서입니다.

이 예의 조건

  • 용량 2인 삽입 순서 버퍼이며 갱신 순서 버퍼가 아닙니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보동일 센서의 값을 교체해도 기존 순서는 남는다 동일 센서의 값을 교체해도 기존 순서는 남는다 LatestReadingBufferSolution의 용량 2와 A, B, A, C 입력에서 반환값과 삽입 순서를 추적합니다. 같은 키 · 삽입 순서 동일 센서의 값을 교체해도 기존 순서는 남는다 A 값을 교체해도 A의 위치는 그대로입니다. 뒤의 takeOldest는 갱신된 A 값을 가져옵니다. A · 20.1℃ 반환값 INSERTED 저장된 순서·값 A:20.1 B · 21.0℃ 반환값 INSERTED 저장된 순서·값 A:20.1, B:21.0 A · 20.8℃ 반환값 REPLACED 저장된 순서·값 A:20.8, B:21.0 C · 19.7℃ 반환값 FULL 저장된 순서·값 A:20.8, B:21.0 뒤의 takeOldest는 A의 20.8℃ 값을 꺼냅니다. 기존 키에 대한 put은 A를 뒤로 옮기지 않으므로 최신 갱신 순서가 아니라 최초 삽입 순서입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A와 B 삽입

두 새 센서를 용량 안에 등록합니다.

  • 반환: INSERTED 두 번
  • 순서·값: A:20.1,B:21.0
  • 처음 삽입한 A가 가장 오래된 위치를 갖습니다.
같은 키 교체

A의 값을 20.8로 바꾸되 위치는 유지합니다.

  • 반환: REPLACED
  • 순서·값: A:20.8,B:21.0
  • 기존 키 put은 A를 뒤로 옮기지 않습니다.
새 키 거절과 인출

C:19.7은 FULL이며 takeOldest는 갱신된 A를 꺼냅니다.

  • C 후: A:20.8,B:21.0 유지
  • 인출값: A:20.8
  • 용량 거절이 기존 값을 지우지 않으며 위치와 최신 값은 별개입니다.

교체가 삽입 순서를 바꿔야 하는지는 별도의 제품 결정입니다.


이 절에서 남겨야 할 판단 기준

스레드 안전하다는 말과 업무적으로 손실이 없다는 말은 다릅니다.

모니터는 컬렉션의 동시 변경을 직렬화하지만, 가득 찬 상황에서 무엇을 할지는 API 설계자가 정해야 합니다.

시도·수용·거부·소비·잔여 수를 서로 맞춰 볼 수 있을 때 비로소 유한 버퍼의 동작을 확인할 수 있습니다.

다음 대기 구현으로 넘어가기 전에 FULL이 정상 분기인지 장애인지 결정하세요.

정상 분기라면 반환값과 지표를 준비하고, 장애라면 예외나 내구 저장소를 사용합니다.

이 선택을 미룬 채 void put만 노출하면 가장 중요한 과부하 정보가 사라집니다.