코딩하는 개굴이

[Kotlin] Data class, equals 를 재정의 할 거면 hashCode는 ? 본문

안드로이드/KOTLIN

[Kotlin] Data class, equals 를 재정의 할 거면 hashCode는 ?

개굴이모자 2024. 3. 11. 10:01
반응형

별 생각 없이 코드를 수정하다가 그냥 노란 워닝이 불편해서 자동으로 lint 플러그인 수정을 눌렀다가 큰코를 다친 적이 있었다.

이후로 왜인지 모르게 map 에서 remove 가 안되는 현상이 지속되어 오작동이 연쇄적으로 발생했는데,

그렇다. 본인은 아무 생각 없이 hashCode 를 오버라이드하는 실수를 저지른 것이다…

 

그러지 말라, 과거의 나... 전원을 꺼버릴라

 

반성하는 의미로 equals 와 hashCode 에 대해 더 깊이 알아보려한다.

 

어디서 들어는 보았다.

자바에서 equals 를 재정의할 때 hashCode 도 재정의해야한다는 말이 있다. 자바에서 클래스는 Object 를 상속받기 때문에 Object Class 에 정의된 toString, hashCode, equals 메서드를 재정의할 수 있다. Kotlin 에서도 이와 마찬가지로, Any Class 를 상속받으며 그에 정의된 세 메서드를 재정의 할 수 있는 것이다.

 

비록 우리는 비교할 때 객체의 동일성을 원하는 대로 만들 수는 없다.

이유인 즉슨 그를 위해서는 객체의 메모리 위치가 같은지 비교해야하지만 이는 자바에서 직접 제어할 수는 없기 때문이다.

 

그 대신, 우리는 재정의 할 수 있었던 equals, hashCode 를 이용해 객체의 동등성을 비교하여 객체의 내용이 같은지를 비교할 수 있다.

 

 

 

객체 비교하기

class User(val id: String, val name: String)

val user1 = User("1", "apple")
val user2 = User("1", "apple")

println(user1 == user2)

위 의 결과는 false 가 나올 것이다. equals 를 재정의하지 않는다면 객체 간의 관계에 대해 동일성 즉 저장된 위치가 같은지만을 보게 되기 때문이다.

User 클래스의 구조 상 우리는 id 를 기준으로한 동등성 연산이 필요하기 때문에 아래와 같이 구현할 수 있다.

class User(val id: String, val name: String) {
	override fun equals(other: Any?): Boolean {
		return if (other is User) {
			other.id == this.id
		} else {
			false
		}
	}
}

val user1 = User("1", "apple")
val user2 = User("1", "apple")

println(user1 == user2)

수정된 코드의 결과는 이제 원하던 대로 true 가 나오게 된다. 그러나, 우리는 아까 위에서 언급했듯 hashCode 를 재정의하지 않았다. 따라서, equals 는 노란줄이 뜨며 This class overrides "equals()" and should therefore also override "hashCode()". 이런 문구를 보여줄 것이다.

 

사실 hashCode 의 재정의가 없더라도 코드는 의도된 대로 돌아가는데 우리는 왜 재정의를 해야하는 것일까?

 

물론 일반적인 객체의 판별에 있어서는 equals() 메서드만으로 충분할 수 있다. hashCode() 는 어디까지나 객체가 같은지 여부를 판단하는데 도움을 주는 보조 역할을 하기 때문인데, 재정의하는 이유는 해시 기반의 자료구조에서 두드러지게 보이게 된다.

그렇다. 본인이 hashCode 로 인해 뻘짓을 했던 것도
클래스를 HashMap 구현체가 사용했기에 문제가 발생한 것이었던 것이었던 것이다.

 

 

