본문 바로가기

컬렉션 구현 선택 기준

List·Set·Map·Deque 선택을 순서·중복·조회 키·양끝 연산에서 시작하고 실제 연산 분포로 구현을 좁힙니다.

컬렉션 선택을 클래스 이름 암기로 시작하면 ArrayList와 LinkedList 비교에서 멈춥니다.

먼저 결과가 순서를 갖는지, 중복을 허용하는지, 키로 값을 찾는지, 양끝 제거가 필요한지 결정합니다.

인터페이스가 정해진 뒤 조회·삽입·범위 질의의 실제 비율로 구현을 고릅니다.


단일 List 검색의 비용

게시글 slug를 ArrayList에 저장하고 매번 포함 여부로 중복을 검사하면 n개 추가의 누적 검색이 커집니다.

키-값 관계까지 indexOf와 병렬 List로 표현하면 관계 불일치 위험도 생깁니다.

lab/ListForEveryRequirementBug.java
import java.util.ArrayList;
import java.util.List;

public final class ListForEveryRequirementBug {
    public static void main(String[] args) {
        List<String> unique = new ArrayList<>();
        int comparisons = 0;
        for (int value = 0; value < 1000; value++) {
            String slug = "post-" + value;
            for (String existing : unique) {
                comparisons++;
                if (existing.equals(slug)) break;
            }
            if (!unique.contains(slug)) unique.add(slug);
        }
        System.out.println("size=" + unique.size() + ", manual-comparisons=" + comparisons);
    }
}

manual-comparisons=499500은 수동 for 루프의 comparisons++만 센 값입니다. 서로 다른 1,000개 slug에서 0 + ... + 999를 합한 결과이며, 바로 뒤 contains의 내부 비교는 포함하지 않습니다.

중복 없는 존재 검사가 본질이면 Set이 요구를 직접 표현합니다.

499,500은 1,000×999/2입니다. 원 코드는 같은 입력마다 뒤의 contains도 다시 검색하므로 이 숫자를 전체 문자열 비교 횟수나 시간으로 부르면 안 됩니다. 또한 이미 있던 slug를 넣는 별도 입력에서는 찾은 위치에서 비교가 멈춰 이 합과 달라집니다. 원 예제는 모두 다른 slug를 넣은 조건에서 누적 선형 탐색을 보여 줍니다.

순서가 필요하면 LinkedHashSet으로 세부 구현을 선택합니다.


인터페이스 선택 질문 네 개

원소를 index·순서로 다루고 중복을 허용하면 List입니다.

값 존재와 집합 연산이 중심이면 Set입니다.

키와 값 관계를 저장하면 Map입니다.

최근/가장 오래된 원소를 양끝에서 처리하면 Deque입니다.

PriorityQueue는 입력 순서가 아니라 우선순위 제거가 필요할 때 별도 후보입니다.

우선순위 큐는 향상된 for문으로 읽은 순서까지 우선순위 정렬을 보장하는 구조가 아닙니다. 우선순위대로 처리하는 요구는 poll을 반복해 꺼내는 순서로 판단해야 합니다. “FIFO 대기 명령”과 “기한이 가장 빠른 작업부터 꺼내기”는 서로 다른 제거 규칙이므로 같은 Queue라는 이름만 보고 바꾸지 않습니다.

인터페이스는 불필요한 연산도 제한합니다.

Queue 매개변수는 소비자가 index get에 의존하지 않게 하고, Set은 중복 add의 의미를 boolean으로 보여 줍니다.

변수 타입을 가장 넓은 Collection로 무조건 올리면 키 조회나 양끝 의미가 사라져 오히려 규칙이 약해질 수 있습니다.


구현 선택 기준: 연산 분포·규모

List의 일반 기본값은 ArrayList입니다.

임의 조회와 전체 순회가 빠르고 메모리 효율이 좋습니다.

이미 원하는 위치로 이동한 ListIterator에서 중간 추가·삭제를 반복하는 등 구체 요구가 있을 때 LinkedList를 검토합니다. 위치까지 이동하는 비용은 별도입니다.

Queue·Stack에는 ArrayDeque가 기본입니다.

Set과 Map은 순서가 없으면 Hash 계열, 최초 삽입 순서면 LinkedHash 계열, 정렬·범위면 Tree 계열을 고려합니다.

평균 O(1)은 좋은 hashCode와 적절한 적재 계수가 전제이고 Tree의 O(log n)은 비교자 규칙이 전제입니다.

src/CollectionChoiceScenarios.java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;

public final class CollectionChoiceScenarios {
    public static void main(String[] args) {
        List<String> orderedTitles = new ArrayList<>();
        orderedTitles.add("array");
        orderedTitles.add("array");
        Set<String> firstTags = new LinkedHashSet<>();
        firstTags.addAll(orderedTitles);
        Map<String, Integer> totals = new HashMap<>();
        for (String title : orderedTitles) {
            totals.merge(title, 1, Integer::sum);
        }
        Queue<String> pending = new ArrayDeque<>();
        pending.offer("print");
        System.out.println(orderedTitles);
        System.out.println(firstTags);
        System.out.println(totals);
        System.out.println(pending.poll());
    }
}
네 컬렉션에 넣은 값과 실제 출력

중복 제목 두 개는 List에 남고 Set은 하나만 보관하며 Map은 횟수 2를 셉니다. 별도 Queue에는 print 명령을 넣어 꺼냅니다.

컬렉션 선택 · 역할별 출력

네 컬렉션에 넣은 값과 실제 출력

제목 array 두 개를 List·Set·Map으로 다룹니다. Queue에는 별도 명령 print를 넣고 꺼냅니다.

