...
equals 메소드
어떤 두 참조 변수의 값이 같은지 다른지 동등 여부를 비교해야 할때 사용하는 것이 equals() 메서드이다.
대표적으로 String 타입의 변수를 비교할때 가장 많이 거론되는 메서드일 것이다.
String s1 = "Hello";
String s2 = "Hello";
System.out.println(s1 == s2); // 주소 비교 false
System.out.println(s1.equals(s2)); // 값 비교 true
그러면 문자열이 아닌 클래스 자료형의 객체 데이터일 경우 equals() 메소드는 어떻게 다뤄질까?
어렵게 생각할 필요 없다. 비교할 대상이 객체일 경우 객체의 주소를 이용하여 비교한다.
즉, 객체 자체를 비교할때는 == 이나 equals() 나 똑같다고 보면 된다.
class Person {
String name;
public Person(String name) {
this.name = name;
}
public class Example {
public static void main(String[] args) {
Person person1 = new Person("홍길동");
Person person2 = new Person("홍길동");
System.out.println(person1 == person2); // == 은 객체타입인경우 주소값을 비교한다. 서로다른 객체는 다른 주소를 가지고 있기 때문에 false가 출력됨
System.out.println(person1.equals(person2)) // equals또한 객체타입인경우 주소값을 비교하기 때문에 false가 출력된다.
}
}
equals 오버라이딩
하지만 잘 생각해보자. 위의 person1 변수와 person2 변수는 각기 다른 객체를 초기화하여 힙 영역에 따로 저장해두고 있으니 두 객체를 비교하면 주소가 일치하지 않아 당연히 false가 뜬다.
하지만 이는 컴퓨터적인 관점에서 바라본 입장이다.
외부적인 관점에서는 두 객체는 똑같은 Person 클래스 타입이고 똑같은 이름 값 "홍길동"을 지니고 있다.
즉, 사용 입장에서는 두 객체는 어찌보면 같은 데이터다 라고 볼 수도 있는 것이다. 물론 프로그램적인 입장에서는 둘은 다르다는 것이 옳지만, 데이터적인 입장에서는 둘은 어찌보면 같다고 봐야 할지도 모른다.
따라서 만일 객체 자료형을 비교를 할때, 주소 값이 아닌 객체의 필드값을 기준으로 동등 비교 기준을 변경하고 싶다면, equals 메서드를 오버라이딩해서 주소가 아닌 필드값을 비교하도록 재정의 해주면 된다.
import java.util.Objects;
class Person {
String name;
public Person(String name) {
this.name = name;
}
// 객체 주소 비교가 아닌 Person 객체의 사람 이름이 동등한지 비교로 재정의 하기 위해 오버라이딩
public boolean equals(Object o) {
if (this == o) return true; // 만일 현 객체 this와 매개변수 객체가 같을 경우 true
if (!(o instanceof Person)) return false; // 만일 매개변수 객체가 Person 타입과 호환되지 않으면 false
Person person = (Person) o; // 만일 매개변수 객체가 Person 타입과 호환된다면 다운캐스팅(down casting) 진행
return Objects.equals(this.name, person.name); // this객체 이름과 매개변수 객체 이름이 같을경우 true, 다를 경우 false
}
}
public class Main {
public static void main(String[] args) {
Person p1 = new Person("홍길동");
Person p2 = new Person("홍길동"); // 동명이인
System.out.println(p1.equals(p2)); // true
}
}
[ Objects 클래스 ]
Object 클래스가 아닌, Objects 클래스임을 조심하자.
Objects 클래스는 java.util 패키지에 있는 또다른 클래스이다. (Object 클래스는 java.lang 패키지에 포함)
Objects 클래스는 객체 비교, 해시 코드 생성, null 여부, 객체 문자열 리턴 등의 연산을 수행하는 정적 메소드들로 구성되어 있으며, 개발자가 가져가 쓰기 편하게 하기 위해서 구현되었다.
위의 코드에서 Object 클래스의 equals 메서드를 재정의 하였기 때문에, 본래의 equals 메서드 동작 결과를 얻기 위해 Objects 클래스의 메서드를 사용하는 것이라고 이해하면 된다.
equals 오버라이딩 쉽게 이해하자
오버라이드(Override) 자체에 집착하지말고 이렇게 생각을 환기해보자.
- 아니
==으로 비교했더니 주소로 비교하고 앉아있는데 다른 고유의 값으로 비교하도록 설정할수없나? - 내가 직접 메서드 정의해서 비교 로직을 만들어 써야 되겠네
- 그런데 부모 클래스로 다룰때도 비교하는 메서드를 사용하고 싶은데 (다형성)
- 모든 클래스가 기본적으로 상속하는 최상위 클래스 Object에 미리
equals()라는 메서드가 있으니 이걸 오버라이드 해서 재활용 하면 되지 않을까?
이러한 기법의 대표적인 예이가 바로 String 클래스의 equals 메서드이다.
String 클래스의 equals 오버라이딩
원래 equals() 메서드는 객체의 주소값을 기준으로 동등 비교를 한다고 했었다. 문자열을 저장하는 String 클래스도 사실 객체 타입이기 때문에 예외가 없다. 즉, 원래대로라면 문자열 값이 아닌 주소값을 비교하게 되어있다. 하지만 String의 equals() 메서드가 문자열 값으로 비교한 이유가, 바로 위의 Person 클래스 예제와 같이 실제 자바의 String 클래스에도 equals() 메소드를 살펴보면 다음과 같이 equals가 재정의 되어있어 그런 것이다.
hashCode 메소드
hashCode 메서드는 객체의 주소 값을 이용해서 해싱(hashing) 기법을 통해 해시 코드를 만든 후 반환한다.기 때문에 서로 다른 두 객체는 같은 해시 코드를 가질 수 없게 된다. 그래서 해시코드는 객체의 지문이라고도 한다.
엄밀히 말하면 해시코드는 주소값은 아니고, 주소값으로 만든 고유한 숫자값이라고 하는게 옳다.
class Person {
String name;
public Person(String name) {
this.name = name;
}
}
public class Main {
public static void main(String[] args) {
Person p1 = new Person("홍길동");
Person p2 = new Person("홍길동");
// 객체 인스턴스마다 각기 다른 주해시코드(주소))를 가지고 있다.
System.out.println(p1.hashCode()); // 622488023
System.out.println(p2.hashCode()); // 1933863327
}
}
실제 Object 클래스에 정의된 hashCode() 메서드 정의를 보면 다음과 같이 되어있다.
보지못한 생소한 native 키워드가 보이는데, 이 native 키워드가 들어간 메소드는 OS가 가지고 있는 메소드를 뜻한다.
JVM(자바 가상머신)에 대해 자세히 공부하신 독자 분이라면, JNI(Java Native Interface) 에 대해 들어본 적이 있을 것이다. JNI는 C나 저수준의 언어로 작성된 native 코드를 JVM에 적재시키고 실행해주는 머신인데, 바로 이 native 코드중 하나가 hashCode() 메서드이다.
이 네이티브 메서드는 OS에 C언어로 작성되어있어 그 안의 내용은 볼 수 없고, 오로지 사용만 할 수 있다. 그래서 마치 추상 메서드 처럼 정의 되어 있는 것이다.
hashCode 오버라이딩
위에서 equals() 메소드 오버라이딩 하였던 코드를 직접 실행해본 독자분들은 아마 아래와 같은 경고 문구를 봤을 것이다.
내용을 살펴보니 equals() 는 오버라이딩 하면서 왜 hashCode() 는 오버라이딩 안하냐는 경고이다.
경고가 뜨는 이유는, 만일 객체의 주소가 아닌 객체의 필드의 값을 비교하기위해 equals() 를 오버라이딩 시킨다면 당연히 hashCode도 같이 객체의 필드를 다루도록 오버라이딩 해야되기 때문이다.
왜냐하면 equals() 의 결과가 true 인 두 객체의 해시코드는 반드시 같아야한다는 자바의 규칙 때문에 그렇다.
equals와 hashCode는 같이 재정의하라는 말을 다들 한 번쯤 들어봤을 것이다.
대부분의 IDE Generate 기능에서도 equals와 hashCode를 같이 재정의해준다.
그렇다면 equals와 hashCode를 왜 같이 재정의해야 하는 걸까?
결론부터 말하자면 두 메소드를 재정의 하지 않을시, hash 값을 사용하는 Collection Framework(HashSet, HashMap, HashTable)을 사용할 때 문제가 발생하기 때문이다. 이에 대해서 차근차근 알아보도록 하자.
equals만 재정의할 경우
우선 예제로 사용될 Person 클래스를 살펴보자.
Person 클래스에는 equals 메서드만 오버라이딩 하였다. 따라서 p1 과 p2 객체는 해시코드가 다름에도 불구하고 논리적으로 같은 객채로 판단된다.
class Person {
public 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 p = (Person) o;
return Objects.equals(name, p.name);
}
}
public class ClassTest {
public static void main(String[] args) throws Exception {
Person p1 = new Person("홍길동");
Person p2 = new Person("홍길동");
// 두 객체의 해시 코드
System.out.println(p1.hashCode()); // 460141958
System.out.println(p2.hashCode()); // 1163157884
// 해시코드가 달라도, equals를 재정의 했기 때문에 동등함
System.out.println(p1.equals(p2)); // true
// 리스트를 생성하고 두 객체 데이터를 추가한다.
List<Person> people = new ArrayList<>();
people.add(p1);
people.add(p2);
// 그리고 리스트의 길이를 출력한다.
System.out.println(people.size()); // 2
}
}
그리고 마지막에 Person 객체 2개를 ArrayList 자료형에 넣어주고 리스트의 크기(길이)를 출력해봤더니, 출력 결과는 당연히 2 가 된다.
그렇다면 이번엔 List 자료형대신 중복된 값을 허용하지 않는 리스트인 Set 자료형을 이용해보자.
Collection에 중복되지 않는 Person 객체만 넣으라는 요구사항이 추가되었다. 요구사항을 반영하기 위해 ArrayList에서 중복 값을 허용하지 않는 HashSet으로 로직을 바꿨다.
public static void main(String[] args) {
Set<Car> cars = new HashSet<>();
cars.add(new Car("foo"));
cars.add(new Car("foo"));
System.out.println(cars.size());
}
로직의 결과를 예상해보자면, 우리는 Person 객체의 name 문자열이 같으면 같은 객체로 판별하도록 equals 메서드를 재정의 하였었다. 따라서 같은 이름을 가진 p1과 p2 객체를 HashSet 자료형에 넣는다면 중복 판별되어 하나의 데이터만 컬렉션에 들어가 있어야 한다.
그러나 HashSet의 size가 1이 나올 거라 예상했지만, 예상과 다르게 그대로 2가 출력된다.
p1과 p2는 논리적으로 같다고 정의하였지만 해시코드가 다르기 때문에 중복된 데이터가 컬렉션에 추가된 것이다.
hashCode 와 equals 동작 순서
위처럼 동작되는 이유는 hash 값을 사용하는 Collection(HashMap, HashSet, HashTable)은 객체가 논리적으로 같은지 비교할 때 아래 그림과 같은 과정을 거치기 때문이다.
가장 먼저 데이터가 추가된다면, 그 데이터의 hashCode()의 리턴 값을 컬렉션에 가지고 있는지 비교한다.
만일 해시코드가 같다면 그제서야 다음으로 equals() 메서드의 리턴 값을 비교하게 되고, true이면 논리적으로 같은 객체라고 판단한다.
위의 예제 코드에서 HashSet에 Person 객체를 추가할 때도 위와 같은 과정으로 중복 여부를 판단하고 HashSet에 추가되게 된다. 이때 Person 클래스에는 hashCode 메서드가 재정의 되어있지 않아서 Object 클래스의 hashCode 메서드가 사용되었고 객체마다 다른 값을 리턴하였다.
결국 두 개의 p1 과 p2 객체는 equals로 비교도 하기 전에 서로 다른 hashCode 메서드의 리턴 값으로 인해 다른 객체로 판단되어 무지성으로 컬렉션에 적재한 것이다.
따라서 이러한 오작동을 방지하기 위해 hashCode 메서드도 재정의하여 두 객체의 필드인 name의 문자열값이 같을 경우 같은 해시코드를 갖게 하도록 재구성할 필요가 있다.
다른 컬렉션 자료형인 HashMap 자료형에서도 key값을 새로 입력할 때 기존의 중복되는 키가 있는지 확인 하는데, 이때 hashCode 를 먼저 비교하고, 둘이 같으면 그 다음에서야 equals 를 이용해 검사한다. 그래서 equals 를 다른 필드를 비교하도록 재정의하였다면 equals가 제대로 동작하게 하기위해선 hashCode 메소드도 재정의해야 한다.
equals 와 hashCode 동시 재정의
앞서 살펴봤던 문제를 해결하기 위해 Person 클래스에 hashCode 메서드를 재정의 하였다.
이때 반환되는 해시코드 값을 객체의 주소값이 아닌 Person 객체의 name 필드의 문자열 값을 이용해 해시코드를 반환하도록 한다.
[ Objects.hash() 메서드 ]
매개변수로 주어진 값들을 이용해서 고유한 해시 코드를 생성한다.
즉, 동일한 값을 가지는 객체들의 필드로 해시코드를 생성하게 되면, 동일한 해시코드를 가질 수 있게 되어, 이 해시코드 값을 기준으로 재정의한equals()가 동등 비교에 이용한다고 보면 된다.
보통 이 메서드는 클래스가hashCode()를 재정의할 때 리턴값을 생성하기 위해서 사용된다. (다만 속도가 느림)
class Person {
public 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 p = (Person) o;
return Objects.equals(name, p.name);
}
@Override
public int hashCode() {
return Objects.hash(name); // name 필드의 해시코드를 반환한다.
}
}
public class ClassTest {
public static void main(String[] args) throws Exception {
Person p1 = new Person("홍길동");
Person p2 = new Person("홍길동");
// 두 객체의 해시 코드
System.out.println(p1.hashCode()); // 54150093
System.out.println(p2.hashCode()); // 54150093
// 해시코드가 달라도, equals를 재정의 했기 때문에 동등함
System.out.println(p1.equals(p2)); // true
// SET를 생성하고 두 객체 데이터를 추가한다.
Set<Person> people = new HashSet<>();
people.add(p1);
people.add(p2);
// 그리고 SET의 길이를 출력한다.
System.out.println(people.size()); // 1
}
}
hashCode 메서드를 재정의함에 따라, 두 객체의 해시코드는 같은 값이 나오는 걸 볼수 있고, Set 자료형에도 중복된 데이터 적재로 판단되어 한번만 추가됨을 볼수 있다.
일반적인 String 타입이나 자바에서 미리 만들어놓은 객체 데이터를 적재할때는 문제가 되지 않는다.
문제는 위의 Person 객체와 같이 사용자 정의 클래스 자료형을 만들어 사용할때 equals 와 hashCode를 재정의 하지 않으면 오작동이 일어날수 있다는 점이다. 이 점을 잘 숙지하자.
identityHashCode 메서드
hashCode() 메소드의 본래의 목적은 객체의 주소값을 기반으로 해싱해서 고유한 숫자값을 반환하는 것이다. 이를 통해 객체가 같은지 같지 않은지 판별할 수 있다.
그런데 위와 같이 equals() 와 hashCode() 메서드를 오버라이딩 해버리면, 반환 동작이 객체의 필드를 기준으로 이행되기 때문에, 만일 객체 자체의 주소값(해시코드)를 얻어야 할 상황이 온다면 난감해 질 수 있다.
그래서 자바에서는 똑같이 해시코드를 반환해주는 또다른 메서드인 identityHashCode() 를 만들었다.
즉, hashCode() 를 오버라이딩 해서 쓰는데, 오버라이딩 하기 전의 원조 기능이 필요할때 사용 하는 메서드라고 보면 된다.
아래 코드에서는 Person 클래스는 name 필드의 문자열의 내용이 같으면 동일한 해시코드를 반환하도록 hashCode() 메서드를 재정의 하였기 때문에, 같은 문자열들에 대해 hashCode() 를 호출하면 항상 동일한 코드값을 얻는다.
반면에 System.identityHashCode() 는 따로 독립된 시스템 메서드이기 때문에 모든 객체에 대해 항상 다른 해시 코드값을 반환할 것을 보장한다.
import java.util.Objects;
class Person {
String name;
public Person(String name) {
this.name = name;
}
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person person = (Person) o;
return Objects.equals(this.name, person.name);
}
public int hashCode() {
return Objects.hash(name);
}
}
public class Main {
public static void main(String[] args) {
Person p1 = new Person("홍길동");
Person p2 = new Person("홍길동");
// equals() 와 hashCode() 를 오버라이딩 했기에 두 객체 필드 name의 해시코드가 반환되어 같다.
System.out.println(p1.hashCode()); // 54150093
System.out.println(p2.hashCode()); // 54150093
// 따라서 순수 객체의 주소를 얻고 싶다면 identityHashCode() 를 사용해야 한다.
System.out.println("System.identityHashCode(p1) : " + System.identityHashCode(p1)); // System.identityHashCode(p1) : 622488023
System.out.println("System.identityHashCode(p2) : " + System.identityHashCode(p2)); // System.identityHashCode(p2) : 414493378
}
}
객체의 hashCode는 고유하지 않다 ❌
해시코드 값으로만 두 객체의 동등을 판별하려 할때 이러한 예외적인 케이스가 존재한다.
자세한 내용은 다음 포스팅을 참고하길 바란다.
IDE로 오버라이딩 자동화
equals() 와 hashCode() 메서드를 재정의할때 손코딩 하지말고 IDE를 이용하면 자동 완성 기능으로 매우 간편하게 재정의를 해줄 수 있다.
본 강의는 IntelliJ IDE를 기준으로 설명한다. (이클립스도 가능하다)
1. 오버라이딩 하고 싶은 클래스 블록을 클릭한 상태에서, 상단 메뉴의 코드 → 생성 (alt + insert) 클릭
2. equals() 및 hashCode() 선택
3. 비교 재정의 기준을 정할 필드를 선택
4. 자동 오버라이딩 완료
# 참고자료
https://www.baeldung.com/java-hashcode
https://velog.io/@poiuyy0420/Java%EC%9D%98-equals-%EC%99%80-hashcode
https://www.youtube.com/watch?v=Mc6OaicCZVA
https://www.youtube.com/watch?v=NI6QZy6juc8
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.