hash 와 관련된 자료구조 (hashMap, hashTable) 은 동등성 연산 (equals) 전에 먼저 Hash Value의 비교를 수행한다. 따라서, hashCode 의 값이 같은 경우에만 equals 연산이 수행되는데, hash 와 관련된 자료구조의 사용은 필수적이므로, 동등한 값의 객체에 대해 동일한 hashCode() 동등한 값이라고 정의하려는 객체에 대해 동일한 hashCode 를 반환할 수 있도록 짜는 것은 필수적이라고 할 수 있다.

class User(val id: String, val name: String) {
	override fun equals(other: Any?): Boolean {
		return if (other is User) {
			other.id == this.id
		} else {
			false
		}
	}

	override fun hashCode(): Int {
		return id.toString().toInt()
		// or return id.hashCode()
	}
}

근데 문제는, hashCode 를 이용하든, String.toInt() 를 하든 32비트 내의 문자열에 대해서 해시 값을 비교할 때 hashValue 가 나오는 경우가 존재할 수 있다는 것이다. (본질적으로 충돌이 있다고한다.)

 

이런 케이스를 Hash Collision (해시 충돌) 이라고 하는데, 이런 경우는 Hash Value 가 같으므로 동등성 연산(equals)이 수행된다. 만일 이렇게 Hash Value 들이 같지만 equal 이 다른 객체들이 Map 에 가득찬다면, 같은 해시 값을 갖는 인스턴스들은 LinkedList 형태로 이어지기 때문에, 하나씩 Iteration 이 돌며 동등성 연산이 수행되게 된다. 그에 O(N) 의 시간 복잡도가 필요하게된다. 따라서 최대한 Hash 충돌을 피하는 HashCode 값을 짜야한다는 것이다.

 

 

 

만일 equals 만 재정의한다면?

간혹 우리는 equals 만 재정의해도 돌아가기도 하고, 아까처럼 hashCode 의 재정의에는 충돌의 주의가 필요하기에 고려하기 까다롭다는 이유로 hashCode 의 재정의를 하지 않는 경우가 있다. Java 에서 그러지 말아야한다며 덧붙인 설명은 아래와 같다.

 

재정의를 하지 않는다고 상황이 해결되지는 않는다. 예를 들어 우선 User(”1”, “apple”), User(”1”, “appl”) 을 비교한다고 하면, id 는 같지만 물리적으로 다르기 때문에 hashCode 의 값이 달리 나올 것이다. 즉 equals 를 통해 논리적으로 같다고 정의하였음에도 Object 클래스의 hashCode 가 사용되었기 때문에, 해시 코드 값이 달라 다른 버킷에 저장될 수 있다는 것이다. 그래서 hashCode 의 재정의도 함께 되어야한다는 얘기가 나온 것이다.

 

 

자, 그런데 코틀린의 경우는 얘기가 조금 다르다. 애초에 Java 를 기준으로 위처럼 생각했기 때문에 전반적으로 Kotlin 에서의 동작이 이해가 되지 않았다. 예시와 함께 살펴보자.

 

 

 

정말 그럴까, Custom Data class 의 구현을 아예 안하면?

data class TestData(val id: String, var name: String, val phoneNumber: String, val urls: Array<String>)

위와 같은 데이터 클래스를 만들었다고 해보자. equals 나 hashCode 중 어느것도 재정의하지 않았다.

 

그러면 어떻게 동작하게될까?

 

Java 에서는 Object.hashCode 를 수행해 주소값을 비교한다고 했지만 실제로 코드를 디컴파일해보면 아래와 같다.