중복 제목과 별도 대기 명령의 네 역할 네 영역에서 List는 array 두 개, Set은 array 하나, Map은 array 횟수2를 보관합니다. Queue의 별도 입력은 print이며 poll 출력은 print입니다. 각 실선은 역할별 입력과 결과이며 출력 순서를 데이터 연쇄로 해석하지 않습니다. orderedTitles · List array 두 번 add 순서와 중복 보관 실제 출력 [array, array] firstTags · Set List의 두 array 전달 같은 값 두 번째는 중복 실제 출력 [array] totals · Map merge 두 번 array에 각 +1 1+1=2 실제 출력 {array=2} pending · Queue 별도 명령 print offer 제목 array 입력이 아님 poll 뒤 실제 출력 print / pending 빈 상태 영역은 서로 다른 네 역할 실선: 각 입력·연산·결과 위에서 아래 출력 순서와 구별
중복 제목과 별도 대기 명령의 네 역할 네 영역에서 List는 array 두 개, Set은 array 하나, Map은 array 횟수2를 보관합니다. Queue의 별도 입력은 print이며 poll 출력은 print입니다. 각 실선은 역할별 입력과 결과이며 출력 순서를 데이터 연쇄로 해석하지 않습니다. orderedTitles · List array 두 번 add 순서와 중복 보관 실제 출력 [array, array] firstTags · Set List의 두 array 전달 같은 값 두 번째는 중복 실제 출력 [array] totals · Map merge 두 번 array에 각 +1 1+1=2 실제 출력 {array=2} pending · Queue 별도 명령 print offer 제목 array 입력이 아님 poll 뒤 실제 출력 print / pending 빈 상태 영역은 서로 다른 네 역할 실선: 각 입력·연산·결과 위에서 아래 출력 순서와 구별
네 컬렉션에 넣은 값과 실제 출력
실제 변수·구현원문에서 수행한 연산표준 출력
orderedTitles
ArrayList
"array" 두 번 추가
입력 순서와 중복 보존
[array, array]
firstTags
LinkedHashSet
addAll(orderedTitles)
같은 값은 한 번만 보관
[array]
totals
HashMap
각 제목에 merge로 1 누적
1 + 1 = 2
{array=2}
pending
ArrayDeque
offer("print")
poll()로 명령 제거·반환
print
orderedTitles
ArrayList
원문에서 수행한 연산
"array" 두 번 추가
입력 순서와 중복 보존
표준 출력
[array, array]
firstTags
LinkedHashSet
원문에서 수행한 연산
addAll(orderedTitles)
같은 값은 한 번만 보관
표준 출력
[array]
totals
HashMap
원문에서 수행한 연산
각 제목에 merge로 1 누적
1 + 1 = 2
표준 출력
{array=2}
pending
ArrayDeque
원문에서 수행한 연산
offer("print")
poll()로 명령 제거·반환
표준 출력
print

표의 네 출력은 원문 println 순서입니다. 앞의 세 컬렉션은 제목 array를 다루고, 큐의 입력은 print입니다. poll 뒤 큐가 빈다는 것은 소스 추적이며 따로 출력하지 않습니다. HashMap의 키는 하나뿐이라 여러 키의 반복 순서를 보여 주는 예제가 아닙니다.

예제의 네 컬렉션은 서로 다른 역할을 맡습니다. 앞의 세 컬렉션은 array를 다루고, 큐에는 print 명령을 넣습니다.

이 예제에서 List는 제목 중복을 보존하고, Set은 고유 값을 모으며, Map은 제목별 횟수를 세고, Queue는 처리할 명령을 보관합니다.


불변 반환·가변 내부 저장 결정

내부 구현이 ArrayList라고 반환값도 가변일 필요는 없습니다.

List.copyOf, Set.copyOf, Map.copyOf로 원본의 이후 구조 변경을 반영하지 않는 수정 불가 스냅샷을 만들 수 있습니다. 원소를 깊게 복사하는 것은 아닙니다.

순서까지 반환 계약인 경우: Map.copyOf는 매핑의 순회 순서를 보장하지 않습니다. 아래 저장소가 Collections.unmodifiableMap(new LinkedHashMap<>(totals))를 사용하는 이유는 복사본에서 첫 삽입 순서를 보존하고, 호출자가 그 복사본을 수정하지 못하게 하기 위해서입니다. Collections.unmodifiableMap(totals)만 반환하면 수정 불가인 것은 같지만 원본의 이후 변경을 그대로 보는 뷰가 됩니다. 복사와 수정 방지는 각각 확인해야 합니다. Map의 수정 불가 팩터리 계약과 unmodifiableMap의 뷰 계약은 이 둘을 구분합니다.

반대로 불변 List를 받아 내부에서 추가해야 한다면 new ArrayList<>(source)로 내부에서 소유할 복사본을 만듭니다.

뷰와 스냅샷 차이도 확인합니다.

Map.keySet은 원본 Map의 실시간 뷰여서 Map 변경이 반영됩니다.

List.copyOf는 이후 원본 구조 변경을 반영하지 않습니다.

API 사용자는 어느 시점의 결과인지 알아야 합니다.

동기화 요구는 자료 구조 선택의 별도 축입니다.

synchronized 래퍼, 동시성 컬렉션, 불변 스냅샷은 서로 다른 의미입니다.

단순히 “스레드 안전”이라는 한 단어로 복합 연산 원자성과 반복자 일관성을 가정하지 않습니다.


컬렉션 성능 표를 읽는 법

Big-O 한 칸은 위치 탐색 포함 여부, 평균·최악, 확장 비용, 비교 함수 비용을 생략할 수 있습니다.

LinkedList의 중간 삽입 O(1)은 ListIterator가 삽입 위치에 이미 도달했을 때의 연결 변경 비용입니다. index로 위치를 찾는 탐색은 별도이고, ArrayList 끝 add O(1)은 상환 비용입니다.

HashMap get 평균 O(1)은 해시 분산이 전제입니다.

작은 데이터에서는 코드 단순성·캐시 지역성·할당 수가 복잡도 차이보다 중요할 수 있습니다.

벽시계 측정은 JMH와 실제 작업 부하로 확인하고, 기능 결과가 같은지 먼저 확인합니다.

app/BoardCollectionDesign.java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;

public final class BoardCollectionDesign {
    public static void main(String[] args) {
        Repository repository = new Repository();
        repository.enqueue(new Entry("hash", 50, List.of("java", "set")));
        repository.enqueue(new Entry("queue", 40, List.of("java", "deque")));
        repository.processAll();
        System.out.println(repository.entries());
        System.out.println(repository.tags());
        System.out.println(repository.totals());
    }

    private static final class Repository {
        private final Queue<Entry> pending = new ArrayDeque<>();
        private final List<Entry> entries = new ArrayList<>();
        private final Set<String> tags = new LinkedHashSet<>();
        private final Map<String, Integer> totals = new LinkedHashMap<>();

        void enqueue(Entry e) {
            pending.offer(e);
        }

        void processAll() {
            for (Entry e; (e = pending.poll()) != null; ) {
                entries.add(e);
                tags.addAll(e.tags());
                totals.merge(e.title(), e.viewCount(), Integer::sum);
            }
        }

        List<Entry> entries() {
            return List.copyOf(entries);
        }

        List<String> tags() {
            return List.copyOf(tags);
        }

        Map<String, Integer> totals() {
            return Collections.unmodifiableMap(new LinkedHashMap<>(totals));
        }
    }

