Java 객체 동등성 비교 및 해시 코드 활용

자바에서 객체의 동등성을 비교하고 컬렉션에서 효율적으로 객체를 관리하기 위해 equals()hashCode() 메서드를 올바르게 이해하고 구현하는 것은 매우 중요합니다. 특히 Collection 프레임워크의 remove(), contains()와 같은 메서드들은 객체의 동등성 판단에 이 두 메서드를 활용합니다.

ArrayList에서의 equals() 활용

ArrayList와 같은 순차적인 컬렉션은 객체를 비교할 때 주로 equals() 메서드를 사용합니다. 객체를 찾아 제거하거나 포함 여부를 확인할 때, 리스트의 각 요소를 순회하며 대상 객체와 equals() 비교를 수행합니다.

다음 예제는 PersonIdentity 클래스에 equals() 메서드만 오버라이드한 경우 ArrayList에서 객체가 정상적으로 제거되는 것을 보여줍니다.


import java.util.ArrayList;
import java.util.Collection;
import java.util.Objects; // For Objects.equals and Objects.hash

public class ArrayListComparisonDemo {
    public static void main(String[] args) {
        Collection<Object> dataList = new ArrayList<>();
        dataList.add("Initial String");
        dataList.add(new PersonIdentity("P1001", "Alice Smith"));
        dataList.add(Integer.valueOf(200));

        System.out.println("초기 리스트: " + dataList);

        dataList.remove("Initial String");
        dataList.remove(Integer.valueOf(200));

        // PersonIdentity 객체를 생성하여 제거 시도
        PersonIdentity targetPerson = new PersonIdentity("P1001", "Alice Smith");
        boolean removed = dataList.remove(targetPerson);

        System.out.println("PersonIdentity 제거 성공 여부: " + removed);
        System.out.println("최종 리스트: " + dataList);
    }
}

class PersonIdentity {
    private String identifier;
    private String fullName;

    public PersonIdentity(String identifier, String fullName) {
        this.identifier = identifier;
        this.fullName = fullName;
    }

    public String getIdentifier() { return identifier; }
    public String getFullName() { return fullName; }

    @Override
    public String toString() {
        return "PersonIdentity{identifier='" + identifier + "', fullName='" + fullName + "'}";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PersonIdentity that = (PersonIdentity) o;
        return Objects.equals(identifier, that.identifier) &&
               Objects.equals(fullName, that.fullName);
    }
    // hashCode() 메서드는 여기서 아직 오버라이드되지 않았습니다.
}

위 코드의 실행 결과는 다음과 같습니다:


초기 리스트: [Initial String, PersonIdentity{identifier='P1001', fullName='Alice Smith'}, 200]
PersonIdentity 제거 성공 여부: true
최종 리스트: []

ArrayListequals()만을 사용하여 동일한 객체를 찾아 성공적으로 제거했음을 알 수 있습니다.

HashSet에서의 hashCode()의 중요성

반면, HashSet과 같은 해시 기반 컬렉션은 객체를 저장하고 검색할 때 hashCode()equals() 메서드를 모두 사용합니다. 효율적인 검색을 위해 먼저 객체의 hashCode() 값을 사용하여 저장될 버킷(bucket)을 결정하고, 해당 버킷 내에서만 equals() 메서드를 통해 실제 객체 비교를 수행합니다.

만약 equals() 메서드만 오버라이드하고 hashCode() 메서드를 오버라이드하지 않으면 (즉, Object 클래스의 기본 hashCode()를 사용하면), 동일하다고 간주되는 두 객체가 다른 해시 코드를 가질 수 있습니다. 이 경우 HashSet은 두 객체가 서로 다른 버킷에 있다고 판단하여 equals() 비교 기회조차 얻지 못할 수 있습니다.

다음 예제는 PersonIdentity 클래스에 여전히 equals()만 오버라이드된 상태에서 HashSet을 사용할 때 객체 제거가 실패하는 것을 보여줍니다.


import java.util.Collection;
import java.util.HashSet;
import java.util.Objects;

public class HashSetFailureDemo {
    public static void main(String[] args) {
        Collection<Object> dataSet = new HashSet<>();
        dataSet.add("Initial String");
        dataSet.add(new PersonIdentity("P1001", "Alice Smith")); // 이 객체가 HashSet에 추가됨
        dataSet.add(Integer.valueOf(200));

        System.out.println("초기 HashSet: " + dataSet);

        dataSet.remove("Initial String");
        dataSet.remove(Integer.valueOf(200));

        // PersonIdentity 객체를 생성하여 제거 시도
        PersonIdentity targetPerson = new PersonIdentity("P1001", "Alice Smith");
        boolean removed = dataSet.remove(targetPerson); // 제거 실패 예상

        System.out.println("PersonIdentity 제거 성공 여부: " + removed);
        System.out.println("최종 HashSet: " + dataSet);
    }
}

// PersonIdentity 클래스는 위와 동일하게 hashCode()가 오버라이드되지 않은 상태입니다.
// class PersonIdentity { /* ... */ @Override public boolean equals(...) { ... } }

위 코드의 실행 결과는 다음과 같습니다:


초기 HashSet: [Initial String, PersonIdentity{identifier='P1001', fullName='Alice Smith'}, 200]
PersonIdentity 제거 성공 여부: false
최종 HashSet: [PersonIdentity{identifier='P1001', fullName='Alice Smith'}]

