Comparator: comparing과 thenComparing으로 여러 기준 정렬하기
1. 개요
실제 정렬 문제는 거의 한 가지 기준만으로 충분하지 않습니다. 예를 들어 주문을 우선순위로 정렬하되, 같은 우선순위면 합계 금액이 높은 것부터, 여전히 같으면 오래된 주문부터 배치하는 식입니다.
이 글에서는 Comparator.comparing과 thenComparing으로 이런 comparator를 만드는 방법을 설명합니다. 또한 null을 처리하는 방법, reversed()를 올바르게 사용하는 방법, comparator 사용 시 흔한 실수들을 알아보겠습니다.
2. 데이터 준비
주문을 나타내는 간단한 record를 사용합니다:
import java.time.LocalDate;
public record Order(String customer, int priority, double total, LocalDate createdAt) {
}
그리고 샘플 목록입니다. 초기 순서는 An, Binh, Chi, Dung, Em입니다:
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class SampleData {
public static List<Order> orders() {
return new ArrayList<>(List.of(
new Order("An", 2, 150.0, LocalDate.of(2026, 10, 1)),
new Order("Binh", 1, 300.0, LocalDate.of(2026, 10, 3)),
new Order("Chi", 2, 300.0, LocalDate.of(2026, 10, 2)),
new Order("Dung", 1, 300.0, LocalDate.of(2026, 10, 1)),
new Order("Em", 2, 150.0, LocalDate.of(2026, 9, 30))
));
}
}
List.of는 불변 리스트를 반환하므로 new ArrayList<>(...)로 감쌉니다. 그 리스트에서 sort를 호출하면 UnsupportedOperationException이 발생합니다.
3. 한 가지 기준의 Comparator와 comparing
3.1. comparing과 method reference
Comparator.comparing은 key extractor 함수를 받습니다. 이 함수는 Comparable 값을 반환하고, comparator는 이 키들을 비교합니다:
List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparing(Order::customer));
System.out.println(orders.stream().map(Order::customer).toList());
[An, Binh, Chi, Dung, Em]
Order::customer는 String을 반환합니다. String은 Comparable을 구현하므로 comparator는 문자열의 자연 순서를 사용합니다.
3.2. comparingInt, comparingLong, comparingDouble
원시 타입 키의 경우 특화된 변형을 사용하세요. 이들은 매번 비교할 때 Integer나 Double로 boxing하지 않습니다:
List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparingDouble(Order::total));
System.out.println(orders.stream().map(Order::customer).toList());
[An, Em, Binh, Chi, Dung]
An과 Em은 모두 합계가 150입니다. 이들은 초기 순서를 유지합니다. List.sort는 TimSort를 사용하는데, 이는 안정적인 (stable) 정렬 알고리즘입니다. comparator에 따라 같은 원소들은 순서가 바뀌지 않습니다.
3.3. 키를 위한 comparator를 사용하는 comparing
comparing의 두 번째 오버로드는 키를 비교하는 추가 comparator를 받습니다. 이를 통해 한 가지 기준에 대해서만 내림차순으로 정렬할 수 있습니다:
orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));
이 명령은 가장 최근의 주문을 맨 앞에 배치합니다. 이 방식은 여러 기준을 조합할 때 매우 유용합니다. 다음 섹션에서 보실 것입니다.
4. thenComparing으로 여러 기준 정렬하기
thenComparing은 이전 comparator가 0을 반환할 때만 사용됩니다. 즉, 이전 기준에서 두 원소가 같을 때만 적용됩니다. 초기 요구사항으로 돌아가면:
priority를 오름차순으로 (1이 최고 우선순위)total을 내림차순으로createdAt을 오름차순으로 (오래된 주문부터)
Comparator<Order> byBusinessRule = Comparator
.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed())
.thenComparing(Order::createdAt);
List<Order> orders = SampleData.orders();
orders.sort(byBusinessRule);
System.out.println(orders.stream().map(Order::customer).toList());
[Dung, Binh, Chi, Em, An]
단계별로 진행되는 과정은 다음과 같습니다:
priority = 1그룹은 Binh와 Dung입니다. 둘 다total = 300이므로 날짜 기준이 결정합니다. Dung (01/10)이 Binh (03/10) 앞에 옵니다.priority = 2그룹은 An, Chi, Em입니다. Chi는 300이므로 그룹의 맨 앞에 옵니다. An과 Em은 모두 150이므로 Em (30/09)이 An (01/10) 앞에 옵니다.
두 번째 줄을 주목하세요: reversed()는 total을 위한 별도의 comparator에서 호출됩니다. 이 명령은 전체 체인에 적용되지 않습니다. 이것이 많은 사람들이 실수하는 부분입니다.
5. reversed()의 함정
reversed()는 호출된 전체 comparator를 역순으로 바꿉니다. 다음 코드를 보세요:
Comparator<Order> wrong = Comparator
.comparingInt(Order::priority)
.thenComparingDouble(Order::total)
.reversed();
List<Order> orders = SampleData.orders();
orders.sort(wrong);
System.out.println(orders.stream().map(Order::customer).toList());
[Chi, An, Em, Binh, Dung]
작성자는 ’priority 오름차순, total 내림차순’을 원했습니다. 결과는 둘 다 내림차순이 됩니다: priority = 2 그룹이 맨 앞으로 밀려납니다. 이유는 reversed()가 두 기준에서 합성된 comparator에 적용되었기 때문입니다.
두 가지 올바른 방법이 있습니다. 더 읽기 쉬운 것을 선택하세요:
// 방법 1: 키를 위한 별도의 comparator, 그 다음 역순
Comparator<Order> c1 = Comparator.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed());
// 방법 2: thenComparing에 키의 comparator를 전달
Comparator<Order> c2 = Comparator.comparingInt(Order::priority)
.thenComparing(Order::total, Comparator.reverseOrder());
방법 2가 더 짧지만 double을 Double로 boxing해야 합니다. 몇 천 개의 원소로는 이 차이가 무시할 수 있습니다. 대용량 데이터를 지속적으로 정렬할 때는 방법 1을 우선으로 하세요.
주의: 체인의 끝에서
reversed()를 호출하는 것은 모든 기준을 역순으로 하고 싶을 때만 올바릅니다. 한 가지 기준만 역순으로 하려면 그 기준 바로 뒤에서 역순으로 하세요.
6. null 값 처리
6.1. 키가 null일 수 있는 경우
Comparator.comparing(Customer::nickname)은 nickname이 null이면 NullPointerException을 던집니다. 키의 comparator를 nullsFirst 또는 nullsLast로 감싸세요:
public record Customer(String nickname) {
}
List<Customer> customers = new ArrayList<>(List.of(
new Customer("lan"),
new Customer(null),
new Customer("An"),
new Customer("binh"),
new Customer("Cuong")
));
customers.sort(Comparator.comparing(
Customer::nickname,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)
));
System.out.println(customers.stream().map(Customer::nickname).toList());
[An, binh, Cuong, lan, null]
이 예제는 String.CASE_INSENSITIVE_ORDER를 사용하므로 대문자와 소문자를 동등하게 비교합니다. Comparator.naturalOrder()를 사용하면 결과는 [An, Cuong, binh, lan, null]입니다. 이유는 String의 자연 순서가 Unicode 코드에 따르고, 대문자가 소문자보다 먼저 오기 때문입니다.
6.2. 원소 자체가 null인 경우
리스트의 원소 자체가 null일 수 있으면 전체 comparator를 가장 바깥쪽에 감싸세요:
Comparator<Order> safe = Comparator.nullsFirst(byBusinessRule);
이 두 경우는 다릅니다. 키를 감싸는 것은 null 원소로부터 보호하지 않으며, 원소를 감싸는 것도 null 키로부터 보호하지 않습니다.
7. lambda로 인한 타입 추론 오류
다음 코드는 컴파일되지 않습니다:
// 오류: Object 클래스에서 customer() 메소드를 찾을 수 없음
Comparator<Order> c = Comparator.comparing(o -> o.customer())
.thenComparing(Order::createdAt);
컴파일러는 메소드 호출 체인에서 왼쪽에서 오른쪽으로 타입을 추론합니다. comparing(...) 호출에서 최종 목표가 Comparator<Order>라는 것을 아직 모르므로 매개변수 o가 Object로 추론됩니다.
세 가지 해결 방법이 있습니다:
Comparator.comparing(Order::customer) // method reference
Comparator.comparing((Order o) -> o.customer()) // 매개변수 타입 선언
Comparator.<Order, String>comparing(o -> o.customer()) // type witness
Method reference가 가장 간단합니다. 가능할 때마다 사용하세요.
8. Comparator에 대한 테스트 작성
Comparator는 비즈니스 규칙을 포함하므로 별도로 테스트해야 합니다. 다음 의존성을 추가하세요:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
Comparator를 고정된 위치에 배치하면 재사용하기 쉽습니다:
import java.util.Comparator;
public final class OrderComparators {
public static final Comparator<Order> BY_BUSINESS_RULE = Comparator
.comparingInt(Order::priority)
.thenComparing(Comparator.comparingDouble(Order::total).reversed())
.thenComparing(Order::createdAt);
private OrderComparators() {
}
}
그 다음 테스트를 작성하세요:
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderComparatorsTest {
@Test
void givenOrders_whenSortByBusinessRule_thenPriorityAscTotalDescDateAsc() {
List<Order> orders = SampleData.orders();
orders.sort(OrderComparators.BY_BUSINESS_RULE);
assertThat(orders)
.extracting(Order::customer)
.containsExactly("Dung", "Binh", "Chi", "Em", "An");
assertThat(orders).isSortedAccordingTo(OrderComparators.BY_BUSINESS_RULE);
}
@Test
void givenReversedAtEnd_whenSort_thenAllCriteriaAreReversed() {
List<Order> orders = SampleData.orders();
orders.sort(java.util.Comparator.comparingInt(Order::priority)
.thenComparingDouble(Order::total)
.reversed());
assertThat(orders.getFirst().priority()).isEqualTo(2);
}
}
containsExactly는 결과의 정확한 순서를 확인합니다. isSortedAccordingTo는 리스트가 해당 comparator에 따라 올바르게 정렬되었는지 확인합니다. 두 번째 테스트는 체인 끝에서 reversed()의 동작을 기록하여 나중에 이 실수를 반복하지 않도록 합니다. getFirst()는 Java 21의 List의 새로운 메소드입니다.
9. 흔한 실수와 언제 사용하지 말아야 할지
9.1. 뺄셈으로 비교하기
// 이렇게 하지 마세요
Comparator<Order> bad = (a, b) -> a.priority() - b.priority();
뺄셈은 오버플로우될 수 있습니다. 예를 들어 Integer.MIN_VALUE - 1은 큰 양수를 반환하므로 비교 결과가 잘못됩니다. Comparator.comparingInt(...)나 Integer.compare(a, b)를 사용하세요.
9.2. TreeSet/TreeMap에서 Comparator와 equals의 불일치
TreeSet은 comparator가 0을 반환할 때 두 원소를 같다고 봅니다. 이는 equals를 사용하여 중복을 확인하지 않습니다:
Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());
System.out.println(set.size());
2
다섯 개의 주문이 두 개만 남았습니다. 각 priority마다 하나씩입니다. TreeSet이나 TreeMap에 comparator를 사용할 때는 마지막 기준으로 주문 ID 같은 고유한 값을 추가하세요. 이렇게 하면 comparator는 실제로 같은 원소에서만 0을 반환합니다.
9.3. comparing 체인을 사용하지 말아야 할 때
- 기준이 서로 의존적인 경우. 예: ‘VIP 주문은 합계로 비교하고, 일반 주문은 날짜로 비교’
if가 있는 람다 형태의 comparator를 작성하는 것이 억지로 구부린thenComparing체인보다 명확합니다. - 키를 계산하는 데 비용이 큰 경우. Key extractor는 비교할 때마다, 약
n log n번 호출됩니다. 키가 문자열을 파싱하거나 서비스를 호출해야 한다면 미리 한 번 계산한 후 정렬하세요. - 정렬을 데이터베이스에서 할 수 있는 경우. 데이터가 SQL 쿼리에서 나온다면
ORDER BY가 보통 더 빠르고 불필요한 데이터를 메모리로 로드하는 것을 피할 수 있습니다.
10. 결론
이 글에서 당신은 다음을 배웠습니다:
comparing,comparingInt,comparingDouble을 사용하여 키에서 comparator를 만드는 방법thenComparing으로 여러 기준을 조합하고,reversed()또는Comparator.reverseOrder()로 한 가지 기준만 역순으로 하는 방법nullsFirst와nullsLast로 키 레벨과 원소 레벨 모두에서null을 처리하는 방법- lambda의 타입 추론 오류, 뺄셈 오버플로우,
TreeSet에서 원소 손실을 피하는 방법
비즈니스 comparator를 이름이 명확한 상수로 설정하고, 항상 기대하는 순서를 확인하는 테스트를 포함하세요. 정렬 규칙이 변경되면 테스트가 즉시 알려줄 것입니다.