Equals와 hashCode

이전 포스팅(DTO와 VO 그리고 Entity의 차이)에서 살짝 언급했는데, 이제서야 정리를 하게되었다.

equals()hashCode()가 무엇이고, 그래서 이들을 언제 사용하는지에 대해 정리해보았다.

무엇인가

두 메소드 모두 Object의 메소드이다.

equals()객체의 값의 일치여부(boolean)을 반환하는 타입이다.

hashCode()는 객체의 주소값(int)을 이용하여 객체 고유의 해시코드를 리턴하는 함수이다.

어떻게 동작하는가

1
2
3
4
5
6
7
8
9
class DevAndy {
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = new String("hello");

System.out.printf("%s : %b\n","str1==str2",(str1==str2));
System.out.printf("%s : %b\n","str1.equals(str2)",(str1.equals(str2)));
}
}

String형 데이터를 2개 선언했다. 그러나 String Constant Pool에 생성하지 않고, new 라는 키워드로 heap에 선언했다.

String 변수를 선언하면서 리터럴을 이용하는것과 new 키워드로 선언하는 것의 차이는 아래의 포스팅을 참고바란다.

DevAndy - String 변수를 선언하는 2가지 방식

리터럴로 선언한것과 달리 heap에 생성한 변수들을 == 연산자와 equals()로 비교를 해보았다.

1
2
str1==str2 : false
str1.equals(str2) : true

== 연산자의 결과는 false이고, equals()의 결과는 true이다.

== 연산자는 리터럴 값을 비교하는데 new String()new String()은 서로 다른 주소값을 참조하는 객체이기 때문에 false를 반환한것이다.

반면 equals()는 어떻게 두 변수가 같다고 판단한걸까?

인텔리제이에서 equals() 메소드를 클릭해서 내부를 열어보았다(?)

먼저 객체 리터럴을 비교해서 true면 그대로 반환한다. 그러나 같지않다면 그 다음으로 String 타입인지 데이터 타입을 체크하여 이때부터 본격적으로 비교를 들어간다. 최종적으로 언어코드(Latin/UTF16)에 따라 equals() 결과를 반환한다.

그럼 여기서 사용한 equals()는 어떻게 동작할까?

비교하는 대상의 객체들의 길이를 비교하고 길이가 다르다면 false를 반환한다. 길이가 같다면, 여기서 문자열 길이만큼 각각의 문자형 리터럴을 직접 비교한다.

equals()가 동작하는건 이제 알겠는데, hashcode()는 언제 써야할까?

언제 써야할까

위의 예제코드는 String형 변수를 비교하는 경우였다. 그렇다면, 컬렉션 프레임워크를 사용하는 경우엔 어떨까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class DevAndy {
class Card {
String name;
String number;
public Card(String name, String number) {
this.name = name;
this.number = number;
}

@Override
public String toString() {
return "Card{" +
"name='" + name + '\'' +
", number=" + number +
'}';
}
}

public void makeSet() {
Set<Card> cardSet = new HashSet<>();
set.add(new Card("Hyundai","0000-0000-0000"));
set.add(new Card("Hyundai","0000-0000-0000"));

for (Card card : set) {
System.out.println(card.toString());
}
System.out.println("size : "+set.size());
}

public static void main(String[] args) {
DevAndy dev = new DevAndy();
dev.makeSet();
}
}
1
2
3
Card{name='Hyundai', number='0000-0000-0000'}
Card{name='Hyundai', number='0000-0000-0000'}
set size : 2

코드를 천천히 살펴보도록 하자. 중첩 클래스를 이용하여 Card 라는 클래스를 선언했다. 그리고 makeSet()에서는 HashSet을 이용하여 Card 객체를 삽입했는데, 이상한 결과가 출력된걸 알 수 있다.

Set은 중복을 허용하지 않는다. 따라서 똑같은 속성을 지닌 Card 객체를 HashSet에 삽입했다면, HashSet에선 중복되는 데이터 하나는 삭제하고, size()1을 반환했어야 했다.

그런데 size()2로 반환했다는건 toString()을 통해서도 확인했지만, new로 선언한 두 Card 객체를 컴파일러에서 서로 다르다고 판단했던것이다.

그럼 왜 다르다고 판단한건지 hashCode를 출력해서 확인해보자.

HashSet에서 Card를 꺼내서 출력하는 코드를 아래처럼 바꿔서 다시 main 메소드를 실행해보자.

1
2
3
for (Card card : set) {
System.out.println(card.toString());
}

출력결과는 아래와 같다.

1
2
3
2101440631
1975358023
set size : 2

정말 달랐다!

hashCode가 달랐기 때문에 컴파일러에선 같은 속성을 지닌 Card 객체들을 다르게 본 것이다.

이건 개발자가 의도한대로 프로그래밍되었다고 볼 수 없다. 바로 이 때가 equals()hashCode()를 오버라이딩해야 하는 순간이다.

cmd + n을 입력해서 인텔리제이에서 자동으로 생성하는 equals()hashCode()를 생성했다.

중첩 클래스인 Card의 코드만 살펴보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Card {
String name;
String number;
public Card(String name, String number) {
this.name = name;
this.number = number;
}

@Override
public String toString() {
return "Card{" +
"name='" + name + '\'' +
", number=" + number +
'}';
}

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

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

다시 main 메소드를 실행해보니 다른 결과가 출력된다.

1
2
Card{name='Hyundai', number='0000-0000-0000'}
set size : 1

비로소 HashSet에서 똑같은 속성을 지닌 Card 객체를 중복으로 인식하여 똑같은 객체는 삭제하고, 하나의 Card 객체만 갖고있는걸 확인할 수 있다.

여기서 중요한건 equals()만 오버라이딩해서는 안되고, hashCode()까지 오버라이딩해야 정상적으로 동작한다는 것이다.



References