    private record Entry(String title, int viewCount, List<String> tags) {
        Entry {
            if (title == null || title.isBlank() || viewCount < 0) {
                throw new IllegalArgumentException("invalid entry");
            }
            tags = List.copyOf(tags);
        }
    }
}

두 게시글을 처리한 네 저장소의 최종 값

pending 두 Entry를 FIFO로 꺼내 entries/tags/totals에 각각 반영합니다. 고정 네 역할에서 두 처리의 누적과 큐 소진을 추적합니다.

두 게시글을 처리한 네 저장소의 최종 값

hash와 queue 게시글을 FIFO로 처리하면 entries에는 두 게시글이 등록 순서로 남고 tags에는 처음 등장한 세 태그가, totals에는 제목별 조회수가 남습니다.

저장소 설계 · 네 가지 역할

두 게시글을 처리한 네 저장소의 최종 값

두 게시글을 FIFO로 처리한 뒤, 등록 순서·고유 태그·제목별 합계를 서로 다른 저장소에 보관합니다.

FIFO 처리와 세 저장 역할로 향하는 반영 pending의 hash와 queue 두 Entry를 FIFO로 꺼내 entries,tags,totals에 각각 반영합니다. entries의 hash→queue, tags의 java→set→deque, totals의 hash50/queue40을 보존합니다. 분기 선은 같은 입력을 각 필드에 반영하는 관계이지 출력 필드 사이 호출 사슬이 아닙니다. pending · FIFO 입력 hash50 / java,set queue40 / java,deque entries hash → queue 등록 순서 보관 tags java → set → deque 첫 등장 고유 totals hash→50 / queue→40 제목별 누적 실선: 각 poll한 Entry를 서로 다른 필드에 반영 최종 pending=[]는 추적값 / 세 반환은 독립 구조 snapshot
FIFO 처리와 세 저장 역할로 향하는 반영 pending의 hash와 queue 두 Entry를 FIFO로 꺼내 entries,tags,totals에 각각 반영합니다. entries의 hash→queue, tags의 java→set→deque, totals의 hash50/queue40을 보존합니다. 분기 선은 같은 입력을 각 필드에 반영하는 관계이지 출력 필드 사이 호출 사슬이 아닙니다. pending · FIFO 입력 hash50 / java,set queue40 / java,deque entries · 등록 순서 hash → queue 같은 두 Entry를 보관 tags · 첫 등장 고유 java → set → deque java 중복은 추가 안 함 totals · 제목별 누적 hash→50 / queue→40 복사한 Map을 감싸 반환 실선: 두 poll의 값을 각 필드로 entries→tags→totals 사슬 아님 마지막 poll=null / pending=[] 세 반환값은 구조 snapshot
두 게시글을 처리한 네 저장소의 최종 값
내부 필드·타입처리·반환에서 맡은 역할최종 값과 출력 여부
pending
Queue<Entry>
poll(): hash → queue
마지막 빈 조회는 null
[]
소스 추적 · 출력하지 않음
entries
List<Entry>
add(e)로 등록 순서 보관
entries(): List.copyOf
첫 게시글: title=hash
viewCount=50
tags=[java, set]
둘째 게시글: title=queue
viewCount=40
tags=[java, deque]
tags
Set<String>
addAll(e.tags())
고유 값·첫 등장 순서
tags(): List.copyOf
[java, set, deque]
실제 둘째 출력
totals
Map<String, Integer>
제목별 조회수 merge
복사한 LinkedHashMap을
unmodifiableMap으로 감쌈
{hash=50, queue=40}
실제 셋째 출력
pending
Queue<Entry>
처리·반환에서 맡은 역할
poll(): hash → queue
마지막 빈 조회는 null
최종 값과 출력 여부
[]
소스 추적 · 출력하지 않음
entries
List<Entry>
처리·반환에서 맡은 역할
add(e)로 등록 순서 보관
entries(): List.copyOf
최종 값과 출력 여부
첫 게시글: title=hash
viewCount=50
tags=[java, set]
둘째 게시글: title=queue
viewCount=40
tags=[java, deque]
tags
Set<String>
처리·반환에서 맡은 역할
addAll(e.tags())
고유 값·첫 등장 순서
tags(): List.copyOf
최종 값과 출력 여부
[java, set, deque]
실제 둘째 출력
totals
Map<String, Integer>
처리·반환에서 맡은 역할
제목별 조회수 merge
복사한 LinkedHashMap을
unmodifiableMap으로 감쌈
최종 값과 출력 여부
{hash=50, queue=40}
실제 셋째 출력

첫 출력은 두 Entry의 리스트이며 표에는 각 원소의 title, viewCount, tags 필드를 풀어 적었습니다. 태그 순서는 LinkedHashSet의 첫 등장 순서입니다. totals()는 새 LinkedHashMap 복사본을 감싸므로 내부 Map의 실시간 뷰를 반환하지 않습니다. 반환 뒤 변경을 시도하는 실험은 이 main에 없습니다.

이 예의 조건

  • 세 결과 snapshot은 독립 구조이며 화살표는 필드 간 호출 사슬이 아니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2pending의 hash와 queue 두 Entry를 FIFO로 꺼내 entries,tags,totals에 각각 반영합니다. entries의 hash→queue, tags의 java→set→deque, totals의 hash 50/queue 40을 보존합니다. 분기 선은 같은 입력을 각 필드에 반영하는 관계이지 출력 필드 사이 호출 사슬이 아닙니다.
