Published on

Java 21 SequencedCollection로 정렬·역순 처리

Authors

서버 코드에서 정렬은 흔하지만, 정렬된 결과를 앞에서부터 또는 뒤에서부터 다루는 요구는 생각보다 자주 나옵니다. 예를 들어 “최신 20개”, “가장 오래된 20개”, “상위 N개/하위 N개” 같은 기능은 결국 순서가 있는 컬렉션을 양끝에서 다루는 문제로 귀결됩니다.

Java 21에는 이런 요구를 더 명확하게 표현할 수 있도록 SequencedCollection / SequencedSet / SequencedMap 계열이 들어왔습니다. 핵심은 첫 요소/마지막 요소 접근, 양끝 삽입/삭제, 그리고 무엇보다도 **역순 뷰(reversed())**를 표준화했다는 점입니다.

이 글에서는 SequencedCollection을 중심으로:

  • 정렬 후 앞/뒤에서 꺼내는 패턴
  • reversed()를 이용한 역순 처리
  • 기존 Collections.reverse / descendingSet / Deque와의 차이
  • 성능과 가변성(뷰의 동작 방식) 주의점

을 실전 코드로 정리합니다.

SequencedCollection이 해결하는 문제

기존에도 List는 인덱스로 앞뒤 접근이 가능했고, Deque는 양끝 연산이 가능했으며, NavigableSetdescendingSet()이 있었습니다. 그런데 API가 제각각이라 “순서가 있는 컬렉션”을 일반화해 다루기 어려웠습니다.

Java 21의 SequencedCollection은 다음 의도를 표준 인터페이스로 드러냅니다.

  • getFirst() / getLast()
  • addFirst(e) / addLast(e)
  • removeFirst() / removeLast()
  • reversed() : 역순으로 순회하는 뷰 반환

즉, 정렬된 결과를 다룰 때 “앞에서부터”와 “뒤에서부터”를 같은 추상화로 표현할 수 있습니다.

어떤 타입이 SequencedCollection을 구현하나

대표적으로 다음 구현체들이 Java 21에서 시퀀스 API를 제공합니다.

  • List 계열: ArrayList, LinkedList 등(정확히는 ListSequencedCollection을 확장)
  • Deque 계열: ArrayDeque, LinkedList
  • SortedSet/NavigableSet 계열은 SequencedSet으로 확장

중요 포인트는, Set이라도 “정렬된/순서가 있는 Set”에서만 시퀀스 개념이 성립한다는 점입니다. 그래서 HashSet 같은 무순서 컬렉션은 대상이 아닙니다.

정렬 이후 앞/뒤에서 꺼내기

가장 흔한 작업은 “정렬한 뒤 상위 N개” 또는 “정렬한 뒤 하위 N개”입니다. SequencedCollection이 있으면 코드의 의도가 더 또렷해집니다.

예제: 점수 리스트에서 상위 N개/하위 N개

import java.util.*;

public class TopBottomN {
    static List<Integer> topN(List<Integer> scores, int n) {
        var sorted = new ArrayList<>(scores);
        sorted.sort(Comparator.naturalOrder());

        // 상위 N개: 뒤에서부터
        var result = new ArrayList<Integer>(n);
        var rev = sorted.reversed();
        int count = 0;
        for (int s : rev) {
            result.add(s);
            if (++count == n) break;
        }
        return result;
    }

    static List<Integer> bottomN(List<Integer> scores, int n) {
        var sorted = new ArrayList<>(scores);
        sorted.sort(Comparator.naturalOrder());

        // 하위 N개: 앞에서부터
        var result = new ArrayList<Integer>(n);
        int count = 0;
        for (int s : sorted) {
            result.add(s);
            if (++count == n) break;
        }
        return result;
    }

    public static void main(String[] args) {
        List<Integer> scores = List.of(10, 70, 30, 90, 50, 80);
        System.out.println(topN(scores, 3));
        System.out.println(bottomN(scores, 3));
    }
}

여기서 포인트는 sorted.reversed()가 “역순 리스트를 새로 만들어서 복사”하는 방식이 아니라, **역순 뷰(view)**로 동작한다는 점입니다. 즉, 큰 리스트를 뒤집기 위해 추가 메모리를 크게 쓰지 않아도 됩니다.

reversed()는 복사본이 아니라 뷰다

reversed()는 대부분의 구현에서 원본 컬렉션을 참조하는 뷰를 반환합니다. 따라서 다음 성질을 이해해야 합니다.

  • 원본이 바뀌면 뷰의 순회 결과도 바뀔 수 있음
  • 뷰를 수정하면 원본이 바뀔 수 있음(구현체에 따라 지원 범위가 다름)
  • 동시 수정(concurrent modification) 규칙은 기존 컬렉션의 규칙을 그대로 따름

예제: reversed 뷰 수정이 원본에 반영되는지 확인

