이펙티브 자바 완벽 공략 1부

아이템 14. Comparable을 구현할지 고민하라

아이템 14. Comparable을 구현할지 고민하라


아이템 14. 핵심 정리 1 - Comparable 규약

아이템 14. Comparable을 구현할지 고민하라

ComparableObject가 직접 제공하는 메서드는 아니지만, 자바 컬렉션 프레임워크와 정렬 시스템에서 매우 중요한 역할을 하는 인터페이스다.

특히:

  • TreeSet
  • TreeMap
  • Collections.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;

같은 필드가 있다고 가정해보자.


비교 우선순위를 정해야 한다

정렬은 결국:

무엇을 먼저 비교할 것인가?

의 문제다.


전화번호라면 보통:

  1. areaCode
  2. prefix
  3. 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은 단순 비교 기능이 아니라 “객체의 자연 질서”를 정의하는 계약이며, 확장이 필요할 때는 상속보다 컴포지션이 훨씬 안전하다.



© 2020. All rights reserved.

SIKSIK