4hash50
5java,set
6queue40
7java,deque
15hash→50
16queue→40
19최종 pending=[]는 추적값
20세 반환은 독립 구조 snapshot
원 도표의 설명과 주석
구분내용
설명pending의 hash와 queue 두 Entry를 FIFO로 꺼내 entries,tags,totals에 각각 반영합니다. entries의 hash→queue, tags의 java→set→deque, totals의 hash 50/queue 40을 보존합니다. 분기 선은 같은 입력을 각 필드에 반영하는 관계이지 출력 필드 사이 호출 사슬이 아닙니다.
전체 읽기 정보두 게시글을 처리한 네 저장소의 최종 값 두 게시글을 처리한 네 저장소의 최종 값 hash와 queue 게시글을 FIFO로 처리하면 entries에는 두 게시글이 등록 순서로 남고 tags에는 처음 등장한 세 태그가, totals에는 제목별 조회 수가 남습니다. 저장소 설계 · 네 가지 역할 두 게시글을 처리한 네 저장소의 최종 값 두 게시글을 FIFO로 처리한 뒤, 등록 순서·고유 태그·제목별 합계를 서로 다른 저장소에 보관합니다. 두 게시글을 처리한 네 저장소의 최종 값 내부 필드·타입 처리·반환에서 맡은 역할 최종 값과 출력 여부 pending Queue<Entry> poll() : hash → queue 마지막 빈 조회는 null [] 소스 추적 · 출력하지 않음 entries List<Entry> add(e) 로 등록 순서 보관 entries() : List.copyOf 첫 게시글: title=hash viewCount=50 tags=[java, set] 둘째 게시글: title=queue viewCount=40 tags=[java, deque] tags Set<String> addAll(e.tags()) 고유 값·첫 등장 순서 tags() : List.copyOf [java, set, deque] 실제 둘째 출력 totals Map<String, Integer> 제목별 조회 수 merge 복사한 LinkedHashMap을 unmodifiableMap 으로 감쌈 {hash=50, queue=40} 실제 셋째 출력 pending Queue<Entry> 처리·반환에서 맡은 역할 poll() : hash → queue 마지막 빈 조회는 null 최종 값과 출력 여부 [] 소스 추적 · 출력하지 않음 entries List<Entry> 처리·반환에서 맡은 역할 add(e) 로 등록 순서 보관 entries() : List.copyOf 최종 값과 출력 여부 첫 게시글: title=hash viewCount=50 tags=[java, set] 둘째 게시글: title=queue viewCount=40 tags=[java, deque] tags Set<String> 처리·반환에서 맡은 역할 addAll(e.tags()) 고유 값·첫 등장 순서 tags() : List.copyOf 최종 값과 출력 여부 [java, set, deque] 실제 둘째 출력 totals Map<String, Integer> 처리·반환에서 맡은 역할 제목별 조회 수 merge 복사한 LinkedHashMap을 unmodifiableMap 으로 감쌈 최종 값과 출력 여부 {hash=50, queue=40} 실제 셋째 출력 첫 출력은 두 Entry 의 리스트이며 표에는 각 원소의 title , viewCount , tags 필드를 풀어 적었습니다. 태그 순서는 LinkedHashSet의 첫 등장 순서입니다. totals() 는 새 LinkedHashMap 복사본을 감싸므로 내부 Map의 실시간 뷰를 반환하지 않습니다. 반환 뒤 변경을 시도하는 실험은 이 main에 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력 큐

hash 50과 queue 40 Entry를 pending에 둡니다.

  • pending: hash(java,set),queue(java,deque)
  • 저장: 아직 반영 전
  • FIFO의 두 입력을 처리 단위로 보관한다.
첫 반영

hash를 꺼내 세 필드에 반영합니다.

  • entries: hash
  • tags: java,set
  • totals: hash50
  • 하나의 Entry가 서로 다른 저장 책임에 사용된다.
둘째 반영

queue를 꺼내 같은 책임들을 갱신합니다.

  • entries: hash,queue
  • tags: java,set,deque
  • totals: hash50,queue40
  • pending: []
  • 이미 존재한 java는 고유 태그로 한 번만 남는다.

두 게시글을 처리한 네 저장소의 최종 값

pending 두 Entry를 FIFO로 꺼내 entries/tags/totals에 각각 반영합니다. 고정 네 역할에서 두 처리의 누적과 큐 소진을 추적합니다.

두 게시글을 처리한 네 저장소의 최종 값

hash와 queue 게시글을 FIFO로 처리하면 entries에는 두 게시글이 등록 순서로 남고 tags에는 처음 등장한 세 태그가, totals에는 제목별 조회수가 남습니다.

저장소 설계 · 네 가지 역할

두 게시글을 처리한 네 저장소의 최종 값

두 게시글을 FIFO로 처리한 뒤, 등록 순서·고유 태그·제목별 합계를 서로 다른 저장소에 보관합니다.

FIFO 처리와 세 저장 역할로 향하는 반영 pending의 hash와 queue 두 Entry를 FIFO로 꺼내 entries,tags,totals에 각각 반영합니다. entries의 hash→queue, tags의 java→set→deque, totals의 hash50/queue40을 보존합니다. 분기 선은 같은 입력을 각 필드에 반영하는 관계이지 출력 필드 사이 호출 사슬이 아닙니다. pending · FIFO 입력 hash50 / java,set queue40 / java,deque entries hash → queue 등록 순서 보관 tags java → set → deque 첫 등장 고유 totals hash→50 / queue→40 제목별 누적 실선: 각 poll한 Entry를 서로 다른 필드에 반영 최종 pending=[]는 추적값 / 세 반환은 독립 구조 snapshot
FIFO 처리와 세 저장 역할로 향하는 반영 pending의 hash와 queue 두 Entry를 FIFO로 꺼내 entries,tags,totals에 각각 반영합니다. entries의 hash→queue, tags의 java→set→deque, totals의 hash50/queue40을 보존합니다. 분기 선은 같은 입력을 각 필드에 반영하는 관계이지 출력 필드 사이 호출 사슬이 아닙니다. pending · FIFO 입력 hash50 / java,set queue40 / java,deque entries · 등록 순서 hash → queue 같은 두 Entry를 보관 tags · 첫 등장 고유 java → set → deque java 중복은 추가 안 함 totals · 제목별 누적 hash→50 / queue→40 복사한 Map을 감싸 반환 실선: 두 poll의 값을 각 필드로 entries→tags→totals 사슬 아님 마지막 poll=null / pending=[] 세 반환값은 구조 snapshot
두 게시글을 처리한 네 저장소의 최종 값
내부 필드·타입처리·반환에서 맡은 역할최종 값과 출력 여부
pending
Queue<Entry>
poll(): hash → queue
마지막 빈 조회는 null
[]
소스 추적 · 출력하지 않음
entries
List<Entry>
add(e)로 등록 순서 보관
entries(): List.copyOf
첫 게시글: title=hash
viewCount=50
tags=[java, set]
둘째 게시글: title=queue
viewCount=40
tags=[java, deque]
tags
Set<String>
addAll(e.tags())
고유 값·첫 등장 순서
tags(): List.copyOf
[java, set, deque]
실제 둘째 출력
totals
Map<String, Integer>
제목별 조회수 merge
복사한 LinkedHashMap을
unmodifiableMap으로 감쌈
{hash=50, queue=40}
실제 셋째 출력
pending
Queue<Entry>
처리·반환에서 맡은 역할
poll(): hash → queue
마지막 빈 조회는 null
최종 값과 출력 여부
[]
소스 추적 · 출력하지 않음
entries
List<Entry>
처리·반환에서 맡은 역할
add(e)로 등록 순서 보관
entries(): List.copyOf
최종 값과 출력 여부
첫 게시글: title=hash
viewCount=50
tags=[java, set]
둘째 게시글: title=queue
viewCount=40
tags=[java, deque]
tags
Set<String>
처리·반환에서 맡은 역할
addAll(e.tags())
고유 값·첫 등장 순서
tags(): List.copyOf
최종 값과 출력 여부
[java, set, deque]
실제 둘째 출력
totals
Map<String, Integer>
처리·반환에서 맡은 역할
제목별 조회수 merge
복사한 LinkedHashMap을
unmodifiableMap으로 감쌈
최종 값과 출력 여부
{hash=50, queue=40}
실제 셋째 출력

