- Published on
Java 21 SequencedCollection로 순서 보장 컬렉션
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 애플리케이션에서 “순서”는 생각보다 자주 계약이 됩니다. 예를 들어 최근 N개 이벤트를 앞에서부터 보여주거나, LRU 캐시에서 가장 오래된 항목을 제거하거나, 설정 값을 “마지막에 덮어쓴 값” 기준으로 해석하는 경우가 그렇습니다.
그런데 Java에서는 오랫동안 순서 관련 API가 컬렉션마다 흩어져 있었습니다.
List는 인덱스로 순서를 다루지만,first/last같은 표현은 없었습니다.Deque는addFirst/addLast가 있지만 “리스트처럼” 다루기엔 타입이 다릅니다.LinkedHashMap은 삽입 순서(또는 접근 순서)를 보장하지만, 표준Map인터페이스만 보면 그 사실이 드러나지 않습니다.
Java 21은 이 문제를 SequencedCollection, SequencedSet, SequencedMap으로 정리했습니다. 핵심은 “순서를 가진 컬렉션이라면 공통으로 제공해야 할 연산”을 표준 인터페이스로 끌어올린 것입니다.
SequencedCollection이 해결하는 문제
Java 21의 Sequenced 계열은 다음을 목표로 합니다.
- 순서가 있는 컬렉션을 타입 레벨에서 명확히 표현
- 첫/마지막 요소 접근, 앞/뒤 삽입 같은 연산을 공통 API로 제공
- 역순 뷰(
reversed)를 표준화
이제 메서드 시그니처에 “이 컬렉션은 순서가 있다”를 명시할 수 있고, 구현체가 List든 Deque든 호출 코드는 동일한 연산을 사용할 수 있습니다.
핵심 인터페이스 한눈에 보기
SequencedCollection
SequencedCollection은 “순서를 가진 컬렉션”의 최소 공통 연산을 제공합니다.
E getFirst()/E getLast()void addFirst(E e)/void addLast(E e)E removeFirst()/E removeLast()SequencedCollection<E> reversed()
즉, “앞/뒤”라는 개념을 공통 언어로 제공합니다.
SequencedSet
SequencedSet은 Set이면서 순서가 있는 경우를 표현합니다. 대표 구현은 LinkedHashSet입니다.
- 중복 불가(
Set계약) - 순서 보장(보통 삽입 순서)
SequencedCollection의 first/last/reversed 연산 제공
SequencedMap
SequencedMap은 순서가 있는 Map을 표현합니다. 대표 구현은 LinkedHashMap입니다.
Map.Entry기준firstEntry/lastEntry접근pollFirstEntry/pollLastEntry제거putFirst/putLast로 “앞/뒤”에 엔트리를 배치SequencedMap<K,V> reversed()
LinkedHashMap의 “순서가 있다”는 사실이 이제 타입으로 드러납니다.
Java 21에서 실제로 어떤 타입이 Sequenced를 구현하나
자주 쓰는 구현체 기준으로 보면 다음과 같습니다.
ArrayList는SequencedCollectionLinkedList는SequencedCollection(그리고Deque도 구현)LinkedHashSet은SequencedSetLinkedHashMap은SequencedMap
반대로 HashSet, HashMap은 여전히 순서를 보장하지 않습니다.
List에서 getFirst/getLast/reversed 사용하기
Java 21부터 List는 SequencedCollection을 확장하므로, getFirst/getLast/reversed를 바로 쓸 수 있습니다.
import java.util.*;
public class SequencedListDemo {
public static void main(String[] args) {
List<Integer> xs = new ArrayList<>(List.of(10, 20, 30));
System.out.println(xs.getFirst()); // 10
System.out.println(xs.getLast()); // 30
xs.addFirst(5);
xs.addLast(40);
System.out.println(xs); // [5, 10, 20, 30, 40]
var rev = xs.reversed();
System.out.println(rev); // [40, 30, 20, 10, 5]
// reversed()는 “복사본”이 아니라 “뷰(view)”라는 점이 중요
rev.removeFirst();
System.out.println(xs); // [5, 10, 20, 30]
}
}
reversed는 복사본이 아니라 뷰
위 예제처럼 reversed()는 일반적으로 원본과 연결된 뷰입니다. 즉,
- reversed에서 제거하면 원본에서도 제거됩니다.
- 원본을 수정하면 reversed에도 반영됩니다.
이 특성은 성능에는 유리하지만, “역순 스냅샷”이 필요하면 명시적으로 복사해야 합니다.
List<Integer> snapshot = new ArrayList<>(xs.reversed());
Deque를 굳이 노출하지 않고 앞/뒤 연산 제공하기
과거에는 “양쪽 삽입/삭제”가 필요하면 API 타입으로 Deque를 받는 경우가 많았습니다. 하지만 호출자가 Deque의 의미를 정확히 이해하지 못하면 오용될 수 있습니다.
이제는 “순서 컬렉션”이라는 의도를 더 넓게 표현하는 SequencedCollection을 매개변수로 받을 수 있습니다.
import java.util.*;
public class RecentBuffer {
// 최근 N개를 유지하는 버퍼: 새 항목은 뒤에 추가, 초과하면 앞에서 제거
public static <E> void pushWithLimit(SequencedCollection<E> c, E item, int limit) {
c.addLast(item);
while (c.size() > limit) {
c.removeFirst();
}
}
public static void main(String[] args) {
SequencedCollection<String> c = new LinkedList<>();
pushWithLimit(c, "a", 3);
pushWithLimit(c, "b", 3);
pushWithLimit(c, "c", 3);
pushWithLimit(c, "d", 3);
System.out.println(c); // [b, c, d]
}
}
이 코드는 LinkedList뿐 아니라 ArrayList에도 적용됩니다. 즉, 구현체 선택을 호출자에게 열어두면서도 “앞/뒤” 연산을 안정적으로 사용 가능합니다.
LinkedHashMap을 SequencedMap으로 다루기
LinkedHashMap은 삽입 순서를 유지하지만, 예전에는 메서드 시그니처가 Map이면 그 사실이 사라졌습니다. Java 21에서는 SequencedMap으로 타입을 올려 “순서 기반 연산”을 명시적으로 사용할 수 있습니다.
import java.util.*;
public class SequencedMapDemo {
public static void main(String[] args) {
SequencedMap<String, Integer> m = new LinkedHashMap<>();
m.put("a", 1);
m.put("b", 2);
m.put("c", 3);
System.out.println(m.firstEntry()); // a=1
System.out.println(m.lastEntry()); // c=3
m.putFirst("z", 0);
m.putLast("x", 99);
System.out.println(m); // {z=0, a=1, b=2, c=3, x=99}
var rev = m.reversed();
System.out.println(rev.firstEntry()); // x=99
// pollFirstEntry/pollLastEntry로 큐처럼 사용
System.out.println(m.pollFirstEntry()); // z=0
System.out.println(m.pollLastEntry()); // x=99
System.out.println(m); // {a=1, b=2, c=3}
}
}
putFirst/putLast의 의미
putFirst(k,v)는 엔트리를 “맨 앞” 위치에 오도록 배치합니다.putLast(k,v)는 엔트리를 “맨 뒤” 위치에 오도록 배치합니다.
이미 존재하는 키를 다시 넣을 때의 “재배치” 동작은 구현체에 좌우될 수 있으니, 순서가 비즈니스 로직의 핵심이라면 테스트로 계약을 고정하는 편이 안전합니다.
LRU 캐시와 accessOrder: SequencedMap만으로 충분할까
LinkedHashMap에는 accessOrder라는 강력한 옵션이 있습니다. new LinkedHashMap<>(initialCapacity, loadFactor, true)로 만들면 “접근 순서”가 유지되어 LRU 구현에 적합합니다.
다만 SequencedMap은 “순서가 있다”는 사실과 “앞/뒤 연산”을 표준화할 뿐,
- 그 순서가 삽입 순서인지
- 접근 순서인지
까지 의미적으로 고정해주지는 않습니다. 즉, LRU라면 여전히 LinkedHashMap의 생성 옵션과 removeEldestEntry 같은 패턴을 함께 써야 합니다.
import java.util.*;
public class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LruCache(int maxSize) {
super(16, 0.75f, true); // accessOrder=true
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
public static void main(String[] args) {
SequencedMap<String, Integer> cache = new LruCache<>(3);
cache.put("a", 1);
cache.put("b", 2);
cache.put("c", 3);
cache.get("a"); // a가 최신으로 이동
cache.put("d", 4); // 가장 오래된 b가 제거
System.out.println(cache); // {c=3, a=1, d=4} 같은 형태
}
}
여기서도 SequencedMap으로 받아두면, 캐시를 “순서 있는 맵”으로 취급하면서 firstEntry/lastEntry 등 공통 연산을 활용할 수 있습니다.
마이그레이션 팁: API 시그니처를 Sequenced로 올릴 때의 기준
1) 반환 타입부터 올리기
기존에 List를 반환하던 메서드가 있고, 호출자가 “첫/마지막”을 자주 쓴다면 반환 타입을 SequencedCollection으로 바꾸는 것을 고려할 수 있습니다.
- 장점: 구현체를 숨긴 채 의도를 표현
- 단점: 호출자가 인덱스 기반 연산이 필요하면 다시
List가 필요
즉, 인덱싱이 핵심이면 List, 앞/뒤가 핵심이면 SequencedCollection이 더 맞습니다.
2) 매개변수는 더 일반적으로
“앞/뒤 삽입과 제거” 정도만 필요하면 Deque 대신 SequencedCollection이 더 유연합니다. 특히 테스트에서 ArrayList를 써도 동작시키고 싶을 때 유리합니다.
3) 순서 의존 로직은 테스트로 고정
순서가 결과에 영향을 주는 로직은, 구현체 변경이나 JDK 업데이트로 미묘한 차이가 나면 장애로 이어질 수 있습니다. first/last/reversed를 사용한다면 다음을 테스트로 고정하세요.
- 빈 컬렉션에서
getFirst/getLast호출 시 예외 reversed()뷰의 변경 전파 여부putFirst/putLast가 기존 키에 대해 어떻게 동작하는지
자주 하는 실수와 주의점
빈 컬렉션 예외
getFirst/getLast/removeFirst/removeLast는 빈 컬렉션에서 예외가 발생합니다. “없으면 null”이 필요하면, 호출 전 isEmpty() 체크 또는 Optional 래핑 유틸을 두는 편이 낫습니다.
static <E> Optional<E> firstOrEmpty(SequencedCollection<E> c) {
return c.isEmpty() ? Optional.empty() : Optional.of(c.getFirst());
}
reversed를 복사본으로 착각
앞서 말했듯 reversed는 보통 뷰입니다. 로그 출력용으로만 잠깐 쓰는 게 아니라면, 변경 전파를 의도했는지 항상 확인하세요.
“정렬된 순서”와 “시퀀스 순서”를 혼동
TreeMap은 키 정렬 순서를 가지지만, 이번 Sequenced의 핵심은 “앞/뒤가 있는 시퀀스”입니다. 정렬 컬렉션과는 목적이 다릅니다.
실무에서 어디에 쓰나: 이벤트 타임라인, 최근 목록, 우선순위
- 최근 N개 요청/에러 로그 버퍼:
SequencedCollection로 앞에서 제거, 뒤에 추가 - UI에 보여줄 “최근 본 상품”: reversed 뷰로 최신부터 출력
- 설정 병합에서 “마지막 값 우선”:
SequencedMap의lastEntry를 빠르게 참조
프론트엔드 렌더링에서도 “순서”는 중요합니다. 특히 SSR이나 하이드레이션에서 데이터 순서가 바뀌면 경고나 UI 불일치로 이어질 수 있습니다. Next.js 환경에서 이런 문제를 다룬 글로는 Next.js Hydration mismatch 경고 완전 해결법, Next.js Hydration failed 경고 7가지 원인·해결도 함께 참고할 만합니다.
정리
Java 21의 SequencedCollection/SequencedSet/SequencedMap은 “순서가 있는 컬렉션”을 표준 인터페이스로 끌어올려, 코드의 의도를 더 명확히 하고 공통 연산을 통일합니다.
List에서getFirst/getLast/reversed를 자연스럽게 사용Deque를 노출하지 않고도 앞/뒤 삽입/삭제 요구사항을 표현LinkedHashMap을SequencedMap으로 다루며 first/last/poll/reversed 같은 순서 기반 연산을 명시
순서가 비즈니스 로직의 일부라면, 이제는 “문서나 주석”이 아니라 “타입”으로 계약을 드러내는 쪽으로 API를 정리해볼 만합니다.