Java Comparable과 Comparator — 자연 순서와 커스텀 정렬

java.lang.Comparable과 java.util.Comparator의 차이, compareTo() 반환값 규칙, Comparator.comparing()과 thenComparing()으로 다중 기준 정렬 체이닝, Collections.sort와 TreeMap에서의 활용법을 실전 코드로 정리한다

· 6 min read · PALDYN Team

지난 글에서 finalize() 폐기 이유와 올바른 자원 관리 방법을 살펴봤다. 이번에는 Java에서 객체를 정렬하는 두 가지 방법인 **Comparable**과 **Comparator**를 다룬다. 둘 다 정렬 기준을 정의하지만 목적과 사용 방식이 다르다.

왜 두 가지가 필요한가

정수나 문자열은 크기가 자명하다. 하지만 Person 객체는 이름순인지, 나이순인지, 아니면 두 기준을 조합해야 하는지 알 수 없다. Java는 이 문제를 두 가지 방식으로 해결한다.

  • Comparable: 클래스 자체에 “이 클래스의 자연 순서”를 내장
  • Comparator: 외부에서 임의의 정렬 기준을 주입

Comparable — 자연 순서 정의

java.lang.Comparable<T> 인터페이스는 메서드 하나만 가진다.

public interface Comparable<T> {
    int compareTo(T o);
}

반환값 규칙: 음수이면 this가 앞, 0이면 동등, 양수이면 this가 뒤.

record Age(int value) implements Comparable<Age> {
    @Override
    public int compareTo(Age other) {
        return Integer.compare(this.value, other.value);
    }
}

var ages = new ArrayList<>(List.of(new Age(30), new Age(20), new Age(25)));
Collections.sort(ages);  // Comparable 자동 사용
System.out.println(ages); // [Age[value=20], Age[value=25], Age[value=30]]

Integer.compare(a, b)를 사용하는 것이 올바르다. a - b 뺄셈 방식은 정수 오버플로우 버그를 일으킬 수 있다.

Comparable을 구현하는 JDK 클래스들

String, Integer, Double, LocalDate, BigDecimal 등 자연 순서가 자명한 클래스들이 Comparable을 구현한다.

List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
Collections.sort(names); // String.compareTo() 사용
System.out.println(names); // [Alice, Bob, Charlie]

TreeSet<Integer> nums = new TreeSet<>();
nums.addAll(List.of(5, 3, 8, 1));
System.out.println(nums); // [1, 3, 5, 8] — 자동 정렬

TreeSet, TreeMap, PriorityQueue는 원소가 Comparable을 구현하거나 외부 Comparator를 받아야 한다.

Comparator — 외부 정렬 기준

java.util.Comparator<T>는 두 객체를 비교하는 함수형 인터페이스다.

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
}

람다나 메서드 참조로 간결하게 작성한다.

record Person(String name, int age) { }

Comparator<Person> byName = (a, b) -> a.name().compareTo(b.name());
Comparator<Person> byAge  = Comparator.comparingInt(Person::age);

var people = new ArrayList<>(List.of(
    new Person("Charlie", 30),
    new Person("Alice", 25),
    new Person("Bob", 25)
));

people.sort(byName);   // 이름순
people.sort(byAge);    // 나이순
people.sort(byAge.reversed()); // 나이 역순

Comparable vs Comparator 비교

Comparator.comparing() 팩터리 메서드

Comparator.comparing()은 키 추출 함수를 받아 Comparator를 만든다.

// 키 추출 함수로 Comparator 생성
Comparator<Person> c1 = Comparator.comparing(Person::name);
Comparator<Person> c2 = Comparator.comparingInt(Person::age);
Comparator<Person> c3 = Comparator.comparingDouble(Person::salary);

comparing() vs comparingInt(): comparingInt()int 키를 오토박싱 없이 처리해 성능이 더 좋다. comparingLong(), comparingDouble()도 같은 이유로 존재한다.

thenComparing() — 다중 기준 체이닝

record Employee(String dept, String name, int salary) { }

// 부서 오름차순 → 이름 오름차순 → 급여 내림차순
Comparator<Employee> order =
    Comparator.comparing(Employee::dept)
              .thenComparing(Employee::name)
              .thenComparingInt(Employee::salary)
              .reversed();

employees.sort(order);

thenComparing()은 앞 기준이 동순위일 때 다음 기준을 적용한다. reversed()는 지금까지 정의한 전체 순서를 뒤집는다.

Comparator 체이닝 — 다중 기준 정렬

null 처리

null이 포함된 컬렉션을 정렬할 때 nullsFirst() / nullsLast()를 사용한다.

List<String> withNulls = Arrays.asList("B", null, "A", null, "C");

// null이 앞에
withNulls.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(withNulls); // [null, null, A, B, C]

// null이 뒤에
withNulls.sort(Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println(withNulls); // [A, B, C, null, null]

TreeMap에서의 Comparator

TreeMap에 생성자로 Comparator를 전달하면 키의 자연 순서 대신 커스텀 순서로 관리된다.

// 대소문자 무시 정렬
var map = new TreeMap<String, Integer>(String.CASE_INSENSITIVE_ORDER);
map.put("banana", 2);
map.put("Apple", 1);
map.put("cherry", 3);

System.out.println(map.firstKey()); // Apple
System.out.println(map);            // {Apple=1, banana=2, cherry=3}

Comparable과 Comparator 선택 기준

상황권장
클래스에 “자명한 자연 순서”가 하나 있음Comparable 구현
다양한 정렬 기준이 필요함Comparator 사용
외부 라이브러리 클래스 정렬Comparator (소스 수정 불가)
클래스를 내 코드로 정의함Comparable + 필요 시 Comparator 추가

compareTo() 계약

compareTo()equals()일관성을 유지해야 한다. compareTo() == 0이면 equals() == true를 반환하는 것이 권장된다. TreeSetTreeMapequals() 대신 compareTo()로 동등성을 판단한다. 두 메서드가 불일치하면 SortedSet/SortedMap 동작이 일반 Set/Map과 달라진다.

// 위험: compareTo()와 equals() 불일치
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b));      // false — 스케일 다름
System.out.println(a.compareTo(b));   // 0 — 수치는 같음

// TreeSet은 compareTo() 사용 → 중복으로 간주
TreeSet<BigDecimal> ts = new TreeSet<>();
ts.add(a); ts.add(b);
System.out.println(ts.size()); // 1 — a와 b를 같은 값으로 봄

// HashSet은 equals() 사용 → 다른 값으로 간주
HashSet<BigDecimal> hs = new HashSet<>();
hs.add(a); hs.add(b);
System.out.println(hs.size()); // 2

ComparableComparator는 Java 컬렉션 프레임워크, 스트림 정렬, 우선순위 큐의 핵심이다. 다음 글에서는 **불변 객체(Immutable Object)**를 다룬다. 불변 객체 설계의 원칙과 장점, 그리고 자바에서 불변 클래스를 만드는 올바른 방법을 살펴볼 것이다.


지난 글: Java finalize() 제거 — try-with-resources와 Cleaner 대안

다음 글: Java 불변 객체 — Immutable Object 설계와 활용


읽어주셔서 감사합니다. 😊