import java.util.*;

public class ReversedViewMutation {
    public static void main(String[] args) {
        var list = new ArrayList<>(List.of("a", "b", "c"));
        var rev = list.reversed();

        // rev의 첫 요소는 원본의 마지막 요소
        System.out.println(rev.getFirst());

        // rev에 addFirst를 하면 원본의 addLast와 같은 의미가 된다
        rev.addFirst("z");

        System.out.println(list);
        System.out.println(rev);
    }
}

이런 동작은 “역순 복사본”을 기대하는 개발자에게는 놀랍지만, 대규모 데이터에서 성능과 메모리를 아끼는 데 유리합니다. 다만, 뷰를 외부로 노출할 때는 캡슐화 관점에서 주의가 필요합니다.

정렬과 역순을 함께 쓸 때의 권장 패턴

정렬과 역순을 섞어 쓸 때는 보통 아래 3가지 중 하나를 고릅니다.

  1. 오름차순 정렬 후 reversed()로 내림차순 순회
  2. 처음부터 내림차순 Comparator로 정렬
  3. NavigableSet/TreeMap 같은 정렬 컬렉션을 사용하고 reversed() 또는 descending 뷰를 사용

1) 오름차순 정렬 + reversed() 순회

  • 장점: 정렬 기준이 하나일 때 코드가 깔끔
  • 장점: 뒤집기 비용이 별도 복사 없이 뷰로 해결
  • 단점: “내림차순 리스트 자체”가 필요하면 뷰를 계속 들고 있어야 함
var sorted = new ArrayList<>(items);
sorted.sort(Comparator.comparing(Item::score));
for (var x : sorted.reversed()) {
    // 높은 점수부터 처리
}

2) 내림차순 Comparator로 정렬

  • 장점: 결과가 실제로 내림차순으로 저장됨
  • 단점: Comparator를 뒤집는 코드가 길어질 수 있음
sorted.sort(Comparator.comparing(Item::score).reversed());

여기서의 reversed()Comparatorreversed()이고, 컬렉션의 reversed()와는 다른 개념입니다. 이름이 같아서 헷갈리기 쉬우니 리뷰 시 체크 포인트로 잡아두면 좋습니다.

3) TreeSet, TreeMap 등 정렬 컬렉션 + reversed()

정렬이 “한 번 하고 끝”이 아니라 “계속 삽입/삭제되며 정렬 상태를 유지”해야 한다면, 매번 리스트를 정렬하는 것보다 TreeSet/TreeMap이 적절합니다.

import java.util.*;

public class Leaderboard {
    record UserScore(String userId, int score) {}

    public static void main(String[] args) {
        var set = new TreeSet<UserScore>(
            Comparator.comparingInt(UserScore::score)
                      .thenComparing(UserScore::userId)
        );

        set.add(new UserScore("u1", 50));
        set.add(new UserScore("u2", 90));
        set.add(new UserScore("u3", 70));

        // SequencedSet의 reversed(): 높은 점수부터 순회
        for (var us : set.reversed()) {
            System.out.println(us);
        }

        // 첫/마지막도 명확
        System.out.println("min=" + set.getFirst());
        System.out.println("max=" + set.getLast());
    }
}

Deque와 SequencedCollection의 역할 분담

Deque는 원래부터 양끝 연산을 위한 인터페이스였고, 큐/스택을 표현하는 데 강점이 있습니다. 반면 SequencedCollection은 “순서가 있는 컬렉션 일반”에 초점을 맞춥니다.

  • 큐/스택 의미가 중요하면 Deque
  • 정렬된 결과를 앞/뒤에서 읽거나, 역순 뷰로 순회하는 등 “순서 일반화”가 목적이면 SequencedCollection

특히 ListSequencedCollection을 확장하면서, 이제는 List만으로도 getFirst() / getLast() 같은 의도가 명확한 코드를 만들 수 있습니다.

실전: 페이지네이션에서 최신/과거 방향 전환

API에서 흔한 요구가 “최신순으로 보여주다가, 특정 시점부터 과거로 더 보기” 같은 패턴입니다. DB 쿼리로 해결하는 게 정석이지만, 캐시된 메모리 컬렉션을 다룰 때도 있습니다.

아래 예시는 메모리 내 이벤트 목록을 시간 오름차순으로 유지한 뒤, 화면 요구에 따라 최신부터 또는 과거부터 슬라이스하는 방식입니다.

import java.time.*;
import java.util.*;

public class EventPager {
    record Event(Instant at, String message) {}

    static List<Event> page(List<Event> eventsAsc, int limit, boolean newestFirst) {
        SequencedCollection<Event> view = newestFirst ? eventsAsc.reversed() : eventsAsc;

        var out = new ArrayList<Event>(limit);
        int c = 0;
        for (var e : view) {
            out.add(e);
            if (++c == limit) break;
        }
        return out;
    }

