6562 단어
33 분
[ Effective Java ] equals, hashcode

들어가며#

이번 포스트에서는 객체의 equals 메서드에 대해 살펴보려고 한다. 객체 지향 프로그래밍에서 equals는 객체 간의 동등성을 비교하는 중요한 역할을 한다. 하지만 equals 메서드를 항상 재정의할 필요는 없다. 특정한 상황에서는 equals가 필요하지 않으며, 오히려 불필요한 재정의가 코드를 복잡하게 만들 수도 있다.

equals가 필요 없는 경우#

equals가 필요 없는 대표적인 경우는 다음과 같다.

  1. 각 인스턴스가 본질적으로 고유한 경우
  2. 인스턴스의 ‘논리적 동치성(logical equality)‘을 검사할 필요가 없다.
  3. 상위 클래스에서 재정의한 equals가 하위 클래스에서도 적절하다.
  4. 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.

각 인스턴스가 본질적으로 고유한 경우에는 equals를 재정의할 필요가 없다. 예를 들어, ThreadSocket과 같은 시스템 리소스를 나타내는 객체는 각 인스턴스가 유일하게 생성되므로, 동등성을 비교할 일이 거의 없다. 이런 경우 equals를 재정의하기보다는 기본적으로 객체의 참조 값이 같은지를 비교하는 방식이 적절하다.

인스턴스의 ‘논리적 동치성(logical equality)‘을 검사할 필요가 없는 경우에도 equals를 재정의하지 않아도 된다. 예를 들어, 특정한 값이 아니라 동작 자체에 초점을 맞춘 객체라면, 굳이 equals를 통해 동등성을 판단할 필요가 없다. 이는 대부분 상태를 가지지 않고, 특정 기능을 수행하는 서비스 객체나 핸들러 객체 같은 경우에 해당한다.

상위 클래스에서 재정의한 equals가 하위 클래스에서도 적절한 경우에도 굳이 equals를 다시 재정의할 필요가 없다. 만약 상속받은 클래스가 equals를 올바르게 구현하고 있고, 해당 동작이 하위 클래스에서도 변함없이 유지된다면, 그대로 사용해도 무방하다. 예를 들어, HashMapKeySet 내부 클래스처럼 기존 Set 동작을 유지하는 경우에는 equals를 다시 정의할 필요가 없다.

 클래스가 private이거나 package-private이며 equals 메서드를 호출할 일이 없는 경우에도 equals를 재정의할 필요가 없다. 객체가 외부에서 비교될 일이 없는 내부 구현용 클래스라면, 논리적 동등성을 비교할 이유가 없으므로, equals를 추가하는 것은 불필요한 작업이 될 수 있다. 이런 경우 기본적으로 Objectequals 구현을 그대로 사용해도 충분하다.

즉, equals를 반드시 구현해야 하는 것이 아니라, 객체의 특성과 사용 방식에 따라 필요 여부를 판단해야 한다.

equals 규약#

자바의 공식문서를 보면 다음과 같은 명세가 있다. equals1.png 각 명세를 번역해보면 다음과 같다.

  • 반사성: null이 아닌 모든 참조 값 x에 대해, x.equals(xtrue다.
  • 대칭성: null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)truey.equals(x)true이다.
  • 추이성: null이 아닌 모든 참조 값, x, y, z에 대해, x.quals(y)true이고, y.equals(z)true면, x.equals(z)true다.
  • 일관성: null이 아닌 모든 참조값 z, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
  • null-아님: null이 아닌 모든 참조 값 x에 대해, x.equals(null)false다.

이제 각 특징에 대해 자세히 알아보자.

반사성(reflexivity), 대칭성(symmetry)#

반사성#

  • A.euals(A) == true 반사성은 단순히 말하면 객체는 자기 자신과 같아야 한다는 뜻이다. 이 요건은 일부러 어기는 경우가 아니라면 만족 못시킬수가 없다. 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것이다.

대칭성#

  • A.equals(B) == B.equals(A) 대칭성은 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다. 반사성 요건과 달리 대칭성 요건은 잘못하면 어길 수가 있다. 다음 코드를 보자.
// 코드 10-1 잘못된 코드 - 대칭성 위배! (54-55쪽)  
public final class CaseInsensitiveString {  
    private final String s;  
  
