Sequenced Collections — Java 21의 새 컬렉션 인터페이스
Sequenced Collections — Java 21의 새 컬렉션 인터페이스
순서가 있는 컬렉션의 "첫 번째 요소"를 가져오는 방법이 왜 제각각이었을까?
Java로 코딩하다 보면 이상한 점을 발견합니다. 리스트의 첫 번째 요소를 가져오려면 list.get(0), Deque에서는 deque.getFirst(), SortedSet에서는 sortedSet.first(), LinkedHashSet에서는... 방법이 없어서 iterator를 꺼내야 합니다.
// 각각 다른 방법으로 "첫 번째 요소"를 꺼냄
list.get(0); // List
deque.getFirst(); // Deque
sortedSet.first(); // SortedSet
linkedHashSet.iterator().next(); // LinkedHashSet — 깔끔하지 않음
마지막 요소는 더 심합니다. list.get(list.size() - 1)처럼 인덱스 계산을 해야 하고, LinkedHashSet은 전체를 순회하지 않는 한 마지막 요소에 접근할 방법이 아예 없습니다.
20년 넘게 이어진 이 불일치를, Java 21이 Sequenced Collections(JEP 431)로 단번에 해결했습니다.
핵심 개념: 순서가 있는 컬렉션의 공통 인터페이스
Sequenced Collections는 "만남 순서(encounter order)가 정의된 컬렉션" 에 공통 인터페이스를 부여합니다. 세 가지 인터페이스가 추가되었습니다:
| 인터페이스 | 확장하는 기존 인터페이스 | 핵심 역할 |
|---|---|---|
SequencedCollection<E> | Collection<E> | 순서가 있는 모든 컬렉션의 공통 조상 |
SequencedSet<E> | SequencedCollection<E>, Set<E> | 중복 없는 순서 컬렉션 |
SequencedMap<K,V> | Map<K,V> | 순서가 있는 맵 |
계층 구조가 어떻게 바뀌었나
Collection
│
SequencedCollection ← 신규
┌────┴────┐
List SequencedSet ← 신규
┌────┴────┐
SortedSet LinkedHashSet
│
NavigableSet
Map
│
SequencedMap ← 신규
┌────┴────┐
SortedMap LinkedHashMap
│
NavigableMap
기존 컬렉션 클래스들이 새 인터페이스를 자동으로 구현 하게 되었습니다. ArrayList는 이미 SequencedCollection이고, LinkedHashSet은 SequencedSet입니다.
SequencedCollection — 첫/끝 접근의 통일
public interface SequencedCollection<E> extends Collection<E> {
// 역순 뷰
SequencedCollection<E> reversed();
// 첫 번째/마지막 요소 접근
default E getFirst() { /* ... */ }
default E getLast() { /* ... */ }
// 첫 번째/마지막에 추가
default void addFirst(E e) { /* ... */ }
default void addLast(E e) { /* ... */ }
// 첫 번째/마지막 요소 제거
default E removeFirst() { /* ... */ }
default E removeLast() { /* ... */ }
}
이제 어떤 순서 컬렉션이든 같은 방법으로 접근합니다:
var list = List.of("A", "B", "C");
var deque = new ArrayDeque<>(list);
var linkedSet = new LinkedHashSet<>(list);
// 전부 동일한 메서드!
System.out.println(list.getFirst()); // A
System.out.println(deque.getFirst()); // A
System.out.println(linkedSet.getFirst()); // A
System.out.println(list.getLast()); // C
System.out.println(deque.getLast()); // C
System.out.println(linkedSet.getLast()); // C
reversed() — 역순 뷰
reversed()는 **새로운 컬렉션을 만들지 않습니다 **. 원본의 역순 뷰(view)를 반환합니다.
var names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));
// 역순 뷰 — 원본과 연결되어 있음
SequencedCollection<String> reversed = names.reversed();
System.out.println(reversed); // [Charlie, Bob, Alice]
// 원본을 변경하면 뷰도 바뀜
names.add("Dave");
System.out.println(reversed); // [Dave, Charlie, Bob, Alice]
역순으로 for-each를 돌릴 때 특히 유용합니다:
// Java 21 이전: 역순 순회가 번거로움
for (int i = list.size() - 1; i >= 0; i--) {
process(list.get(i));
}
// Java 21 이후: 깔끔
for (var item : list.reversed()) {
process(item);
}
SequencedSet — 순서 + 중복 제거
SequencedSet은 SequencedCollection과 Set을 동시에 확장합니다. addFirst()와 addLast()에 특별한 동작이 추가됩니다:
var colors = new LinkedHashSet<>(List.of("Red", "Green", "Blue"));
System.out.println(colors); // [Red, Green, Blue]
// 이미 있는 요소를 addFirst → 기존 위치에서 제거 후 맨 앞으로 이동
colors.addFirst("Blue");
System.out.println(colors); // [Blue, Red, Green]
// 새 요소를 addLast → 맨 뒤에 추가
colors.addLast("Yellow");
System.out.println(colors); // [Blue, Red, Green, Yellow]
Set의 유일성을 유지하면서 순서를 재배치할 수 있다는 게 핵심입니다. LRU 캐시 같은 패턴에서 유용합니다.
SequencedMap — 순서가 있는 맵
public interface SequencedMap<K, V> extends Map<K, V> {
SequencedMap<K, V> reversed();
// 첫 번째/마지막 엔트리
default Map.Entry<K, V> firstEntry() { /* ... */ }
default Map.Entry<K, V> lastEntry() { /* ... */ }
// 엔트리 제거
default Map.Entry<K, V> pollFirstEntry() { /* ... */ }
default Map.Entry<K, V> pollLastEntry() { /* ... */ }
// 맨 앞/뒤에 삽입
default V putFirst(K k, V v) { /* ... */ }
default V putLast(K k, V v) { /* ... */ }
// 순서가 보장되는 뷰
default SequencedSet<K> sequencedKeySet() { /* ... */ }
default SequencedCollection<V> sequencedValues() { /* ... */ }
default SequencedSet<Map.Entry<K, V>> sequencedEntrySet() { /* ... */ }
}
var scores = new LinkedHashMap<String, Integer>();
scores.put("Alice", 95);
scores.put("Bob", 87);
scores.put("Charlie", 92);
// 첫 번째, 마지막 엔트리
System.out.println(scores.firstEntry()); // Alice=95
System.out.println(scores.lastEntry()); // Charlie=92
// 역순으로 키 순회
for (var key : scores.sequencedKeySet().reversed()) {
System.out.println(key + ": " + scores.get(key));
}
// Charlie: 92
// Bob: 87
// Alice: 95
// 이미 있는 키를 putFirst → 맨 앞으로 이동
scores.putFirst("Charlie", 99);
System.out.println(scores); // {Charlie=99, Alice=95, Bob=87}
firstEntry()와lastEntry()가 반환하는Map.Entry는 unmodifiable snapshot 입니다.setValue()를 호출하면UnsupportedOperationException이 발생합니다.
어떤 클래스가 어떤 인터페이스를 구현하나?
| 클래스 | SequencedCollection | SequencedSet | SequencedMap |
|---|---|---|---|
ArrayList | ✅ | ||
LinkedList | ✅ | ||
ArrayDeque | ✅ | ||
LinkedHashSet | ✅ | ||
TreeSet | ✅ | ||
LinkedHashMap | ✅ | ||
TreeMap | ✅ | ||
HashSet | ❌ | ❌ | |
HashMap | ❌ |
HashSet과 HashMap은 순서가 없으므로 Sequenced 인터페이스를 구현하지 않습니다. 이 부분이 면접에서 자주 나옵니다.
Collections 유틸리티도 업데이트됨
Collections 클래스에 새로운 팩토리 메서드가 추가되었습니다:
// 수정 불가 래퍼
var unmodifiable = Collections.unmodifiableSequencedCollection(list);
var unmodifiableSet = Collections.unmodifiableSequencedSet(linkedHashSet);
var unmodifiableMap = Collections.unmodifiableSequencedMap(linkedHashMap);
기존의 unmodifiableList(), unmodifiableSet()과 같은 패턴입니다.
실전에서 어떻게 쓰나?
1. 메서드 파라미터를 더 유연하게
// Before: List만 받을 수 있음
public void processItems(List<String> items) {
String first = items.get(0);
String last = items.get(items.size() - 1);
}
// After: 순서가 있는 어떤 컬렉션이든 받을 수 있음
public void processItems(SequencedCollection<String> items) {
String first = items.getFirst();
String last = items.getLast();
}
List, Deque, LinkedHashSet 등 어떤 순서 컬렉션이든 하나의 메서드로 처리할 수 있습니다.
2. 역순 스트림 처리
var logs = new ArrayList<>(List.of("INFO: start", "WARN: slow", "ERROR: fail"));
// 최신 로그부터 처리
logs.reversed().stream()
.filter(log -> log.startsWith("ERROR"))
.findFirst()
.ifPresent(System.out::println); // ERROR: fail
3. 최근 N개 항목 관리
// 최근 검색어를 순서대로 유지하면서 중복 제거
var recentSearches = new LinkedHashSet<String>();
public void addSearch(String query) {
recentSearches.remove(query); // 기존 위치에서 제거
recentSearches.addLast(query); // 맨 뒤(최신)에 추가
// 최대 10개 유지
while (recentSearches.size() > 10) {
recentSearches.removeFirst(); // 가장 오래된 것 제거
}
}
주의할 점
지원하지 않는 연산에서의 예외
모든 구현체가 모든 메서드를 지원하는 건 아닙니다:
// 불변 리스트에서 addFirst → UnsupportedOperationException
var immutable = List.of("A", "B");
immutable.addFirst("Z"); // 예외!
// 빈 컬렉션에서 getFirst → NoSuchElementException
var empty = new ArrayList<>();
empty.getFirst(); // 예외!
SortedSet/SortedMap의 addFirst/addLast
TreeSet이나 TreeMap은 정렬 기준이 있기 때문에 addFirst()/addLast()를 호출하면 UnsupportedOperationException이 발생합니다. 순서를 직접 제어할 수 없는 정렬 컬렉션에서는 당연한 동작입니다.
var sorted = new TreeSet<>(List.of(3, 1, 2));
sorted.getFirst(); // 1 — OK (정렬 순서의 첫 번째)
sorted.addFirst(0); // UnsupportedOperationException!
정리
Java 21의 Sequenced Collections는 "순서가 있는 컬렉션"이라는 공통 개념에 ** 통일된 인터페이스 **를 부여한 것입니다. 새로운 자료구조가 추가된 게 아니라, 기존 자료구조들이 새 인터페이스를 구현하게 되면서 API 일관성이 크게 개선되었습니다.
| 핵심 메서드 | 용도 |
|---|---|
getFirst() / getLast() | 첫/끝 요소 조회 |
addFirst() / addLast() | 첫/끝에 삽입 |
removeFirst() / removeLast() | 첫/끝 요소 제거 |
reversed() | 역순 뷰 반환 |
공부하면서 느낀 건, 이 변경의 가치가 "새 메서드 추가"가 아니라 "20년 된 API 불일치 해결" 에 있다는 점입니다. 면접에서 "Java 21에서 컬렉션에 어떤 변화가 있었나요?"라고 물으면, 단순히 메서드를 나열하기보다 **왜 이게 필요했는지 **(LinkedHashSet의 첫/끝 접근 불가, 역순 순회의 불편함)를 먼저 설명하면 좋은 인상을 줄 수 있습니다.