1. 객체 비교의 문제
리마인드: 참조 비교 vs. 객체 내용 비교
여러분은 이미 알고 있듯이, Java에서 == 연산자는 객체에 대해 사용할 때 참조를 비교합니다 — 즉, 같은 메모리 주소를 가리키는지 확인합니다. 필드 값이 같더라도 new로 생성된 두 객체는 == 관점에서는 서로 다릅니다.
Person p1 = new Person("Sasha", 20);
Person p2 = new Person("Sasha", 20);
System.out.println(p1 == p2); // false — 메모리에서 서로 다른 객체입니다!
내용이 같은지를 알고 싶다면 equals(), hashCode()를 사용합니다. 그런데 “누가 더 나이가 많나”, “누가 더 어리나”, “사전순으로 누가 앞서나” 같은 비교는 어떻게 할까요? 예를 들어, 사용자 목록을 나이 또는 이름으로 정렬하려는 경우입니다.
객체의 정렬과 검색이 필요한 이유
사용자 목록이 있고, 나이 기준으로 정렬하고 싶다고 해봅시다:
List<Person> people = new ArrayList<>();
people.add(new Person("Vasya", 25));
people.add(new Person("Petya", 20));
people.add(new Person("Katya", 30));
// 어떻게 정렬할까?
Collections.sort(people); // 어? Java는 Person을 어떻게 비교해야 하는지 모릅니다!
컴파일러가 즉시 경고합니다: Person 클래스가 Comparable 인터페이스를 구현하지 않았다고요. Java는 우리의 생각을 읽지 못하므로, Person에 대해 “더 크다/작다”가 무엇을 의미하는지 모릅니다. 이를 가르치려면 비교 규칙을 명시적으로 정의해야 합니다.
2. Comparable 인터페이스
인터페이스 선언
Comparable 인터페이스는 Java에 “내 클래스는 서로 비교할 수 있고, 방법은 이렇다”라고 알려주는 표준 수단입니다.
public interface Comparable<T> {
int compareTo(T o);
}
익숙한 a.compareTo(b)는 다음을 반환합니다:
- 음수 — a가 b보다 “작다”는 뜻입니다.
- 0 — 두 객체는 같다고 간주됩니다.
- 양수 — a가 b보다 “크다”는 뜻입니다.
예시: Person 클래스의 compareTo 구현
나이로 비교 가능한 Person 클래스를 만들어 봅시다:
public class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// 게터(아래 예제를 위해)
public String getName() { return name; }
public int getAge() { return age; }
// compareTo 메서드 구현
@Override
public int compareTo(Person other) {
// 나이 기준 오름차순 정렬
return Integer.compare(this.age, other.age);
// 대안: return this.age - other.age;
}
}
중요: 내림차순으로 정렬하려면 인자의 순서를 바꾸면 됩니다: Integer.compare(other.age, this.age).
비유. compareTo는 심판과 같습니다. 누가 앞서고, 누가 뒤이고, 누가 동률인지 명확히 판단해야 하죠. 만약 모든 심판(compareTo 메서드)이 제각각 판정하면 — 혼란이 시작됩니다!
3. Comparable 사용
Comparable로 컬렉션 정렬
이제 우리 클래스가 Comparable을 구현했으니 정렬이 “바로” 동작합니다:
List<Person> people = new ArrayList<>();
people.add(new Person("Vasya", 25));
people.add(new Person("Petya", 20));
people.add(new Person("Katya", 30));
Collections.sort(people); // compareTo를 사용!
for (Person p : people) {
System.out.println(p.getName() + " (" + p.getAge() + ")");
}
// Petya (20)
// Vasya (25)
// Katya (30)
리스트의 sort 메서드도 마찬가지입니다:
people.sort(null); // null을 넘기면 compareTo가 사용됩니다
이름으로 정렬
이름 기준으로 정렬하고 싶다면 구현을 바꾸면 됩니다:
@Override
public int compareTo(Person other) {
return this.name.compareTo(other.name);
}
여러 필드로 정렬
가끔은 한 필드로 먼저 비교하고, 같을 때 다른 필드로 비교해야 합니다:
@Override
public int compareTo(Person other) {
int cmp = Integer.compare(this.age, other.age);
if (cmp != 0) return cmp;
return this.name.compareTo(other.name);
}
4. Comparable 구현 시 모범 사례
Comparable 계약을 지키세요
- a.compareTo(b) == 0이라면, b.compareTo(a)도 반드시 0이어야 합니다.
- a.compareTo(b) < 0이면, b.compareTo(a)는 > 0이어야 합니다(그 반대도 마찬가지).
- a.compareTo(b) == 0일 때, 가능하면 a.equals(b)가 true가 되도록 하세요(엄격히 필수는 아닙니다).
왜 중요할까요?
컬렉션(예: TreeSet, TreeMap)과 정렬 메서드는 계약이 깨지면 예측 불가능하게 동작할 수 있습니다. 예를 들어, 중복을 허용하지 않는 컬렉션에 “복제”가 생길 수 있습니다.
equals와 hashCode도 잊지 마세요
compareTo를 구현했다면, equals와 hashCode가 올바르게 구현되어 있는지도 생각해 보세요. 특히 HashSet이나 Map 같은 컬렉션에서 사용할 클래스라면 더욱 중요합니다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person other = (Person) o;
return age == other.age && Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
비교 대상 필드가 null일 수 있다면, 검사 없이 compareTo에서 사용하지 마세요!
필드가 null일 수 있다면 안전한 비교를 사용하세요:
@Override
public int compareTo(Person other) {
return Objects.compare(this.name, other.name, Comparator.nullsFirst(String::compareTo));
}
정렬된 컬렉션에 이미 들어간 후에는 compareTo에 쓰이는 필드를 바꾸지 마세요
그렇게 하면 TreeSet이나 TreeMap 같은 컬렉션 내부에서 객체가 “길을 잃을” 수 있습니다.
5. 학습용 애플리케이션 확장: 사용자 정렬
1단계: 클래스 정의
public class Person implements Comparable<Person> {
private String name;
private int age;
// ... 생성자, 게터, compareTo, equals, hashCode ...
}
2단계: 사용자 추가
List<Person> people = new ArrayList<>();
people.add(new Person("Masha", 23));
people.add(new Person("Grisha", 19));
people.add(new Person("Anya", 25));
3단계: 정렬하고 출력
Collections.sort(people);
for (Person p : people) {
System.out.println(p.getName() + " (" + p.getAge() + ")");
}
결과:
Grisha (19)
Masha (23)
Anya (25)
6. Comparable 동작 개요
┌────────────────────────────┐
│ 사용자 클래스 (Person) │
├────────────────────────────┤
│ implements Comparable │
│ ↓ │
│ public int compareTo(T o) │
│ ↓ │
│ (this < o) → -1 │
│ (this == o) → 0 │
│ (this > o) → 1 │
└────────────────────────────┘
│
▼
Collections.sort(list)
│
▼
정렬이 동작합니다!
7. 유용한 포인트
Collections.sort는 어떻게 동작하나
- 리스트에 Comparable을 구현한 객체가 들어 있으면, 정렬은 그 객체의 compareTo를 사용합니다.
- 구현하지 않았다면 컴파일 오류가 발생합니다.
- 표준 타입(Integer, String 등)은 이미 Comparable이 구현되어 있습니다.
비교 방법을 여러 개로 만들 수 있을까?
- 하나의 클래스에는 Comparable을 통한 “자연 순서”는 하나만 있습니다.
- 대체 순서가 필요하면 Comparator를 사용하세요(다음 강의).
예: 문자열의 compareTo
String a = "apple";
String b = "banana";
System.out.println(a.compareTo(b)); // 음수 — "apple" < "banana" 이기 때문
표: compareTo가 반환하는 값
| 비교 | 반환 값 |
|---|---|
|
|
|
|
|
|
8. Comparable 구현 시 흔한 실수
실수 №1: compareTo 계약 위반.
a.compareTo(b)가 0을 반환하는데 b.compareTo(a)가 0이 아니면, 컬렉션이 이상하게 동작할 수 있습니다. 예를 들어, TreeSet이 두 객체를 서로 다르다고 보고 둘 다 추가할 수 있습니다.
실수 №2: 초기화되지 않은(null) 필드 사용.
비교에 사용하는 필드가 null일 수 있는데 검사를 하지 않으면 NullPointerException이 발생합니다.
실수 №3: compareTo와 equals의 불일치.
compareTo는 객체가 같다(0)고 하는데 equals는 다르다(false)고 하면, 컬렉션 사용 시 버그로 이어집니다.
실수 №4: 정렬된 컬렉션에 넣은 뒤 compareTo에 참여하는 필드를 변경.
마치 이미 알파벳 순으로 줄 서 있는데 갑자기 여권의 성을 바꾸는 것과 같습니다. 컬렉션이 그 객체를 “잃어버릴” 수 있습니다.
실수 №5: -1, 0, 1만 반환함.
compareTo는 어떤 음수나 양수도 반환할 수 있으며, 반드시 -1이나 1일 필요는 없습니다. 다만 단순화를 위해 -1/0/1을 자주 사용합니다.
GO TO FULL VERSION