    public CaseInsensitiveString(String s) {  
        this.s = Objects.requireNonNull(s);  
    }  
  
    @Override 
    public boolean equals(Object o) {  
        if (o instanceof CaseInsensitiveString)  
            return s.equalsIgnoreCase(  
                    ((CaseInsensitiveString) o).s);  
        if (o instanceof String)  // 한 방향으로만 작동한다!  
            return s.equalsIgnoreCase((String) o);  
        return false;  
    }  
}

이 코드는 CaseInsensitiveString이라는 클래스를 정의하며, 내부적으로 String 값을 저장하고 대소문자 구분 없이 문자열을 비교할 수 있도록 equals 메서드를 오버라이드하고 있다. 생성자에서는 null 값이 들어오는 것을 방지하기 위해 Objects.requireNonNull을 사용하여 s가 반드시 null이 아니도록 보장한다.

이 클래스의 핵심 문제는equals메서드가 대칭성을 위반한다는 점이다. equals 메서드는 먼저 전달된 객체가 CaseInsensitiveString 타입인지 확인한 후, 그렇다면 s.equalsIgnoreCase(((CaseInsensitiveString) o).s)를 호출하여 비교한다. 그런데 두 번째 조건으로 oString 타입일 경우에도 s.equalsIgnoreCase((String) o)를 수행하도록 구현되어 있다. 이렇게 구현하면CaseInsensitiveStringString과 비교될 때는 true를 반환하지만,String.equals(CaseInsensitiveString)는 false가 되어 대칭성이 깨진다.

    // 문제 시연 (55쪽)  
    public static void main(String[] args) {  
        CaseInsensitiveString cis = new CaseInsensitiveString("Polish");  
        String polish = "polish";  
        System.out.println(cis.equals(polish));  // true
        System.out.println(cis2.equals(cis));   // false
  
        List<CaseInsensitiveString> list = new ArrayList<>();  
        list.add(cis);  
  
        System.out.println(list.contains(polish));  // false
    }    

main 메서드에서는 이 문제를 직접 확인할 수 있다. 먼저 CaseInsensitiveString 객체를 하나 생성하고, 일반 문자열 polish와 비교하면 cis.equals(polish)의 결과는 true가 된다. 그러나 polish.equals(cis)String 클래스의 equals 메서드가 CaseInsensitiveString을 인식하지 못하기 때문에 false가 된다. 즉, 동일한 두 객체를 비교하더라도 비교하는 방향에 따라 결과가 달라지는 문제가 발생하는 것이다.

또한, 리스트에 CaseInsensitiveString 객체를 추가한 후 list.contains(polish)를 호출하면 false가 반환된다. 이는 contains가 내부적으로 equals를 호출하는데, polish.equals(cis)false를 반환하기 때문에 리스트에서 polish가 포함된 것으로 인식되지 않는 것이다.

그러면 어떻게 구현하는 것이 올바를까? 다음 코드를 보자.

// 수정한 equals 메서드 (56쪽)  
@Override 
public boolean equals(Object o) {  
    return o instanceof CaseInsensitiveString &&  
            ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);  
}

수정된 equals 메서드는 이전 코드에서 발생했던 대칭성 문제를 해결하기 위해 String 타입과의 비교를 제거했다.

이제 equals 메서드는 전달된 객체 oCaseInsensitiveString의 인스턴스인지 먼저 확인한다. 만약 oCaseInsensitiveString이라면, 내부의 s 값을 equalsIgnoreCase 메서드를 사용하여 비교한다. 이 방식은 CaseInsensitiveString 객체끼리만 비교를 수행하게 하므로, 이전 코드에서 발생했던 String 타입과의 비대칭 문제가 사라진다.

이제 이해가 되었다면, 다음 코드를 보자.

// 단순한 불변 2차원 정수 점(point) 클래스 (56쪽)  
public class Point {  
  
    private final int x;  
    private final int y;  
  
    public Point(int x, int y) {  
        this.x = x;  
        this.y = y;  
    }  
}

public class ColorPoint extends Point {  
    private final Color color;  
  
    public ColorPoint(int x, int y, Color color) {  
        super(x, y);  
        this.color = color;  
    }  
  
