자바 해체분석기 첫 시간에 컬렉션을 살펴보며 아래의 두 메소드를 발견했다.
면접 탈락의 가장 큰 원인인 아래 두 메소드를 알아보자..
Equals 메소드
boolean equals(Object o);
equals
메소드는 두 객체가 "같은지" 비교하는 메소드이다. 즉, 어떤 두 참조 변수의 값이 같은지 다른지 동등 여부를 비교해야할 때 사용한다.
대표적으로 String 타입의 변수를 비교할 때 가장 많이 거론되는 메소드일 것이다.
String s1 = "Hello World!";
String s2 = "Hello World!";
System.out.println(s1 == s2); // 주소값을 비교하므로 false
System.out.println(s1.equals(s2)); // 값을 비교하므로 true
그렇다면 문자열이 아닌 클래스 자료형의 객체 데이터일 경우 equals()
메소드는 어떻게 다뤄질까?
비교할 대상이 객체일 경우에는 객체의 주소를 이용해 비교한다.
즉, 객체 자체를 비교할 때는 ==
이나 equals()
는 동일하게 동작한다.
class Welcome {
String message;
public Welcome(String message) {
this.message = message;
}
public class Example {
public static void main(String[] args) {
Welcome welcome1 = new Welcome("Hello World!");
Welcome welcome2 = new Welcome("Hello World!");
System.out.println(welcome1 == welcome2);
// 서로 다른 객체는 다른 주소이므로 주소값 비교에서 false
System.out.println(welcome1.equals(welcome2));
// 객체 타입에서 equals 또한 주소값 비교이므로 false
}
}
}
Equals 오버라이딩
그런데 위의 예시에서 두 welcome1
변수와 welcome2
변수는 각자 다른 객체를 초기화하여 힙 영역에 따로 저장해두고 있기 때문에, 두 객체를 비교하면 주소가 일치하지 않아 false가 출력된다.
그런데 이는 컴퓨터의 입장에서 바라본 결과이다.
외부적인 관점에서는 두 객체는 똑같은 Welcome 클래스 타입이고, 값 또한 동일한 값을 가지고 있다.
즉, 사용하는 입장에서는 두 객체는 같은 데이터라고 볼 수도 있는 것이다. 물론 프로그램의 입장에서는 둘은 다르다는 것이 옳지만, 데이터 관점에서 둘은 같다고 봐야할 수 있다.
따라서, 단일 객체 자료형을 비교할 때, 주소 값이 아닌 객체의 필드값을 기준으로 동등 비교 기준을 변경하고 싶다면, equals
메소드를 오버라이딩을 통해 필드값일 비교하도록 재정의 해야한다.
import java.util.Objects;
class Welcome {
String message;
public Welcome(String message) {
this.message = message;
}
public boolean equals(Object o) {
if (this == o) return true;
// 현 객체 this와 매개변수 객체가 같을 경우 true
if (!(o instanceof Welcome)) return false;
// 매개변수 객체가 호환되지 않는다면 false
Welcome welcome = (Welcome) o;
// 매개변수 객체가 호환된다면 다운캐스팅
return Objects.equals(this.name, welcome.name);
// 값에 대한 비교
}
}
public class Example {
public static void main(String[] args) {
Welcome welcome1 = new Welcome("Hello World!");
Welcome welcome2 = new Welcome("Hello World!");
System.out.println(welcome1 == welcome2);
// 서로 다른 객체는 다른 주소이므로 주소값 비교에서 false
System.out.println(welcome1.equals(welcome2));
// 재정의한 equals 이므로 true
}
}
이는 모든 클래스가 기본적으로 상속하는 최상위 클래스 Object에 이미 equals()
라는 메소드가 존재하기 때문에, 이를 다형성을 통해 재활용한 것이다.
이러한 기법의 대표적 예시가 String 클래스의 equals 메소드이다.
원래 equals()
메소드는 객체의 주소값을 기준으로 동등 비교를 진행한다. 문자열을 저장하는 String 클래스도 사실은 객체 타입이기 때문에 가장 첫 번째 예시에서는 주소 비교가 되어야 한다. 즉, 원래대로라면 문자열 값이 아닌 주소값을 비교하게 되어있다.
그러나, String의 equals()
메소드가 문자열 값으로 비교한 이유는, 바로 위의 클래스 예제와 같이 실제 Java의 String 클래스에서도 equals()
메소드를 살펴보면 재정의를 통해 문제를 해결하였다.
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String) anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i]) {
return false;
}
i++;
}
return true;
}
}
return false;
}
실제 String 클래스에서는 직접 문자열 값을 문자 배열로 만들어 각 배열 요소 문자값을 하나하나 비교함으로써 동등 유무를 걸러낸다.
HashCode 메소드
int hashCode();
hashCode 메소드는 객체의 주소 값을 이용해서 해싱 기법을 통해 해시 코드를 만든 후 반환한다.
그렇기 때문에, 서로 다른 두 객체는 같은 해시 코드를 가질 수 없게 된다. 그래서 해시코드는 객체의 지문이라고 할 수 있다.
따지자면, 해시코드는 주소값이 아닌, 주소값으로 만든 고유한 숫자값이 맞다.
class Welcome {
String message;
public Welcome(String message) {
this.message = message;
}
}
public class Main {
public static void main(String[] args) {
Welcome w1 = new Welcome("Hello World!");
Welcome w2 = new Welcome("Hello World!");
// 객체 인스턴스마다 각자 다른 해시코드를 가지고 있다. (예시)
System.out.println(w1.hashCode()); // 4644653486
System.out.println(w2.hashCode()); // 5684548351
}
}
실제 Object 클래스에 정의된 hashCode()
메소드의 정의를 살펴보면 다음과 같다.
public class Object {
public native int hashCode();
}
위를 살펴보면 생소한 native
키워드가 존재한다. 해당 키워드가 들어간 메소드는 OS가 가지고 있는 메소드를 의미한다.
JVM에 대해 살펴봤다면 JNI(Java Native Interface)에 대해 들어본 적이 있을 것이다. JNI는 C나 저수준의 언어로 작성된 native 코드를 JVM에 적재시키고 실행해주는 머신이다. 여기서 바로 이 native 코드 중 하나가 hashCode()
메소드이다.
이 네이티브 메소드는 OS에 C언어로 작성되어 있어, 그 안의 내용은 볼 수 없고, 오로지 사용만 할 수 있다. 그래서 추상 메소드 처럼 정의되어 있는 것이다.
HashCode 오버라이딩
위에서 equals()
메소드를 오버라이딩하여 사용하는 경우 경고가 발생하게 된다.
경고가 발생하는 이유는 만약 객체의 주소가 아닌 객체의 필드 값을 비교하기 위해 equals()
메소드를 오버라이딩 하는 경우 당연히 hashCode
도 같이 객체의 필드를 다루도록 오버라이딩 해야하기 때문이다.
가장 큰 이유는 제약사항으로 인하여 equals()
의 결과가 true인 두 객체의 hashCode는 반드시 같아야 한다.
이 때문에 equals()
와 hashCode는 같이 재정의하라는 말이 나오게 된 것이다. 그렇다면 왜 같이 재정의해야 할까?
결론부터 말하자면 두 메소드를 같이 재정의하지 않을 시, Hash 값을 사용하는 Collection Framework를 사용할 때 문제가 발생하기 때문이다.
equals 메소드만 재정의 할 경우
우선 예제로 계속 사용하고 있는 Welcome 클래스를 살펴보자
Welcome 클래스에는 equals 메소드만 오버라이딩 하였다. 따라서 아래의 두 객체는 해시코드가 다름에도 불구하고 논리적으로 같은 객체로 판단된다.
import java.util.Objects;
class Welcome {
String message;
public Welcome(String message) {
this.message = message;
}
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Welcome)) return false;
Welcome welcome = (Welcome) o;
return Objects.equals(this.name, welcome.name);
}
}
public class Example {
public static void main(String[] args) {
Welcome welcome1 = new Welcome("Hello World!");
Welcome welcome2 = new Welcome("Hello World!");
// 두 객체의 해시 코드
System.out.println(w1.hashCode()); // 4644653486
System.out.println(w2.hashCode()); // 5684548351
// 해시 코드가 달라도, 재정의한 equals 이므로 값비교를 통해 동등함
System.out.println(welcome1.equals(welcome2));
}
}
여기서 중복을 허용하는 ArrayList 자료형에 두 객체의 값을 넣어보고, 크기를 출력해보면 당연하게 2가 출력된다.
public class Example {
public static void main(String[] args) {
Welcome welcome1 = new Welcome("Hello World!");
Welcome welcome2 = new Welcome("Hello World!");
// 두 객체의 해시 코드
System.out.println(w1.hashCode()); // 4644653486
System.out.println(w2.hashCode()); // 5684548351
// 해시 코드가 달라도, 재정의한 equals 이므로 값비교를 통해 동등함
System.out.println(welcome1.equals(welcome2));
List<Welcome> welcome = new ArrayList<>();
welcome.add(welcome1);
welcome.add(welcome2);
System.out.println(welcome.size()); // 2
}
}
그렇다면 중복된 값을 혀용하지 않는 리스트인 Set 자료형을 사용해보면 어떨까?
public class Example {
public static void main(String[] args) {
Welcome welcome1 = new Welcome("Hello World!");
Welcome welcome2 = new Welcome("Hello World!");
// 두 객체의 해시 코드
System.out.println(w1.hashCode()); // 4644653486
System.out.println(w2.hashCode()); // 5684548351
// 해시 코드가 달라도, 재정의한 equals 이므로 값비교를 통해 동등함
System.out.println(welcome1.equals(welcome2));
List<Welcome> welcome = new HashSet<>();
welcome.add(welcome1);
welcome.add(welcome2);
System.out.println(welcome.size()); // 2
}
}
결과를 예상해보자면, 값을 비교하도록 equals 메소드를 재정의했기 때문에 중복 판별을 통해 하나의 데이터만 컬렉션에 들어가 있어야 한다.
그러나 HashSet의 크기가 1이 나올것이라 예상했지만, 예상과는 다르게 2가 출력된다.
논리적으로 두 객체는 동등하다고 정의하였지만, 해시코드가 다르기 때문에 중복된 데이터가 컬렉션에 추가되었다.
위처럼 동작하는 이유는 hash 값을 사용하는 컬렉션(예를 들어 HashMap, HashSet, HashTable 등)은 객체가 논리적을 같은지 비교할 때 아래 그림과 같은 과정을 거치게 된다.
가장 먼저 데이터가 추가된다면, 그 데이터의 hashCode()
의 반환 값을 컬렉션에 포함되어 있는지 비교한다.
만약 해시코드가 같다면 그 이후에야 equals()
메소드의 반환 값을 비교하게 되고, true라면 논리적으로 같은 객체라고 판단한다.
위의 코드에서도 HashSet에 객체를 추가할 때, 위와 같은 과정으로 중복 여부를 판단하고 추가한다. 이때 hashCode 메소드가 재정의가 되어있지 않아서 Object 클래스의 hashCode 메소드가 사용되었고, 두 객체가 다르다고 판단하게 된 것이다.
따라서, 재정의한 equals()
메소드가 사용되기도 전에 hashCode의 반환 값으로 인하여 다른 객체로 판단되어 두 객체 모두 컬렉션에 적재된 것이다.
이렇게 의도하지 않은대로 동작하는 것을 방지하기 위해 hashCode 메소드도 재정의하여 문자열의 값을 통해 동등성을 비교하도록 재구성할 필요가 있다.
equals와 hashCode 동시 재정의
위의 문제를 해결하기 위해 hashCode 메소드를 재정의해보자. 이때, 반환되는 해시코드의 값을 객체의 주소값이 아닌 객체의 필드를 통해 해시코드를 반환하도록 한다.
import java.util.Objects;
class Welcome {
String message;
public Welcome(String message) {
this.message = message;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Welcome)) return false;
Welcome welcome = (Welcome) o;
return Objects.equals(this.name, welcome.name);
}
@Override
public int hashCode() {
return Objects.hash(message);
}
}
public class Example {
public static void main(String[] args) {
Welcome welcome1 = new Welcome("Hello World!");
Welcome welcome2 = new Welcome("Hello World!");
// 두 객체의 해시 코드
System.out.println(w1.hashCode()); // 4644653486
System.out.println(w2.hashCode()); // 4644653486
// 해시 코드가 달라도, 재정의한 equals 이므로 값비교를 통해 동등함
System.out.println(welcome1.equals(welcome2));
Set<Welcome> welcome = new HashSet<>();
welcome.add(welcome1);
welcome.add(welcome2);
System.out.println(welcome.size()) // 1
}
}
두 메소드를 재정의함에 따라, 두 객체의 해시코드가 같은 값이 나오게 되고, 동등성이 보장되어 Set 자료형에도 중복된 데이터의 적재로 판단되어 한 번만 추가됨을 알 수 있다.
그러나 객체의 hashCode는 고유하지 않을 수 있다. 해시 코드의 값으로만 두 객체의 동등을 판별하려 할 떄 발생하는 문제점 하나가 존재한다.
바로 hashCode()
메소드가 int형 정수를 반환한다는 것이다. 만약 컴퓨터가 32비트 사양이라면 문제되지 않는다. 그러나 현대 시대에서 사용하는 64비트 컴퓨터에서 동작하는 JVM은 기본적으로 8바이트 주소체계를 기본으로 사용하는데, 8바이트의 주소값을 HashCode를 이용해 반환하면 메소드의 타입에 따라 4바이트로 강제 캐스팅되기 때문에 값이 겹칠 수 있다. 이는 해시 충돌을 일으키며, 곧 다른 객체이지만 동일한 해시 코득 값을 가지게 한다.
다행이 이러한 예외가 존재하더라도, 큰 문제가 되지는 않는다. 위의 동작 과정에서 확인했듯, 동등성을 판단할 때는, hashCode 뿐만 아니라 equals 메소드를 사용하여 두 객체의 실제 주소를 비교하도록 구성되어있기 때문이다.