첫 출력은 두 Entry의 리스트이며 표에는 각 원소의 title, viewCount, tags 필드를 풀어 적었습니다. 태그 순서는 LinkedHashSet의 첫 등장 순서입니다. totals()는 새 LinkedHashMap 복사본을 감싸므로 내부 Map의 실시간 뷰를 반환하지 않습니다. 반환 뒤 변경을 시도하는 실험은 이 main에 없습니다.

이 예의 조건

  • 세 결과 snapshot은 독립 구조이며 화살표는 필드 간 호출 사슬이 아니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2pending의 hash와 queue 두 Entry를 FIFO로 꺼내 entries,tags,totals에 각각 반영합니다. entries의 hash→queue, tags의 java→set→deque, totals의 hash 50/queue 40을 보존합니다. 분기 선은 같은 입력을 각 필드에 반영하는 관계이지 출력 필드 사이 호출 사슬이 아닙니다.
4hash50
5java,set
6queue40
7java,deque
15hash→50
16queue→40
19최종 pending=[]는 추적값
20세 반환은 독립 구조 snapshot
원 도표의 설명과 주석
구분내용
설명pending의 hash와 queue 두 Entry를 FIFO로 꺼내 entries,tags,totals에 각각 반영합니다. entries의 hash→queue, tags의 java→set→deque, totals의 hash 50/queue 40을 보존합니다. 분기 선은 같은 입력을 각 필드에 반영하는 관계이지 출력 필드 사이 호출 사슬이 아닙니다.
전체 읽기 정보두 게시글을 처리한 네 저장소의 최종 값 두 게시글을 처리한 네 저장소의 최종 값 hash와 queue 게시글을 FIFO로 처리하면 entries에는 두 게시글이 등록 순서로 남고 tags에는 처음 등장한 세 태그가, totals에는 제목별 조회 수가 남습니다. 저장소 설계 · 네 가지 역할 두 게시글을 처리한 네 저장소의 최종 값 두 게시글을 FIFO로 처리한 뒤, 등록 순서·고유 태그·제목별 합계를 서로 다른 저장소에 보관합니다. 두 게시글을 처리한 네 저장소의 최종 값 내부 필드·타입 처리·반환에서 맡은 역할 최종 값과 출력 여부 pending Queue<Entry> poll() : hash → queue 마지막 빈 조회는 null [] 소스 추적 · 출력하지 않음 entries List<Entry> add(e) 로 등록 순서 보관 entries() : List.copyOf 첫 게시글: title=hash viewCount=50 tags=[java, set] 둘째 게시글: title=queue viewCount=40 tags=[java, deque] tags Set<String> addAll(e.tags()) 고유 값·첫 등장 순서 tags() : List.copyOf [java, set, deque] 실제 둘째 출력 totals Map<String, Integer> 제목별 조회 수 merge 복사한 LinkedHashMap을 unmodifiableMap 으로 감쌈 {hash=50, queue=40} 실제 셋째 출력 pending Queue<Entry> 처리·반환에서 맡은 역할 poll() : hash → queue 마지막 빈 조회는 null 최종 값과 출력 여부 [] 소스 추적 · 출력하지 않음 entries List<Entry> 처리·반환에서 맡은 역할 add(e) 로 등록 순서 보관 entries() : List.copyOf 최종 값과 출력 여부 첫 게시글: title=hash viewCount=50 tags=[java, set] 둘째 게시글: title=queue viewCount=40 tags=[java, deque] tags Set<String> 처리·반환에서 맡은 역할 addAll(e.tags()) 고유 값·첫 등장 순서 tags() : List.copyOf 최종 값과 출력 여부 [java, set, deque] 실제 둘째 출력 totals Map<String, Integer> 처리·반환에서 맡은 역할 제목별 조회 수 merge 복사한 LinkedHashMap을 unmodifiableMap 으로 감쌈 최종 값과 출력 여부 {hash=50, queue=40} 실제 셋째 출력 첫 출력은 두 Entry 의 리스트이며 표에는 각 원소의 title , viewCount , tags 필드를 풀어 적었습니다. 태그 순서는 LinkedHashSet의 첫 등장 순서입니다. totals() 는 새 LinkedHashMap 복사본을 감싸므로 내부 Map의 실시간 뷰를 반환하지 않습니다. 반환 뒤 변경을 시도하는 실험은 이 main에 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
입력 큐

hash 50과 queue 40 Entry를 pending에 둡니다.

  • pending: hash(java,set),queue(java,deque)
  • 저장: 아직 반영 전
  • FIFO의 두 입력을 처리 단위로 보관한다.
첫 반영

hash를 꺼내 세 필드에 반영합니다.

  • entries: hash
  • tags: java,set
  • totals: hash50
  • 하나의 Entry가 서로 다른 저장 책임에 사용된다.
둘째 반영

queue를 꺼내 같은 책임들을 갱신합니다.

  • entries: hash,queue
  • tags: java,set,deque
  • totals: hash50,queue40
  • pending: []
  • 이미 존재한 java는 고유 태그로 한 번만 남는다.

한 애플리케이션에서 자료 구조 하나만 고집하지 않습니다.

대기 작업은 FIFO, 게시글은 중복 허용·등록 순서, tags는 고유·첫 등장 순서, totals는 제목별 조회수 합계를 각각 맡습니다.


record와 원소 소유권: Entry의 record 필드는 다른 값으로 다시 대입할 수 없지만, record라는 이유만으로 필드가 가리키는 목록까지 수정 불가가 되지는 않습니다. 생성자의 tags = List.copyOf(tags)는 호출자가 전달한 원본 목록의 이후 추가·삭제가 이 Entry에 반영되지 않게 하여 태그 구성을 고정합니다. 호출자는 자기 원본 목록을 여전히 바꿀 수 있고, Entry가 보관한 목록만 수정 불가입니다. 여기서는 원소가 수정 불가인 String이어서 충분합니다. 가변 객체를 원소로 담는다면 그 객체의 변경은 별도로 다뤄야 합니다. 이 Repository도 단일 스레드 예제입니다. processAll()의 poll()·목록 추가·집합 추가·누적 갱신은 하나의 원자적 연산으로 묶인 것이 아닙니다. ArrayDeque가 null 원소를 받지 않으므로 이 예제에서 poll()의 null은 빈 큐를 뜻합니다.