// java 로 decompile 시 확인할 수 있는 내부 구현
public int hashCode() {
      String var10000 = this.id;
      int var1 = (var10000 != null ? var10000.hashCode() : 0) * 31;
      String var10001 = this.name;
      var1 = (var1 + (var10001 != null ? var10001.hashCode() : 0)) * 31;
      var10001 = this.phoneNumber;
      var1 = (var1 + (var10001 != null ? var10001.hashCode() : 0)) * 31;
      String[] var2 = this.urls;
      return var1 + (var2 != null ? Arrays.hashCode(var2) : 0);
   }

  public boolean equals(@Nullable Object var1) {
      if (this != var1) {
         if (var1 instanceof TestData) {
            TestData var2 = (TestData)var1;
            if (Intrinsics.areEqual(this.id, var2.id) && Intrinsics.areEqual(this.name, var2.name) && Intrinsics.areEqual(this.phoneNumber, var2.phoneNumber) && Intrinsics.areEqual(this.urls, var2.urls)) {
               return true;
            }
         }

         return false;
      } else {
         return true;
      }
   }

hashCode 는 각 id, name, phoneNumber, urls 들에 대해 각 hashCode 를 이용한 연산을 수행한다.

 

만일 이렇게 된다면 hashCode 만 보게 된다면 TestData(”1”, “name”, “010-…”, arrayOf(””)) 와 TestData(”1”, “name”, “010-…”, arrayOf(””)) 즉 두 값이 같은 값을 가진다면 같다고 여겨지게 될 것이다. 의도한 대로 혼자 잘 만들어졌다.

 

그러나 equals 에서는 객체가 같은지 자체를 보고 있기 때문에 TestData(”1”, “name”, “010-…”, arrayOf(””)) 와 TestData(”1”, “name”, “010-…”, arrayOf(””)) 에 대해서 false 를 반환하게 될 것이다.

 

여기서 우리는 Java와 비교해 kotlin 에서는 hashCode 의 구현을 하지 않으면 주소 값을 보게 되어 같은 값으로 인식되어야하는 상황에서 다르게 인식할 것이다 라는 Java 의 주의 사항이 해당되지 않는다는 것을 알 수 있다. 그러나, equals 에서 객체가 다르기 때문에 우리는 equals 를 구현해야 할 것이다.

 

 

equals 만 구현하면?

equals 만 id 를 기준으로 보게끔 구현한다고 해보자. 그러면 아래와 같이 될 것이다.

instance 가 TestData 인지 확인하고 id 를 비교한다. 그리고 hashCode 의 구현은 이전과 같다.

 

따라서 equals 만 구현하게 된다면 hashCode 는 TestData 의 값 전체들을 합한 연산의 값을 비교하게 되기 때문에 hashCode 의 동작은 id 만 보고자 한다는 의도와는 반하게 될 것이다.

 public boolean equals(@Nullable Object other) {
      return other instanceof TestData ? Intrinsics.areEqual(this.id, ((TestData)other).id) : false;
   }

 public int hashCode() {
      String var10000 = this.id;
      int var1 = (var10000 != null ? var10000.hashCode() : 0) * 31;
      String var10001 = this.name;
      var1 = (var1 + (var10001 != null ? var10001.hashCode() : 0)) * 31;
      var10001 = this.phoneNumber;
      var1 = (var1 + (var10001 != null ? var10001.hashCode() : 0)) * 31;
      String[] var2 = this.urls;
      return var1 + (var2 != null ? Arrays.hashCode(var2) : 0);
   }

 

 

super.hashCode 를 하면?

 

사실 본인이 실수한 부분이 여기에 있다. 당연히 super.hashCode 가 구현을 아예 하지 않을 때와 동일할 것이라 생각하고 lint 노란줄을 없애고자 super.hashCode 를 똭! 하고 넣어버린 것이다.

public boolean equals(@Nullable Object other) {
      return other instanceof TestData ? Intrinsics.areEqual(this.id, ((TestData)other).id) : false;
   }

public int hashCode() {
      return super.hashCode();
   }

 

위는 decompile 한 결과이지만 여기 또한 super.hashCode 인 것을 알 수 있다. 그러나, 여기는 Java 이기 때문에 이 super 는 Object 이다. 따라서 이는 주소값을 보게 될 것이다.

 

