본문 바로가기

CS지식

[JAVA] equals()와 hashCode() 메서드 재정의

반응형

개발하다 보면 객체를 비교해야 할 일이 정말 많죠. "이 두 객체가 같은 걸까?", "이 객체들을 어떻게 정렬하지?"

이런 고민들을 한 번쯤은 해보셨을 겁니다.

 

오늘은 Java에서 객체 비교를 제대로 구현하는 방법에 대해 자세히 알아보겠습니다.

 

1. 객체 비교의 기본 개념

1.1 동일성(Identity)과 동등성(Equality)

  • 동일성: 객체의 참조값(메모리 주소)이 같음 (== 연산자)
  • 동등성: 객체의 상태나 값이 같음 (equals() 메서드)

1.2 equals() 메서드와 동등성

  • equals() 메서드는 객체의 내용이나 상태를 기반으로 두 객체가 같은지를 판단
  • Object 클래스의 기본 equals()는 == 연산과 마찬가지로 객체의 참조를 비교
  • 객체의 동등성 비교를 위해서는 equals() 메서드를 오버라이딩하여 객체의 상태나 값을 비교하는 로직 구현 필요

1.3 hashCode() 메서드의 이해

  • 실행 중에(Runtime) 객체의 메모리 번지를 이용해 유일한 integer 값을 반환
  • 이 값은 해시 기반의 컬렉션(HashMap, HashSet 등)에서 객체를 효율적으로 관리하는 데 사용

2. 왜 메서드 재정의가 필요할까?

기본 메서드의 한계

Java의 모든 클래스는 Object 클래스를 상속받습니다. 하지만 Object 클래스가 제공하는 기본 메서드들은 우리가 원하는 방식으로 동작하지 않을 때가 많습니다.

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

// 메인 메서드에서 테스트
Person person1 = new Person("김철수", 30);
Person person2 = new Person("김철수", 30);

System.out.println(person1.equals(person2)); // false가 출력됩니다.

예시코드에서 비교를 하였을때 왜 false가 나올까요?

 

왜나하면 Object 클래스의 기본 equals() 메소드는 객체의 메모리 주소만 비교하기 때문입니다.

두 객체의 내용이 같더라도 다른 객체로 인식하기때문입니다.

 

재정의가 꼭 필요한 상황

1. HashSet이나 HashMap을 사용할 때

HashSet<Person> people = new HashSet<>();
people.add(new Person("김철수", 30));
people.add(new Person("김철수", 30));
System.out.println(people.size()); // 2가 출력됩니다. (원하는 건 1일텐데...)

 

2. 객체의 동등성 비교가 필요할 때

// 주문 시스템에서
Order order1 = new Order("A12345", LocalDate.now());
Order order2 = new Order("A12345", LocalDate.now());
// 주문번호가 같으면 같은 주문으로 처리하고 싶은데...

 

재정의가 필요 없는 경우

  1. 로그 기록같이 항상 고유해야 하는 경우
  2. 싱글톤 패턴을 사용하는 경우
  3. Enum 클래스인 경우

3. equals와 hashCode 재정의하기

3.1 equals와 hashCode를 함께 재정의해야 하는 이유

HashMap, HashSet 같은 해시 기반 컬렉션의 내부 동작을 이해하면 두 메서드를 함께 재정의해야 하는 이유를 알 수 있습니다.

// 해시 기반 컬렉션 사용 예시
HashMap<Person, String> map = new HashMap<>();
Person person1 = new Person("김철수", 30);
map.put(person1, "개발자");

Person person2 = new Person("김철수", 30); // person1과 동일한 내용
String value = map.get(person2);

 

컬렉션의 내부 동작 순서는

  1. hashCode()로 해시값을 계산해 저장 위치(버킷) 결정
  2. 해당 버킷에 있는 객체들과 equals()로 동등성 비교

 

만약 equals() 메소드만 재정의하고 hashCode()를 재정의하지않으면

Person person1 = new Person("김철수", 30);
Person person2 = new Person("김철수", 30);

System.out.println(person1.equals(person2)); // true
System.out.println(person1.hashCode()); // 예: 500
System.out.println(person2.hashCode()); // 예: 700

 

두 객체는 equals()로는 같지만, 다른 해시값을 가져서 HashMap에서 찾을 수 없게 됩니다.

 

3.2 equals() 메소드 재정의하기

이제 equals() 메소드를 어떻게 재정의를 해야 하는지 보여드리겠습니다.

public class Person {
    private String name;
    private int age;

    @Override
    public boolean equals(Object obj) {
        // 1. 자기 자신과의 비교 - 가장 빠른 성능을 위해 먼저 체크
        if (this == obj) return true;
        
        // 2. null 체크와 타입 체크를 통한 안전성 보장
        if (obj == null || getClass() != obj.getClass()) return false;
        
        // 3. 형변환 - 이제 안전하게 변환할 수 있습니다
        Person other = (Person) obj;
        
        // 4. 필드 비교 - null 안전한 방식으로 비교합니다
        return age == other.age && Objects.equals(name, other.name);
    }
}

 