    // 코드 10-2 잘못된 코드 - 대칭성 위배! (57쪽)  
    @Override 
    public boolean equals(Object o) {  
        if (!(o instanceof ColorPoint))  
            return false;  
        return super.equals(o) && ((ColorPoint) o).color == color;  
    }  

이 코드는 2차원 좌표를 표현하는 Point 클래스와, 여기에 색상을 추가한 ColorPoint 클래스를 정의한다. Point 클래스는 xy 좌표를 필드로 가지고 있으며, 생성자를 통해 값을 받아 초기화한다. ColorPoint 클래스는 Point를 상속하고 있으며, 기존 좌표 정보에 color라는 필드를 추가하여 색상까지 함께 저장할 수 있도록 만들어졌다.

하지만 ColorPoint 클래스에서 오버라이딩한 equals 메서드에는 대칭성 문제가 발생한다. 이 equals 메서드는 전달된 객체가 ColorPoint 타입인지 먼저 확인한 후, 부모 클래스인 Pointequals 메서드를 호출해 좌표 값을 비교하고, 마지막으로 색상까지 동일한지 검사한다. 하지만 문제는 equals 비교 대상이 ColorPoint가 아니라 일반 Point 객체인 경우 무조건 false를 반환한다는 점이다.

    public static void main(String[] args) {  
        // 첫 번째 equals 메서드(코드 10-2)는 대칭성을 위배한다. (57쪽)  
        Point p = new Point(1, 2);  
        ColorPoint cp = new ColorPoint(1, 2, Color.RED);  
        System.out.println(p.equals(cp) + " " + cp.equals(p));  
    }

이러한 구현 방식은 PointColorPoint를 비교할 때 일관성을 유지하지 못하는 문제를 일으킨다. 위 코드를 보면 Point 객체 pColorPoint 객체 cp를 생성한 후 p.equals(cp)를 실행하면 Point 클래스의 equals 메서드는 xy 값만 비교하므로 true를 반환할 수도 있다. 하지만 cp.equals(p)를 실행하면 ColorPointequals 메서드에서는 pColorPoint가 아니기 때문에 false를 반환하게 된다. 즉, 동일한 두 객체를 비교했을 때 비교하는 방향에 따라 결과가 달라지므로, equals가 가져야 할 대칭성(symmetric property)을 위반하는 문제가 발생한다.

추이성(transivity)#

  • A.equals(B) && B.equals(C), A.equals(C) 추이성은 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 것이다. 이 요건도 간단하지만 자칫하면 어기기 쉽다. 상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 상황을 생각해보자.
public class ColorPoint extends Point {  
    private final Color color;  
  
    public ColorPoint(int x, int y, Color color) {  
        super(x, y);  
        this.color = color;  
    }  
  
    // 코드 10-3 잘못된 코드 - 추이성 위배! (57쪽)  
    @Override public boolean equals(Object o) {  
        if (!(o instanceof Point))  
            return false;  
  
        // o가 일반 Point면 색상을 무시하고 비교한다.  
        if (!(o instanceof ColorPoint))  
            return o.equals(this);  
  
        // o가 ColorPoint면 색상까지 비교한다.  
        return super.equals(o) && ((ColorPoint) o).color == color;  
    }  
}

이 코드는 이전에 Point 클래스를 상속한 ColorPoint 클래스를 정의하며, 기존 좌표 정보(x, y)에 색상(color)을 추가하여 색상이 포함된 점을 표현할 수 있도록 설계되었다. 그러나 equals 메서드의 구현 방식이 객체 비교의 중요한 규칙 중 하나인 **추이성(transitive property)**을 위반하는 문제가 있다.

우선, ColorPoint 클래스는 x, y 좌표를 Point에서 상속받고, 추가적으로 색상 정보를 저장할 수 있도록 설계되었다. equals 메서드는 비교 대상 객체 oPoint의 인스턴스가 아니라면 false를 반환한다. 하지만 Point의 인스턴스라면 색상을 무시하고 좌표값만 비교하도록 o.equals(this)를 호출한다. 반면, oColorPoint의 인스턴스인 경우에는 부모 클래스의 equals 메서드를 호출하여 좌표를 비교한 후, 추가적으로 색상까지 비교하여 두 객체가 완전히 동일한지 판단한다.

