ABOUT

성능과 운영 안정성을 함께 끌어올리는 개발자입니다.

92% Positional Error Reduction
79% p95 Latency Improvement
90%+ Long Tasks Reduction

2022.02 · 한국장학재단

우수 멘티

한국장학재단 사회 리더 대학생 멘토링 IT

2022.10 · 동작구청

우수 인재상

동작구청 우수 SW 인재

2025.05 · (주) 그랩

프로그래밍 우수상

(주) 그랩 우수 프로그램 개발

2025.05 · AWSKRUG

AWS한국사용자모임 발표

AI agent 스크립트 튜닝 관련 발표

ComputerScience

Development

Engineering

Trouble Shooting

GUESTBOOK

첫 마음부터
함께 나누는 온기

방명록 작성하러 가기

SUBSCRIBE

최신소식을
편하게 만나보세요.

Comparator

도입

“정렬 기준(비교 규칙)”을 객체 바깥에서 주입하는 전략(Strategy)이다

자바에서 객체를 정렬하거나, 정렬된 자료구조(TreeSet/TreeMap) 혹은 우선순위 큐(PriorityQueue)를 사용할 때 “무엇을 기준으로 앞/뒤를 결정할지”는 결국 비교 규칙의 문제입니다.

Comparator는 그 비교 규칙을 클래스 내부에 고정시키지 않고, 필요할 때마다 외부에서 선택해 적용할 수 있게 해주는 도구입니다. 그래서 Comparator를 이해하면 정렬 코드를 짧게 만드는 수준을 넘어, 다중 정렬(여러 기준), null 처리, 안정성(계약 준수), 컬렉션 동작까지 한 번에 정리됩니다.

정의

두 객체를 비교해 순서를 결정하는 함수형 인터페이스다

java.util.Comparator<T>는 “T 타입 두 개를 비교해서 어떤 게 앞인지”를 결정하는 인터페이스입니다. 핵심 메서드는 compare(o1, o2) 하나입니다.

public interface Comparator<T> {
    int compare(T o1, T o2);
}
compare 반환값의 의미
  • 음수 : o1이 o2보다 앞(작다)
  • 0 : o1과 o2가 동등(같은 순서 취급)
  • 양수 : o1이 o2보다 뒤(크다)
핵심 한 줄
Comparator = “정렬 기준을 함수로 분리한 것”입니다.

필요성

Comparator가 있으면 객체를 수정하지 않고도 다양한 정렬 기준을 상황에 따라 선택할 수 있다
  • 클래스를 바꾸지 않고 정렬 기준을 바꿀 수 있다. (외부 라이브러리 타입도 정렬 가능)
  • 하나의 타입에 대해 여러 정렬 규칙(이름순, 점수순, 날짜순 등)을 쉽게 제공할 수 있다.
  • 정렬뿐 아니라 TreeSet/TreeMap/PriorityQueue 같은 자료구조의 동작 자체가 Comparator에 의해 결정된다.
  • 다중 정렬(1차/2차/3차 키)을 thenComparing으로 깔끔하게 구성할 수 있다.

Comparable vs Comparator

“자기 자신의 자연 정렬”, Comparator는 “외부에서 주입하는 정렬 규칙”이다
구분 Comparable Comparator
정렬 기준 위치 클래스 내부 클래스 외부
정렬 기준 개수 보통 1개(자연 정렬) 여러 개 가능
타입 수정 필요 필요(implements Comparable) 불필요
실무 추천 “대표 기준”이 명확할 때만 대부분의 상황에서 유연함
실무 감각
도메인 객체에 “자연 정렬”이 1개로 고정되기 어렵다면, Comparable보다 Comparator로 정렬 규칙을 분리하는 편이 유지보수에 유리합니다.

기본 사용법

List.sort / Collections.sort / Stream.sorted / Arrays.sort 등에 그대로 주입한다
List<Integer> nums = Arrays.asList(3, 1, 2);

// 오름차순
nums.sort(Integer::compare);

// 내림차순
nums.sort((a, b) -> Integer.compare(b, a));
더 “자바답게” 쓰는 방법
// 오름차순 / 내림차순 유틸
nums.sort(Comparator.naturalOrder());
nums.sort(Comparator.reverseOrder());

