Comparable 인터페이스는 Java 1.2에서 Collections Framework와 함께 도입되어 클래스의 자연 정렬을 정의합니다. 이 계약은 compareTo가 equals와 일관되어야 한다고 명시합니다: (compareTo(x, y) == 0) == (x.equals(y)). 이 일관성은 정렬된 컬렉션이 compareTo를 정렬 및 포함 확인을 위해 사용하기 때문에 일관된 의미를 유지할 수 있도록 합니다.
compareTo가 equals가 서로 다른 것으로 간주하는 객체에 대해 0을 반환할 경우 (또는 그 반대의 경우), TreeMap이나 TreeSet과 같은 정렬된 컬렉션은 정의되지 않은 동작을 나타냅니다. 구체적으로, 내부의 Red-Black 트리 구조는 탐색을 위해 비교를 사용하며, 컬렉션의 contains 또는 remove 메소드가 최종 확인을 위해 equals에 의존할 수 있습니다. 이러한 이분법은 반복 중에 존재하는 것처럼 보이지만 키 조회를 통해 검색할 수 없는 "환상" 요소를 발생시키거나, 겉보기에 존재하지만 제거할 수 없는 요소를 발생시킵니다.
해결책은 두 메소드 간의 엄격한 정렬을 요구합니다. compareTo가 두 객체가 동일하다고 판단할 때(0을 반환), equals는 true를 반환해야 합니다. 구현은 비교 논리를 공통 구성 요소에 위임하거나 동일성 일관성을 존중하는 Comparator 구성을 사용해야 합니다. 자연 정렬이 논리적 동등성과 다를 경우, Comparable을 일관성 있게 구현하는 대신에 컬렉션 생성자에 외부 Comparator 인스턴스를 제공해야 합니다.
public class Employee implements Comparable<Employee> { private final String name; private final int id; public Employee(String name, int id) { this.name = name; this.id = id; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Employee)) return false; Employee other = (Employee) o; return this.id == other.id; // ID를 기준으로 한 동등성 검증 } @Override public int hashCode() { return Integer.hashCode(id); } @Override public int compareTo(Employee other) { return this.name.compareTo(other.name); // 이름을 기준으로 한 정렬! } }
급여 시스템은 직원 보상을 관리하기 위해 **TreeMap<Employee, Salary>**를 사용하며, 여기서 Employee는 이름의 알파벳 순서를 기준으로 Comparable을 구현합니다. 하지만 equals 메소드는 직원 ID와 이름 모두를 고려하여 두 개의 "John Smith" 항목(IDs 101과 102)을 서로 다른 것으로 간주합니다. 보너스를 처리하는 과정에서 시스템은 map.put(new Employee("Smith", 101), new Salary(5000))를 사용하여 급여를 업데이트하려고 하지만, 맵에는 이미 compareTo가 같은 이름 때문에 0을 반환하는 "Smith"(ID 102)에 대한 항목이 포함되어 있습니다.
한 가지 접근 방식은 equals 메소드를 수정하여 이름만 비교하도록 하여 compareTo 논리에 맞추는 것이었습니다. 이것은 계약을 만족시키겠지만, 이름이 같은 두 개별 직원이 별도의 실체로 남아 있어야 하는 비즈니스 요구 사항을 깨트립니다. 장점은 계약 준수; 단점은 실체 아이덴티티 무결성을 해치는 것입니다.
또 다른 접근 방식은 서브클래싱을 통해 TreeMap 검색 로직을 재정의하여 비교 후 equals 검증을 강제하는 것이었습니다. 이는 아이덴티티를 유지했지만, 섬세한 사용자 정의 컬렉션 구현이 필요했습니다. 장점은 비즈니스 논리가 유지되는 것이고, 단점은 코드베이스 전반에 걸쳐 복잡성과 유지 관리 부담이 증가하는 것입니다.
팀은 Employee에서 Comparable 구현을 완전히 제거하고 대신 TreeMap 생성자에 정렬 목적으로 Comparator를 제공하기로 결정했습니다. 이는 객체의 본질적 동등성과 정렬 메커니즘을 분리하여, 자연 equals(ID 기반)가 아이덴티티를 관리하고 외부 비교자가 정렬을 처리하게 하였습니다. 이는 Red-Black 트리 구조 요구 사항과 도메인 모델의 무결성을 모두 보존했습니다.
급여 시스템은 동일한 이름을 가진 개별 직원을 성공적으로 처리하며 키 충돌 오류를 방지했습니다. 보너스 계산은 특정 직원 ID를 정확히 타겟팅하였고, 알파벳 순서 보고는 정확성을 유지했습니다. 아키텍처는 더 유연해져서, Employee 클래스를 수정하지 않고도 단순히 비교자 인스턴스를 교체하여 다른 정렬 순서(입사일, 부서 등)를 가능하게 했습니다.
왜 BigDecimal은 Comparable을 구현하면서도 TreeMap의 키로 사용될 때 계약 위반을 발생시키는가?
BigDecimal은 0.0 및 0.00과 같은 값에 대해 equals와 일관성 없이 Comparable을 구현합니다. new BigDecimal("0.0").equals(new BigDecimal("0.00"))가 false를 반환하는 반면(서로 다른 스케일), compareTo는 0을 반환합니다(같은 숫자 값). TreeMap 키로 사용될 때, 트리는 그것들을 동일하게 간주하지만(containsKey는 equals를 사용하여 검사하기 때문에) 검색 시 실패할 수 있어 삽입 실패나 잘못된 값 검색이 발생합니다. 이러한 불일치는 0.0을 삽입한 후 0.00을 찾을 경우, 트리가 비교를 통해 노드를 찾지만 최종 동등성 검증에서 실패하여 키가 존재함에도 불구하고 null을 반환하는 경우를 의미합니다. 후보자는 BigDecimal을 사용 시 일관된 스케일을 유지하거나 사용자 정의 비교자를 제공해야 합니다.
수동 compareTo 구현과 비교하여 Comparator.comparing 팩토리 메소드가 계약 위반을 방지하는 방식은?
Comparator.comparing API는 Comparable 키 또는 추출 함수에 의존하는 비교자를 생성하지만, 부모 객체의 equals 메소드와 자동으로 동기화되지 않습니다. 후보자는 Comparator.comparing(Employee::getName)를 사용하는 경우에도 Employee.equals가 추가 필드를 고려할 경우 계약 위반이 발생한다는 것을 종종 놓칩니다. 본질적으로, 비교자는 객체가 자신을 동등하게 보는 것과 정확히 같을 때 두 객체를 동등하게 간주해야 하며, 두 메소드 간의 필드 일치를 요구합니다. 해결 방법은 비교자의 동등성 의미(0 반환)가 객체의 동등성에 맞도록 보장하거나, equals 논리를 필드별로 반영하는 thenComparing 체인을 사용하는 것입니다.
compareTo가 예외를 발생시키거나 반사성이 없을 때 어떤 위험한 행동이 나타나는가?
비반사적이거나 예외를 발생시키는 compareTo 구현은 Red-Black 트리의 균형 불변을 손상시켜 IllegalArgumentException("Comparison method violates its general contract") 또는 삽입/정렬 중 무한 루프를 초래할 수 있습니다. TreeMap은 엄격한 약한 정렬을 가정하며, 위반이 발생하면 노드가 유효하지 않은 위치에 배치되어 BST 속성이 파괴됩니다. HashMap은 해시 충돌을 우아하게 처리하는 반면, TreeMap은 트리 구조를 유지하기 위해 완전히 비교 일관성에 의존하므로, 어떤 편차라도 탐색 포인터가 사이클을 형성하거나 null 참조를 발생시켜 JVM을 충돌시키거나 스레드를 멈출 수 있습니다. 후보자는 compareTo가 null을 방어적으로 처리하고, 추이성을 유지하며, 대칭적이어야 함을 보장해야 합니다.