연습 문제

“최근 본 게시글 10개를 중복 없이 최신순으로 보이고, slug로 게시글 상세를 즉시 찾는다”를 구현하세요.

순서 구조와 키 조회 구조를 하나로 억지로 합치지 않습니다.

정답과 해설

ArrayDeque에는 최근 slug 순서를, HashMap에는 slug별 상세를 저장합니다. 목록 상한은 visit의 크기 검사와 removeLast가 적용하며 ArrayDeque 자체의 고정 용량이 아닙니다.

재방문 slug는 Deque 기존 값을 지우고 앞에 넣습니다. 아래 main은 상한 3으로 실행한 작은 예이며, 문제의 10개 상한을 적용하려면 Index에 10을 전달합니다.

exercise/RecentPostIndexSolution.java
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;

public final class RecentPostIndexSolution {
    public static void main(String[] args) {
        Index i = new Index(3);
        i.visit(new Post("array", "Array"));
        i.visit(new Post("hash", "Hash"));
        i.visit(new Post("array", "Array"));
        i.visit(new Post("queue", "Queue"));
        System.out.println(i.recent);
        System.out.println(i.find("hash"));
    }

    private static final class Index {
        final int limit;
        final Deque<String> recent = new ArrayDeque<>();
        final Map<String, Post> bySlug = new HashMap<>();

        Index(int limit) {
            this.limit = limit;
        }

        void visit(Post post) {
            bySlug.put(post.slug(), post);
            recent.remove(post.slug());
            recent.addFirst(post.slug());
            if (recent.size() > limit) recent.removeLast();
        }

        Post find(String slug) {
            return bySlug.get(slug);
        }
    }

    private record Post(String slug, String title) {}
}

네 방문으로 최근 순서와 slug 사전을 갱신한다

네 visit에서 recent 최신순과 bySlug 상세가 함께 바뀝니다. 동일 Index의 재방문 이동·같은 키 교체를 단계로 추적하기 좋습니다.

네 방문으로 최근 순서와 slug 사전을 갱신한다

array를 재방문하면 최근 목록의 기존 위치를 지우고 맨 앞으로 옮깁니다. 제한 3인 실제 실행에는 고유 slug가 3개라 오래된 항목을 퇴출하지 않습니다.

최근 방문 · 두 저장소

네 방문으로 최근 순서와 slug 사전을 갱신한다

재방문한 array는 최근 목록 맨 앞으로 이동합니다. bySlug는 같은 slug의 상세 Post를 교체합니다.

네 방문이 바꾸는 최신순과 상세 사전원 네 visit의 recent와 bySlug 상태를 연결합니다. array 재방문은 기존 최근 위치를 옮기고 상세 값을 교체합니다. 최종queue,array,hash이며 고유키3/limit3라 원 main에서 퇴출은 없습니다. Hash 조회를 그대로 보존합니다.
array 첫 방문recent=[array]bySlug:array→Array
연결 관계array 첫 방문 · recent=[array] · bySlug:array→Array → hash 방문 · recent=[hash,array] · array와 hash 두 키
hash 방문recent=[hash,array]array와 hash 두 키
연결 관계hash 방문 · recent=[hash,array] · array와 hash 두 키 → array 재방문 · recent=[array,hash] · 같은 키 상세 교체 / size2
array 재방문recent=[array,hash]같은 키 상세 교체 / size2
연결 관계array 재방문 · recent=[array,hash] · 같은 키 상세 교체 / size2 → queue 방문 · recent의 최신순 · queue → array → hash · 세 키 / findhash는Hash
queue 방문recent의 최신순queue → array → hash세 키 / findhash는Hash

실선: 같은 Index의 네 visit순서는 최신→오래된limit3 / 원 main은 퇴출 없음

  1. 첫 방문
    slug=array, title=Array

    recent · 최신 → 오래된 순
    [array]
    bySlug 관계 · 순서 비보장
    array → Array
  2. 둘째 방문
    slug=hash, title=Hash

    recent · 최신 → 오래된 순
    [hash, array]
    bySlug 관계 · 순서 비보장
    array → Array
    hash → Hash
  3. 재방문
    slug=array, title=Array

    recent · 최신 → 오래된 순
    [array, hash]
    bySlug 관계 · 순서 비보장
    같은 slug의 Post 교체
    array → Array
    hash → Hash
  4. 넷째 방문
    slug=queue, title=Queue

    recent · 최신 → 오래된 순
    [queue, array, hash]
    bySlug 관계 · 순서 비보장
    array → Array
    hash → Hash
    queue → Queue

각 행은 visit 직후의 소스 추적이며 사전의 화살표는 slug와 Post의 title 관계입니다. 실제 출력은 [queue, array, hash]와 Post[slug=hash, title=Hash]의 두 줄입니다. limit=3에 고유 slug가 3개라 removeLast()는 실행되지 않습니다. 원문에는 bySlug에서 값을 지우는 코드가 없어 최근 목록과 상세 사전의 보관 범위가 다릅니다.

이 예의 조건

  • limit 3이며 원래 main의 고유 키 3에서 퇴출은 없다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 네 visit의 recent와 bySlug 상태를 연결합니다. array 재방문은 기존 최근 위치를 옮기고 상세 값을 교체합니다. 최종 queue,array,hash이며 고유 키 3/limit 3이라 원 main에서 퇴출은 없습니다. Hash 조회를 그대로 보존합니다.
