[미션] Equals & Hashcode의 중요성

jeeeny·2024년 4월 11일

Sky Image

서론

눈 깜짝할 새 우테코 레벨1이 지났다. 원래 목표는 간단하게라도 매주 회고글을 작성하는 거였는데 체스 미션을 하면서 그럴 여유조차 없었다 🥲 레벨1 방학동안이라도 밀린 개념을 정리하고자 벨로그를 켰다. 이번 주제는 블랙잭, 체스 미션을 하면서 가장 인상깊었던 개념인 equals & hashcode이다. 우테코에서 진행하는 메타인지 스터디에서도 2번이나 주제로 준비해갔었다. 개념만을 다룬 글은 아니고 내가 마주쳤던 문제 상황들을 공유하고자 작성하는 글이다 🧚🏻

Equals & Hashcode 왜 정의해?

먼저 간단히 개념정리를 하고 넘어가자!

equals메서드값의 동등 비교를 위해 사용한다. 이때 객체 비교에서는 주소값을 이용해 비교한다.

아래에 Person 클래스가 있다. zeze1, zeze2는 같은 이름을 가진다. 둘은 같은 객체일까?
아니다. 물리적으로 다른 주소값을 가졌기 때문에 서로 다른 객체로 판단된다.

class Person {
    final String name;

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

Person zeze1 = new Person("zeze");
Person zeze2 = new Person("zeze");

System.out.println(zeze1.equals(zeze2)); // false

하지만 데이터가 같으면 같은 객체로 판단되게 하려면 어떻게 해야할까? 즉 출석부에 이름을 저장할 때 이름이 같은 객체 2개가 있더라도 1개만 저장되게 하고 싶으면?

equals를 재정의해주면 된다. 두 객체가 같도록 보장하고 싶은 필드를 이용해 재정의하면 된다.

class Person {
    final String name;

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

    // equals 재정의
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Person person = (Person) o;
        return Objects.equals(name, person.name);
    }
}

Person zeze1 = new Person("zeze");
Person zeze2 = new Person("zeze");

System.out.println(zeze1.equals(zeze2)); // true

hashcode메서드는 객체의 주소값을 이용해 해싱 기법을 통해 해시 코드를 만든 후 값을 반환한다. 이러한 해시 코드 값은 Collection Framework에서 사용된다.

Collection(HashMap, HashSet 등..)은 객체가 논리적으로 같은지 비교할 때 다음의 과정을 거친다. HashSet에 zeze1를 추가한다고 생각해보자.

  1. zeze1의 해시코드 값을 가져와 컬렉션에 존재하는지 확인한다.
  2. 동일한 해시코드 값이 존재하면 다음으로 equals 메서드의 리턴값을 반환하게 되고, true이면 논리적으로 같은 객체로 판단한다.
  3. 동등한 객체가 존재하면 Set에 데이터를 추가하지 않는다.

여기서 중요한 것은 해시코드 값부터 확인하고 equals를 확인한다는 것이다. 따라서 equals를 재정의했더라도 hashcode를 재정의하지 않으면 서로 다른 객체로 판단된다.

zeze1과 zeze2는 다른 객체임으로 해시코드 값이 다르다. 따라서 Set에 두 객체 모두 저장된다.

class Person {
    final String name;

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

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Person person = (Person) o;
        return Objects.equals(name, person.name);
    }
}

Set<Person> set = new HashSet<>();
Person zeze1 = new Person("zeze");
Person zeze2 = new Person("zeze");

set.add(zeze1);
set.add(zeze2);
System.out.println(zeze1.hashCode()); //1157058691
System.out.println(zeze2.hashCode()); //40472007
System.out.println(set.size()); //2

하지만 우리는 동등한 객체로 여기기 때문에 올바른 결과를 위해서는 hashcode를 재정의해서 같은 해시코드 값을 가지게 해야 한다.

