안동민 개발노트

본문 시작

Comparable과 Comparator

뺄셈 비교의 오버플로를 확인하고 동률 원소의 처리와 점수·제목·조회수 기준을 조합하는 정렬 규칙을 살펴봅니다.

정렬은 원소를 보기 좋게 배치하는 기능이면서 TreeSet·TreeMap에서는 동등성 판단에도 영향을 줍니다.

Comparable은 타입이 대표 자연 순서를 하나 소유하고, Comparator는 사용 지점마다 다른 순서를 제공합니다.

반환값의 정확한 크기가 아니라 음수·0·양수의 부호가 순서를 뜻합니다.


정수 뺄셈 Comparator의 순서 역전

return this.score - other.score는 간단하지만 최댓값과 음수 최솟값의 차이가 int 범위를 넘을 수 있습니다.

다음 정렬 결과는 오름차순처럼 보이지만, 별도로 계산한 최댓값과 -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)));
    }
}
정렬이 맞아 보여도 뺄셈 비교의 부호는 틀릴 수 있다

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

정렬이 맞아 보여도 뺄셈 비교의 부호는 틀릴 수 있다
원문에서 관찰한 값실제 출력판단
정렬 결과[-1, 0, 2147483647]이 입력에서는 오름차순처럼 보임
별도 뺄셈 비교compare=-2147483648
2147483647 > -1
그런데 비교 부호는 음수
정렬 결과
실제 출력: [-1, 0, 2147483647]
판단: 이 입력에서는 오름차순처럼 보임
별도 뺄셈 비교
실제 출력: compare=-2147483648
판단:
2147483647 > -1
그런데 비교 부호는 음수

이 오류의 원인은 비교 대상의 차이를 int 결과에 담으려 한 것입니다.

Integer.compare는 두 값을 직접 비교해 오버플로를 피합니다.

객체 필드는 Comparator.comparingInt·comparingLong을 사용하면 의도가 보이고 박싱도 피할 수 있습니다.


Comparable의 대표 순서

PostMetric의 자연 순서를 제목 오름차순과 id 오름차순으로 정합니다.

점수 내림차순 보고서는 Comparator로 따로 만듭니다.

자연 순서를 자주 바뀌는 화면 요구에 맞추면 타입을 사용하는 모든 TreeSet·정렬 결과가 영향을 받습니다.

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

sort(null)은 자연 순서를 사용합니다.

Comparator 체인의 reversed 위치는 주의해야 합니다.

위 표현의 두 번째 reversed는 thenComparingInt(viewCount)까지 만든 전체 비교자를 다시 뒤집습니다. 따라서 실제 순서는 점수 오름차순, 조회수 내림차순, id 오름차순입니다.

각 키 비교자를 따로 뒤집어 조합하는 편이 안전합니다.

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) {}
}
두 번째 reversed는 점수 방향까지 다시 뒤집는다

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

두 번째 reversed는 점수 방향까지 다시 뒤집는다
원문 예제·규칙비교 방향각 예제의 실제 결과 · 제목(점수, 조회수)
첫 예제
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)
첫 예제
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)

비교자 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/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) {}
}
보고서는 thread부터이고 원본은 hash부터다

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

보고서는 thread부터이고 원본은 hash부터다
위치input · 제목(점수, 조회수)report · 제목(점수, 조회수)
index 0hash(80, 50)thread(90, 60)
index 1array(90, 40)array(90, 40)
index 2thread(90, 60)hash(80, 50)
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)

연습 문제

기한 오름차순, 우선순위 내림차순, id 오름차순으로 게시글 검토 작업을 정렬하세요.

TreeSet에 같은 기한·우선순위지만 다른 id를 가진 검토 작업 두 개를 넣어도 모두 남아야 합니다.

정답과 해설

이 예제처럼 id가 서로 다른 작업은 마지막 id 비교까지 포함하면 모두 유지됩니다.

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) {}
}
기한·우선순위가 같아도 id 1과 2는 모두 남는다

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

기한·우선순위가 같아도 id 1과 2는 모두 남는다
실제 순회 위치id기한우선순위
첫 번째32026-07-191
두 번째12026-07-203
세 번째22026-07-203
첫 번째
id: 3
기한: 2026-07-19
우선순위: 1
두 번째
id: 1
기한: 2026-07-20
우선순위: 3
세 번째
id: 2
기한: 2026-07-20
우선순위: 3

size는 3이고 7월 19일 ReviewTask가 먼저입니다.

7월 20일 동률에서는 id 1, 2 순서입니다.

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