HashSet에서 remove() 메서드가 false를 반환하며 객체가 제거되지 않았습니다. 이는 새로 생성한 targetPerson 객체와 컬렉션에 이미 있는 PersonIdentity 객체가 비록 equals() 메서드로는 동일하다고 판단되더라도, 기본 hashCode() 메서드가 다른 해시 코드를 반환했기 때문에 HashSet이 서로 다른 객체로 간주했기 때문입니다.

hashCode()와 equals()의 올바른 구현

해시 기반 컬렉션이 예상대로 작동하려면 equals() 메서드를 오버라이드할 때 반드시 hashCode() 메서드도 함께 오버라이드해야 합니다. 이때 다음 두 가지 규약을 지켜야 합니다:

  1. equals() 메서드가 두 객체를 동일하다고 판단하면, 두 객체의 hashCode() 값은 같아야 합니다.
  2. equals() 메서드가 두 객체를 다르다고 판단하더라도, hashCode() 값은 같을 수 있습니다. (해시 충돌)

다음은 PersonIdentity 클래스에 hashCode() 메서드를 올바르게 추가한 예제입니다.


import java.util.Collection;
import java.util.HashSet;
import java.util.Objects; // For Objects.equals and Objects.hash

public class HashSetSuccessDemo {
    public static void main(String[] args) {
        Collection<Object> dataSet = new HashSet<>();
        dataSet.add("Initial String");
        dataSet.add(new PersonIdentityWithHash("P1001", "Alice Smith")); // 이 객체가 HashSet에 추가됨
        dataSet.add(Integer.valueOf(200));

        System.out.println("초기 HashSet (hashCode 포함): " + dataSet);

        dataSet.remove("Initial String");
        dataSet.remove(Integer.valueOf(200));

        // PersonIdentityWithHash 객체를 생성하여 제거 시도
        PersonIdentityWithHash targetPerson = new PersonIdentityWithHash("P1001", "Alice Smith");
        boolean removed = dataSet.remove(targetPerson); // 제거 성공 예상

        System.out.println("PersonIdentityWithHash 제거 성공 여부: " + removed);
        System.out.println("최종 HashSet (hashCode 포함): " + dataSet);
    }
}

class PersonIdentityWithHash {
    private String identifier;
    private String fullName;

    public PersonIdentityWithHash(String identifier, String fullName) {
        this.identifier = identifier;
        this.fullName = fullName;
    }

    public String getIdentifier() { return identifier; }
    public String getFullName() { return fullName; }

    @Override
    public String toString() {
        return "PersonIdentityWithHash{identifier='" + identifier + "', fullName='" + fullName + "'}";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PersonIdentityWithHash that = (PersonIdentityWithHash) o;
        return Objects.equals(identifier, that.identifier) &&
               Objects.equals(fullName, that.fullName);
    }

    @Override
    public int hashCode() {
        return Objects.hash(identifier, fullName); // Objects.hash()를 사용하여 간편하게 구현
    }
}

위 코드의 실행 결과는 다음과 같습니다:


초기 HashSet (hashCode 포함): [Initial String, PersonIdentityWithHash{identifier='P1001', fullName='Alice Smith'}, 200]
PersonIdentityWithHash 제거 성공 여부: true
최종 HashSet (hashCode 포함): []

hashCode()를 올바르게 오버라이드하자 HashSet이 논리적으로 동일한 객체를 정확히 찾아 제거하는 것을 확인할 수 있습니다.

해시 코드 충돌의 가능성

hashCode()는 두 객체가 equals()로 같으면 반드시 같은 값을 반환해야 하지만, hashCode() 값이 같다고 해서 두 객체가 equals()로 반드시 같아야 하는 것은 아닙니다. 서로 다른 객체라도 동일한 해시 코드를 가질 수 있으며, 이를 '해시 충돌'이라고 합니다. 해시 충돌은 해시 함수의 본질적인 특성이며, 이 경우 equals() 메서드가 최종 동등성 판단을 담당합니다.

자바의 String 클래스에서 해시 충돌을 볼 수 있는 유명한 예시입니다:


public class HashCollisionDemo {
    public static void main(String[] args) {
        String s1 = "Aa";
        String s2 = "BB";

        System.out.println(String.format("s1: '%s', hashCode: %d", s1, s1.hashCode()));
        System.out.println(String.format("s2: '%s', hashCode: %d", s2, s2.hashCode()));
        System.out.println("s1.equals(s2): " + s1.equals(s2));
    }
}

이 코드의 실행 결과는 다음과 같습니다:


s1: 'Aa', hashCode: 2112
s2: 'BB', hashCode: 2112
s1.equals(s2): false

"Aa""BB"는 분명히 다른 문자열이지만, 같은 해시 코드를 가집니다. 그러나 equals() 메서드를 통해 비교하면 false를 반환하여 두 객체가 다르다는 것을 정확히 판단합니다. 이는 해시 기반 컬렉션이 hashCode()로 버킷을 찾은 후, 해당 버킷 내에서 equals()를 사용하여 최종적인 동등성 검사를 수행하는 이유를 잘 보여줍니다.

태그: java equals hashCode Collection HashSet

8월 3일 08:28에 게시됨