코드의 여백

[이펙티브 자바] 모든 객체의 공통 메서드

by rowing0328

※ 책 내용을 바탕으로 제 관점에서 풀어 쓴 글입니다. 일부 내용이 다를 수 있습니다.

 

아이템 10 - equals는 일반 규약을 지켜 재정의하라

equals() 메서드는 두 참조 변수가 동일한 값을 가지는지 여부를 비교하기 위해 사용된다. 객체의 논리적 동치성을 판단할 때 반드시 일반 규약을 지키며 재정의해야 한다.

 

equals 메서드, 언제 재정의해야 할까?

재정의 하지 않아도 되는 경우

  1. 고유 ID를 가지는 경우
    각 인스턴스가 고유한 ID를 가지며, 논리적 동치성 검사가 필요 없는 경우
  2. 상위 클래스의 구현이 적절한 경우
    상위 클래스의 equals가 이미 논리적 동치성을 정확히 구현한 경우
  3. 외부 호출이 없는 경우
    클래스가 private 또는 package-private 범위로 제한되어 외부에서 동치성 비교가 필요하지 않은 경우

재정의가 필요한 경우

  • 객체의 내부 상태(필드 값)가 같을 때 동일한 객체로 간주해야 하는 경우
    이 경우, 논리적 동치성 비교하기 위해 반드시 equals 메서드를 재정의해야 한다.

 

equals의 일반 규약

  • 반사성 (Reflexivity)
    모든 null이 아닌 참조 값 x에 대해 x.equals(x)는 항상 true를 반환해야 한다.
    객체는 자기 자신과 같아야 한다는 기본적인 원칙이다. 이를 위반하면, 컬렉션의 contains 메서드와 같은 동작에서 예기치 못한 결과가 발생할 수 있다.
  • 대칭성 (Symmetry)
    모든 null이 아닌 참조 값 x, y에 대해 x.equals(y)가 true라면, y.equals(x)도 반드시 true여야 한다.
    이를 위반하면, 예를 들어 대소문자를 무시하는 문자열 비교와 같은 상황에서 일관성 없는 동작이 발생할 수 있다.
  • 추이성 (Transitivity)
    모든 null이 아닌 참조 값 x, y, z에 대해 x.equals(y)가 true이고, y.equals(z)도 true라면, x.equals(z)도 반드시 true여야 한다.
    동치 관계는 일관성을 유지해야 하며, 이를 위반하면 컬렉션이나 정렬 알고리즘에서 예상치 못한 동작이 발생할 수 있다. 특히, 상속 구조에서 추가된 필드가 규약 위반의 원인이 될 수 있으므로 주의해야 한다.
  • 일관성 (Consistency)
    모든 null이 아닌 참조 값 x, y에 대해, 여러 번 호출된 x.equals(y)는 객체의 상태가 변경되지 않는 한 항상 동일한 값을 반환해야 한다.
  • null-아님 (Non-nullity)
    모든 null이 아닌 참조 값 x에 대해 x.equals(null)은 항상 false를 반환해야 한다.
    어떤 객체도 null과 같다고 판단되어서는 안 된다. 이는 기본적으로 안전한 동작을 보장하기 위한 규칙이다.

 

equals의 전형적인 검사 패턴

  • 참조 비교
    == 연산자를 사용해 입력 값이 현재 객체와 동일한 참조인지 확인한다. 동일한 참조라면 추가적인 비교를 생략한다.
  • 타입 확인
    instanceof 연산자를 통해 입력 값이 기대하는 타입인지 명확히 검사한다.
  • 타입 캐스팅
    instanceof 검사를 통과한 경우, 입력 값을 올바른 타입으로 캐스팅한다.
  • 핵심 필드 비교
    두 객체의 핵심 필드 값들이 모두 일치하는지 확인한다.
  • null 비교
    x.equals(null)은 항상 false를 반환해야 한다.

 

 

아이템 11 - equals를 재정의하려거든 hasecode도 재정의하라

hashCode 메서드는 객체의 주소 값 또는 상태를 기반으로 해시 코드를 생성해 반환한다. 이를 통해 객체를 효율적으로 검색하거나 관리할 수 있다.

하지만 서로 다른 두 객체가 같은 해시코드를 가질 수 있는 경우(해시 충돌)가 있으므로, 해시코드는 객체의 "지문"으로 비유될 수 있다.

 