11같은 키 상세 교체
12size2
16세 키
17find(hash)는 Hash
20limit3
21원 main은 퇴출 없음
원 도표의 설명과 주석
구분내용
설명원 네 visit의 recent와 bySlug 상태를 연결합니다. array 재방문은 기존 최근 위치를 옮기고 상세 값을 교체합니다. 최종 queue,array,hash이며 고유 키 3/limit 3이라 원 main에서 퇴출은 없습니다. Hash 조회를 그대로 보존합니다.
전체 읽기 정보네 방문으로 최근 순서와 slug 사전을 갱신한다 네 방문으로 최근 순서와 slug 사전을 갱신한다 array를 재방문하면 최근 목록의 기존 위치를 지우고 맨 앞으로 옮깁니다. 제한 3인 실제 실행에는 고유 slug가 3개라 오래된 항목을 퇴출하지 않습니다. 최근 방문 · 두 저장소 네 방문으로 최근 순서와 slug 사전을 갱신한다 재방문한 array는 최근 목록 맨 앞으로 이동합니다. bySlug는 같은 slug의 상세 Post를 교체합니다. 첫 방문 slug=array , title=Array recent · 최신 → 오래된 순 [array] bySlug 관계 · 순서 비보장 array → Array 둘째 방문 slug=hash , title=Hash recent · 최신 → 오래된 순 [hash, array] bySlug 관계 · 순서 비보장 array → Array hash → Hash 재방문 slug=array , title=Array recent · 최신 → 오래된 순 [array, hash] bySlug 관계 · 순서 비보장 같은 slug의 Post 교체 array → Array hash → Hash 넷째 방문 slug=queue , title=Queue recent · 최신 → 오래된 순 [queue, array, hash] bySlug 관계 · 순서 비보장 array → Array hash → Hash queue → Queue 각 행은 visit 직후의 소스 추적이며 사전의 화살표는 slug와 Post의 title 관계입니다. 실제 출력은 [queue, array, hash] 와 Post[slug=hash, title=Hash] 의 두 줄입니다. limit=3 에 고유 slug가 3개라 removeLast() 는 실행되지 않습니다. 원문에는 bySlug 에서 값을 지우는 코드가 없어 최근 목록과 상세 사전의 보관 범위가 다릅니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 두 방문

array 후 hash가 최신 위치로 들어갑니다.

  • recent: [hash,array]
  • bySlug: array→Array,hash→Hash
  • 최근순과 상세 사전이 다른 목적의 상태를 보관한다.
array 재방문

기존 위치를 이동하고 상세 값을 교체합니다.

  • recent: [array,hash]
  • 고유 크기: 2
  • 재방문이 동일 key를 중복 추가하지 않는다.
queue 방문

새 최근 항목을 앞에 넣고 hash를 조회합니다.

  • recent: queue,array,hash
  • 고유 크기: 3
  • find(hash): Hash
  • 조회가 최근 정렬을 다시 바꾸는 코드로 확대하지 않는다.

네 방문으로 최근 순서와 slug 사전을 갱신한다

네 visit에서 recent 최신순과 bySlug 상세가 함께 바뀝니다. 동일 Index의 재방문 이동·같은 키 교체를 단계로 추적하기 좋습니다.

네 방문으로 최근 순서와 slug 사전을 갱신한다

array를 재방문하면 최근 목록의 기존 위치를 지우고 맨 앞으로 옮깁니다. 제한 3인 실제 실행에는 고유 slug가 3개라 오래된 항목을 퇴출하지 않습니다.

최근 방문 · 두 저장소

네 방문으로 최근 순서와 slug 사전을 갱신한다

재방문한 array는 최근 목록 맨 앞으로 이동합니다. bySlug는 같은 slug의 상세 Post를 교체합니다.

네 방문이 바꾸는 최신순과 상세 사전원 네 visit의 recent와 bySlug 상태를 연결합니다. array 재방문은 기존 최근 위치를 옮기고 상세 값을 교체합니다. 최종queue,array,hash이며 고유키3/limit3라 원 main에서 퇴출은 없습니다. Hash 조회를 그대로 보존합니다.
array 첫 방문recent=[array]bySlug:array→Array
연결 관계array 첫 방문 · recent=[array] · bySlug:array→Array → hash 방문 · recent=[hash,array] · array와 hash 두 키
hash 방문recent=[hash,array]array와 hash 두 키
연결 관계hash 방문 · recent=[hash,array] · array와 hash 두 키 → array 재방문 · recent=[array,hash] · 같은 키 상세 교체 / size2
array 재방문recent=[array,hash]같은 키 상세 교체 / size2
연결 관계array 재방문 · recent=[array,hash] · 같은 키 상세 교체 / size2 → queue 방문 · recent의 최신순 · queue → array → hash · 세 키 / findhash는Hash
queue 방문recent의 최신순queue → array → hash세 키 / findhash는Hash

실선: 같은 Index의 네 visit순서는 최신→오래된limit3 / 원 main은 퇴출 없음

  1. 첫 방문
    slug=array, title=Array

    recent · 최신 → 오래된 순
    [array]
    bySlug 관계 · 순서 비보장
    array → Array
  2. 둘째 방문
    slug=hash, title=Hash

    recent · 최신 → 오래된 순
    [hash, array]
    bySlug 관계 · 순서 비보장
    array → Array
    hash → Hash
  3. 재방문
    slug=array, title=Array

    recent · 최신 → 오래된 순
    [array, hash]
    bySlug 관계 · 순서 비보장
    같은 slug의 Post 교체
    array → Array
    hash → Hash
  4. 넷째 방문
    slug=queue, title=Queue

    recent · 최신 → 오래된 순
    [queue, array, hash]
    bySlug 관계 · 순서 비보장
    array → Array
    hash → Hash
    queue → Queue

각 행은 visit 직후의 소스 추적이며 사전의 화살표는 slug와 Post의 title 관계입니다. 실제 출력은 [queue, array, hash]와 Post[slug=hash, title=Hash]의 두 줄입니다. limit=3에 고유 slug가 3개라 removeLast()는 실행되지 않습니다. 원문에는 bySlug에서 값을 지우는 코드가 없어 최근 목록과 상세 사전의 보관 범위가 다릅니다.

이 예의 조건

  • limit 3이며 원래 main의 고유 키 3에서 퇴출은 없다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 네 visit의 recent와 bySlug 상태를 연결합니다. array 재방문은 기존 최근 위치를 옮기고 상세 값을 교체합니다. 최종 queue,array,hash이며 고유 키 3/limit 3이라 원 main에서 퇴출은 없습니다. Hash 조회를 그대로 보존합니다.