class Person {
    final String name;

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

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Person person = (Person) o;
        return Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

Set<Person> set = new HashSet<>();
Person zeze1 = new Person("zeze");
Person zeze2 = new Person("zeze");

set.add(zeze1);
set.add(zeze2);
System.out.println(zeze1.hashCode()); //3735477
System.out.println(zeze2.hashCode()); //3735477
System.out.println(set.size()); //1

참고 : 자바 equals / hashCode 오버라이딩

문제상황1. Map에 저장된 값을 조회하는데 NPE 발생 🧨

Bettings 클래스는 Player와 BetAmount를 각각 키, 값으로 가지는 Map을 필드로 갖는다. findBy를 통해 참여자의 배팅 금액을 조회하려 한다.


public class Bettings {

private fianl Map<Player, BetAmount> playersBetting;

public Bettings() {
        this.playersBetting = new HashMap<>();
}

...

public BetAmount findBy(final Player player) {
		   return playersBetting.get(player);
}

하지만 findBy를 실행하면 NullPointerException 에러가 발생했다.

처음에는 Map에 값이 제대로 들어가지 있지 않나 생각했다. 하지만 브레이크 포인트를 찍어봤을때는 분명 모든 값이 저장되어 있었다.

그렇다면 Map에 저장된 PlayerfindBy 파라미터로 넘겨준 Player가 다르다고 생각해볼 수 있다.

equals & hashcode가 재정의되어 있는지 보기 위해 Player가 상속받는 Participant 클래스를 살펴봤다.

public abstract class Participant {

    private final Name name;
    private final Hands hands;

    protected Participant(final Name name, final Hands hands) {
        this.name = name;
        this.hands = hands;
    }

	...
    
    // equals & hashcode 재정의
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Participant that = (Participant) o;
        return Objects.equals(name, that.name) && Objects.equals(hands, that.hands);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, hands);
    }
}

재정의는 잘 되어 있었다. 하지만 좀 더 자세히 살펴보니 문제점을 발견할 수 있었다. hands까지 재정의해버린 것이었는데 게임 중간에 참여자가 가지고 있는 카드를 의미하는 hands값이 변경될 수 있다는 사실을 간과했다.

즉, Hands가 다음과 같다고 할 때 Player가 처음 생성되는 시점에서는 List<Card> cards에 빈 리스트가 저장된다.

public class Hands {

    public static final int BLACK_JACK = 21;
    private static final int EXTRA_ACE_VALUE = 10;

    private final List<Card> cards;

    public Hands(final List<Card> cards) {
        this.cards = new ArrayList<>(cards);
    }
		
		...
		
	@Override
    public boolean equals(final Object target) {
        if (this == target) {
            return true;
        }

        if (!(target instanceof Hands hands)) {
            return false;
        }

        return Objects.equals(cards, hands.cards);
    }

    @Override
    public int hashCode() {
        return Objects.hash(cards);
    }
}

그리고 게임이 진행되면서 List<Card> cards 에 카드가 추가되면서 생성시 Player의 해시코드와 조회시 Player 해시코드가 같지 않게 된다. 결국 존재하지 않는 값을 Map에서 조회해서 NPE가 발생했던 것이다.

  1. 생성시 Player 해시코드 : 1484430
  2. 조회시 Player 해시코드 : 217061984

해결방법. Equals & Hashcode 재정의

이름은 중복을 허용하지 않고, 변하지 않는 값이므로 name 만으로 해시코드값을 계산한도록 변경해주었다.

public abstract class Participant {

    private final Name name;
    private final Hands hands;

    protected Participant(final Name name, final Hands hands) {
        this.name = name;
        this.hands = hands;
    }

	...