    public static void main(String[] args) {  
        // 두 번째 equals 메서드(코드 10-3)는 추이성을 위배한다. (57쪽)  
        ColorPoint p1 = new ColorPoint(1, 2, Color.RED);  
        Point p2 = new Point(1, 2);  
        ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);  
        System.out.printf("%s %s %s%n",  
                          p1.equals(p2), p2.equals(p3), p1.equals(p3));  
    }  

이러한 구현 방식은 추이성 문제를 일으킨다. 위 코드를 보면 ColorPoint 객체 p1(1,2) 좌표와 RED 색상으로 생성하고, 일반 Point 객체 p2(1,2) 좌표로 생성한 후, 또 다른 ColorPoint 객체 p3(1,2) 좌표와 BLUE 색상으로 생성하였다.

이때 equals 비교를 수행하면,

  1. p1.equals(p2)true가 된다. p1ColorPoint지만, Point와 비교할 때 색상을 무시하기 때문에 true를 반환한다.
  2. p2.equals(p3)true가 된다. p2는 일반 Point이므로, p3의 색상을 무시하고 좌표만 비교하기 때문이다.
  3. 하지만 p1.equals(p3)false가 된다. p1p3는 둘 다 ColorPoint이므로, 색상까지 고려하여 비교하게 되고, 색상이 다르므로 false가 반환된다.

이제 문제가 명확해진다. p1.equals(p2)true, p2.equals(p3)true임에도 불구하고 p1.equals(p3)false가 되면서 추이성(transitivity)이 깨지는 문제가 발생한다. 즉, 객체 비교의 결과가 일관되지 않아 예측하기 어려운 문제가 생긴다. 문제의 원인은ColorPointPoint와 비교할 때는 색상을 무시하지만,ColorPoint끼리 비교할 때는 색상을 고려하도록 구현된 점이다. 이런 방식은 논리적으로 타당해 보일 수 있지만, equals 메서드의 규칙을 어기게 되어 객체 비교가 일관되지 않게 된다.

그러면 이걸 해결할 방법은 있을까? 없다. 구체 클래스를 확장해서 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

// 잘못된 코드 - 리스코프 치환 원칙 위배! (59쪽)  
@Override public boolean equals(Object o) {  
    if (o == null || o.getClass() != getClass())  
        return false;  
    Point p = (Point) o;  
    return p.x == x && p.y == y;  
}

이번 equals는 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다. Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다. 그런데 이 방식에서는 그렇지 못하다.

다음 코드를 보자.

public class CounterPoint extends Point {  
    private static final AtomicInteger counter =  
            new AtomicInteger();  
  
    public CounterPoint(int x, int y) {  
        super(x, y);  
        counter.incrementAndGet();  
    }  
    public static int numberCreated() { return counter.get(); }  
}

이 코드는 Point 클래스를 상속한 CounterPoint 클래스를 정의하며, 객체가 생성될 때마다 개수를 자동으로 증가시키는 기능을 추가한다. 하지만 이전의 ColorPoint와는 다르게, 이 클래스에서는 equals와 관련된 문제가 아닌 객체 수를 추적하는 방식이 주요한 특징이다.

CounterPoint 클래스는 AtomicInteger 타입의 counter라는 정적 필드를 선언하고, CounterPoint 객체가 생성될 때마다 counter.incrementAndGet()을 호출하여 생성된 객체 수를 증가시킨다. 이렇게 하면 프로그램 실행 중에 몇 개의 CounterPoint 객체가 생성되었는지 추적할 수 있으며, numberCreated() 메서드를 통해 현재까지 생성된 객체의 개수를 확인할 수 있다.

이전 코드들과의 차이점은 CounterPointequals를 직접 오버라이드하지 않았다는 점이다. 즉, Point 클래스의 equals 메서드를 그대로 상속받아 사용한다. 따라서 CounterPointPoint와 동일한 기준으로 비교되며, 좌표(x, y 값)가 동일하면 같은 객체로 간주된다.

하지만 CounterPoint 객체를 Point 객체와 비교할 때, 추적을 위한 counter 필드는 equals의 비교 대상이 아니라는 점을 유의해야 한다. 만약 equals를 오버라이드하여 counter까지 비교하게 만든다면, 객체 생성 순서에 따라 equals의 결과가 달라질 수도 있으며, 이는 equals의 대칭성이나 추이성을 위반할 가능성을 높인다. 다행히 이 코드에서는 equals를 건드리지 않음으로써 이런 문제를 회피하고 있다.