11같은 키 상세 교체
12size2
16세 키
17find(hash)는 Hash
20limit3
21원 main은 퇴출 없음
원 도표의 설명과 주석
구분내용
설명원 네 visit의 recent와 bySlug 상태를 연결합니다. array 재방문은 기존 최근 위치를 옮기고 상세 값을 교체합니다. 최종 queue,array,hash이며 고유 키 3/limit 3이라 원 main에서 퇴출은 없습니다. Hash 조회를 그대로 보존합니다.
전체 읽기 정보네 방문으로 최근 순서와 slug 사전을 갱신한다 네 방문으로 최근 순서와 slug 사전을 갱신한다 array를 재방문하면 최근 목록의 기존 위치를 지우고 맨 앞으로 옮깁니다. 제한 3인 실제 실행에는 고유 slug가 3개라 오래된 항목을 퇴출하지 않습니다. 최근 방문 · 두 저장소 네 방문으로 최근 순서와 slug 사전을 갱신한다 재방문한 array는 최근 목록 맨 앞으로 이동합니다. bySlug는 같은 slug의 상세 Post를 교체합니다. 첫 방문 slug=array , title=Array recent · 최신 → 오래된 순 [array] bySlug 관계 · 순서 비보장 array → Array 둘째 방문 slug=hash , title=Hash recent · 최신 → 오래된 순 [hash, array] bySlug 관계 · 순서 비보장 array → Array hash → Hash 재방문 slug=array , title=Array recent · 최신 → 오래된 순 [array, hash] bySlug 관계 · 순서 비보장 같은 slug의 Post 교체 array → Array hash → Hash 넷째 방문 slug=queue , title=Queue recent · 최신 → 오래된 순 [queue, array, hash] bySlug 관계 · 순서 비보장 array → Array hash → Hash queue → Queue 각 행은 visit 직후의 소스 추적이며 사전의 화살표는 slug와 Post의 title 관계입니다. 실제 출력은 [queue, array, hash] 와 Post[slug=hash, title=Hash] 의 두 줄입니다. limit=3 에 고유 slug가 3개라 removeLast() 는 실행되지 않습니다. 원문에는 bySlug 에서 값을 지우는 코드가 없어 최근 목록과 상세 사전의 보관 범위가 다릅니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 두 방문

array 후 hash가 최신 위치로 들어갑니다.

  • recent: [hash,array]
  • bySlug: array→Array,hash→Hash
  • 최근순과 상세 사전이 다른 목적의 상태를 보관한다.
array 재방문

기존 위치를 이동하고 상세 값을 교체합니다.

  • recent: [array,hash]
  • 고유 크기: 2
  • 재방문이 동일 key를 중복 추가하지 않는다.
queue 방문

새 최근 항목을 앞에 넣고 hash를 조회합니다.

  • recent: queue,array,hash
  • 고유 크기: 3
  • find(hash): Hash
  • 조회가 최근 정렬을 다시 바꾸는 코드로 확대하지 않는다.

최신순은 queue, array, hash이고 Map 조회는 Hash 게시글을 반환합니다.

visit는 두 구조 갱신을 한 메서드에 모은 단일 스레드 예제이며 원자적 갱신을 보장하지 않습니다. recent에서 오래된 slug를 제거해도 bySlug에서는 지우지 않으므로 최근 목록과 상세 사전의 보관 범위가 다릅니다. 또한 recent.remove(slug)는 값을 찾아 순회하므로 방문 처리 전체가 O(1)은 아닙니다.

원 main은 고유 slug가 세 개뿐이라 퇴출 조건을 실행하지 않습니다. 다음은 같은 상한 3에서 실제 네 번째 키와 재방문을 확인하는 추가 연습입니다.

퇴출 뒤에도 상세를 찾을 수 있는가

원 main이 끝난 Index에 thread/Thread를 방문한 뒤 hash/Hash updated를 다시 방문합니다. 두 시점의 recent, find("hash") 결과와 bySlug 크기를 각각 예측하세요.

최근 목록과 상세 사전의 다른 범위 확인

thread 방문 직후 recent는 [thread, queue, array]입니다. 오래된 hash는 recent에서 퇴출되지만 bySlug에는 남으므로 find("hash")는 기존 Hash 게시글이고 사전 크기는 4입니다. hash 재방문 뒤 recent는 [hash, thread, queue]이고 find("hash")는 Hash updated 게시글, 사전 크기는 4 그대로입니다. 같은 slug의 상세 값 교체는 새 키 추가가 아닙니다. 최근 목록 상한만으로 상세 사전의 메모리 상한까지 보장하지 않습니다.

상한을 최근 목록의 양수 최대 개수로 받는 API라면 Index 생성자에 if (limit < 1) throw new IllegalArgumentException("limit"); 검증을 추가하는 별도 정책이 필요합니다. 원 생성자는 이 검사를 하지 않습니다. 상한 0을 “아무 최근 항목도 보관하지 않음”으로 허용할 수도 있지만 음수 상한까지 같은 뜻으로 받아서는 요구가 모호해집니다. 먼저 허용 값을 정한 뒤 그 경계를 검사하세요.

최종 선택 순서는 인터페이스 의미, 순서·중복·키·양끝 규칙, 실제 연산 분포, 반환 소유권, 동시성 요구입니다.

클래스 이름을 외우는 것보다 이 질문을 반복하면 새로운 구현도 같은 기준으로 평가할 수 있습니다.

추가 연습: 순서를 보존하는 뷰와 스냅샷

LinkedHashMap<String, Integer>에 먼저 hash=50을 넣습니다. 그때 같은 원본을 감싸는 수정 불가 뷰 live와, LinkedHashMap으로 복사한 뒤 감싸는 수정 불가 스냅샷 snapshot을 각각 만드세요. 이후 원본에서 hash=60으로 바꾸고 queue=40을 추가합니다. 두 결과의 키 순서와 hash 값을 예상하고, live.put("set", 30)이 허용되는지 설명하세요. 모든 작업은 한 스레드에서 이 순서대로 실행합니다.

정답 및 풀이 보기

live는 [hash, queue]와 60을, snapshot은 [hash]와 50을 보여 줍니다. 기본 삽입 순서 LinkedHashMap에서 기존 키의 값 교체는 키를 맨 뒤로 옮기지 않습니다. 뷰의 조회가 원본을 읽어도 뷰를 통한 put은 허용되지 않아 UnsupportedOperationException이 발생합니다.

exercise/OrderedMapSnapshotSolution.java
import java.util.Collections;
import java.util.LinkedHashMap;

public class OrderedMapSnapshotSolution {
    public static void main(String[] args) {
        var source = new LinkedHashMap<String, Integer>();
        source.put("hash", 50);
        var live = Collections.unmodifiableMap(source);
        var snapshot = Collections.unmodifiableMap(new LinkedHashMap<>(source));

        source.put("hash", 60);
        source.put("queue", 40);
        System.out.println("live-keys=" + live.keySet());
        System.out.println("live-hash=" + live.get("hash"));
        System.out.println("snapshot-keys=" + snapshot.keySet());
        System.out.println("snapshot-hash=" + snapshot.get("hash"));
        try {
            live.put("set", 30);
        } catch (UnsupportedOperationException expected) {
            System.out.println("view-rejects-put");
        }
    }
}

출력은 live-keys=[hash, queue], live-hash=60, snapshot-keys=[hash], snapshot-hash=50, view-rejects-put입니다. 값이 Integer여서 원소 내부 변경은 없으며, 가변 값을 깊게 복사했다는 예제는 아닙니다.