그러면 우리는 아까 위에서 언급했던 equals 에서는 같다고 정의했으나 주소값이 다르기 때문에 다른 버킷에 저장되는 불상사가 발생하는 코드를 만들게 되는 것이다.

 

 

 

아 그러면 대체 어찌하라는 것이오

이것도 문제 있고 저것도 문제있고 어쩌자는거지 어쩔티비 저쩔스타일러

 

자, 진정하자. 천천히 지금까지 우리가 decompile로 코드까지 까보면서 알 수 있었던걸 정리해보도록 하자.

  • 첫번째, kotlin hashCode 에서는 java 와 달리 data class 의 재정의를 하지 않으면 Object 의 비교가 아니라 자체적으로 구현된 동작을 수행한다.
    • 이는 각 값들의 hashCode 를 비교하기 때문에 만일 각 값들이 같으면 같다고 반환된다.
  • 두번째, kotlin hashCode 의 재정의를 super.hashCode 로 한다면 Object.hashCode 가 호출되어 주소값을 비교하게 된다.
  • 세번째, kotlin에서 equals 만 정의한다면 hashCode는 값 전체들을 비교하기 때문에 만일 id 외의 값이 달라졌다면 같다고 판단하지 않을 수 있다.
    • 그렇게 된다면 hashMap 등 hashCode 를 먼저 보는 자료구조에 대해서 오작동이 유발된다.

 

그렇다면 우리는 한가지 결론에 이른다. 포스팅의 타이틀과 같이 hashCode 를 정의하면 된다는 것이다.

data class TestData(val id: String, var name: String, val phoneNumber: String, val urls: Array<String>) {
    override fun equals(other: Any?): Boolean {
        return if(other is TestData) {
            id == other.id
        } else {
            false
        }
    }

    override fun toString(): String {
        return "[TestData] id: $id, name: $name, phoneNumber: $phoneNumber, urls: $urls"
    }

    override fun hashCode(): Int {
        return id.hashCode()
    }
}   
   
   
   
// ============================== decompile result ==============================

   public boolean equals(@Nullable Object other) {
      return other instanceof TestData ? Intrinsics.areEqual(this.id, ((TestData)other).id) : false;
   }
   
   @NotNull
   public String toString() {
      return "[TestData] id: " + this.id + ", name: " + this.name + ", phoneNumber: " + this.phoneNumber + ", urls: " + this.urls;
   }

   public int hashCode() {
      return this.id.hashCode();
   }

 

왜 hashCode 를 정의해야하고 kotlin 의 hashCode 는 왜 재정의하지 않아도 java 와 달리 오작동을 안하는지 살펴보기위해 먼 길을 돌아왔지만 결론은 동일하다.

 

equals 와 같이 hashCode 를 정의해야한다는 것이다. 위처럼 id 를 equals 가 보고있다면 hashCode 도 동일하게 id 의 hashCode 를 보게하면 decompile 은 string.hashCode 를 수행해 이를 비교하여 동일한 동작을 할 것이다. 그러나, 충돌의 주의점은 여전히 존재한다. 따라서 주의하여 재정의를 해야한다는 것은 동일하다.

 

 

 

또 한가지 드는 생각이 있을 것이다.

 

아니 그러면, equals 를 재정의할 때 만일 모든 값이 같은지를 본다면 hashCode 를 구현하지 않아도 되는 것이 아닌가?

 

맞다. kotlin 의 data class 는 구현하지 않으면 우선 default 로 지금까지 본 예시와 같이 자동으로 모든 값의 hashCode 에 대해 연산하여 값을 내어준다. 따라서 구현하지 않아도 동일한 동작을 하게 될 것이다.

 

 

 

결국 java 에는 없는 data class 를 kotlin 이 만들면서 고민한 결과는 아래와 같은 결론에 도달한 것이다.

 

‘설령 물리적으로 다른 데이터라도, 논리적으로 값이 같다면 같은 데이터로 볼 것이다.’

 

 

 

참고 링크

반응형
Comments