	@Override
    public boolean equals(final Object target) {
        if (this == target) {
            return true;
        }
        if (!(target instanceof Participant participant)) {
            return false;
        }
        return Objects.equals(name, participant.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

문제상황2. 분명 삭제했는데, 삭제가 안 됐네 …. ? 😨

체스 미션 도중 발생한 문제였다. 분명 문제 없이 돌아가는 걸 확인하고 미션을 제출했는데 리뷰어로부터 다음과 같은 리뷰를 받았다.

별 문제 아니겠거니, 금방 고치겠다 싶었는데... 하루꼬박 걸려 해결했다. 왜 삭제되어야 할 객체가 남아있는지 확인하기 위해 저장된 모든 기물들의 정보를 출력해서 확인해봤다. 삭제되어야 할 블랙이 삭제되지 않았다...!

문제상황1과 비슷하게 변화 가능성을 예상하지 못하고 Set에 기물들을 저장한 것이 문제였다.

ChessBoard는 Set자료구조를 사용해 Piece 기물들을 저장한다. 그리고 기물이 다른 기물을 잡을 때는 Set에서 해당 Piece를 제거한다.

public class ChessBoard {

    private final Set<Piece> pieces;

    private ChessBoard(final Set<Piece> pieces) {
        this.pieces = pieces;
    }
    
    ...
    
	   private void catchPiece(final Piece currentPiece, final Position targetPosition) {
        pieces.remove(findPieceBy(targetPosition));
        currentPiece.move(targetPosition);
    }

Set의 remove 메서드는 해시코드 값을 이용해 해당 객체를 조회하고 있으면 삭제한다.

 public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
 }

Piece는 color와 position으로 해시코드를 계산한다. Position이 바뀐다면 Piece의 해시코드 값도 바뀌는 것이다.

public abstract class Piece {

    protected final Color color;
    protected Position position;

    protected Piece(final Color color, final Position position) {
        this.color = color;
        this.position = position;
    }
    
    ...
    
    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        final Piece piece = (Piece) o;
        return color == piece.color && Objects.equals(position, piece.position);
    }

    @Override
    public int hashCode() {
        return Objects.hash(color, position);
    }  
  }

그런데 Piece는 움직일 때마다 Position을 바꾼다. 결국 처음 Set에 저장된 Piece와 조회시 Piece의 해시코드 값이 달라 제대로 remove되지 않았던 것이다.

해결방법. Map사용하는 구조로 변경

Position을 final로 두고 새로운 Positon으로 바뀔 때마다 값을 update해주는 방식도 고려해봤다.

public abstract class Piece {

    protected final Color color;
    protected final Position position;

    protected Piece(final Color color, final Position position) {
        this.color = color;
        this.position = position;
    }
    ...
    
    public void updatePosition(final Position destination) {
		    position.update(destination);
    }
}

하지만 체스 게임에서 중요한 데이터인 위치값을 업데이트하는 메서드를 만드는 것은 위험부담이 크다고 생각했다. 이후 누군가 해당 메서드를 잘못 사용하면 기물들의 위치가 꼬이면서 체스 게임이 엉망진창이되는 것이다. 따라서 다른 방법을 생각해야 했다.

Map이 Position을 키로 가지고 있으면 문제가 해결된다. 말 그대로 체스판위에 기물들이 있다고 생각하는 것이다. 이렇게 하면 Position은 변하지 않기 때문에 Map의 키로 사용해도 괜찮다.

public class ChessBoard {

    private final Map<Position, Piece> pieces;

    public ChessBoard(final Map<Position, Piece> pieces) {
        this.pieces = pieces;
    }
    
		...

    private void catchPiece(final Piece currentPiece, final Position target) {
        pieces.remove(target);
        pieces.put(target, currentPiece);
    }

1,2단계 미션까지 마치고 중요한 도메인 구조를 싹 바꿔야해서 눈물을 머금고 💧 리팩토링을 진행했다. 더불어 동등성이 얼마나 중요한 개념인지 처절하게 느꼈다.

결론

객체가 Collection Framework(Set, Map)에 저장되면서 모든 문제들이 발생했다. 그리고 이는 객체의 값이 변할 수 있기 때문이었다. 따라서 객체의 필드를 최대한 final 로 지정하는게 매우 중요할 것 같다 🙂

실제로 이팩티브 자바에는 다음의 내용이 있다.

17. 변경 가능성을 최소화하라
Map이나 Set 내부에 value로서 들어간 객체들에 변경이 생기면 불변식이 허물어지는데, 불변 객체를 사용하면 그런 걱정은 없다.

크게 중요하다고 생각하지 않고 넘어간 개념이었는데 이번 미션들을 계기로 찐하게 겪어본 것 같아서 좋다!ㅎㅎ

profile
나의 성장 기록

2개의 댓글

comment-user-thumbnail
2024년 4월 11일

소름돋는 equals and hashcode ㅋㅋㅋ
글 재밌게 잘읽었오

1개의 답글