equals() 메소드는 코드예시와 같이 순서대로 구현하면됩니다.

1. 자기 자신과의 비교

2. null 체크와 타입 체크를 통한 안전성 보장

3. 형변환

4. 필드 비교

 

3.3 hashCode 메서드 재정의하기

hashCode() 메소드는 equals()와 반드시 함께 재정의해야 합니다.

가장 간단한 방법은 Objects.hash()를 사용하는 것입니다.

@Override
public int hashCode() {
    // equals에서 비교하는 필드들을 모두 포함해야 합니다
    return Objects.hash(name, age);
}

 

equals() 메소드에서 비교하는 필드들을 모두 포함해야 합니다.

 

만약에 성능이 중요한 경우에 특히 불변 객체라면 해시값을 캐싱하는 방식을 사용할수 있습니다.

public class Person {
    private final String name;
    private final int age;
    private int hashCode; // 해시값을 저장할 필드

    @Override
    public int hashCode() {
        if (hashCode == 0) { // 한 번만 계산하고 저장
            hashCode = Objects.hash(name, age);
        }
        return hashCode;
    }
}

 


4. 객체의 상태를 문자열로 표현하기 (toString)

객체의 상태를 문자열로 표현하기 (toString) 개발하다 보면 객체의 상태를 콘솔에 출력하거나 로그로 남겨야 할 때가 많습니다. 이럴 때 toString 메서드를 재정의하면 편리합니다.

@Override
public String toString() {
    return String.format("Person(이름: %s, 나이: %d)", name, age);
}

 

toString을 재정의하면 다음과 같은 상황에서 유용합니다.

Person person = new Person("김철수", 30);

// 1. System.out.println으로 객체 상태 확인
System.out.println(person); // "Person(이름: 김철수, 나이: 30)" 출력

// 2. 디버깅할 때 객체 정보 확인
List<Person> people = Arrays.asList(person);
System.out.println(people); // "[Person(이름: 김철수, 나이: 30)]" 출력

// 3. 로깅할 때 객체 정보 남기기
logger.info("회원가입: {}", person);

 


5. 객체 정렬을 위한 Comparable 구현 객체 정렬

객체 정렬을 위한 Comparable 구현 객체들을 정렬하고 싶다면 Comparable 인터페이스를 구현해야 합니다.

예를 들어, 회원 목록을 나이순으로 정렬하되, 나이가 같으면 이름순으로 정렬하고 싶다면

public class Person implements Comparable<Person> {
    private String name;
    private int age;

    @Override
    public int compareTo(Person other) {
        // 먼저 나이로 비교
        int ageComparison = Integer.compare(this.age, other.age);
        
        // 나이가 같다면 이름으로 비교
        return ageComparison != 0 ? ageComparison 
                                : this.name.compareTo(other.name);
    }
}

 

이렇게 재정의를 하게되면 

List<Person> members = Arrays.asList(
    new Person("김철수", 30),
    new Person("이영희", 25),
    new Person("박지민", 30)
);

// 1. Collections.sort로 정렬
Collections.sort(members);
// 결과: [이영희(25), 김철수(30), 박지민(30)]

// 2. TreeSet으로 자동 정렬
TreeSet<Person> sortedMembers = new TreeSet<>(members);

// 3. 스트림 정렬
List<Person> sortedList = members.stream()
    .sorted()
    .collect(Collectors.toList());

 

코드 예시와같이 활용할수 있습니다.

 


마무리..를 하며 다시한번 정리

  1. equals()와 hashCode()는 반드시 함께 재정의해야 합니다.
    • equals()만 재정의: 해시 기반 컬렉션에서 객체를 찾을 수 없음
    • hashCode()만 재정의: 객체의 동등성 비교가 제대로 되지 않음
  2. 객체의 동등성 비교가 필요한 경우
    • equals() 메서드 재정의
    • 관련 필드들을 모두 포함한 hashCode() 구현
    • 필요한 경우 toString()으로 디버깅 지원
    • 정렬이 필요하다면 Comparable 구현
  3. 재정의가 필요하지 않은 경우
    • 로그 기록처럼 항상 고유해야 하는 경우
    • 싱글톤 패턴
    • Enum 클래스
  4. 성능 최적화
    • 불변 객체의 경우 hashCode 캐싱 고려
    • equals() 메서드에서 비교 비용이 적은 필드부터 비교

이러한 원칙들을 잘 지켜서 구현하면, 안정적이고 예측 가능한 객체 비교 로직을 만들 수 있습니다.

특히 HashMap, HashSet 같은 컬렉션을 사용할 때 발생할 수 있는 많은 버그들을 예방할 수 있습니다~

 

 

반응형