Skip to main content

03. 모든 객체의 공통 메서드

Object는 자바에서 모든 오브젝트의 최상위 클래스이며 상속을 통해서 사용하도록 설계되었다.

Object 클래스에서 final이 아닌 메서드들 equals, hashCode, toString, clone, finalize 모두 오버라이딩을 염두에 두고 설계된 것이기 때문이다.

이번 장에서는 final아 아닌 메서드들을 언제, 어떻게 재정의를 해야하는지에 대해서 살펴보자.

eqauls는 일반 규약을 지켜 재정의하라#

item 10

결론부터 이야기하자면, equals 메소드는 가급적 재정의하지 않는 편이 좋다.

개인적인 의견이지만, lombok 라이브러리에서 @EqualsAndHashCode를 지원하는 마당에, 이 아이템이 어떤 의미를 갖는지에 대해서 의문이 들었다.

equals 메서드를 재정의하는데에 있어 동치관계를 구현해줘야한다.

  1. 반사성(reflexivity) : null이 아닌 모든 참조값에 x에 대해 x.equals(x)는 true다.

  2. 대칭성(symmetry) : null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)가 true면 y.equals(x)도 true이다.

  3. 추이성(transitivity) : null이 아닌 모든 참조 값 x,y,z에 대해 x.equals(y)가 true면 y.equals(x)도 true이면 x.equals(z)도 true이다.

  4. 일관성(consistency) : null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.

  5. null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 true이다.

이후에 나오는 대부분의 예제는 Equals 메서드를 재정의하면서 발생할 수 있는 LCP(리스코프 치환 원칙) 위배 예제와 상속 대신 컴포지션을 사용하라는 식의 객체지향관련 이야기가 주를 이룬다.

(Point 클래스와 이를 상속받은 클래스 간의 종속성 문제를 이야기한다)

equals를 재정의하면서 주의해야할 또 다른 점은 hashCode 메소드도 반드시 재정의를 해주어야한다는 점인데, 이는 다음 아이템에서 소개할 예정이다.

equals를 재정의하려거든 hashCode도 재정의하라#

item 11

equals를 재정의한 클래스라면 모두 hashCode 또한 재정의해야 한다.

그렇지않으면 해당 클래스의 인스턴스를 HashMap이나 HashSet같은 컬렉션의 원소로 사용할 때 문제를 일으킬 수 있다.

hashCode를 잘못 재정의헀을 때 크게 나타날 수 있는 문제는 논리적으로 같은 객체는 같은 해시코드를 반환해야한다 는 점이다.

이 역시도 lombok 라이브러리를 사용하거나 Kotlin의 data 클래스를 사용하면 손쉽게 EqualsHashCode 메서드를 알아서 재정의해준다.

toString을 항상 재정의하라#

item 12

자바에서 프레임워크를 쓰다보면 종종 toString() 메소드로 인해 고통받는 일이 생긴다.

이번 시간에는 Object의 메서드 중 하나인 toString()에 대해서 알아보자.

Object 클래스의 기본 toString 메서드는 꽤나 불친절하다.

가령 PhoneNumber 클래스의 toString을 호출해보면 Phonenumber@abbdd와 같은 출력 결과를 확인해볼 수 있다.

toString()은 인간이 읽기 편한 형태가 되어야하므로, 우리는 이 toString()를 재정의해볼 것이다.

toString() 메서드는 우리가 직접 호출하지않더라도, println이나 assert 구문에 넘길 때, 혹은 디버거가 객체를 출력할 때 자동으로 호출되는 메소드이다.

즉, 우리가 명시적으로 호출하지않더라도 어딘가에서는 쓰일 수 있다는 이야기이다.

toString을 똑바로 정의했다면 아래와 같은 코드는 그 의미를 명확히 파악할 수 있게 될 것이다.

System.out.println(phonNumber + "에 연결할 수 없습니다.");

즉, toString을 사용한다면 가급적 해당 객체가 갖고 있는 모든 정보들을 노출시켜주는 편이 좋다.

clone 재정의는 주의해서 진행하라#

item 13

Clonable 인터페이스는 복제가능한 클래스임을 명시하는 인터페이스임에도, 그 목적을 이루지 못했다.

clone 메서드가 정의된 클래스는 Object 클래스이며 그 마저도 protected 접근제한자라는게 문제다.

그래서 Clonable를 구현한다고 해서 외부 객체에서 clone 메서드를 호출할 수 없다는 점이 아이러니한 점이다.

메서드 하나 없는 Clonable 인터페이스는 Object 클래스의 protected 메서드인 clone 메서드의 동작 방식을 결정한다.

Clonable 인터페이스를 구현한 클래스의 인스턴스에서 clone 메서드를 호출하면 그 객체의 필드를 하나하나 복사한 객체를 반환하며, Clonable 인터페이스를 구현하지않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 반환한다.

이는 인터페이스를 상당히 이례적으로 사용한 예라고 하며 저자는 따라하지 말것을 강조했다.

Clonable로 구현한 클래스는 clone 메서드를 public으로 제공하고 사용자는 당연히 복제가 제대로 이루어지리라 기대하게 된다.

그 기대에 대한 리턴은 깨지기 쉽고, 위험하고 모순적인 매커니즘이 탄생한다.

그 모순은 바로 생성자를 호출하지 않고도 객체를 생성할 수 있게 되어버리는 것이다.

예제를 봐보자.

@Override
public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // 일어날 수 없는 일
}
}

Object 클래스의 clone을 호출하면 Object 타입이 반환되지만, PhoneNumberclone 메서드는 PhoneNumber을 반환하게 했다.

