본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
17장 : 순회·정렬·컬렉션 선택

Comparable과 Comparator

뺄셈 비교의 오버플로와 동률 원소 손실을 재현하고 점수·주제·시간 기준을 조합해 안정된 정렬 규칙을 만듭니다.

정렬은 원소를 보기 좋게 배치하는 기능이면서 TreeSet·TreeMap에서는 동등성 판단에도 영향을 줍니다. Comparable은 타입이 대표 자연 순서를 하나 소유하고, Comparator는 사용 지점마다 다른 순서를 제공합니다. 반환값의 정확한 크기가 아니라 음수·0·양수의 부호가 순서를 뜻합니다.

정수 뺄셈 Comparator의 순서 역전

return this.score - other.score는 간단하지만 최댓값과 음수 최솟값의 차이가 int 범위를 넘을 수 있습니다. 다음 정렬 결과는 큰 값이 작은 값보다 앞/뒤 관계를 잘못 판단합니다.

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)));
    }
}

이 오류의 원인은 비교 대상의 차이를 int 결과에 담으려 한 것입니다. Integer.비교는 두 값을 직접 비교해 오버플로를 피합니다. 객체 필드는 Comparator.comparingInt·comparingLong을 사용하면 의도가 보이고 박싱도 피할 수 있습니다.

Comparable의 대표 순서

StudySession의 자연 순서를 주제 오름차순과 id 오름차순으로 정합니다. 점수 내림차순 보고서는 Comparator로 따로 만듭니다. 자연 순서를 자주 바뀌는 화면 요구에 맞추면 타입을 사용하는 모든 TreeSet·정렬 결과가 영향을 받습니다.

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

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

    private record Session(int id, String topic, int minutes, int score)
            implements Comparable<Session> {
        public int compareTo(Session other) {
            int topicOrder = topic.compareTo(other.topic);
            return topicOrder != 0 ? topicOrder : Integer.compare(id, other.id);
        }
    }
}

sort(null)은 자연 순서를 사용합니다. Comparator 체인의 reversed 위치는 주의해야 합니다. 위 표현은 thenComparingInt(minutes)까지 만든 전체 비교자를 다시 뒤집을 수 있어 의도와 다를 수 있습니다. 각 키 비교자를 따로 뒤집어 조합하는 편이 안전합니다.

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

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

    private record Session(String topic, int minutes, int score) {}
}

비교자 0과 원소 동등성

List.sort()는 비교 결과가 0인 두 원소를 모두 유지하며 안정 정렬이므로 기존 상대 순서를 보존합니다. TreeSet은 0을 같은 원소로 보고 두 번째 add()false로 만듭니다. 점수만 비교하면 같은 점수의 서로 다른 세션을 잃습니다.

비교 규칙은 sign(compare(a,b)) == -sign(compare(b,a)), 추이성, 일관성을 지켜야 합니다. equals와 일관되지 않은 비교자를 TreeSet에 쓸 수는 있지만 Set 동등성 의미가 놀라워질 수 있어 문서화가 필요합니다.

null 정렬은 Comparator.nullsFirst 또는 nullsLast로 명시할 수 있습니다. 도메인이 null을 금지한다면 생성자 검증이 더 낫고, 비교자가 조용히 null을 허용하게 만들지 않습니다.

비교자 자체도 작은 값 표로 확인한다

오름차순 비교자라면 compare(a, a)는 0이고, a가 b보다 앞설 때 compare(a, b)는 음수이며 반대 호출은 양수여야 합니다. 세 값 a < b < c를 준비해 a < c도 음수인지 확인하면 추이성 실수를 찾기 쉽습니다. 여러 thenComparing을 조합한 뒤에는 동률·역순·완전 동률 사례를 각각 실행합니다.

정렬 결과만 한 번 보면 우연히 맞아 보일 수 있습니다. 비교자를 TreeSet에 사용할 계획이라면 서로 다른 식별 원소가 0으로 비교되지 않는지도 size로 확인합니다. binarySearch에는 List를 정렬한 바로 그 비교자를 전달해야 삽입 위치와 검색 판단이 일치합니다.

정렬 API의 원본 변경 여부

Arrays.sort()는 배열을, List.sort()Collections.sort()는 가변 List를 제자리에서 변경합니다. 스트림의 sorted().toList()는 원본 순서를 유지하고 새 비수정 List를 반환합니다. 호출자가 원본을 계속 사용한다면 복사본 정렬을 선택합니다.

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

public final class StudyRankingCli {
    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::minutes).reversed())
                        .thenComparing(Entry::topic);
        List<Entry> report = input.stream().sorted(ranking).toList();
        System.out.println("input-first=" + input.getFirst().topic());
        System.out.println("ranking=" + report);
    }

    private record Entry(String topic, int minutes, int score) {}
}

연습 문제

기한 오름차순, 우선순위 내림차순, id 오름차순으로 Task를 정렬하세요. TreeSet에 같은 기한·우선순위지만 다른 id 두 개를 넣어도 모두 남아야 합니다.

정답과 해설

마지막 id 비교가 완전한 순서를 만들어 서로 다른 Task의 비교 결과 0을 막습니다.

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

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

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

size는 3이고 7월 19일 Task가 먼저입니다. 7월 20일 동률에서는 id 1, 2 순서입니다.

정렬 설계는 자연 순서 하나와 사용 사례 순서를 분리하고, 숫자 비교에 비교 메서드를 쓰며, 비교자 0의 Set 의미를 확인하는 세 단계로 검산합니다.