본문 바로가기

Comparable과 Comparator

원소가 스스로 기본 순서를 갖게 하는 Comparable과 쓰는 곳마다 다른 순서를 넘기는 Comparator를 만들어 보고, 뺄셈 비교의 오버플로와 reversed()가 뒤집는 범위, 비교 결과가 0일 때 정렬과 TreeSet이 다르게 동작하는 이유를 배웁니다.

게시판에서 게시글을 점수 순, 제목 순, 최신 순으로 보여 주려면 목록을 정렬해야 합니다. 그런데 정렬 메서드는 게시글 두 개 가운데 어느 쪽이 앞인지 스스로 알지 못합니다. 정수와 문자열은 정렬되는데 직접 만든 record는 정렬되지 않는다는 것부터 확인해 보겠습니다. List의 sort 메서드에 비교 방법 대신 null을 넘기면, 원소가 원래 가진 기본 순서로 정렬하라는 뜻입니다.

lab/SortWithoutOrder.java
import java.util.ArrayList;
import java.util.List;

public final class SortWithoutOrder {
    public static void main(String[] args) {
        List<Integer> scores = new ArrayList<>(List.of(80, 95, 70));
        scores.sort(null);
        System.out.println(scores);

        List<String> titles = new ArrayList<>(List.of("thread", "array", "hash"));
        titles.sort(null);
        System.out.println(titles);

        List<Post> posts = new ArrayList<>(List.of(new Post("thread", 70), new Post("array", 40)));
        try {
            posts.sort(null);
        } catch (ClassCastException e) {
            System.out.println(e.getMessage());
        }
    }

    private record Post(String title, int viewCount) {}
}

세 목록의 정렬 결과가 차례로 한 줄씩 출력됩니다. 마지막 줄은 Post 목록을 정렬하다 예외가 나서 catch가 출력한 예외 메시지입니다.