비교 방법의 차이

  • == 연산자
    Primitive 타입은 값을 비교한다.
    Reference 타입은 메모리 주소를 비교한다.
  • equals() 메서드
    기본값은 ==과 동일하지만, 필요에 따라 Override하여 논리적으로 같은 객체를 정의할 수 있다.
  • hashCode() 메서드
    논리적으로 같은 객체라면 항상 같은 hashCode를 반환해야 한다.
    이를 재정의하지 않으면 equals와 hashCode의 불일치로 인해 해시 기반 컬렉션(HashMap, HashSet 등)에서 예상치 못한 동작이 발생할 수 있다.

 

잘못된 예

// 안내방송을 위한 TTS (text to speak) 예약 Map
Speaker speaker1 = new Speaker("수업 시작 시간입니다.");
Map<Speaker, LocalTime> localTimeMap = new HashMap<>();
localTimeMap.put(speaker1, LocalTime.of(9, 0);

// 수업 시작 시간을 10분 당기기로 하였다.
Speaker speaker2 = new Speaker("수업 시작 시간입니다.");
localTimeMap.put(speaker2, LocalTime.of(8, 50);

실제로 안내 방송은 8시 50분과 9시에 두번 울리게 된다.

이와 같은 동작이 발생하는 이유는 Hash 값을 사용하는 컬렉션(HashMap, HashSet, HashTable)이 객체를 논리적으로 비교할 때 다음과 같은 과정을 따르기 때문이다.

 

  • hashCode 검증
    컬렉션은 먼저 객체의 hashCode를 사용해 해당 객체가 저장된 위치를 찾는다.
  • equals 검증
    동일한 해시코드의 객체가 여러 개 있을 경우, equals 메서드를 사용해 논리적으로 같은 객체인지 추가로 검증한다.

 

hashCode와 equals 메서드가 일관성 있게 동작하지 않으면 논리적으로 같은 객체라도 다른 객체로 취급되며, 위와 같은 문제가 발생할 수 있다.


간단한 방식의 hsah

// 가장 Simple하게 적용해야 한다면!
@Override
public int hashCode() {
	int result = message.hashCode();
    return result;
}

// 속도를 고려해야 한다.
@Override
public int hashCode() {
	return Objects.hash(modelName, company); // Objects의 경우 속도가 아쉽다.
}

 

Lombok을 사용한 Equals와 HashCode 구현

@EqualsAndHashCode
public class EqualsAndHashCodeExample {
	private transient int transientVar = 10;
    private String name;
    private double score;
    private String[] tags;
    @EqualsAndHashCode.Exclude private int id;
}

 

 

아이템 12 - toString을 항상 override 하라

기본적으로 toString() 메서드는 객체의 클래스 이름과 @ 뒤에 16진수 해시코드를 결합한 문자열 형식(className@hashcode)을 반환한다.

 

toString의 일반 규약

toString() 메서드는 간결하고 사람이 읽기 쉬운 형태로, 객체의 유익한 정보를 제공해야 한다.

객체의 상태를 표현하는 데 도움이 되는 필드 값을 포함시키는 것이 좋다.

 

Lombok을 사용한 toString 구현

@AllArgsConstructor
@ToString
public class Laptop {
    private String modelName;
    private String company;
}

// 출력 결과
Laptop(modelName=그램 16인치, company=삼성)

 

불필요한 변수가 있을 경우

// 특정 변수를 제외하는 방법
@AllArgsConstructor
@ToString(exclude = {"modelName"})
public class Laptop {
    private String modelName;
    private String company;
}

// 더 나은 방법으로는 @ToString.Exclude 어노테이션 사용
@ToString
public class Laptop {
    @ToString.Exclude private String modelName;
    private String company;
}

 

 

아이템 13 - clone 재정의는 주의해서 사용하라

clone() 메서드는 객체의 복사본을 반환하는 기능을 한다.

하지만 복사의 정확한 의미는 클래스마다 다를 수 있으며, 이를 잘못 구현하거나 사용할 경우 예상치 못한 문제를 초래할 위험이 있다.

 

배열 복사의 두가지 방법

// Shallow Copy (얕은 복사)
int[] a = {1,2,3,4};
int[] b = a;

b는 a와 동일한 참조값을 가지므로, 같은 배열을 가리킨다.

따라서 배열의 내용이 변경되면 a와 b 모두 영향을 받는다.

 

// Deep copy (깊은 복사)
b = a.clone();

clone() 메서드를 사용하면 새로운 배결 객체가 생성된다.

두 배열의 요소는 동일하지만, a와 b는 서로 다른 참조값을 가진다.

즉, 한 배열의 내용이 변경되어도 다른 배열에는 영향을 미치지 않는다.

 

clone() 사용 시 주의사항

Laptop[] a = {new Laptop("그램 16인치", "삼성")};
Laptop[] b = a.clone();
b[0].setCompany("LG");

System.out.println(a[0] == b[0]); // true

 

clone() 메서드는 얕은 복사(Shallow Copy)를 수행한다.

이로 인해 b는 a와 별도의 배열 객체이지만, 배열 내부 요소들은 같은 객체를 참조한다.

따라서, a[0]과 b[0]는 동일한 객체를 가리키며, 한쪽에서 객체의 내용을 변경하면 다른 쪽에도 영향을 미친다.

 

왜 이런 현상이 발생하는가?

a.clone()은 배열의 객체 참조만 복사하기 때문이다.

이로 인해 객체의 내용이 아닌 참조값이 복사되며, 복사된 배열과 원본 배열은 동일한 객체를 가리키게 된다.

 

Why not use clone() ?

class A implements Cloneable {
    public A clone() throws CloneNotSupportedException {
        return (A) super.clone();
    }
}

class B extends A {
    public B clone() throws CloneNotSupportedException {
        return (B) super.clone(); // A의 clone이 실행됨
    }
}

clone() 메서드가 객체의 복사본을 생성하지만, 클래스에 따라 동작이 다르다.

상속 구족에서 clone()을 사용할 경우, 하위 클래스 객체가 제대로 복사되지 않는 문제가 발생할 수 있다.

이는 상위 클래스의 clone() 메서드가 호출되기 때문에, 하위 클래스의 추가 필드가 누락되거나 불완전한 복사로 이어질 수 있다.

 

clone() 대신 명확하고 안전한 복사 방법

// Copy Constructor (명확한 복사)
public Yum(Yum yum) {
    this.field1 = yum.field1;
    this.field2 = yum.field2;
}

// Copy Factory Method (유연하고 직관적인 복사)
public static Yum newInstance(Yum yum) {
    Yum copy = new Yum();
    copy.field1 = yum.field1;
    copy.field2 = yum.field2;
    return copy;
}

Primitive 타입 배열이 아닌 경우, clone()은 얕은 복사로 인해 참조 공유 문제가 발생할 수 있으므로 사용하지 말자.

Cloneable 인터페이스는 구현 방식에 따라 불완전한 복사가 이루어질 수 있어 확장에 주의해야 한다.

 

 

아이템 14 - Comparable을 구현할지 고려하라

public class Person {
	private int age;
    private String name;
    private double height;
}

위 코드는 나이, 키, 이름 순으로 정렬하는 예제를 위한 간단한 Person 클래스이다.

 

compareTo 메서드 구현

public int compareTo(Person p) {
	int result = Integer.compare(age, p.age);
    
    if (result == 0) {
    	result = Double.compare(height, p.height);
    }
    
    if (result == 0) {
    	result = name.compareTo(p.name);
	}
    
    return result;
}

위 코드는 Comparable 인터페이스를 구현하여 Person 객체의 정렬 기준을 정의한다.

 

Comparator를 사용한 또 다른 구현

private static final Comparator<Person> COMPARATOR = Comparator.comparingInt(Person::getAge)
		.thenComparingDouble(Person::getHeight)
        .thenComparing(person -> person.getName()
);

public int compareTo(Person p) {
    return COMPARATOR.compare(this, p);
}

위 코드는 Comparator를 활용하여 Person 객체의 정렬 기준을 정의하고, Comparable 인터페이스의 compareTo 메서드에서 이를 간단히 호출하는 방식이다.

 

compareTo의 규약

  • x.compareTo(y) > 0
    x가 y보다 크다는 의미이다.
  • x.compareTo(y) == 0
    x와 y가 같음을 의미한다.
  • x.compareTo(y) < 0
    x가 y보다 작다는 의미이다.
  • 비교 할 수 없는 경우
    ClassCastException이 발생한다.

 

 

마무리

Lombok과 같은 라이브러리를 활용하면,

반복적인 코드를 줄이고, 명확하고 읽기 쉬운 코드를 작성할 수 있다는 점에서 큰 도움이 된다.

 

하지만, 편의 기능을 제대로 활용하려면

그 뒤에 숨겨진 기본 원리와 동작 방식을 이해하는 것이 중요하다는 점을 느꼈다.

 

이러한 이해가 뒷받침될 때,

상황에 맞는 적절한 선택과 활용이 가능하다는 생각이 들었다.

블로그의 정보

코드의 여백

rowing0328

활동하기