Composition#

이전 코드에서 ColorPointPoint상속하여 색상을 추가하는 방식으로 구현되었지만, 이 방식은 equals의 **대칭성(symmetric)**이나 **추이성(transitive)**을 위반하는 문제를 초래했다. 이를 해결하기 위해 _이펙티브 자바_에서는 **컴포지션(composition)**을 활용하는 방법을 제안하고 있다.

// 코드 10-5 equals 규약을 지키면서 값 추가하기 (60쪽)  
public class ColorPoint {  
    private final Point point;  
    private final Color color;  
  
    public ColorPoint(int x, int y, Color color) {  
        point = new Point(x, y);  
        this.color = Objects.requireNonNull(color);  
    }  
  
    /**  
     * 이 ColorPoint의 Point 뷰를 반환한다.  
     */    
    public Point asPoint() {  
        return point;  
    }  
  
    @Override 
    public boolean equals(Object o) {  
        if (!(o instanceof ColorPoint))  
            return false;  
        ColorPoint cp = (ColorPoint) o;  
        return cp.point.equals(point) && cp.color.equals(color);  
    }  
  
    @Override 
    public int hashCode() {  
        return 31 * point.hashCode() + color.hashCode();  
    }  
}

위 코드에서는 ColorPoint가 더 이상 Point를 상속하지 않고, 대신 Point필드로 포함하는 형태로 변경되었다. 즉, ColorPointPoint와 색상 정보를 개별적으로 관리하며, 내부적으로 Point 객체를 직접 가지고 있다. 이 방식은ColorPointPoint를 상속하지 않으면서도,Point의 기능을 그대로 활용할 수 있도록 한다.

위 코드에서 중요한 부분 중 하나는 asPoint 메서드다. 이 메서드는 ColorPoint 객체에서 내부적으로 가지고 있는 Point 객체를 반환하는 역할을 한다. 즉, ColorPoint가 필요에 따라 일반 Point처럼 동작할 수 있도록 해준다. 이를 통해 ColorPoint 객체가 Point가 필요한 코드에서 자연스럽게 사용할 수 있는 유연성을 제공한다.

equals 메서드도 개선되었다. 기존 코드에서는 PointColorPoint가 서로 비교될 수 있도록 작성되었기 때문에, 비교의 방향에 따라 결과가 달라지는 문제가 있었다. 하지만 이번 코드에서는 equals 비교 시 ColorPoint 객체끼리만 비교하도록 제한하고, 비교할 때 내부의 Point 객체와 색상을 각각 확인하는 방식으로 변경되었다. 이렇게 하면 ColorPointPoint와 직접 비교되지 않으므로, equals 규칙을 위반할 가능성이 사라진다.

또한, hashCode 메서드도 함께 구현되었다. equals를 오버라이드할 때는 hashCode도 함께 재정의해야 하며, 두 객체가 논리적으로 동일하다면 같은 해시 값을 가져야 한다. 여기서는 내부 Point 객체의 hashCode와 색상의 hashCode를 조합하여 새로운 hashCode 값을 생성하도록 구현되었다. 이를 통해 HashMap이나 HashSet 같은 자료구조에서도 예상대로 동작할 수 있도록 했다.

일관성(consistency), null 아님#

일관성#

  • A.equals(B) == A.equals(B) 일관성은 두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 서로 다를 수도 같을 수도 있는 반면, 불변 객체는 한번 다르면 끝까지 달라야 한다.

null-아님#

  • A.equals(null) == false 마지막 요건은 공식적인 이름이 없어 책에서는 ‘null-아님’이라고 부른다. null-아님은 이름처럼 모든 객체가 null과 같지 않아야 한다는 뜻이다.

equals 구현 방법과 주의 사항#

구현 방법(best practice)#

public class Point {  
  
    private final int x;  
    private final int y;  
  
    public Point(int x, int y) {  
        this.x = x;  
        this.y = y;  
    }  
  