[70, 80, 95]
[array, hash, thread]
class SortWithoutOrder$Post cannot be cast to class java.lang.Comparable (SortWithoutOrder$Post is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')

정수는 작은 수부터, 문자열은 사전순으로 정렬되었지만 Post 목록에서는 ClassCastException이 났습니다. 메시지를 보면 정렬 도중 Post를 java.lang.Comparable로 바꾸려다 실패했다는 것을 알 수 있습니다. Integer와 String은 Comparable을 구현해 자기들끼리 비교하는 방법을 갖고 있지만, Post에는 그런 방법이 없기 때문입니다.

정렬에 필요한 것은 결국 '두 원소 가운데 어느 쪽이 앞인가'에 답하는 규칙입니다. 자바에서는 이 규칙을 두 가지 방법으로 줄 수 있습니다. 원소의 클래스가 Comparable을 구현해 스스로 기본 순서를 갖게 하거나, 정렬할 때마다 Comparator라는 별도의 비교 객체를 넘기는 것입니다. 이 글에서는 두 방법을 차례로 구현하고, 비교 규칙을 잘못 만들었을 때 생기는 문제를 살펴봅니다.

비교 결과는 부호로 읽는다

Comparable의 compareTo와 Comparator의 compare는 모두 두 원소를 비교해 int 하나를 돌려줍니다. 이 값은 크기가 아니라 부호로 읽습니다. a.compareTo(b)가 음수이면 a가 b보다 앞이고, 0이면 이 기준으로는 순서를 가릴 수 없으며, 양수이면 a가 b보다 뒤라는 뜻입니다. 오름차순 정렬이라면 '앞'은 '작다'와 같은 말입니다.

lab/CompareSigns.java
public final class CompareSigns {
    public static void main(String[] args) {
        System.out.println(Integer.compare(3, 5));
        System.out.println(Integer.compare(5, 5));
        System.out.println("array".compareTo("hash"));
        System.out.println("hash".compareTo("array"));
    }
}

네 줄 모두 두 값을 비교한 결과를 int로 출력합니다. 값의 크기보다 부호가 무엇인지를 보면서 읽어 보세요.

-1
0
-7
7

Integer.compare(3, 5)는 3이 5보다 작으니 음수인 -1을 돌려주고, 같은 값끼리는 0을 돌려줍니다. 문자열 array와 hash의 비교는 -7인데, String의 compareTo가 처음으로 다른 글자인 a와 h의 문자 코드 차이를 돌려주기 때문입니다. 하지만 정렬은 7이라는 크기를 쓰지 않고 음수라는 사실만 씁니다. 그래서 비교 결과를 == -1이나 == 1로 검사하면 안 되고, 언제나 < 0, == 0, > 0으로 검사해야 합니다.

Comparable로 기본 순서 정하기

클래스가 Comparable<T>를 구현하면 그 타입의 객체들은 기본 순서를 갖게 되는데, 이를 자연 순서(natural ordering)라고 합니다. Integer는 수의 크기, String은 사전순이 자연 순서입니다. 게시글의 통계를 담는 record PostMetric에 자연 순서를 정해 보겠습니다. 제목 사전순으로 놓고, 제목이 같으면 id가 작은 쪽을 앞에 둡니다.

private record PostMetric(int id, String title, int viewCount, int score)
        implements Comparable<PostMetric> {
    public int compareTo(PostMetric other) {
        int titleOrder = title.compareTo(other.title);
        return titleOrder != 0 ? titleOrder : Integer.compare(id, other.id);
    }
}

compareTo는 먼저 String의 compareTo로 제목을 비교하고, 결과가 0이 아니면 그대로 돌려줍니다. 제목이 같을 때만 Integer.compare로 id를 비교합니다. id를 두 번째 기준으로 둔 것은 제목이 같은 서로 다른 게시글이 비교 결과 0을 받지 않게 하려는 것입니다. 비교 결과가 0인 원소가 어떻게 다뤄지는지는 뒤에서 자세히 봅니다.

점수가 아니라 제목과 id를 자연 순서로 고른 데에도 이유가 있습니다. 자연 순서는 타입마다 하나뿐이고, sort(null)이나 비교자 없이 만든 TreeSet처럼 그 타입을 정렬하는 모든 곳에 영향을 줍니다. '점수 높은 순'처럼 화면마다 다르고 자주 바뀌는 요구를 자연 순서에 넣으면, 화면 하나를 고칠 때 그 타입을 쓰는 모든 정렬 결과가 함께 바뀝니다. 그래서 자연 순서에는 그 타입에서 가장 기본이 되는 순서를 두고, 나머지는 다음 절의 Comparator로 만듭니다.

참고로 Comparable을 구현하지 않은 타입의 목록을 Collections.sort(posts)로 정렬하려고 하면 컴파일 단계에서 거부됩니다. Collections.sort는 원소 타입이 Comparable을 구현해야 한다고 선언되어 있기 때문입니다. 반면 앞에서 본 posts.sort(null)은 컴파일을 통과한 뒤 실행 중에 ClassCastException을 냅니다.

Comparator로 쓰는 곳마다 다른 순서 만들기

같은 게시글이라도 목록 화면은 제목 순으로, 인기 글 보고서는 점수 순으로 보여 줘야 할 수 있습니다. 이렇게 쓰는 곳마다 다른 순서는 Comparator<T>로 만듭니다. Comparator는 두 원소를 받아 앞에서 본 규칙대로 부호를 돌려주는 compare(T a, T b) 메서드를 가진 인터페이스입니다. 원소의 클래스를 고치지 않고도 필요한 만큼 만들 수 있다는 점이 Comparable과 다릅니다.

구분Comparable<T>Comparator<T>
패키지java.langjava.util
비교 메서드compareTo(T other)compare(T a, T b)
누가 구현하나원소의 클래스 자신원소와 별개인 비교 객체
타입마다 몇 개하나(자연 순서)필요한 만큼
쓰는 예list.sort(null), new TreeSet<>()list.sort(c), new TreeSet<>(c)

비교자를 클래스로 직접 구현할 수도 있지만, 보통은 Comparator가 제공하는 메서드를 이어 붙여 만듭니다. 이 글에서 쓰는 메서드는 다음과 같습니다.

코드만들어지는 비교자
Comparator.comparingInt(PostMetric::score)점수(int)를 꺼내 오름차순으로 비교
Comparator.comparing(PostMetric::title)제목을 꺼내 그 자연 순서(사전순)로 비교
c.reversed()비교자 c의 순서를 반대로 뒤집은 비교자
c.thenComparingInt(PostMetric::id)c의 결과가 0일 때만 id로 다시 비교
c.thenComparing(other)c의 결과가 0일 때만 비교자 other로 다시 비교

PostMetric::score는 'PostMetric을 받아 score()를 호출하는 함수'를 짧게 쓴 메서드 참조(method reference)입니다. comparingInt는 이 함수로 두 원소에서 int 값을 하나씩 꺼내 Integer.compare로 비교하는 비교자를 만듭니다. 꺼낸 값을 int 그대로 비교하므로 comparing을 쓸 때와 달리 Integer로 박싱하지 않습니다. 람다와 메서드 참조는 30장에서 자세히 다룹니다.

이제 PostMetric 목록을 자연 순서와 비교자로 각각 정렬해 보겠습니다. 비교자 byScore는 '점수 높은 순, 점수가 같으면 조회 수 높은 순, 그것도 같으면 id 순'을 의도하고 만든 것입니다.

src/PostMetricOrdering.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public final class PostMetricOrdering {
    public static void main(String[] args) {
        List<PostMetric> posts =
                new ArrayList<>(
                        List.of(
                                new PostMetric(3, "thread", 70, 90),
                                new PostMetric(1, "array", 40, 90),
                                new PostMetric(2, "hash", 50, 80)));
        posts.sort(null);
        System.out.println("natural=" + posts);
        Comparator<PostMetric> byScore =
                Comparator.comparingInt(PostMetric::score)
                        .reversed()
                        .thenComparingInt(PostMetric::viewCount)
                        .reversed()
                        .thenComparingInt(PostMetric::id);
        posts.sort(byScore);
        System.out.println("report=" + posts);
    }

    private record PostMetric(int id, String title, int viewCount, int score)
            implements Comparable<PostMetric> {
        public int compareTo(PostMetric other) {
            int titleOrder = title.compareTo(other.title);
            return titleOrder != 0 ? titleOrder : Integer.compare(id, other.id);
        }
    }
}

입력 순서는 thread, array, hash이고, 출력의 두 줄은 같은 목록을 두 번 정렬한 결과입니다. 둘째 줄이 의도대로 점수 높은 순인지 확인해 보세요.

natural=[PostMetric[id=1, title=array, viewCount=40, score=90], PostMetric[id=2, title=hash, viewCount=50, score=80], PostMetric[id=3, title=thread, viewCount=70, score=90]]
report=[PostMetric[id=2, title=hash, viewCount=50, score=80], PostMetric[id=3, title=thread, viewCount=70, score=90], PostMetric[id=1, title=array, viewCount=40, score=90]]

natural= 줄은 sort(null)이 자연 순서를 쓴 결과입니다. 제목 사전순으로 array, hash, thread가 되었고, 제목이 모두 다르니 두 번째 기준인 id는 쓰이지 않았습니다. 그런데 report= 줄은 의도와 다릅니다. 점수 80으로 가장 낮은 hash가 맨 앞에 왔습니다. 원인은 두 번째 reversed()에 있습니다.

reversed()가 뒤집는 범위

reversed()는 그 메서드를 부른 비교자, 즉 그 줄까지 이어 붙여 만든 비교자 전체를 뒤집습니다. 바로 앞에 붙인 기준 하나만 뒤집는 것이 아닙니다. byScore가 만들어지는 과정을 한 줄씩 따라가 보겠습니다.

이어 붙인 코드그 시점까지 만들어진 비교자
comparingInt(PostMetric::score)점수 오름차순
.reversed()점수 내림차순
.thenComparingInt(PostMetric::viewCount)점수 내림차순, 같으면 조회 수 오름차순
.reversed()전체를 뒤집어 점수 오름차순, 같으면 조회 수 내림차순
.thenComparingInt(PostMetric::id)점수 오름차순, 같으면 조회 수 내림차순, 같으면 id 오름차순

두 번째 reversed()가 앞에서 이미 내림차순으로 만든 점수까지 다시 뒤집어 오름차순으로 돌려놓았습니다. 그래서 최종 비교자는 '점수 낮은 순, 같으면 조회 수 높은 순, 같으면 id 순'입니다. 이 기준이라면 점수 80인 hash가 먼저이고, 점수 90으로 같은 thread와 array 가운데서는 조회 수가 더 많은 thread(70)가 array(40)보다 앞에 옵니다. 출력이 정확히 그렇습니다.

reversed()를 체인 중간에 붙이면 이렇게 앞의 기준까지 함께 뒤집힙니다. 기준마다 방향을 따로 정하려면, 기준 하나짜리 비교자를 각각 만들어 필요한 것만 뒤집은 다음 thenComparing으로 이어 붙입니다.

src/ExplicitComparatorChain.java
import java.util.Comparator;
import java.util.List;

public final class ExplicitComparatorChain {
    public static void main(String[] args) {
        Comparator<PostMetric> scoreDesc = Comparator.comparingInt(PostMetric::score).reversed();
        Comparator<PostMetric> viewCountDesc =
                Comparator.comparingInt(PostMetric::viewCount).reversed();
        Comparator<PostMetric> order =
                scoreDesc.thenComparing(viewCountDesc).thenComparing(PostMetric::title);
        List<PostMetric> result =
                List.of(
                                new PostMetric("hash", 50, 90),
                                new PostMetric("array", 40, 90),
                                new PostMetric("thread", 60, 80))
                        .stream()
                        .sorted(order)
                        .toList();
        System.out.println(result);
    }

    private record PostMetric(String title, int viewCount, int score) {}
}

order는 각자 뒤집은 두 비교자 뒤에 제목 비교자를 이어 붙인 것입니다. 점수가 같은 글이 있을 때 둘째 기준이 어느 방향으로 적용되는지 보세요.

[PostMetric[title=hash, viewCount=50, score=90], PostMetric[title=array, viewCount=40, score=90], PostMetric[title=thread, viewCount=60, score=80]]

scoreDesc와 viewCountDesc는 각자 자기 기준 하나만 뒤집었으므로, 둘을 이어 붙인 order는 의도대로 '점수 높은 순, 같으면 조회 수 높은 순, 같으면 제목 순'입니다. 이 예제의 입력은 앞 예제와 다르다는 점에 주의하세요. 여기서는 hash와 array가 점수 90으로 같고 thread가 80입니다. 그래서 점수가 같은 두 글 가운데 조회 수가 더 많은 hash(50)가 array(40)보다 앞에 오고, 점수가 낮은 thread가 마지막입니다.

두 번째 reversed는 점수 방향까지 다시 뒤집는다

첫 예제의 자연 순서는 제목과 id 오름차순입니다. 첫 예제의 두 번째 reversed는 점수 오름차순과 조회수 내림차순을 만듭니다. 둘째 예제는 키마다 뒤집어 점수와 조회수를 모두 내림차순으로 만듭니다. 두 예제의 입력값은 서로 다릅니다.

COMPARATOR · reversed 범위

두 번째 reversed는 점수 방향까지 다시 뒤집는다

둘째 reversed는 앞의 점수·조회수 체인 전체를 뒤집습니다. 두 예제의 입력 값은 서로 다릅니다.

전체 반전과 키별 반전의 다른 범위 첫 프로그램의 natural과 report, 별도 ExplicitComparatorChain의 결과를 영역으로 나눕니다. 첫 report는 점수↑·조회수↓·id↑이고 둘째는 점수↓·조회수↓·제목↑입니다. 두 프로그램의 서로 다른 조회수 입력을 보존합니다. 첫 예제 natural 제목 다음 id 오름 array / hash / thread 실제 결과 array90·40 hash80·50 / thread90·70 첫 예제 report 두 번째 전체 reversed 점수↑ / 조회수↓ / id↑ 실제 결과 hash80·50 thread90·70 / array90·40 둘째 예제 키별 반전 각 키만 reversed 점수↓ / 조회수↓ / 제목↑ 별도 입력의 결과 hash90·50 array90·40 / thread80·60 값 표기: 점수·조회수 영역은 각 비교 규칙과 결과 둘째 프로그램의 입력은 다름
전체 반전과 키별 반전의 다른 범위 첫 프로그램의 natural과 report, 별도 ExplicitComparatorChain의 결과를 영역으로 나눕니다. 첫 report는 점수↑·조회수↓·id↑이고 둘째는 점수↓·조회수↓·제목↑입니다. 두 프로그램의 서로 다른 조회수 입력을 보존합니다. 첫 예제 natural 제목 다음 id 오름 array / hash / thread 실제 결과 array90·40 hash80·50 / thread90·70 첫 예제 report 두 번째 전체 reversed 점수↑ / 조회수↓ / id↑ 실제 결과 hash80·50 thread90·70 / array90·40 둘째 예제 키별 반전 각 키만 reversed 점수↓ / 조회수↓ / 제목↑ 별도 입력의 결과 hash90·50 array90·40 / thread80·60 값 표기: 점수·조회수 영역은 각 비교 규칙과 결과 둘째 프로그램의 입력은 다름

첫 예제
natural

비교 방향
제목 ↑
id ↑
각 예제의 실제 결과 · 제목(점수, 조회수)
array(90, 40)
hash(80, 50)
thread(90, 70)

첫 예제
report

비교 방향
점수 ↑ · 조회수 ↓
id ↑
각 예제의 실제 결과 · 제목(점수, 조회수)
hash(80, 50)
thread(90, 70)
array(90, 40)

둘째 예제
키마다 reversed()

비교 방향
점수 ↓ · 조회수 ↓
제목 ↑
각 예제의 실제 결과 · 제목(점수, 조회수)
hash(90, 50)
array(90, 40)
thread(80, 60)

thenComparing으로 이은 비교는 사전에서 단어의 순서를 정하는 방식과 같아서 사전식 비교라고 부릅니다. 첫 글자로 순서가 정해지면 둘째 글자는 보지 않듯이, 앞 기준이 0이 아닌 값을 돌려주면 뒤 기준은 아예 호출되지 않습니다. 여러 기준의 값을 더하거나 평균을 내는 것이 아니므로, 기준을 적는 순서가 곧 우선순위입니다.

뺄셈으로 비교하면 생기는 오버플로

compareTo를 처음 직접 짤 때 흔히 쓰는 코드가 return this.score - other.score;입니다. 앞의 값이 작으면 음수, 같으면 0, 크면 양수가 나오니 규칙에 딱 맞아 보입니다. 하지만 두 값의 차이가 int의 범위를 넘으면 결과가 반대쪽 끝으로 넘어가 부호가 뒤집힙니다. 아래 프로그램은 두 값을 받아 left - right를 돌려주는 람다식 (left, right) -> left - right를 비교자로 넘겨 정렬하고, Integer.MAX_VALUE에서 -1을 뺀 값도 따로 출력합니다.

lab/SubtractionComparatorOverflowBug.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public final class SubtractionComparatorOverflowBug {
    public static void main(String[] args) {
        List<Integer> values = new ArrayList<>(List.of(Integer.MAX_VALUE, -1, 0));
        values.sort((left, right) -> left - right);
        System.out.println(values);
        System.out.println("compare=" + (Integer.MAX_VALUE - (-1)));
    }
}

첫 줄은 정렬 결과이고, 둘째 줄은 이 비교자가 Integer.MAX_VALUE와 -1을 이 순서로 비교했다면 돌려줄 값입니다. 두 수의 실제 차이가 int의 최댓값보다 1 크다는 점을 기억하고 보세요.

[-1, 0, 2147483647]
compare=-2147483648
정렬이 맞아 보여도 뺄셈 비교의 부호는 틀릴 수 있다

원문 정렬 결과는 -1, 0, 2147483647 순서이지만 Integer.MAX_VALUE에서 -1을 뺀 비교 결과는 -2147483648입니다. 정렬 결과 한 번만 보는 검사는 오버플로를 놓칩니다.

COMPARATOR · 오버플로

정렬이 맞아 보여도 뺄셈 비교의 부호는 틀릴 수 있다

정렬 출력 한 번으로 안전성을 판단하지 않습니다. MAX_VALUE − (−1)은 int 범위를 넘습니다.

이번 정렬 결과와 별도 뺄셈의 부호 오류 두 영역은 원 main에서 정렬한 결과와 별도 한 쌍 비교입니다. 정렬은 −1,0,MAX처럼 보이지만 MAX−(−1)의 int 결과는 −2147483648로 순서를 잘못 말합니다. 한 관측으로 비교 계약 전체를 입증하지 않습니다. 원 main의 정렬 MAX, −1, 0 입력 뺄셈 비교로 sort 이번 실행의 출력 −1, 0, MAX 겉으로는 오름차순 별도의 한 쌍 비교 MAX − (−1) 수학적 차 2147483648 int 범위를 넘음 실제 int 결과 −2147483648 MAX가 작다는 음수 부호 영역은 서로 다른 검사 실선: 입력·계산·결과 한 정렬 결과로 계약 입증 불가
이번 정렬 결과와 별도 뺄셈의 부호 오류 두 영역은 원 main에서 정렬한 결과와 별도 한 쌍 비교입니다. 정렬은 −1,0,MAX처럼 보이지만 MAX−(−1)의 int 결과는 −2147483648로 순서를 잘못 말합니다. 한 관측으로 비교 계약 전체를 입증하지 않습니다. 원 main의 정렬 MAX, −1, 0 입력 뺄셈 비교로 sort 이번 실행의 출력 −1, 0, MAX 겉으로는 오름차순 별도의 한 쌍 비교 MAX − (−1) 수학적 차 2147483648 int 범위를 넘음 실제 int 결과 −2147483648 MAX가 작다는 음수 부호 영역은 서로 다른 검사 실선: 입력·계산·결과 한 정렬 결과로 계약 입증 불가

정렬 결과

실제 출력
[-1, 0, 2147483647]
판단
이 입력에서는 오름차순처럼 보임

별도 뺄셈 비교

실제 출력
compare=-2147483648
판단
2147483647 > -1
그런데 비교 부호는 음수

정렬 결과는 오름차순으로 맞게 나왔습니다. 그런데 두 번째 줄을 보면 Integer.MAX_VALUE - (-1)이 -2147483648입니다. 수학적으로는 2147483648이지만, int가 담을 수 있는 최댓값 2147483647을 넘었기 때문에 값이 int의 최솟값으로 넘어가 버린 것입니다. 이 뺄셈을 비교 결과로 쓰면 'MAX_VALUE가 -1보다 작다'는 틀린 대답이 됩니다.

그런데도 정렬이 맞게 나온 것은 운이 좋았기 때문입니다. OpenJDK 21에서 비교자가 불린 순서를 출력해 보면 compare(-1, 2147483647), compare(0, -1), compare(0, 2147483647), compare(0, -1)의 네 번이고, 문제가 되는 compare(2147483647, -1)은 한 번도 불리지 않습니다. 반대 방향의 -1 - 2147483647은 정확히 int의 최솟값이라 넘치지 않고 올바른 음수가 나옵니다. 입력 순서만 바꿔서 문제가 되는 비교가 일어나게 하면 결과가 바로 틀어집니다.

lab/SubtractionComparatorWrongOrder.java
import java.util.ArrayList;
import java.util.List;

public final class SubtractionComparatorWrongOrder {
    public static void main(String[] args) {
        List<Integer> bySubtraction = new ArrayList<>(List.of(-1, Integer.MAX_VALUE));
        bySubtraction.sort((left, right) -> left - right);
        System.out.println("subtraction=" + bySubtraction);

        List<Integer> byCompare = new ArrayList<>(List.of(-1, Integer.MAX_VALUE));
        byCompare.sort(Integer::compare);
        System.out.println("Integer.compare=" + byCompare);
    }
}

두 목록은 같은 원소 -1과 Integer.MAX_VALUE로 시작하고, 정렬에 쓴 비교자만 다릅니다. 두 줄의 순서가 같은지 비교해 보세요.

subtraction=[2147483647, -1]
Integer.compare=[-1, 2147483647]

원소가 두 개뿐인데도 뺄셈 비교자는 [2147483647, -1]이라는 틀린 순서를 만들었습니다. 정렬이 compare(2147483647, -1)을 부르자 넘쳐서 음수가 돌아왔고, 정렬은 그 대답을 믿고 MAX_VALUE를 앞에 두었습니다. 같은 입력을 Integer::compare로 정렬하면 올바른 순서가 나옵니다.

Integer.compare(x, y)는 빼지 않고 x < y와 x == y를 직접 따져 -1, 0, 1 가운데 하나를 돌려주므로 넘칠 일이 없습니다. 객체의 필드로 비교할 때는 Comparator.comparingInt, comparingLong, comparingDouble을 쓰면 내부에서 같은 방식으로 비교합니다. 점수가 0부터 100 사이처럼 좁은 범위라서 뺄셈이 넘치지 않는 경우라도, 읽는 사람이 값의 범위를 따져 보지 않아도 되도록 비교 메서드를 쓰는 편이 낫습니다.

비교자가 지켜야 할 규칙

뺄셈 비교자의 문제는 틀린 값을 한 번 돌려주는 데서 끝나지 않습니다. 정렬 알고리즘은 비교자가 몇 가지 규칙을 지킨다고 믿고 비교 횟수를 줄이기 때문에, 규칙이 깨지면 어떤 결과가 나올지 알 수 없습니다. Comparator 문서가 정한 규칙은 다음 세 가지이며, Comparable의 compareTo에도 똑같이 적용됩니다.

  • 순서를 바꿔 비교하면 부호도 반대여야 합니다. compare(a, b)가 음수이면 compare(b, a)는 양수이고, 한쪽이 0이면 다른 쪽도 0입니다. 따라서 compare(a, a)는 언제나 0입니다.
  • 추이성(transitivity)이 있어야 합니다. a가 b보다 뒤이고 b가 c보다 뒤이면, a도 c보다 뒤여야 합니다.
  • compare(a, b)가 0인 두 원소는 다른 어떤 원소 c와 비교해도 같은 부호의 결과를 받아야 합니다.

뺄셈 비교자는 첫 번째 규칙부터 어깁니다. compare(2147483647, -1)과 compare(-1, 2147483647)이 둘 다 -2147483648이라서, 두 원소가 서로 자기가 상대보다 앞이라고 주장하는 셈입니다. 정렬 결과만 보아서는 이런 문제가 드러나지 않을 수 있다는 것을 앞에서 확인했습니다.

그래서 직접 만든 비교자는 작은 값 몇 개로 규칙을 하나씩 확인해 보는 것이 좋습니다. 같은 원소끼리 0이 나오는지, a가 b보다 앞일 때 compare(a, b)는 음수이고 compare(b, a)는 양수인지, 세 값이 a, b, c 순서로 앞설 때 compare(a, c)도 음수인지 봅니다. thenComparing으로 기준을 이었다면 첫 기준만 다른 경우, 첫 기준은 같고 둘째 기준이 다른 경우, 모든 기준이 같은 경우를 하나씩 넣어 봅니다. 정렬한 목록에서 Collections.binarySearch로 원소를 찾을 때도 정렬에 쓴 것과 같은 순서의 비교자를 넘겨야 합니다. 검색은 목록이 그 순서로 정렬되어 있다고 믿고 절반씩 건너뛰기 때문입니다.

더 깊이: 규칙을 어긴 비교자로 정렬하면

List.sort 문서에는 비교자가 규칙을 어긴 것을 발견하면 IllegalArgumentException을 던질 수 있다고 적혀 있습니다. 다만 이 예외는 선택 사항이라 언제나 나오는 것은 아닙니다. 아래 프로그램은 무작위 정수 2,000개짜리 목록 200개를 뺄셈 비교자로 정렬하고, 그 결과를 Integer::compare로 정렬한 결과와 비교합니다.

lab/BrokenComparatorSort.java
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public final class BrokenComparatorSort {
    public static void main(String[] args) {
        Random random = new Random(1);
        int thrown = 0, wrong = 0, correct = 0;
        for (int round = 0; round < 200; round++) {
            List<Integer> values = new ArrayList<>();
            for (int i = 0; i < 2000; i++) values.add(random.nextInt());
            List<Integer> expected = new ArrayList<>(values);
            expected.sort(Integer::compare);
            try {
                values.sort((left, right) -> left - right);
                if (values.equals(expected)) correct++;
                else wrong++;
            } catch (IllegalArgumentException e) {
                if (thrown++ == 0) System.out.println(e.getMessage());
            }
        }
        System.out.println("thrown=" + thrown + ", wrong=" + wrong + ", correct=" + correct);
    }
}

OpenJDK 21에서의 실행 결과는 다음과 같습니다. 첫 줄은 처음 예외가 났을 때 그 메시지를 한 번만 출력한 것이고, 둘째 줄은 200번의 정렬을 결과별로 센 값입니다.

Comparison method violates its general contract!
thrown=41, wrong=159, correct=0

200번 가운데 41번은 Comparison method violates its general contract!라는 메시지의 예외로 끝났고, 나머지 159번은 예외 없이 틀린 순서를 만들었습니다. 올바르게 정렬된 경우는 한 번도 없습니다. 예외가 일부에서만 나는 것은, OpenJDK가 객체 정렬에 쓰는 TimSort가 이미 정렬했다고 믿은 구간들을 합치다가 앞뒤가 맞지 않는 상황을 만났을 때만 규칙 위반을 알아챌 수 있기 때문입니다. Random에 같은 시드를 주면 같은 수열이 나오므로 같은 JDK에서는 이 결과가 그대로 재현되지만, 예외가 몇 번 나는지는 정렬 구현에 따라 달라질 수 있습니다. 실무 로그에서 이 메시지를 보았다면 뺄셈처럼 규칙을 어기는 비교자나, 정렬하는 동안 값이 바뀌는 필드를 비교하는 코드를 의심해 보세요.

비교 결과가 0인 두 원소

비교 결과 0은 '이 기준으로는 두 원소의 순서를 가릴 수 없다'는 뜻일 뿐, 두 원소가 같은 객체라는 뜻은 아닙니다. 그런데 0을 받은 쪽이 그 원소들을 어떻게 다루는지는 쓰는 곳마다 다릅니다. List.sort는 0이 나온 원소를 모두 남기고, 원래 목록에서의 앞뒤 순서도 그대로 둡니다. 이런 정렬을 안정 정렬(stable sort)이라고 하며, List.sort 문서는 이 메서드가 같은 원소의 순서를 바꾸면 안 된다고 명시합니다. 반면 TreeSet은 15장의 Set 구현 비교에서 본 것처럼 비교 결과가 0인 원소를 같은 원소로 봅니다.

앞에서 만든 PostMetric의 자연 순서로 이 차이를 확인해 보겠습니다. 같은 게시글(id 1, 제목 array)을 두 번 측정해서 조회 수와 점수가 다른 값 두 개를 만들었습니다. 자연 순서는 제목과 id만 보므로 두 값의 compareTo는 0이지만, record의 equals는 모든 필드를 비교하므로 false입니다.

src/NaturalOrderVsEquals.java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

public final class NaturalOrderVsEquals {
    public static void main(String[] args) {
        PostMetric before = new PostMetric(1, "array", 40, 90);
        PostMetric after = new PostMetric(1, "array", 50, 80);
        System.out.println("compareTo=" + before.compareTo(after) + ", equals=" + before.equals(after));

        Set<PostMetric> tree = new TreeSet<>();
        System.out.println("tree add: " + tree.add(before) + ", " + tree.add(after));
        System.out.println("tree=" + tree);

        Set<PostMetric> hash = new HashSet<>(List.of(before, after));
        System.out.println("hash size=" + hash.size());

        List<PostMetric> list = new ArrayList<>(List.of(after, before));
        list.sort(null);
        System.out.println("list=" + list);
    }

    private record PostMetric(int id, String title, int viewCount, int score)
            implements Comparable<PostMetric> {
        public int compareTo(PostMetric other) {
            int titleOrder = title.compareTo(other.title);
            return titleOrder != 0 ? titleOrder : Integer.compare(id, other.id);
        }
    }
}

출력은 두 값의 compareTo와 equals 결과, TreeSet, HashSet, 정렬한 List 순서로 나옵니다. 같은 두 값을 넣었는데 세 컬렉션에 원소가 각각 몇 개 남는지 보세요.

compareTo=0, equals=false
tree add: true, false
tree=[PostMetric[id=1, title=array, viewCount=40, score=90]]
hash size=2
list=[PostMetric[id=1, title=array, viewCount=50, score=80], PostMetric[id=1, title=array, viewCount=40, score=90]]

비교자 없이 만든 TreeSet은 자연 순서를 쓰므로, 두 번째 add에서 이미 같은 원소가 있다고 보고 false를 돌려줍니다. 기존 원소를 바꾸지도 않으니 처음 넣은 측정값(조회 수 40)만 남습니다. HashSet은 equals와 hashCode로 같은지 판단하므로 두 값을 모두 담아 크기가 2입니다. List는 정렬해도 원소를 버리지 않으므로 둘 다 남는데, 조회 수 50인 값이 앞에 있는 것은 처음 목록에 그 순서로 넣었고 안정 정렬이 0이 나온 두 원소의 순서를 바꾸지 않았기 때문입니다. TreeMap도 같은 방식으로 키를 찾으므로, 비교 결과가 0인 키로 put하면 새 항목이 생기지 않고 기존 키에 붙은 값만 바뀝니다.

Comparable 문서는 compareTo가 0인 경우와 equals가 true인 경우가 정확히 일치하는 자연 순서를 'equals와 일관된(consistent with equals)' 순서라고 부릅니다. 꼭 그래야 하는 것은 아니지만 그렇게 만들기를 강하게 권하는데, 일관되지 않으면 위처럼 같은 원소들을 HashSet에 넣을 때와 TreeSet에 넣을 때 결과가 달라지기 때문입니다. 일관되지 않은 자연 순서를 둘 때는 그 사실을 클래스 문서에 밝혀 두어야 합니다.

PostMetric의 경우에는 무엇을 하려는지부터 정해야 합니다. 같은 게시글의 측정값을 최신 값으로 바꾸는 것이 목적이라면 Set에 다시 add할 것이 아니라, id를 키로 하는 Map에 새 값을 넣어 교체하는 편이 맞습니다. 측정값을 모두 보관해야 한다면 List에 담아 정렬합니다.

더 깊이: BigDecimal의 자연 순서

표준 라이브러리에도 equals와 일관되지 않은 자연 순서가 있습니다. Comparable 문서가 예로 드는 BigDecimal은 4.0과 4.00처럼 값은 같고 소수 자릿수(scale)만 다른 두 수를 compareTo로는 같다고 보지만, equals로는 다르다고 봅니다.

lab/BigDecimalOrder.java
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

public final class BigDecimalOrder {
    public static void main(String[] args) {
        BigDecimal a = new BigDecimal("4.0");
        BigDecimal b = new BigDecimal("4.00");
        System.out.println("compareTo=" + a.compareTo(b) + ", equals=" + a.equals(b));
        Set<BigDecimal> hash = new HashSet<>(List.of(a, b));
        Set<BigDecimal> tree = new TreeSet<>(List.of(a, b));
        System.out.println("HashSet=" + hash.size() + ", TreeSet=" + tree.size() + " " + tree);
    }
}

앞의 NaturalOrderVsEquals와 같은 구조로, 두 값의 비교 결과와 두 집합의 크기를 출력합니다. 같은 두 값을 받은 두 집합의 크기가 같은지 보세요.

compareTo=0, equals=false
HashSet=2, TreeSet=1 [4.0]

같은 두 값을 HashSet에 넣으면 두 개가 남고, TreeSet에 넣으면 먼저 넣은 4.0 하나만 남습니다. 금액처럼 BigDecimal을 다루는 코드에서 집합이나 맵의 구현을 바꾸면 결과가 달라질 수 있으니, 어떤 기준으로 같은 값을 판단할지 먼저 정해 두어야 합니다.

null이 섞인 목록 정렬하기

자연 순서로 정렬하는 목록에 null이 섞여 있으면 NullPointerException이 납니다. null에는 compareTo를 호출할 수 없고, Comparable 문서도 compareTo(null)은 이 예외를 던져야 한다고 정해 두었기 때문입니다. null을 맨 앞이나 맨 뒤에 모아 두고 정렬하려면 Comparator.nullsFirst나 nullsLast로 기존 비교자를 감쌉니다.

lab/NullOrdering.java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

public final class NullOrdering {
    public static void main(String[] args) {
        List<String> titles = new ArrayList<>(Arrays.asList("thread", null, "array"));
        titles.sort(Comparator.nullsLast(Comparator.naturalOrder()));
        System.out.println("nullsLast=" + titles);
        titles.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
        System.out.println("nullsFirst=" + titles);
        try {
            titles.sort(null);
        } catch (NullPointerException e) {
            System.out.println("natural order: NullPointerException");
        }
    }
}

같은 목록을 세 가지 방법으로 차례로 정렬합니다. 마지막 sort(null)은 예외로 끝나므로, catch가 출력한 문장이 마지막 줄이 됩니다.

nullsLast=[array, thread, null]
nullsFirst=[null, array, thread]
natural order: NullPointerException

nullsLast(Comparator.naturalOrder())는 null을 다른 모든 값보다 뒤에 두고, null이 아닌 값끼리는 감싼 비교자인 자연 순서로 비교합니다. nullsFirst는 null을 앞에 둔다는 점만 다릅니다. 다만 게시글 제목처럼 애초에 null이 들어오면 안 되는 값이라면, 비교자가 null을 조용히 받아 주게 하기보다 생성자에서 거부하는 편이 낫습니다. 정렬이 null을 받아 주면 잘못 들어온 값이 목록 끝에 숨은 채 오래 남기 때문입니다.

정렬하면 원본이 바뀌는가

정렬 메서드는 원본을 직접 바꾸는 것과 정렬된 새 결과를 만드는 것으로 나뉩니다. Arrays.sort는 배열을, List.sort와 Collections.sort는 목록을 제자리에서 정렬하므로 원래 순서가 사라집니다. 그래서 List.of로 만든 목록처럼 수정할 수 없는 목록에 sort를 부르면 UnsupportedOperationException이 납니다. 원본 순서를 그대로 두고 싶다면 스트림의 sorted()로 정렬한 뒤 toList()로 새 목록을 받습니다.

app/BoardRankingCli.java
import java.util.Comparator;
import java.util.List;

public final class BoardRankingCli {
    public static void main(String[] args) {
        List<Entry> input =
                List.of(
                        new Entry("hash", 50, 80),
                        new Entry("array", 40, 90),
                        new Entry("thread", 60, 90));
        Comparator<Entry> ranking =
                Comparator.comparingInt(Entry::score)
                        .reversed()
                        .thenComparing(Comparator.comparingInt(Entry::viewCount).reversed())
                        .thenComparing(Entry::title);
        List<Entry> report = input.stream().sorted(ranking).toList();
        System.out.println("input-first=" + input.getFirst().title());
        System.out.println("ranking=" + report);
    }

    private record Entry(String title, int viewCount, int score) {}
}

첫 줄은 정렬한 뒤에도 원본 input의 첫 원소가 그대로인지 확인하는 출력이고, 둘째 줄이 스트림으로 정렬한 새 목록입니다. 원본은 List.of로 만든 수정할 수 없는 목록이라는 점을 기억하고 보세요.

input-first=hash
ranking=[Entry[title=thread, viewCount=60, score=90], Entry[title=array, viewCount=40, score=90], Entry[title=hash, viewCount=50, score=80]]

input은 수정할 수 없는 목록이지만, 스트림은 원본을 읽기만 하므로 문제가 없습니다. 첫 줄에서 input의 첫 원소가 여전히 hash인 것은 원본 순서가 그대로라는 뜻입니다. getFirst()는 Java 21에 추가된 메서드로, 비어 있지 않은 목록에서는 get(0)과 같습니다. ranking은 맨 앞 기준에만 reversed()를 붙이고 조회 수 비교자는 따로 뒤집어 넘겼으므로 '점수 높은 순, 같으면 조회 수 높은 순, 같으면 제목 순'입니다. 그래서 점수 90인 두 글 가운데 조회 수가 많은 thread가 array보다 앞서고, 점수 80인 hash가 마지막입니다.

보고서는 thread부터이고 원본은 hash부터다

sorted가 새 report를 만들어 input 등록 순서는 유지합니다. 입력·정렬 호출·두 목록 관찰을 단계로 보여 원본 비변경을 확인할 수 있습니다.

보고서는 thread부터이고 원본은 hash부터다

BoardRankingCli의 input은 hash, array, thread 순서이고 sorted().toList()로 만든 report는 thread, array, hash 순서입니다. 실제 출력은 input의 첫 제목 hash와 report 전체를 확인합니다. input 전체 순서는 원문과 API 동작을 근거로 비교합니다.

정렬 복사본 · 원본 보존

보고서는 thread부터이고 원본은 hash부터다

입력은 hash부터 그대로이고, 보고서 복사본만 점수·조회수 내림차순으로 정렬됩니다.

원 input과 새 ranking의 별도 순서 원 input은 hash,array,thread 순서이며 input-first는 hash입니다. 보고서는 같은 원소를 점수·조회수 내림차순으로 thread,array,hash 순서에 보관합니다. 원본의 제자리 정렬이나 깊은 원소 복사가 아닙니다. input 원본 등록 순서 hash80·50 array90·40 / thread90·60 원본 첫 제목 input-first=hash report 결과 sorted로 새 결과 점수↓ / 조회수↓ / 제목↑ 실제 report 순서 thread90·60 array90·40 / hash80·50 실선: 각각의 읽기·정렬 관계 report는 새 결과 목록 input을 제자리 정렬 안 함 같은 원소 참조 / 다른 목록 순서
원 input과 새 ranking의 별도 순서 원 input은 hash,array,thread 순서이며 input-first는 hash입니다. 보고서는 같은 원소를 점수·조회수 내림차순으로 thread,array,hash 순서에 보관합니다. 원본의 제자리 정렬이나 깊은 원소 복사가 아닙니다. input 원본 등록 순서 hash80·50 array90·40 / thread90·60 원본 첫 제목 input-first=hash report 결과 sorted로 새 결과 점수↓ / 조회수↓ / 제목↑ 실제 report 순서 thread90·60 array90·40 / hash80·50 실선: 각각의 읽기·정렬 관계 report는 새 결과 목록 input을 제자리 정렬 안 함 같은 원소 참조 / 다른 목록 순서

index 0

input · 제목(점수, 조회수)
hash(80, 50)
report · 제목(점수, 조회수)
thread(90, 60)

index 1

input · 제목(점수, 조회수)
array(90, 40)
report · 제목(점수, 조회수)
array(90, 40)

index 2

input · 제목(점수, 조회수)
thread(90, 60)
report · 제목(점수, 조회수)
hash(80, 50)

이 예의 조건

  • 새 정렬 결과는 같은 원소 참조이며 input 제자리 정렬/깊은 복사가 아니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 input은 hash,array,thread 순서이며 input-first는 hash입니다. 보고서는 같은 원소를 점수·조회 수 내림차순으로 thread,array,hash 순서에 보관합니다. 원본의 제자리 정렬이나 깊은 원소 복사가 아닙니다.
6array90·40
12점수↓
13조회 수↓
14제목↑
17array90·40
22같은 원소 참조
23다른 목록 순서
원 도표의 설명과 주석
구분내용
설명원 input은 hash,array,thread 순서이며 input-first는 hash입니다. 보고서는 같은 원소를 점수·조회 수 내림차순으로 thread,array,hash 순서에 보관합니다. 원본의 제자리 정렬이나 깊은 원소 복사가 아닙니다.
전체 읽기 정보보고서는 thread부터이고 원본은 hash부터다 보고서는 thread부터이고 원본은 hash부터다 BoardRankingCli의 input은 hash, array, thread 순서이고 sorted().toList()로 만든 report는 thread, array, hash 순서입니다. 실제 출력은 input의 첫 제목 hash와 report 전체를 확인합니다. input 전체 순서는 원문과 API 동작을 근거로 비교합니다. 정렬 복사본 · 원본 보존 보고서는 thread부터이고 원본은 hash부터다 입력은 hash부터 그대로이고, 보고서 복사본만 점수·조회 수 내림차순으로 정렬됩니다. index 0 input · 제목(점수, 조회 수) hash(80, 50) report · 제목(점수, 조회 수) thread(90, 60) index 1 input · 제목(점수, 조회 수) array(90, 40) report · 제목(점수, 조회 수) array(90, 40) index 2 input · 제목(점수, 조회 수) thread(90, 60) report · 제목(점수, 조회 수) hash(80, 50)
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원본 순서

등록된 input을 읽습니다.

  • input: hash80/50,array90/40,thread90/60
  • 첫 제목: hash
  • 원본 만남 순서가 별도 계약으로 남는다.
보고서 정렬

점수↓·조회 수↓·제목↑로 새 순서를 만듭니다.

  • report: thread90/60,array90/40,hash80/50
  • 보고서 목적의 순서를 새 결과에 반영한다.
두 목록 관찰

input 첫 값과 report 순서를 각각 읽습니다.

  • input-first: hash
  • report-first: thread
  • 객체 원소: 공유
  • 목록 구조 분리가 원소 깊은 독립성을 뜻하지 않는다.

보고서는 thread부터이고 원본은 hash부터다

sorted가 새 report를 만들어 input 등록 순서는 유지합니다. 입력·정렬 호출·두 목록 관찰을 단계로 보여 원본 비변경을 확인할 수 있습니다.

보고서는 thread부터이고 원본은 hash부터다

BoardRankingCli의 input은 hash, array, thread 순서이고 sorted().toList()로 만든 report는 thread, array, hash 순서입니다. 실제 출력은 input의 첫 제목 hash와 report 전체를 확인합니다. input 전체 순서는 원문과 API 동작을 근거로 비교합니다.

정렬 복사본 · 원본 보존

보고서는 thread부터이고 원본은 hash부터다

입력은 hash부터 그대로이고, 보고서 복사본만 점수·조회수 내림차순으로 정렬됩니다.

원 input과 새 ranking의 별도 순서 원 input은 hash,array,thread 순서이며 input-first는 hash입니다. 보고서는 같은 원소를 점수·조회수 내림차순으로 thread,array,hash 순서에 보관합니다. 원본의 제자리 정렬이나 깊은 원소 복사가 아닙니다. input 원본 등록 순서 hash80·50 array90·40 / thread90·60 원본 첫 제목 input-first=hash report 결과 sorted로 새 결과 점수↓ / 조회수↓ / 제목↑ 실제 report 순서 thread90·60 array90·40 / hash80·50 실선: 각각의 읽기·정렬 관계 report는 새 결과 목록 input을 제자리 정렬 안 함 같은 원소 참조 / 다른 목록 순서
원 input과 새 ranking의 별도 순서 원 input은 hash,array,thread 순서이며 input-first는 hash입니다. 보고서는 같은 원소를 점수·조회수 내림차순으로 thread,array,hash 순서에 보관합니다. 원본의 제자리 정렬이나 깊은 원소 복사가 아닙니다. input 원본 등록 순서 hash80·50 array90·40 / thread90·60 원본 첫 제목 input-first=hash report 결과 sorted로 새 결과 점수↓ / 조회수↓ / 제목↑ 실제 report 순서 thread90·60 array90·40 / hash80·50 실선: 각각의 읽기·정렬 관계 report는 새 결과 목록 input을 제자리 정렬 안 함 같은 원소 참조 / 다른 목록 순서

index 0

input · 제목(점수, 조회수)
hash(80, 50)
report · 제목(점수, 조회수)
thread(90, 60)

index 1

input · 제목(점수, 조회수)
array(90, 40)
report · 제목(점수, 조회수)
array(90, 40)

index 2

input · 제목(점수, 조회수)
thread(90, 60)
report · 제목(점수, 조회수)
hash(80, 50)

이 예의 조건

  • 새 정렬 결과는 같은 원소 참조이며 input 제자리 정렬/깊은 복사가 아니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 input은 hash,array,thread 순서이며 input-first는 hash입니다. 보고서는 같은 원소를 점수·조회 수 내림차순으로 thread,array,hash 순서에 보관합니다. 원본의 제자리 정렬이나 깊은 원소 복사가 아닙니다.
6array90·40
12점수↓
13조회 수↓
14제목↑
17array90·40
22같은 원소 참조
23다른 목록 순서
원 도표의 설명과 주석
구분내용
설명원 input은 hash,array,thread 순서이며 input-first는 hash입니다. 보고서는 같은 원소를 점수·조회 수 내림차순으로 thread,array,hash 순서에 보관합니다. 원본의 제자리 정렬이나 깊은 원소 복사가 아닙니다.
전체 읽기 정보보고서는 thread부터이고 원본은 hash부터다 보고서는 thread부터이고 원본은 hash부터다 BoardRankingCli의 input은 hash, array, thread 순서이고 sorted().toList()로 만든 report는 thread, array, hash 순서입니다. 실제 출력은 input의 첫 제목 hash와 report 전체를 확인합니다. input 전체 순서는 원문과 API 동작을 근거로 비교합니다. 정렬 복사본 · 원본 보존 보고서는 thread부터이고 원본은 hash부터다 입력은 hash부터 그대로이고, 보고서 복사본만 점수·조회 수 내림차순으로 정렬됩니다. index 0 input · 제목(점수, 조회 수) hash(80, 50) report · 제목(점수, 조회 수) thread(90, 60) index 1 input · 제목(점수, 조회 수) array(90, 40) report · 제목(점수, 조회 수) array(90, 40) index 2 input · 제목(점수, 조회 수) thread(90, 60) report · 제목(점수, 조회 수) hash(80, 50)
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원본 순서

등록된 input을 읽습니다.

  • input: hash80/50,array90/40,thread90/60
  • 첫 제목: hash
  • 원본 만남 순서가 별도 계약으로 남는다.
보고서 정렬

점수↓·조회 수↓·제목↑로 새 순서를 만듭니다.

  • report: thread90/60,array90/40,hash80/50
  • 보고서 목적의 순서를 새 결과에 반영한다.
두 목록 관찰

input 첫 값과 report 순서를 각각 읽습니다.

  • input-first: hash
  • report-first: thread
  • 객체 원소: 공유
  • 목록 구조 분리가 원소 깊은 독립성을 뜻하지 않는다.

새로 생긴 것은 목록뿐이고 원소는 복사되지 않았습니다. report의 칸들은 input과 같은 Entry 객체를 가리킵니다. Entry는 바뀌지 않는 record라서 문제가 없지만, 원소가 변경 가능한 객체라면 한쪽에서 원소를 고친 결과가 다른 쪽에서도 보입니다. toList()가 돌려준 목록도 수정할 수 없으므로, 정렬 결과에 원소를 더 넣어야 한다면 new ArrayList<>(report)처럼 복사해서 씁니다. 원본과 복사본이 무엇을 공유하는지는 다음 글에서 더 자세히 다룹니다.

정리

  • 정렬에는 '두 원소 가운데 어느 쪽이 앞인가'에 답하는 비교 규칙이 필요합니다. 비교 결과는 음수, 0, 양수의 부호로 읽고, 크기에는 뜻이 없습니다.
  • Comparable은 타입이 스스로 갖는 하나의 자연 순서이고, Comparator는 쓰는 곳마다 따로 만드는 순서입니다. 자연 순서에는 자주 바뀌지 않는 기본 순서를 둡니다.
  • reversed()는 그때까지 이어 붙인 비교자 전체를 뒤집습니다. 기준마다 방향을 정하려면 기준별 비교자를 따로 뒤집어 thenComparing으로 잇습니다.
  • 뺄셈 비교는 값이 넘쳐 부호가 뒤집힐 수 있습니다. Integer.compare나 Comparator.comparingInt 같은 비교 메서드를 씁니다.
  • 비교 결과가 0인 원소를 List.sort는 순서를 지킨 채 모두 남기고, TreeSet과 TreeMap은 같은 원소로 봅니다. 자연 순서는 가능하면 equals와 일관되게 만듭니다.
  • List.sort와 Arrays.sort는 원본을 바꾸고, stream().sorted().toList()는 원본을 그대로 둔 채 수정할 수 없는 새 목록을 만듭니다.

연습 문제

  1. 게시글 검토 작업 ReviewTask(int id, LocalDate deadline, int priority)를 마감일이 빠른 순, 마감일이 같으면 우선순위가 높은 순, 그것도 같으면 id가 작은 순으로 정렬하는 비교자를 만드세요. 그 비교자로 만든 TreeSet에 아래 세 작업을 넣었을 때 마감일과 우선순위가 같은 두 작업도 모두 남아야 합니다. 출력될 크기와 id 순서를 먼저 예상한 뒤 실행하세요. 이어서 비교자에서 id 기준을 빼면 TreeSet의 크기와 순서가 어떻게 바뀌는지, 같은 비교자로 세 작업을 List에 담아 정렬하면 어떻게 되는지도 예상해 보세요.
    • id 2, 마감일 2026년 7월 20일, 우선순위 3
    • id 1, 마감일 2026년 7월 20일, 우선순위 3
    • id 3, 마감일 2026년 7월 19일, 우선순위 1
  2. 아래 프로그램의 두 비교자는 reversed()의 위치만 다릅니다. 실행하기 전에 first=와 second= 줄에 출력될 제목 순서를 예상하세요.
exercise/ReversedPlacementQuiz.java
import java.util.Comparator;
import java.util.List;

public final class ReversedPlacementQuiz {
    public static void main(String[] args) {
        List<Entry> entries =
                List.of(new Entry("hash", 80), new Entry("array", 90), new Entry("thread", 90));
        Comparator<Entry> first =
                Comparator.comparingInt(Entry::score).thenComparing(Entry::title).reversed();
        Comparator<Entry> second =
                Comparator.comparingInt(Entry::score).reversed().thenComparing(Entry::title);
        System.out.println("first=" + entries.stream().sorted(first).map(Entry::title).toList());
        System.out.println("second=" + entries.stream().sorted(second).map(Entry::title).toList());
    }

    private record Entry(String title, int score) {}
}
  1. 게시글을 작성 시각 순으로 정렬하려고 아래 비교자를 만들었습니다. createdAt()은 1970년 1월 1일부터 지난 밀리초 수를 long으로 돌려줍니다. 이 비교자의 문제를 설명하고 고치세요.
Comparator<Post> byCreatedAt = (a, b) -> (int) (a.createdAt() - b.createdAt());

힌트

  1. 우선순위만 내림차순이어야 하므로 우선순위 비교자를 따로 만들어 뒤집은 뒤 thenComparing에 넘기세요. id 기준을 빼면 같은 마감일과 우선순위를 가진 두 작업의 비교 결과가 0이 됩니다.
  2. reversed()는 그 앞까지 이어 붙인 비교자 전체를 뒤집습니다. first는 점수와 제목을 모두 뒤집고, second는 점수만 뒤집습니다.
  3. 두 long 값의 차이는 int의 범위를 넘을 수 있고, long을 int로 바꾸면 아래쪽 32비트만 남습니다.
1번 정답과 해설

마감일은 LocalDate의 자연 순서로 비교하고, 우선순위 비교자만 따로 뒤집어 thenComparing에 넘긴 뒤, 마지막에 id를 비교합니다. LocalDate는 Comparable을 구현하며 날짜가 빠를수록 앞이므로, Comparator.comparing(ReviewTask::deadline)만으로 마감일 오름차순이 됩니다.

exercise/ReviewTaskComparatorSolution.java
import java.time.LocalDate;
import java.util.Comparator;
import java.util.Set;
import java.util.TreeSet;

public final class ReviewTaskComparatorSolution {
    public static void main(String[] args) {
        Comparator<ReviewTask> order =
                Comparator.comparing(ReviewTask::deadline)
                        .thenComparing(Comparator.comparingInt(ReviewTask::priority).reversed())
                        .thenComparingInt(ReviewTask::id);
        Set<ReviewTask> tasks = new TreeSet<>(order);
        tasks.add(new ReviewTask(2, LocalDate.of(2026, 7, 20), 3));
        tasks.add(new ReviewTask(1, LocalDate.of(2026, 7, 20), 3));
        tasks.add(new ReviewTask(3, LocalDate.of(2026, 7, 19), 1));
        System.out.println("size=" + tasks.size() + ", order=" + tasks);
    }

    private record ReviewTask(int id, LocalDate deadline, int priority) {}
}

실행 결과:

size=3, order=[ReviewTask[id=3, deadline=2026-07-19, priority=1], ReviewTask[id=1, deadline=2026-07-20, priority=3], ReviewTask[id=2, deadline=2026-07-20, priority=3]]
기한·우선순위가 같아도 id 1과 2는 모두 남는다

원문 TreeSet은 검토 작업 세 개를 모두 유지합니다. 7월 19일 작업이 먼저이고, 기한과 우선순위가 같은 7월 20일 작업은 id 1, id 2 순서로 남습니다.

TREESET · 동점 해소

기한·우선순위가 같아도 id 1과 2는 모두 남는다

기한이 먼저, 우선순위가 다음, id가 마지막 기준입니다. 마지막 기준 덕분에 원소 셋이 모두 남습니다.

기한·우선순위 동률에서 id가 구별하는 원소원 세 작업을 기한→우선순위→id 규칙으로 판단하면 id3,id1,id2이며 size3입니다. 실선은 판단 규칙과 결과 관계이지 TreeSet 내부 비교 호출 순서를 재현한 선이 아닙니다.
세 작업 입력id2:7/20·우선3id1:7/20·우선3id3:7/19·우선1
연결 관계세 작업 입력 · id2:7/20·우선3 · id1:7/20·우선3 · id3:7/19·우선1 → 기한을 먼저 비교 · 7/19의 id3가 먼저 · 나머지는 기한 동률
기한을 먼저 비교7/19의 id3가 먼저나머지는 기한 동률
연결 관계기한을 먼저 비교 · 7/19의 id3가 먼저 · 나머지는 기한 동률 → 동률에서 우선·id 비교 · 우선3으로도 동률 · id1 < id2가 구별
동률에서 우선·id 비교우선3으로도 동률id1 < id2가 구별
연결 관계동률에서 우선·id 비교 · 우선3으로도 동률 · id1 < id2가 구별 → TreeSet 실제 결과 · id3 → id1 → id2 · size=3 / 둘 다 유지
TreeSet 실제 결과id3 → id1 → id2size=3 / 둘 다 유지

실선: 비교 규칙→순서 결정실제 내부 비교 호출 순서 아님

첫 번째

id
3
기한
2026-07-19
우선순위
1

두 번째

id
1
기한
2026-07-20
우선순위
3

세 번째

id
2
기한
2026-07-20
우선순위
3

마감일이 7월 19일로 가장 빠른 id 3이 먼저입니다. 나머지 두 작업은 마감일도 우선순위도 같아서 마지막 기준인 id로 순서가 정해지고, id 1이 id 2보다 앞섭니다. 세 작업의 비교 결과가 서로 0이 아니므로 TreeSet에 모두 남아 크기가 3입니다. 우선순위 비교자를 .thenComparingInt(ReviewTask::priority).reversed()처럼 체인 뒤에 붙여 뒤집었다면 마감일까지 함께 뒤집혀, 마감일이 늦은 id 1과 id 2가 id 3보다 먼저 나왔을 것입니다.

비교자에서 .thenComparingInt(ReviewTask::id)를 빼면, id 2와 id 1은 마감일과 우선순위가 같아 비교 결과가 0이 됩니다. TreeSet은 먼저 들어간 id 2를 남기고 두 번째로 넣은 id 1의 add에서 false를 돌려주므로, 크기는 2이고 순서는 id 3, id 2입니다. 같은 비교자로 세 작업을 List에 담아 정렬하면 원소를 버리지 않으니 셋 다 남고, 안정 정렬이 넣은 순서를 지키므로 id 3, id 2, id 1 순서가 됩니다. 마지막 기준은 같은 순서 안에서 누가 앞인지를 정할 뿐 아니라, TreeSet이 어떤 원소들을 서로 다르다고 볼지도 정합니다.

2번 정답과 해설

실행 결과:

first=[thread, array, hash]
second=[array, thread, hash]

first는 '점수 오름차순, 같으면 제목 오름차순'인 비교자를 통째로 뒤집었으므로 '점수 내림차순, 같으면 제목 내림차순'이 됩니다. 점수 90인 thread와 array 가운데 제목이 사전순으로 뒤인 thread가 먼저 나오고, 점수 80인 hash가 마지막입니다. second는 점수 비교자만 뒤집은 뒤 제목 기준을 이어 붙였으므로 '점수 내림차순, 같으면 제목 오름차순'입니다. 그래서 점수 90인 두 글이 array, thread 순서로 나옵니다. 두 비교자 모두 점수가 높은 글을 앞에 두지만, 점수가 같은 글끼리의 순서가 반대입니다.

3번 정답과 해설

두 시각의 차이는 long으로는 정확하게 계산되지만, 그 값을 int로 바꾸는 순간 아래쪽 32비트만 남습니다. 차이가 int의 범위(약 ±21억)를 넘으면 부호가 뒤집히거나 0이 될 수 있습니다. 밀리초로 21억은 약 25일이므로, 한 달쯤 떨어진 두 게시글만 있어도 순서가 틀어집니다.

exercise/LongCastComparatorBug.java
import java.util.Comparator;

public final class LongCastComparatorBug {
    public static void main(String[] args) {
        Post older = new Post("older", 1_767_225_600_000L);
        Post newer = new Post("newer", 1_770_225_600_000L); // 30억 밀리초(약 35일) 뒤
        Comparator<Post> broken = (a, b) -> (int) (a.createdAt() - b.createdAt());
        Comparator<Post> fixed = Comparator.comparingLong(Post::createdAt);
        System.out.println("broken=" + broken.compare(older, newer));
        System.out.println("fixed=" + fixed.compare(older, newer));
    }

    private record Post(String title, long createdAt) {}
}

실행 결과:

broken=1294967296
fixed=-1

older가 먼저 쓰였으니 비교 결과는 음수여야 합니다. 그런데 차이 -3,000,000,000을 int로 바꾸면 2의 32제곱인 4,294,967,296을 더한 값과 같은 1,294,967,296이 남아, older가 newer보다 뒤라는 양수가 나왔습니다. 차이가 2의 32제곱의 배수(약 49.7일의 배수)라면 결과가 0이 되어, TreeSet에서는 작성 시각이 다른 두 게시글 가운데 하나가 사라집니다. Comparator.comparingLong(Post::createdAt)이나 Long.compare(a.createdAt(), b.createdAt())처럼 long을 그대로 비교하는 메서드로 바꾸면 해결됩니다.

다음 글

다음 글에서는 정렬 결과와 원본이 무엇을 공유하는지 더 살펴봅니다. List.of, List.copyOf, Arrays.asList로 만든 목록이 변경을 어디까지 허용하는지 비교하고, 비교 결과가 0인 원소를 List와 TreeSet이 어떻게 다르게 남기는지도 다시 확인합니다.