도입
자바에서 객체를 정렬하거나, 정렬된 자료구조(TreeSet/TreeMap) 혹은 우선순위 큐(PriorityQueue)를 사용할 때 “무엇을 기준으로 앞/뒤를 결정할지”는 결국 비교 규칙의 문제입니다.
Comparator는 그 비교 규칙을 클래스 내부에 고정시키지 않고, 필요할 때마다 외부에서 선택해 적용할 수 있게 해주는 도구입니다. 그래서 Comparator를 이해하면 정렬 코드를 짧게 만드는 수준을 넘어, 다중 정렬(여러 기준), null 처리, 안정성(계약 준수), 컬렉션 동작까지 한 번에 정리됩니다.
정의
java.util.Comparator<T>는 “T 타입 두 개를 비교해서 어떤 게 앞인지”를 결정하는 인터페이스입니다. 핵심 메서드는 compare(o1, o2) 하나입니다.
public interface Comparator<T> {
int compare(T o1, T o2);
}
- 음수 : o1이 o2보다 앞(작다)
- 0 : o1과 o2가 동등(같은 순서 취급)
- 양수 : o1이 o2보다 뒤(크다)
필요성
- 클래스를 바꾸지 않고 정렬 기준을 바꿀 수 있다. (외부 라이브러리 타입도 정렬 가능)
- 하나의 타입에 대해 여러 정렬 규칙(이름순, 점수순, 날짜순 등)을 쉽게 제공할 수 있다.
- 정렬뿐 아니라 TreeSet/TreeMap/PriorityQueue 같은 자료구조의 동작 자체가 Comparator에 의해 결정된다.
- 다중 정렬(1차/2차/3차 키)을 thenComparing으로 깔끔하게 구성할 수 있다.
Comparable vs Comparator
| 구분 | Comparable | Comparator |
|---|---|---|
| 정렬 기준 위치 | 클래스 내부 | 클래스 외부 |
| 정렬 기준 개수 | 보통 1개(자연 정렬) | 여러 개 가능 |
| 타입 수정 필요 | 필요(implements Comparable) | 불필요 |
| 실무 추천 | “대표 기준”이 명확할 때만 | 대부분의 상황에서 유연함 |
기본 사용법
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 작성 패턴
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; }
}
students.sort(Comparator.comparingInt(Student::getScore));
students.sort(Comparator.comparingInt(Student::getScore).reversed());
Comparator<Student> cmp =
Comparator.comparingInt(Student::getScore).reversed()
.thenComparingInt(Student::getAge)
.thenComparing(Student::getName);
students.sort(cmp);
null 처리
정렬 대상에 null이 섞여 있거나, 정렬 키(예: name)가 null일 수 있으면 Comparator가 NPE를 내기 쉽습니다. 이때 nullsFirst / nullsLast를 사용하면 규칙을 명확히 할 수 있습니다.
List<Integer> list = Arrays.asList(3, null, 1);
list.sort(Comparator.nullsLast(Integer::compareTo));
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은 “정렬된 구조”라서 Comparator를 기반으로 삽입 위치를 찾습니다. 이때 compare 결과가 0이면 동일한 원소(또는 동일한 키)로 간주합니다.
즉, equals()는 false인데 Comparator가 0을 반환하면, TreeSet에서는 “이미 있는 값”으로 판단해서 삽입이 무시될 수 있습니다.
// 나이만 비교하면, 같은 나이 학생은 TreeSet에서 중복 취급될 수 있음
Comparator<Student> byAge = Comparator.comparingInt(Student::getAge);
Set<Student> set = new TreeSet<>(byAge);
PriorityQueue에서의 활용
PriorityQueue는 기본적으로 “가장 작은 값”이 먼저 나오는 min-heap 형태로 동작합니다. 하지만 Comparator를 주입하면 “가장 큰 값”이 먼저 나오게 만들거나, 커스텀 우선순위를 만들 수 있습니다.
PriorityQueue<Integer> pq = new PriorityQueue<>(Comparator.reverseOrder());
pq.offer(3);
pq.offer(1);
pq.offer(5);
System.out.println(pq.poll()); // 5
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여야 한다.
- 일관성: 같은 입력에 대해 비교 결과가 계속 같아야 한다. (비교 기준이 중간에 변하면 안 됨)
자주 하는 실수
// BAD: 값이 큰 경우 오버플로우로 잘못된 비교가 될 수 있음
Comparator<Integer> bad = (a, b) -> a - b;
// GOOD
Comparator<Integer> good = Integer::compare;
정렬 결과가 “동점이면 아무렇게나” 되어도 괜찮은 문제도 있지만, TreeSet/TreeMap처럼 compare=0을 중복으로 보는 구조에서는 동점 처리 누락이 데이터 유실로 이어질 수 있습니다.
Comparator가 참조하는 필드가 변경되면 정렬 결과가 뒤틀릴 수 있습니다. 특히 TreeSet/TreeMap에 넣은 뒤 값이 바뀌면 “찾을 수 없는 값”이 되는 것처럼 이상한 상황이 생길 수 있습니다.
성능 팁
- 가능하면 comparingInt / comparingLong / comparingDouble을 사용해 박싱 비용을 줄인다.
compare()안에서 DB 조회/복잡한 파싱 같은 비싼 작업을 하지 않는다.- 키 추출이 무겁다면 정렬 전에 키를 미리 계산해 캐싱(예: Schwartzian transform 느낌)하는 것도 고려한다.
- 문자열 정렬이 언어 규칙(로케일)에 민감하면 Collator 같은 도구를 고려한다.
요약
- ✅ Comparator는 두 객체를 비교해 정렬 순서를 결정하는 외부 규칙이다.
- ✅ Comparable은 자연 정렬(클래스 내부), Comparator는 커스텀 정렬(클래스 외부)이다.
- ✅ comparing/thenComparing/reversed/nullsFirst(nullsLast)를 조합하면 다중 정렬을 깔끔하게 만들 수 있다.
- ✅ TreeSet/TreeMap에서는 compare=0이 “중복”이므로 동점 처리(타이브레이커)가 특히 중요하다.
- ✅ 계약(대칭성/추이성/일관성)을 깨면 정렬 중 예외나 비정상 동작이 생길 수 있다.