    @Override 
    public boolean equals(Object o) {  
        if (this == o) {  // 반사성
            return true;  
        }  
  
        if (!(o instanceof Point)) {  // 타입 비교
            return false;  
        }  
  
        Point p = (Point) o;  
        return p.x == x && p.y == y;  
    }
}

지금까지의 내용을 종합해서 equals 메서드 구현 방법을 단계별로 정리해보자.

  1. == 연산자를 통해 입력이 자기 자신의 참조인지 확인한다. 참조 타입의 필드는 각각의 equals 메서드를 사용한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  3. 입력을 올바른 타입으로 형변환한다.
  4. 입력 객체와 자기 자신의 대응되는 ‘핵심’ 필드들이 모두 일치하는지 하나씩 검사한다.

주의: float, double#

float와 double은 각각의 정적 메서드인 Float.compare(float, float), Double.compare(double, double)로 비교한다. float와 double은 Float.NaN, -0.0f, 특수한 부동소수 값 등을 다뤄야 하기 때문이다. equals를 사용해도 되지만, 이 메서드들은 오토박싱을 수반할 수 있으니 성능상 좋지 않다.

null을 허용하는 경우#

때로는 null도 정상 값으로 취급하는 참조 타입 필드도 있다. 이런 필드는 정적 메서드인 Objects.equals(Object, Object)로 비교하여 NullPointerException 발생을 예방하자.

hashcode()도 함께 재정의#

equals를 사용하는 경우에는 hashcode도 함께 재정의해야 한다.

public final class PhoneNumber {  
    private final short areaCode, prefix, lineNum;  
  
    public PhoneNumber(int areaCode, int prefix, int lineNum) {  
        this.areaCode = rangeCheck(areaCode, 999, "area code");  
        this.prefix   = rangeCheck(prefix,   999, "prefix");  
        this.lineNum  = rangeCheck(lineNum, 9999, "line num");  
    }  
  
    private static short rangeCheck(int val, int max, String arg) {  
        if (val < 0 || val > max)  
            throw new IllegalArgumentException(arg + ": " + val);  
        return (short) val;  
    }  
  
    @Override 
    public boolean equals(Object o) {  
        if (o == this)  
            return true;  
        if (!(o instanceof PhoneNumber))  
            return false;  
        PhoneNumber pn = (PhoneNumber)o;  
        return pn.lineNum == lineNum && pn.prefix == prefix  
                && pn.areaCode == areaCode;  
    }
}

위 코드와 같이 equals를 정의하였는데 hashcode를 정의하지 않은 코드가 있다고 하자.

public class HashMapTest {  
  
    public static void main(String[] args) {  
        Map<PhoneNumber, String> map = new HashMap<>();  
  
        PhoneNumber number1 = new PhoneNumber(123, 456, 7890);  
        PhoneNumber number2 = new PhoneNumber(123, 456, 7890);  
  
//         TODO 같은 인스턴스인데 다른 hashCode//         다른 인스턴스인데 같은 hashCode를 쓴다면?  
        System.out.println(number1.equals(number2));  
        System.out.println(number1.hashCode());  
        System.out.println(number2.hashCode());  
  
        map.put(number1, "speculatingwook");  
        map.put(number2, "wook");  
  
        String s = map.get(number2);  
        String s1 = map.get(new PhoneNumber(123, 456, 7890));  
        System.out.println("number2: " + s);  
        System.out.println("new object same number: " + s1);  
    }  
}

각각의 PhoneNumber 객체를 두개 만들고, 같은 번호를 저장했을 때, 출력값에서는 어떤 값이 나올까?

hashcode1.png

다음과 같이 equals는 true 가 나오지만, hashcode 값이 다르고 같은 필드의 데이터 값으로 Hashmap의 값을 들고 오려고 해도 null이 나오는 것을 볼 수 있다.

해시 코드 값이 같더라도equals가 다르면 정상적으로 동작하지만, 해시 충돌이 발생하면 성능이 저하될 수 있다. 해시 충돌이 많아지면HashMap내부에서 체이닝(LinkedList)을 사용하여 충돌을 해결해야 하므로, 성능이 떨어질 수 있다.

그러면 HashCode는 어떻게 작성하는 것이 좋을까?

@Override 
public int hashCode() {  
    int result = Short.hashCode(areaCode); // 1  
    result = 31 * result + Short.hashCode(prefix); // 2  
    result = 31 * result + Short.hashCode(lineNum); // 3  
    return result;  
}