    public static void main(String[] args) {
        var events = new ArrayList<Event>();
        events.add(new Event(Instant.parse("2024-01-01T00:00:00Z"), "start"));
        events.add(new Event(Instant.parse("2024-01-02T00:00:00Z"), "a"));
        events.add(new Event(Instant.parse("2024-01-03T00:00:00Z"), "b"));

        // 정렬 보장
        events.sort(Comparator.comparing(Event::at));

        System.out.println(page(events, 2, true));
        System.out.println(page(events, 2, false));
    }
}

이 패턴의 장점은 newestFirst 같은 플래그가 붙어도 코드가 지저분해지지 않고, reversed()라는 표준 API로 의도가 바로 드러난다는 점입니다.

주의점: reversed() 뷰와 동시성, 불변 컬렉션

1) 동시 수정 예외

원본 컬렉션을 순회하면서 원본 또는 뷰를 수정하면, 기존 ArrayList의 규칙처럼 ConcurrentModificationException이 날 수 있습니다. 해결책은 다음 중 하나입니다.

  • 순회 중 수정하지 않기
  • 수정이 필요하면 ListIterator 사용 또는 별도 버퍼에 모아 처리
  • 아예 불변 스냅샷을 만들어 사용

2) 불변 리스트에서의 reversed()

List.of(...) 같은 불변 리스트도 reversed()는 만들 수 있지만, 당연히 addFirst 같은 변경 연산은 지원하지 않습니다(호출 시 UnsupportedOperationException 가능). 즉, reversed()가 “가변 뷰”를 보장한다는 뜻은 아닙니다.

3) 성능 감각

  • ArrayList에서 getFirst() / getLast()는 사실상 get(0) / get(size-1)과 같고 매우 빠릅니다.
  • LinkedList는 양끝 연산이 빠르지만, 인덱스 접근이 느립니다.
  • reversed()는 복사 비용을 줄여주지만, 뷰 계층이 늘어나는 만큼 디버깅 시 “이 컬렉션이 원본인가 뷰인가”를 의식해야 합니다.

정렬 자체가 O(n log n)이므로, 역순 처리를 위해 별도 복사까지 더하는 게 병목이 되는 경우가 있고, 이때 reversed() 뷰가 의미 있게 작동합니다.

기존 방식과 비교: 무엇이 좋아졌나

Collections.reverse와의 차이

Collections.reverse(list)리스트 자체를 제자리에서 뒤집습니다. 즉, 원본 순서가 바뀌므로 부작용이 큽니다.

반면 list.reversed()는 대부분 원본을 유지한 채 역순 뷰로 순회할 수 있습니다. “역순으로 읽기만” 필요한 경우 더 안전한 선택지가 됩니다.

descendingSet()NavigableSet에만 있었고, List에는 대응되는 표준이 없었습니다. Java 21에서는 SequencedSetreversed()로 개념이 통일되어, 리스트/셋/맵에서 유사한 방식으로 “역순 뷰”를 다룰 수 있게 됐습니다.

마이그레이션 팁

레거시 코드에서 다음 패턴을 발견하면 SequencedCollection로 점진적으로 치환할 수 있습니다.

  • list.get(0)list.getFirst()
  • list.get(list.size() - 1)list.getLast()
  • “역순 순회”를 위해 인덱스를 감소시키는 for 루프를 list.reversed() 기반 루프로

특히 코드 리뷰에서 size()-1 인덱싱은 실수(빈 리스트 처리 누락)로 이어지기 쉬운데, getLast()는 빈 컬렉션에서 예외가 명확해져 의도가 더 분명해집니다. 빈 케이스가 정상 흐름이라면 isEmpty() 체크를 함께 두는 편이 좋습니다.

함께 읽으면 좋은 글

컬렉션을 뷰로 다룬다는 건 “겉보기 동작은 단순하지만, 내부 상태 공유로 인해 예상치 못한 상호작용”이 생길 수 있다는 의미이기도 합니다. 비슷한 맥락에서 캐시/상태 공유로 인한 함정을 다룬 글도 참고할 만합니다.

정리

Java 21의 SequencedCollection은 “정렬된 결과를 앞/뒤에서 다룬다”는 흔한 요구를 더 명확하고 일관된 API로 표현하게 해줍니다.

  • 정렬 후 상위 N개는 reversed()로 자연스럽게 처리 가능
  • reversed()는 보통 복사본이 아니라 이므로 가변성/동시 수정에 주의
  • List에서도 getFirst() / getLast()가 표준화되어 인덱스 트릭이 줄어듦

정렬과 역순 처리를 자주 하는 코드베이스라면, Java 21 업그레이드 이후 가장 빨리 체감되는 개선 중 하나가 이 시퀀스 컬렉션 계열 API일 것입니다.