Comparator 작성 패턴

comparing / comparingInt + thenComparing 조합을 익히면 다중 정렬이 매우 깔끔해진다

Comparator는 람다로도 만들 수 있지만, 실무/코테에서 안정성과 가독성을 위해 Comparator.comparing 계열을 적극적으로 쓰는 것이 좋습니다.

예제 도메인
class Student {
    private final String name;
    private final int score;
    private final int age;

    public Student(String name, int score, int age) {
        this.name = name;
        this.score = score;
        this.age = age;
    }
    public String getName() { return name; }
    public int getScore() { return score; }
    public int getAge() { return age; }
}
1) 1차 정렬: 점수 오름차순
students.sort(Comparator.comparingInt(Student::getScore));
2) 점수 내림차순
students.sort(Comparator.comparingInt(Student::getScore).reversed());
3) 다중 정렬: 점수 내림차순 → 나이 오름차순 → 이름 오름차순
Comparator<Student> cmp =
    Comparator.comparingInt(Student::getScore).reversed()
              .thenComparingInt(Student::getAge)
              .thenComparing(Student::getName);

students.sort(cmp);
TIP
다중 정렬에서 tie-breaker(동점 처리)를 끝까지 명시해두면, 정렬 결과가 예측 가능해지고 디버깅이 훨씬 쉬워집니다.

null 처리

Comparator.nullsFirst / nullsLast를 쓰면 null 정렬 규칙을 안정적으로 정의할 수 있다

정렬 대상에 null이 섞여 있거나, 정렬 키(예: name)가 null일 수 있으면 Comparator가 NPE를 내기 쉽습니다. 이때 nullsFirst / nullsLast를 사용하면 규칙을 명확히 할 수 있습니다.

1) 원소 자체가 null일 때
List<Integer> list = Arrays.asList(3, null, 1);

list.sort(Comparator.nullsLast(Integer::compareTo));
2) 키 추출 결과가 null일 때
Comparator<Student> cmp =
    Comparator.comparing(Student::getName,
                         Comparator.nullsLast(String::compareTo));

students.sort(cmp);

정렬 API에서의 활용

“정렬 함수”뿐 아니라 “정렬 기반 자료구조”의 핵심 파라미터다
사용처 예시 의미
List 정렬 list.sort(cmp) 리스트를 cmp 기준으로 정렬
Stream 정렬 stream.sorted(cmp) 정렬된 스트림 생성
TreeSet/TreeMap new TreeSet(cmp) 정렬 규칙(=중복 판정까지) 결정
PriorityQueue new PriorityQueue(cmp) 우선순위(루트) 규칙 결정

TreeSet / TreeMap 주의점

TreeSet/TreeMap에서 compare가 0이면 “같은 원소/같은 키”로 취급되어 데이터가 사라질 수 있다

TreeSet/TreeMap은 “정렬된 구조”라서 Comparator를 기반으로 삽입 위치를 찾습니다. 이때 compare 결과가 0이면 동일한 원소(또는 동일한 키)로 간주합니다.

즉, equals()는 false인데 Comparator가 0을 반환하면, TreeSet에서는 “이미 있는 값”으로 판단해서 삽입이 무시될 수 있습니다.

// 나이만 비교하면, 같은 나이 학생은 TreeSet에서 중복 취급될 수 있음
Comparator<Student> byAge = Comparator.comparingInt(Student::getAge);

Set<Student> set = new TreeSet<>(byAge);
해결 팁
TreeSet/TreeMap에 넣을 거라면, 비교 기준을 끝까지 정의해서 의도치 않은 compare=0 상황을 줄이는 것이 안전합니다. (예: 나이 → 이름 → ID)

PriorityQueue에서의 활용

PriorityQueue는 Comparator에 의해 “무엇이 우선순위가 높은가”가 결정된다

PriorityQueue는 기본적으로 “가장 작은 값”이 먼저 나오는 min-heap 형태로 동작합니다. 하지만 Comparator를 주입하면 “가장 큰 값”이 먼저 나오게 만들거나, 커스텀 우선순위를 만들 수 있습니다.