각 필드의 타입이 primitive라면 그에 해당하는 hashCode 메서드를 호출하여 사용하면 된다. 만약 레퍼런스 타입이라면, 해당 객체의 hashCode 메서드를 호출하여 사용하면 된다. 위 코드에서 그리고 31을 곱하여 사용하였는데, 31이 해시 충돌이 최대한 나지 않는 소수여서 그렇다고 한다.

실제 작성하여 사용 할 때에는 다음과 같이 사용하는 것이 일반적이다.

@Override
public int hashCode(){
	return Object.hash(areaCode, prefix, lineNum);
}

Object.hash()를 타고 들어가보면

public static int hashCode(Object a[]) {  
    if (a == null)  
        return 0;  
  
    int result = 1;  
  
    for (Object element : a)  
        result = 31 * result + (element == null ? 0 : element.hashCode());  
  
    return result;  
}

사실상 전에 짠 것과 같은 코드를 볼 수 있다.

실제로 활용하는 방법#

위 규약들을 모두 신경쓰면서 equals 메서드를 구현한다는 것은 여간 쉬운 일이 아니다. 그래서 실제 프로젝트에서 equals를 사용할 때 좋은 방법들을 소개해보려 한다.

Lombok#

@EqualsAndHashCode  
@ToString  
public class Point {  
    private final int x;  
    private final int y;  
  
    public Point(int x, int y) {  
        this.x = x;  
        this.y = y;  
    }  
}

Lombok을 사용하면 컴파일 타임에equalshashCode메서드를 자동 생성해준다. 하지만, 일부 개발자들은 런타임에 어떤 코드가 실행될지 명확하지 않다는 점에서 Lombok 사용을 꺼리기도 한다. 컴파일 타임에 코드를 주입해주는 방식은 자바에서 의도하지 않은 동작이기 때문이다. 이후 자바 버젼에서 막을 수도 있다는 문제가 있지만, 일단 JDK 17버전까지는 정상적으로 동작한다고 한다.

위 코드를 컴파일 후 생기는 코드들을 보게 되면 다음과 같다.

public class Point {  
    private final int x;  
    private final int y;  
  
    public Point(int x, int y) {  
        this.x = x;  
        this.y = y;  
    }  
  
    public boolean equals(final Object o) {  
        if (o == this) {  
            return true;  
        } else if (!(o instanceof Point)) {  
            return false;  
        } else {  
            Point other = (Point)o;  
            if (!other.canEqual(this)) {  
                return false;  
            } else if (this.x != other.x) {  
                return false;  
            } else {  
                return this.y == other.y;  
            }  
        }  
    }  
  
    protected boolean canEqual(final Object other) {  
        return other instanceof Point;  
    }  
  
    public int hashCode() {  
        int PRIME = true;  
        int result = 1;  
        result = result * 59 + this.x;  
        result = result * 59 + this.y;  
        return result;  
    }  
  
    public String toString() {  
        return "Point(x=" + this.x + ", y=" + this.y + ")";  
    }  
}

record#

만약 자바 17버전을 사용한다면 자바에서 도입한 record 클래스를 사용하는 것도 방법이다. record는 불변 객체(Immutable Object)를 간결하게 표현하기 위한 자바의 새로운 클래스 유형이다.equals,hashCode,toString이 자동 생성되므로, 불필요한 보일러플레이트 코드를 줄일 수 있다.

public record Point(int x, int y) {}

위처럼 한 줄로 작성하면, 기존 클래스에서 수동으로 작성해야 했던 equals, hashCode, toString을 자동으로 처리해준다.

출처#

  • 이펙티브 자바 아이템 10: equals는 일반 규약을 지켜 재정의하라.
  • 이펙티브 자바 아이템 11: equals를 재정의하려거든 hashCode도 재정의하라.
  • 백기선의 이펙티브 자바(인프런)
  • Object 공식 문서
[ Effective Java ] equals, hashcode
https://blog-full-of-desire-v3.vercel.app/posts/java/effective-java/item1011-equals/equals-hashcode/
저자
SpeculatingWook
게시일
2025-03-10
라이선스
CC BY-NC-SA 4.0