이를 자바에서 convariant return typing이라고 부르며 한국말로는 어렵게 공변 변환 타이핑이라고 부른다.

의미는 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다는 것이다.

위 코드에서 사실상 예외는 일어나지 않는데 clone 메서드에서 예외를 던지다보니, 거추장한 코드가 되었다.

Clonable을 구현한 클래스를 복제하는 것은 얼핏 보면 굉장히 편리해보이기도 하다.

그러나 HashTable과 같은 자료구조에서 clone은 버킷마다 리스트를 새롭게 생성하고, 원본 데이터의 키-값 쌍을 복제본 테이블의 put 메서드를 활용해 복사해주어야하는데 꽤 번거롭다. (Deep Copy 이슈)

일단 왜 이런 작업을 해야하는가에 대해 의문을 품을 수 있다.

clone 메소드는 얼핏 보면 편리해보이지만, 사실은 쓰레드 안전하지 않으며 Objectclone 자체가 동기화를 전혀 고려하지않았다.

그래서 super.clone 외에 별 다른 작업을 하지 않는다하더라도 clone을 오버라이딩해서 쓰레드 세이프하게 개발해주어야한다.

또한 clone 메소드 자체가 예외를 던지게끔 설계가 되어있다보니, 재정의하는 곳에서 clone의 예외를 없애주어야하며 public 접근제한자로 만들어주어야한다. (그래야 사용하기 편리하다.)

이를 요악해보면 Objectclone 메서드는 어째, 잘못 만들어진 것 같아보이기도 하다.

저자 역시 clone을 사용하기 보다는 다른 방법으로 복사를 권장하고 있다.

Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없지만, 그렇지 않은 상황이라면 가급적 복사 생성자복사 팩터리를 고려하는 것이 좋다.

public Yum(Yum yum) { ... }
public static Yum newInstance(Yum yum) { ... };

복사 생성자와 그 변형인 복사 팩터리는 Cloneable/clone 방식보다 훨씬 더 나은 대안이다.

복사 생성자와 복사 팩터리는 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다는 강력한 강점이 있다.

복사 생성자/복사 팩터리의 더 정확한 이름은 Conversion ConstructorConversion Factory이다.

이를 이용해 클라이언트는 원본의 구현 타입에 얽매이지 않고, 복제본의 타입을 직접 선택할 수 있다.

저자 역시 새로운 인터페이스를 만들 때 Cloneable을 확장하지 말것을 강조하였고, 가급적이면 생성자와 팩터리를 이용하는 것이 최고라고 이야기하였다.

다만, 배열만큼은 clone 메서드를 사용하는 것이 예외적으로 가장 깔끔한 방법이라고 한다.

Comparable을 구현할지 고려하라#

item 14

이번 장의 마지막에서는 Comparable 인터페이스의 유일한 메서드인 compareTo에 대해서 알아보자.

눈치 챘을지 모르겠지만, compareTo는 유일하게 이번 장에서 Object의 메서드가 아닌 메서드이다.

성격은 두 가지만 빼면 Objectequals와 같다.

compareToequals와 다른 점 첫 번째는, 동치성 비교에 더해 순서까지 비교할 수 있다는 점이며, 제네릭하다는 것이다.

즉, Comparable 구현했다는 것은 해당 클래스의 인스턴스들에게 자연적인 순서가 있음을 의미한다.

사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입은 Comparable를 구현하고 있다.

만약에 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하도록 하자.

compareTo는 이전 hashCode와 마찬가지로 규약을 지켜야한다.

첫 번째 규약은 두 객체의 참조의 순서를 바꿔 비교해도 항상 예상한 결과가 나와야한다.

두 번째 규약은 첫 번째가 두 번째보다 크고 두 번째가 세 번째보다 크다면, 아래의 수식이다.

a > b 이고,
b > c 라면,
a > c 이다.
# 이산수학인가..?

마지막 규약은 크기가 같은 객체들 끼리는 어떤 객체와 비교하더라도 항상 같아야한다는 것이다.

위 세 규약은 반사성, 대칭성, 추이성을 충족해야한다는 것을 의미하기도 한다.

compareTo의 마지막 규약은 필수는 아니지만, 지키게 되면 이는 equals 메서드와 같은 결과를 얻을 수 있게 된다.

물론 정렬된 컬렉션에서 compareTo의 결과가 equals의 결과와 다르더라도, 문제는 없다.

참고로 정렬된 컬렉션들은 동치성 비교에 equals가 아닌, compareTo를 사용한다.

그러니 이는 큰 문제는 아니지만 가급적 지켜주는 것이 좋다.

자바 8에서는 Comparator 인터페이스가 일련의 비교자 생성 메서드와 팀을 꾸려 메서드 연쇄 방식으로 비교자를 생성할 수 있게 지원한다.

이 방법은 간결하고 매력적이지만, 약간의 성능 저하가 있다고 한다.

모던 자바를 사용하는 프로젝트라면 이 방법을 적극 사용하는 편이 좋다.

static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());

순서를 고려해야하는 값 클래스를 만든다면 Comparable 인터페이스를 구현하여 그 인스턴스를 쉽게 정렬, 검색, 비교할 수 있게 하는 컬렉션과 어우러지도록 해야한다.

그리고 Primitive 타입의 비교가 필요하다면 박싱 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하도록 하자.

Reference#

스크린샷 2021-04-16 오후 4 24 23

이펙티브 자바 Effective Java 3/E

저자 : 조슈아 블로크

Last updated on