이펙티브 자바 완벽 공략 1부
아이템 14. Comparable을 구현할지 고민하라
아이템 14. Comparable을 구현할지 고민하라
- 아이템 14. Comparable을 구현할지 고민하라
- 아이템 14. 핵심 정리 1 - Comparable 규약
- 1. 반사성(Reflexivity)
- 2. 대칭성(Symmetry)
- 3. 추이성(Transitivity)
- 4. compareTo와 equals의 일관성
- 1. 정렬 가능
- 2. TreeSet 사용 가능
- 3. 타입 안정성
- 아이템 14. 핵심 정리 1 - Comparable 구현 방법 1
- Comparable 구현하기
- Comparable은 제네릭 인터페이스다
- 왜 제네릭이 중요한가?
- compareTo 메서드 구현하기
- @Override는 꼭 붙이자
- compareTo 구현 방법
- 비교 우선순위를 정해야 한다
- 구현 예시
- 왜 이렇게 구현할까?
- compare 메서드 사용하기
- 장점
- 상속이 문제를 만드는 순간
- 해결 방법: 컴포지션 사용하기
- 왜 Effective Java가 컴포지션을 강조할까?
- Java 8 이후 더 좋아진 Comparator
- 핵심 정리
- 한 줄 정리
아이템 14. 핵심 정리 1 - Comparable 규약
아이템 14. Comparable을 구현할지 고민하라
Comparable은 Object가 직접 제공하는 메서드는 아니지만, 자바 컬렉션 프레임워크와 정렬 시스템에서 매우 중요한 역할을 하는 인터페이스다.
특히:
TreeSetTreeMapCollections.sort()Arrays.sort()
같은 정렬 기반 기능들은 대부분 Comparable에 의존한다.
즉:
객체의 자연스러운 순서(Natural Order)
를 정의하는 인터페이스라고 이해하면 된다.
Comparable이란?
Comparable은 다음과 같은 형태를 가진다.
public interface Comparable<T> {
int compareTo(T o);
}
compareTo의 의미
현재 객체와 전달받은 객체를 비교해서:
- 음수 → 현재 객체가 더 작다
- 0 → 같다
- 양수 → 현재 객체가 더 크다
를 의미한다.
예시
BigDecimal n1 = new BigDecimal("1");
BigDecimal n2 = new BigDecimal("2");
System.out.println(n1.compareTo(n2));
결과
음수
중요한 점
여기서 절대:
반드시 -1이 나오겠지
라고 기대하면 안 된다.
Comparable 규약은 이렇게 정의한다
음수 / 0 / 양수
만 보장한다.
즉:
- -1
- -100
- Integer.MIN_VALUE
모두 가능하다.
compareTo 구현 시 반드시 지켜야 하는 규약
1. 반사성(Reflexivity)
자기 자신과 비교하면 같아야 한다.
x.compareTo(x) == 0
의미
자기 자신은 자기 자신과 같은 순서여야 한다.
너무 당연한 규칙이다.
2. 대칭성(Symmetry)
x.compareTo(y)
가 양수라면:
y.compareTo(x)
는 음수여야 한다.
예시
10 > 5
라면:
5 < 10
이어야 한다.
즉 관계가 뒤집혀야 한다
한쪽이 크면 반대쪽은 작아야 한다.
3. 추이성(Transitivity)
가장 중요한 규약 중 하나다.
예시
A > B
B > C
라면:
A > C
여야 한다.
깨지면 어떤 일이 발생할까?
정렬 알고리즘이 무너진다.
즉:
- TreeSet
- TreeMap
- sort()
같은 자료구조가 비정상 동작할 수 있다.
실제로는 매우 위험하다
추이성이 깨진 compareTo는:
- 무한 루프
- 잘못된 정렬
- 데이터 유실
같은 심각한 문제를 만들 수 있다.
4. compareTo와 equals의 일관성
이 부분이 가장 헷갈리는 규약이다.
일반적으로 권장되는 규칙
x.compareTo(y) == 0
이면:
x.equals(y) == true
가 되는 것이 좋다.
하지만 반드시 강제되지는 않는다
대표적인 예외가 바로:
BigDecimal
이다.
BigDecimal 예시
new BigDecimal("2.0")
new BigDecimal("2.00")
compareTo 결과
compareTo == 0
즉 같은 값으로 본다.
equals 결과
equals == false
왜 다를까?
BigDecimal의 equals는:
스케일(scale)
까지 비교하기 때문이다.
즉:
2.0 != 2.00
로 본다.
그런데 compareTo는?
순수 숫자 값만 비교한다.
즉:
2.0 == 2.00
로 판단한다.
왜 이런 설계를 했을까?
BigDecimal은 단순 숫자가 아니라:
정밀도와 표현 방식
까지 중요한 클래스이기 때문이다.
예시
2.0 / 3 = 0.7
2.00 / 3 = 0.67
처럼 결과 정밀도가 달라질 수 있다.
그래서 Effective Java는 권장한다
만약:
compareTo == 0
인데:
equals == false
인 경우가 있다면:
반드시 문서화하라
실제로 BigDecimal 문서에도 명시되어 있다.
Comparable의 가장 큰 장점
1. 정렬 가능
Collections.sort(list)
가능.
2. TreeSet 사용 가능
TreeSet<PhoneNumber>
가능.
3. 타입 안정성
Comparable은 제네릭 기반이다.
Comparable<PhoneNumber>
장점
컴파일 시점 타입 체크 가능.
즉:
잘못된 타입 비교 방지
가 가능하다.
compareTo 구현 시 추천 방식
예전에는 보통:
if (x < o.x) return -1;
if (x > o.x) return 1;
return 0;
처럼 구현했다.
최신 방식
return Integer.compare(x, o.x);
여러 필드 비교 시
Comparator
.comparing(Person::getName)
.thenComparing(Person::getAge)
같은 방식 사용 가능.
핵심 정리
- Comparable은 객체의 자연 순서를 정의한다
- compareTo는 음수/0/양수를 반환한다
- 구체적인 숫자 값 자체에 의존하면 안 된다
- 반사성/대칭성/추이성을 만족해야 한다
- compareTo와 equals는 가능하면 일관성 유지
- BigDecimal은 대표적인 예외 사례다
- 정렬 컬렉션은 compareTo에 강하게 의존한다
한 줄 정리
Comparable은 단순 비교 기능이 아니라, “객체의 자연 질서(Natural Order)”를 정의하는 계약이다.
아이템 14. 핵심 정리 1 - Comparable 구현 방법 1
아이템 14. Comparable 구현 방법과 컴포지션 활용하기
이전 글에서는 Comparable 인터페이스가 무엇인지, 그리고 compareTo()가 지켜야 하는 규약들에 대해 정리했다. 이번에는 실제로 우리가 만든 클래스에서 어떻게 Comparable을 구현하는지, 그리고 왜 상속보다 컴포지션이 더 좋은 선택이 되는지 살펴보자.
Comparable 구현하기
우리가 만든 클래스에 자연스러운 순서(Natural Order)를 부여하고 싶다면 가장 먼저 해야 할 일은 Comparable 인터페이스를 구현하는 것이다.
예를 들어 PhoneNumber라는 클래스가 있다고 해보자.
public class PhoneNumber implements Comparable<PhoneNumber> {
}
여기서 중요한 점은:
Comparable<PhoneNumber>
처럼 제네릭 타입을 지정한다는 것이다.
Comparable은 제네릭 인터페이스다
Comparable은 다음과 같이 선언되어 있다.
public interface Comparable<T> {
int compareTo(T o);
}
여기서 T는 특별한 의미를 가진 클래스가 아니다.
그냥:
비교 대상 타입
을 의미하는 제네릭 타입 변수일 뿐이다.
왜 제네릭이 중요한가?
예전 equals()는 이런 형태였다.
boolean equals(Object obj)
즉 항상 Object를 받아야 했다.
그래서:
- 형변환 필요
- instanceof 검사 필요
- 타입 안정성 부족
같은 문제가 있었다.
compareTo는 다르다
int compareTo(PhoneNumber o)
처럼:
비교 대상 타입이 명확하다
라는 엄청난 장점이 있다.
즉:
- 컴파일 타임 타입 체크 가능
- 불필요한 형변환 제거
- 코드 안정성 증가
라는 이점을 가진다.
compareTo 메서드 구현하기
인터페이스를 구현하면 IDE가 자동으로 메서드를 생성해준다.
@Override
public int compareTo(PhoneNumber o) {
return 0;
}
@Override는 꼭 붙이자
@Override는 단순 장식이 아니다.
컴파일러에게:
이 메서드는 오버라이딩이다
라고 알려주는 역할을 한다.
장점
만약 메서드 시그니처가 틀리면:
comparetoo(...)
같은 오타를 컴파일 타임에 바로 잡아준다.
즉:
컴파일러가 실수를 검증해주는 장치
인 셈이다.
compareTo 구현 방법
PhoneNumber에는:
short areaCode;
short prefix;
short lineNum;
같은 필드가 있다고 가정해보자.
비교 우선순위를 정해야 한다
정렬은 결국:
무엇을 먼저 비교할 것인가?
의 문제다.
전화번호라면 보통:
- areaCode
- prefix
- lineNum
순으로 비교하는 것이 자연스럽다.
구현 예시
@Override
public int compareTo(PhoneNumber o) {
int result = Short.compare(areaCode, o.areaCode);
if (result == 0) {
result = Short.compare(prefix, o.prefix);
if (result == 0) {
result = Short.compare(lineNum, o.lineNum);
}
}
return result;
}
왜 이렇게 구현할까?
정렬 기준이 여러 개일 때는:
앞의 비교 결과가 같을 때만
다음 필드를 비교
해야 하기 때문이다.
compare 메서드 사용하기
Primitive Wrapper들은 비교 메서드를 제공한다.
예:
Integer.compare(...)
Short.compare(...)
Long.compare(...)
장점
직접:
if (a < b)
같은 코드를 쓰는 것보다:
- 안전
- 가독성 좋음
- overflow 위험 감소
라는 장점이 있다.
상속이 문제를 만드는 순간
이제 중요한 부분이다.
Point 클래스가 있다고 해보자
class Point implements Comparable<Point>
그리고:
x → 먼저 비교
y → 나중 비교
하도록 구현되어 있다고 하자.
NamedPoint를 만들고 싶다
class NamedPoint extends Point
그리고:
좌표가 같다면 이름까지 비교하고 싶다
고 생각할 수 있다.
문제 발생
이런 생각을 하게 된다.
compareTo(NamedPoint o)
를 새로 만들면 되지 않을까?
하지만 이건 오버라이딩이 아니다
이건:
오버로딩(overloading)
이다.
왜?
상위 클래스는:
compareTo(Point o)
를 가지고 있다.
그런데 하위 클래스는:
compareTo(NamedPoint o)
를 만들었기 때문이다.
파라미터 타입이 다르다
즉:
다른 메서드
가 되어버린다.
결과적으로 다형성이 깨진다
오버라이딩된 메서드만 다형성이 적용된다.
오버로딩은 아니다.
즉:
의도한 compareTo 확장이 실패
한다.
해결 방법: 컴포지션 사용하기
이 문제를 해결하는 가장 좋은 방법이 바로:
컴포지션(Composition)
이다.
상속 대신 필드로 가진다
class NamedPoint implements Comparable<NamedPoint> {
private final Point point;
private final String name;
}
장점
이제 NamedPoint는:
Point를 상속받지 않는다
따라서:
Comparable<NamedPoint>
를 자유롭게 구현할 수 있다.
compareTo 구현
@Override
public int compareTo(NamedPoint o) {
int result = point.compareTo(o.point);
if (result == 0) {
result = name.compareTo(o.name);
}
return result;
}
훨씬 깔끔하다
이 방식은:
- equals 규약도 지키기 쉽고
- compareTo 확장도 자연스럽고
- 다형성 문제도 발생하지 않는다
왜 Effective Java가 컴포지션을 강조할까?
상속은:
기존 계약(contract)에 묶인다
라는 문제가 있다.
특히:
- equals
- hashCode
- compareTo
같은 규약 기반 메서드는 상속과 굉장히 충돌하기 쉽다.
그래서 Effective Java의 핵심 철학 중 하나
상속보다 컴포지션을 우선하라.
Java 8 이후 더 좋아진 Comparator
Java 8부터는:
Comparator.comparing(...)
같은 기능들이 추가되면서 구현이 훨씬 간결해졌다.
예:
Comparator
.comparing(PhoneNumber::getAreaCode)
.thenComparing(PhoneNumber::getPrefix)
.thenComparing(PhoneNumber::getLineNum);
이 부분은 이후 Comparator를 다루면서 더 자세히 살펴보게 된다.
핵심 정리
- Comparable은 자연 순서를 정의하는 인터페이스다
- 제네릭 기반이라 타입 안정성이 높다
- compareTo는 음수/0/양수를 반환해야 한다
- 비교 우선순위를 명확히 정해야 한다
- 여러 필드는 순차 비교 방식 사용
- 상속으로 compareTo를 확장하려 하면 문제가 생긴다
- equals 문제처럼 compareTo도 컴포지션이 훨씬 안전하다
- Java 8 이후에는 Comparator API 활용이 권장된다
한 줄 정리
Comparable은 단순 비교 기능이 아니라 “객체의 자연 질서”를 정의하는 계약이며, 확장이 필요할 때는 상속보다 컴포지션이 훨씬 안전하다.