1) max-heap 만들기
PriorityQueue<Integer> pq = new PriorityQueue<>(Comparator.reverseOrder());
pq.offer(3);
pq.offer(1);
pq.offer(5);

System.out.println(pq.poll()); // 5
2) “점수 높은 학생 우선”
PriorityQueue<Student> pq =
    new PriorityQueue<>(Comparator.comparingInt(Student::getScore).reversed());

pq.offer(new Student("kim", 90, 20));
pq.offer(new Student("lee", 80, 21));

System.out.println(pq.poll().getName()); // kim

Comparator 계약

반사성/대칭성/추이성을 만족해야 하며, 위반하면 정렬 중 예외가 발생할 수 있다

Comparator는 아무렇게나 작성해도 컴파일은 됩니다. 하지만 “정렬 알고리즘이 믿고 사용할 수 있는 규칙”을 지켜야 실제 런타임에서 문제가 없습니다.

중요한 계약(요지)
  • 대칭성: a와 b를 비교한 결과의 부호는, b와 a를 비교한 결과의 부호와 반대여야 한다.
  • 추이성: a > b이고 b > c라면 a > c여야 한다.
  • 일관성: 같은 입력에 대해 비교 결과가 계속 같아야 한다. (비교 기준이 중간에 변하면 안 됨)
자주 겪는 런타임 오류
정렬 중에 Comparison method violates its general contract! 같은 예외가 발생했다면, Comparator가 추이성/일관성을 깨뜨렸을 가능성이 큽니다.

자주 하는 실수

Comparator 실수는 “뺄셈 비교”, “동점 처리 누락”, “가변 키”, “TreeSet 중복 판정”에서 많이 터진다
실수 1) 뺄셈으로 비교(오버플로우 위험)
// BAD: 값이 큰 경우 오버플로우로 잘못된 비교가 될 수 있음
Comparator<Integer> bad = (a, b) -> a - b;

// GOOD
Comparator<Integer> good = Integer::compare;
실수 2) 동점 처리(thenComparing) 누락으로 결과가 애매해짐

정렬 결과가 “동점이면 아무렇게나” 되어도 괜찮은 문제도 있지만, TreeSet/TreeMap처럼 compare=0을 중복으로 보는 구조에서는 동점 처리 누락이 데이터 유실로 이어질 수 있습니다.

실수 3) 비교 기준이 중간에 바뀌는 “가변 키”

Comparator가 참조하는 필드가 변경되면 정렬 결과가 뒤틀릴 수 있습니다. 특히 TreeSet/TreeMap에 넣은 뒤 값이 바뀌면 “찾을 수 없는 값”이 되는 것처럼 이상한 상황이 생길 수 있습니다.

성능 팁

정렬에서는 Comparator가 매우 많이 호출되므로, compare 내부에서 비싼 계산을 피하는 것이 중요하다
  • 가능하면 comparingInt / comparingLong / comparingDouble을 사용해 박싱 비용을 줄인다.
  • compare() 안에서 DB 조회/복잡한 파싱 같은 비싼 작업을 하지 않는다.
  • 키 추출이 무겁다면 정렬 전에 키를 미리 계산해 캐싱(예: Schwartzian transform 느낌)하는 것도 고려한다.
  • 문자열 정렬이 언어 규칙(로케일)에 민감하면 Collator 같은 도구를 고려한다.

요약

정렬 기준을 분리해 유연성을 얻는 도구이며, 계약 준수와 동점 처리까지 챙겨야 안전하다
  • ✅ Comparator는 두 객체를 비교해 정렬 순서를 결정하는 외부 규칙이다.
  • ✅ Comparable은 자연 정렬(클래스 내부), Comparator는 커스텀 정렬(클래스 외부)이다.
  • ✅ comparing/thenComparing/reversed/nullsFirst(nullsLast)를 조합하면 다중 정렬을 깔끔하게 만들 수 있다.
  • ✅ TreeSet/TreeMap에서는 compare=0이 “중복”이므로 동점 처리(타이브레이커)가 특히 중요하다.
  • ✅ 계약(대칭성/추이성/일관성)을 깨면 정렬 중 예외나 비정상 동작이 생길 수 있다.
728x90