Sequenced Collections — Java 21의 새 컬렉션 인터페이스

순서가 있는 컬렉션의 "첫 번째 요소"를 가져오는 방법이 왜 제각각이었을까?

Java로 코딩하다 보면 이상한 점을 발견합니다. 리스트의 첫 번째 요소를 가져오려면 list.get(0), Deque에서는 deque.getFirst(), SortedSet에서는 sortedSet.first(), LinkedHashSet에서는... 방법이 없어서 iterator를 꺼내야 합니다.

JAVA
// 각각 다른 방법으로 "첫 번째 요소"를 꺼냄
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>순서가 있는 맵

계층 구조가 어떻게 바뀌었나

PLAINTEXT
        Collection

    SequencedCollection    ← 신규
       ┌────┴────┐
      List   SequencedSet  ← 신규
               ┌────┴────┐
          SortedSet   LinkedHashSet

          NavigableSet

          Map

      SequencedMap         ← 신규
       ┌────┴────┐
  SortedMap   LinkedHashMap

  NavigableMap

기존 컬렉션 클래스들이 새 인터페이스를 자동으로 구현 하게 되었습니다. ArrayList는 이미 SequencedCollection이고, LinkedHashSetSequencedSet입니다.


SequencedCollection — 첫/끝 접근의 통일

JAVA
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()  { /* ... */ }
}

이제 어떤 순서 컬렉션이든 같은 방법으로 접근합니다:

JAVA
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)를 반환합니다.

JAVA
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
// 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 — 순서 + 중복 제거

SequencedSetSequencedCollectionSet을 동시에 확장합니다. addFirst()addLast()에 특별한 동작이 추가됩니다:

JAVA
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 — 순서가 있는 맵

JAVA
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() { /* ... */ }
}
JAVA
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.Entryunmodifiable snapshot 입니다. setValue()를 호출하면 UnsupportedOperationException이 발생합니다.


어떤 클래스가 어떤 인터페이스를 구현하나?

클래스SequencedCollectionSequencedSetSequencedMap
ArrayList
LinkedList
ArrayDeque
LinkedHashSet
TreeSet
LinkedHashMap
TreeMap
HashSet
HashMap

HashSet과 HashMap은 순서가 없으므로 Sequenced 인터페이스를 구현하지 않습니다. 이 부분이 면접에서 자주 나옵니다.


Collections 유틸리티도 업데이트됨

Collections 클래스에 새로운 팩토리 메서드가 추가되었습니다:

JAVA
// 수정 불가 래퍼
var unmodifiable = Collections.unmodifiableSequencedCollection(list);
var unmodifiableSet = Collections.unmodifiableSequencedSet(linkedHashSet);
var unmodifiableMap = Collections.unmodifiableSequencedMap(linkedHashMap);

기존의 unmodifiableList(), unmodifiableSet()과 같은 패턴입니다.


실전에서 어떻게 쓰나?

1. 메서드 파라미터를 더 유연하게

JAVA
// 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. 역순 스트림 처리

JAVA
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개 항목 관리

JAVA
// 최근 검색어를 순서대로 유지하면서 중복 제거
var recentSearches = new LinkedHashSet<String>();

public void addSearch(String query) {
    recentSearches.remove(query);    // 기존 위치에서 제거
    recentSearches.addLast(query);   // 맨 뒤(최신)에 추가

    // 최대 10개 유지
    while (recentSearches.size() > 10) {
        recentSearches.removeFirst();  // 가장 오래된 것 제거
    }
}

주의할 점

지원하지 않는 연산에서의 예외

모든 구현체가 모든 메서드를 지원하는 건 아닙니다:

JAVA
// 불변 리스트에서 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이 발생합니다. 순서를 직접 제어할 수 없는 정렬 컬렉션에서는 당연한 동작입니다.

JAVA
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의 첫/끝 접근 불가, 역순 순회의 불편함)를 먼저 설명하면 좋은 인상을 줄 수 있습니다.

댓글 로딩 중...