* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
(자바 버전 여행 ⑲)
자바 21의 큰 사건은 가상 스레드지만, 매일 컬렉션을 만지는 사람에겐 이 작은 변화가 은근히 반갑다. Sequenced Collections. “순서가 있는 컬렉션에서 첫 요소·끝 요소를 다루는 법”이 드디어 하나로 통일됐다.
제각각이던 첫·끝 요소
생각해 보면 이상했다. 순서가 있는 컬렉션인데 첫·끝을 꺼내는 방법이 다 달랐다.
| 하고 싶은 일 | List | Deque | LinkedHashMap |
|---|---|---|---|
| 첫 요소 | list.get(0) | deque.getFirst() | map.entrySet().iterator().next() |
| 끝 요소 | list.get(size-1) | deque.getLast() | (마지막까지 순회) |
| 뒤집기 | Collections.reverse(…) | 별도 | 제각각 |
표로만 보면 감이 안 오니 코드로 옮겨 보자. “첫 요소와 끝 요소를 출력한다” — 같은 일인데 자바 20까지는 관용구가 세 가지, Java 21 컬렉션에서는 한 가지다.
// Java 20까지 — 같은 일, 컬렉션마다 다른 관용구
var list = new ArrayList<>(List.of("home", "search", "cart"));
var deque = new ArrayDeque<>(list);
var set = new LinkedHashSet<>(list);
System.out.println(list.get(0) + " ~ " + list.get(list.size() - 1));
System.out.println(deque.getFirst() + " ~ " + deque.getLast());
String lastItem = null;
for (var s : set) lastItem = s; // Set의 끝 요소는 전체 순회뿐
System.out.println(set.iterator().next() + " ~ " + lastItem);
// Java 21 — getFirst() / getLast(), same API for List, Deque, Set
System.out.println(list.getFirst() + " ~ " + list.getLast());
System.out.println(deque.getFirst() + " ~ " + deque.getLast());
System.out.println(set.getFirst() + " ~ " + set.getLast());
JDK 21에서 실행하면 여섯 줄 모두 같은 답이 나온다. 아래 세 줄을 얻는 코드가 얼마나 단정해졌는지가 포인트다.
home ~ cart
home ~ cart
home ~ cart
home ~ cart
home ~ cart
home ~ cart
하나의 인터페이스로
자바 21은 SequencedCollection·SequencedMap이라는 공통 인터페이스를 넣고, List·Deque·LinkedHashSet 등이 이를 구현하게 했다. 이제 어디서든 같은 이름을 쓴다.
var list = new ArrayList<>(List.of("a", "b", "c"));
list.getFirst(); // "a" (list.get(0) 대신)
list.getLast(); // "c" (list.get(size-1) 대신)
list.addFirst("z");// 맨 앞에 추가
list.reversed(); // [c, b, a, z] 를 보는 뷰
var map = new LinkedHashMap<String,Integer>();
map.put("x", 1); map.put("y", 2);
map.firstEntry(); // x=1
map.lastEntry(); // y=2
특히 reversed()는 새 리스트를 복사하는 게 아니라 뒤집힌 뷰다. 원본과 연결돼 있어 가볍다.
직접 해보기 — 첫·끝·뒤집기 통일해 보기
손에 익은 get(0) 대신 새 메서드를 써 보자.
ArrayList를 만들고getFirst()·getLast()로 양끝을 꺼낸다.addFirst()로 맨 앞에 넣고 결과를 확인한다.reversed()가 원본을 바꾸지 않는 ‘뷰’임을 확인한다.
var list = new ArrayList<>(List.of(1, 2, 3));
System.out.println(list.getFirst() + " / " + list.getLast()); // 1 / 3
list.addFirst(0);
System.out.println(list); // [0, 1, 2, 3]
System.out.println(list.reversed()); // [3, 2, 1, 0]
System.out.println(list); // [0, 1, 2, 3] (원본 그대로)
실행해 보면 이렇게 나온다.
1 / 3
[0, 1, 2, 3]
[3, 2, 1, 0]
[0, 1, 2, 3]
여기서 조금씩 바꿔 보자.
- 빈 리스트에
getFirst()를 부르면 어떤 예외가 나는지 본다(NoSuchElementException). LinkedHashMap으로firstEntry()·lastEntry()를 확인한다.
한 걸음 더 — reversed() 뷰에 요소를 추가하면 원본에 어떻게 반영되는지 실험해 보자. 답은 아래 “reversed()는 복사가 아니라 ‘뷰’” 절에서 실행 결과로 확인할 수 있다.
✅ 확인 — Sequenced Collections가 무엇을 통일했는지, reversed가 복사가 아니라 뷰라는 걸 설명할 수 있으면 통과다.
새 인터페이스 셋 — Collection·Set·Map
자바 21은 SequencedCollection·SequencedSet·SequencedMap을 추가했다. List·Deque·LinkedHashSet·LinkedHashMap이 이걸 구현해서, 이제 첫·끝·뒤집기를 공통 메서드로 쓴다. 컬렉션마다 다른 관용구를 외울 필요가 없어졌다.
기존 계층 어디에 끼어드는지 한번 그려 두면 기억에 오래 남는다. 눈여겨볼 건 빠진 쪽 — HashSet·HashMap은 순서가 없으니 이 계층 밖이다.
graph BT LIST[List
ArrayList · LinkedList] --> SC[SequencedCollection] DEQ[Deque
ArrayDeque] --> SC SSET[SequencedSet
LinkedHashSet · TreeSet] --> SC SC --> COL[Collection] SMAP[SequencedMap
LinkedHashMap · TreeMap] --> MAP[Map]
list.getFirst(); // 옛 list.get(0)
list.getLast(); // 옛 list.get(list.size() - 1)
list.addFirst(x);
list.reversed(); // 역순 뷰
// Map도 통일
map.firstEntry(); map.lastEntry(); map.putFirst(k, v);
여섯 메서드 한 바퀴 — addFirst부터 removeLast까지
SequencedCollection이 통일한 메서드는 여섯 개 — 추가(addFirst·addLast)·조회(getFirst·getLast)·제거(removeFirst·removeLast)다. 한 번에 도는 Sequenced Collections 예제로 손에 익혀 두자.
var wagon = new ArrayList<>(List.of("b", "c"));
wagon.addFirst("a"); // addFirst — insert at the front
wagon.addLast("d"); // addLast — insert at the end
System.out.println(wagon);
System.out.println(wagon.getFirst() + " / " + wagon.getLast()); // read only
System.out.println(wagon.removeFirst() + " / " + wagon.removeLast()); // read + remove
System.out.println(wagon);
[a, b, c, d]
a / d
a / d
[b, c]
get은 읽기만, remove는 읽으면서 빼낸다. 출력이 같아 보여도 remove 줄이 지나간 뒤 리스트는 [b, c]로 줄어 있다.
reversed()는 복사가 아니라 ‘뷰’
중요한 디테일이다. reversed()는 새 리스트를 만드는 게 아니라 역순 뷰(view)를 돌려준다. 원본과 연결돼 있어 뷰를 고치면 원본도 바뀌고, 원본이 바뀌면 뷰에 반영된다. 복사가 아니라 가벼운 뷰라 성능 부담이 없다. 독립된 복사가 필요하면 new ArrayList<>(list.reversed())로 감싸면 된다.
말로만 들으면 긴가민가하니 양방향으로 직접 확인해 보자. 원본을 고치고 뷰를 보고, 뷰를 고치고 원본을 본다.
var nums = new ArrayList<>(List.of(1, 2, 3));
var rev = nums.reversed(); // reversed() returns a view, not a copy
System.out.println(rev);
nums.add(4); // 원본을 고치면
System.out.println(rev); // 뷰에 바로 반영된다
rev.addFirst(5); // 뷰를 고치면 (뷰의 맨 앞 = 원본의 맨 뒤)
System.out.println(nums); // 원본도 바뀐다
System.out.println(rev);
[3, 2, 1]
[4, 3, 2, 1]
[1, 2, 3, 4, 5]
[5, 4, 3, 2, 1]
뷰의 addFirst(5)가 원본에는 맨 뒤 추가로 나타난다. 거울에 비친 손을 움직이는 셈 — 방향만 뒤집혔을 뿐 같은 리스트다.
SequencedMap — LinkedHashMap으로 첫·끝 엔트리 다루기
Map 쪽 통일도 짚고 가자. LinkedHashMap은 삽입 순서를 기억하는데, 정작 “첫 엔트리”를 꺼내려면 entrySet().iterator().next()라는 장황한 주문이 필요했다. SequencedMap이 되면서 이런 접근이 전부 한 단어로 줄었다.
var visits = new LinkedHashMap<String, Integer>();
visits.put("mon", 120);
visits.put("tue", 98);
visits.put("wed", 143);
System.out.println(visits.firstEntry()); // firstEntry — 첫 엔트리
System.out.println(visits.lastEntry()); // lastEntry — 끝 엔트리
System.out.println(visits.sequencedKeySet()); // 순서 보장 key view
var first = visits.pollFirstEntry(); // poll = get + remove
System.out.println(first + " → " + visits);
visits.putFirst("sun", 77); // putFirst — 맨 앞에 삽입
System.out.println(visits);
System.out.println(visits.reversed()); // Map에도 역순 뷰가 있다
mon=120
wed=143
[mon, tue, wed]
mon=120 → {tue=98, wed=143}
{sun=77, tue=98, wed=143}
{wed=143, tue=98, sun=77}
firstEntry·lastEntry는 읽기만, pollFirstEntry·pollLastEntry는 꺼내면서 제거한다. sequencedKeySet()·sequencedValues()·sequencedEntrySet()은 순서 보장 뷰라 visits.sequencedKeySet().getLast()처럼 이어 쓸 수 있다. TreeMap이 이미 쓰던 어휘가 LinkedHashMap까지 확장된 셈이다.
빈 컬렉션 주의 — 예외
getFirst()·getLast()는 컬렉션이 비어 있으면 NoSuchElementException을 던진다. 옛 list.get(0)이 IndexOutOfBounds를 던지던 것과 비슷하니, 비었는지 먼저 확인하거나 예외를 감안하고 쓴다. 무심코 첫 요소를 꺼내다 터지는 실수를 미리 막자.
그 밖의 함정 두 가지
실무에서 밟기 쉬운 함정이 둘 더 있다. 하나는 불변 리스트, 하나는 HashSet이다.
List.of("a", "b").addFirst("z");
// → UnsupportedOperationException (immutable list)
Object hash = new HashSet<>(List.of(1, 2));
Object linked = new LinkedHashSet<>(List.of(1, 2));
System.out.println(hash instanceof SequencedCollection); // false
System.out.println(linked instanceof SequencedCollection); // true
불변 리스트의 addFirst는 터진다 — List.of(...)가 돌려주는 불변 리스트도 List인 이상 addFirst가 보이고 컴파일도 되지만, 실행하면 UnsupportedOperationException이다. 예전 add()와 같은 규칙인데 “새 메서드니까 되겠지” 하고 방심하기 쉽다.
HashSet은 Sequenced가 아니다 — HashSet·HashMap은 애초에 순서가 없어서 SequencedCollection·SequencedMap 계층에 들어가지 않는다. getFirst()를 부르는 코드는 아예 컴파일되지 않는다. 순서가 필요하면 LinkedHashSet(삽입 순서)이나 TreeSet(정렬 순서)으로 바꿔야 한다.
LinkedHashSet의 addFirst는 ‘이동’도 한다 — 이미 있는 요소에 addFirst를 부르면 중복 삽입이 아니라 맨 앞으로 자리를 옮긴다. [a, b, c]에서 addFirst("b")를 하면 [b, a, c]. 아래 히스토리 예제가 바로 이 성질을 써먹는다.
어디에 쓰나 — 최근 본 문서 히스토리
이 API가 실제로 빛나는 자리 하나를 보여 주고 마무리하자. “최근 본 문서” 목록이다. 조건은 셋 — 새 문서는 맨 앞에, 다시 연 문서도 맨 앞으로, 목록은 N개까지. 예전엔 remove 후 add(0, ...) 같은 우회로가 필요했지만, 지금은 LinkedHashSet 하나로 곧장 써진다.
var history = new LinkedHashSet<String>();
int MAX = 3;
for (String doc : List.of("보고서.pdf", "예산.xlsx", "회의록.docx",
"보고서.pdf", "제안서.pptx")) {
history.addFirst(doc); // addFirst — 새 문서든 재방문이든 맨 앞으로
if (history.size() > MAX)
history.removeLast(); // removeLast — 가장 오래된 것 밀어내기
System.out.println(doc + " 열람 → " + history);
}
보고서.pdf 열람 → [보고서.pdf]
예산.xlsx 열람 → [예산.xlsx, 보고서.pdf]
회의록.docx 열람 → [회의록.docx, 예산.xlsx, 보고서.pdf]
보고서.pdf 열람 → [보고서.pdf, 회의록.docx, 예산.xlsx]
제안서.pptx 열람 → [제안서.pptx, 보고서.pdf, 회의록.docx]
넷째 줄이 핵심이다. 이미 있던 보고서.pdf를 다시 열자 중복 없이 맨 앞으로 이동했다. LRU 캐시, 명령 히스토리, 최근 검색어 같은 “최근 것이 앞에 오는 버퍼”가 전부 이 패턴이다. addFirst + removeLast 두 줄이면 끝난다.
작지만 매일 쓰는 개선
화려한 기능은 아니다. 하지만 ‘첫·끝 다루기’는 코드에서 매일 나오고, 그때마다 컬렉션 종류를 따져 다른 메서드를 쓰던 걸 하나로 통일한 건 은근히 반갑다. 이런 다듬기가 쌓여 언어가 편해진다. 가상 스레드(20편)가 21의 스타라면, 이건 조용한 조연이다.
소소한 정리는 여기까지. 이제 자바 21의 진짜 사건, 동시성의 판을 바꾼 가상 스레드로 간다.
자주 묻는 질문 (FAQ)
Sequenced Collections가 뭘 바꾸나요?
List·Deque·LinkedHashMap마다 제각각이던 첫·끝 요소 접근을 getFirst()·getLast()로 통일했습니다.
reversed()는 새 리스트를 만드나요?
아닙니다. 원본과 연결된 ‘뒤집힌 뷰’라 가볍습니다.
HashSet에서도 getFirst()를 쓸 수 있나요?
아니요. HashSet·HashMap은 순서가 없어 SequencedCollection·SequencedMap이 아니고, getFirst()를 부르는 코드는 컴파일되지 않습니다. 순서가 필요하면 LinkedHashSet(삽입 순서)이나 TreeSet(정렬 순서)을 쓰면 됩니다.
List.of()로 만든 리스트에 addFirst()를 부르면 어떻게 되나요?
컴파일은 되지만 실행 시 UnsupportedOperationException이 발생합니다. List.of()가 돌려주는 불변 리스트는 기존 add()처럼 addFirst()·removeLast() 같은 변경 메서드를 전부 거부합니다.

답글 남기기