코틀린 data class에서 자동으로 처리하는 equals와 hashCode를 알아보자.



개인 광고 영역

효과적인 코틀린 사용하기 2 번째 글로 data class의 equals와 hashCode를 알아보려고 한다.


이 글에서 알아볼 내용

  • data class는 왜 사용하는지, Equals, hashCode를 왜 재정의 해야 하는지 살펴본다.
  • 값 비교할 때 사용하는 ==(equals)와 메모리 비교를 위한 ===를 알아본다.


드로이드 나이츠 2020 발표에 포함되어 있는 내용

이 내용은 발표 자료 맨 끝부분에 일부 포함되어 있어 링크합니다.


data class는 왜 쓰고 있을까?

Kotlin의 data class를 사용하는 이유는 간단한데, kotlinlang 문서 내용을 나열한다.

  • equals()/hashCode() 제공
  • toString() 제공
  • componentN() 함수 제공으로 선언 순서대로 값을 componentN으로 정의 가능
  • copy() 함수를 제공

이 중 equals와 hashCode 부분에 대해서 정리하려고 작성하는 글이다.

이 2개의 이유만으로도 충분히 equals와 hashCode 정의에 대해서 만 알아도 data class의 장점은 충분히 알 수 있다.


data class에서 제공하는 것들

equals와 hashCode는 이번 글에서 만은 부분 알아볼테니 간단하게나마 toString, componentN, copy를 알아보고 넘어가자.

data class에서 제공하는 것들에서 살펴볼 간단한 data class UserInfo를 아래와 같이 정의한다.

data class UserInfo(
    val name: String,
    val age: Int
)

data class에서 제공하는 것들 - toString

toString은 data class 정의에서 매우 편한 기능 중 하나이다.

data에 포함되어 있는 데이터를 빠르게 출력하여 볼 수 있으며, 아래 변수를 추가로 정의한다고 해도 toString을 매번 수정할 필요 없이 kotlin에서 자동으로 정의해 주기 때문에 매우 편하다.

이를 디컴파일 하면 아래와 같다.

@NotNull
public String toString() {
   return "UserInfo(name=" + this.name + ", age=" + this.age + ")";
}

data class에서 제공하는 것들 - componentN

componentN은 Destructuring Declarations 문서를 참고하면 자세한 내용을 알 수 있다.

위에서 정의한 data class인 UserInfo의 데이터를 val userInfo = UserInfo()의 형태가 아닌, 풀어쓰면 편한 경우가 있다.

이 경우 아래와 같이 ()로 묶어주고, 순서대로 변수 이름을 명시하면 되겠다.

val (name, age) = UserInfo("당신의 이름", 20)

tvName.text = name
tvAge.text = age.toString()

이렇게 정의하면 바로 name, age의 값에 직접 접근할 수 있다. 다만, data class가 거대하다면 componentN은 오히려 불편할 수 있다.

예를 들면 아래처럼 새로운 변수를 끼어 넣는다면?

data class UserInfo(
    val id: Long, // 여기서 새로 추가
    val name: String,
    val age: Int,
    val optionName: String // 여기서 새로 추가
)

id와 optionName이라는 새로운 변수를 두 개 추가했다. 이때 맨 끝에 추가한 게 아닌 중간에 끼어 넣었다면?

componentN은 맨 첫번째부터 1, 2…의 형태로 만들어진다. 그러니 기존 1이 name이었으나, id가 추가되어 1이 id로 변경되었다.

이로 인해 사용하던 코드 어디선가 즉시 오류가 발생하여 전부다 수정해 줘야 하는 단점이 생긴다.

그래서 이미 componentN을 사용하고 있을 수 있는 경우라면 꼭 기존 정의 뒤에다가 추가해야 오류 없이 사용이 가능하다.

data class UserInfo(
    val name: String,
    val age: Int,
    val id: Long,
    val optionName: String
)

fun test() {
    val (name, age) = UserInfo("당신의 이름", 20, 1000, "옵션은 아직 없다") // 문제 없이 실행
}

추가로 componentN에 대한 디컴파일 코드는 아래와 같다.

최초 data class에 name, age를 순서대로 정의했기에 아래와 같다.

@NotNull
public final String component1() {
   return this.name;
}

public final int component2() {
   return this.age;
}

중간에 끼어 넣는다면 아래처럼 변경되니 주의하여 사용하시길

public final long component1() {
   return this.id;
}

@NotNull
public final String component2() {
   return this.name;
}

public final int component3() {
   return this.age;
}

data class에서 제공하는 것들 - copy

마지막으로 copy이다. immutable 정의에서 매우 유용하게 사용할 수 있다.

위에서 작성한 코드에서 이름만을 교체하고 싶다면 보통 이렇게 할 수 있을 것이다.

data class UserInfo(
  val name: String,
  val age: Int,
  val id: Long
)

val userInfo = UserInfo("사용자 이름", 20, 1000)
val newInfo = UserInfo(userInfo.name, userInfo.age, 1001)

여기서 id 값만 교체하고 싶다면 위 코드와 같이 UserInfo의 각각 값을 모두 복사하고, 마지막의 id 값은 카피가 아닌 직접 수정해서 정의할 수 있다.

현재는 3개뿐이라서 간단하지만 실상은 RecyclerView에 포함되어 있는 isSelected 값 하나 바꾸려고 저런 긴 코드를 작성한다고 생각해보면 매우 귀찮다. 그리고 추가하면 또 수정해야 하고…

다행히 copy를 활용하여 빠른 해결이 가능한데, 얼마나 간단한가.

val userInfo = UserInfo("사용자 이름", 20, 1000)
//        val newInfo = UserInfo(userInfo.name, userInfo.age, 1001)
val newUserInfo = userInfo.copy(id = 1001)

참고로 내부 코드는 아래와 같다.

내부적으로 $default 함수를 통해 동작한다.

@NotNull
public final UserInfo copy(@NotNull String name, int age) {
   Intrinsics.checkParameterIsNotNull(name, "name");
   return new UserInfo(name, age);
}

// $FF: synthetic method
public static UserInfo copy$default(UserInfo var0, String var1, int var2, int var3, Object var4) {
   if ((var3 & 1) != 0) {
      var1 = var0.name;
   }

   if ((var3 & 2) != 0) {
      var2 = var0.age;
   }

   return var0.copy(var1, var2);
}


Equals와 hashCode

본론으로 돌아와 equals와 hashCode를 살펴보도록 하겠다.

이 부분은 Java Object 명세에 따르는 부분으로 자세한 내용은 Effective Java 책 또는 Kotlin in action을 참고하셔도 좋음.

몇 가지 규칙을 정리하면 아래와 같다.

  • Class에 equals를 정의했다면, 반드시 hashCode도 재정의해야 한다.
  • 2개 객체의 equals가 동일하다면 반드시 hashCode도 동일한 값이 리턴되어야 한다.
  • 이런 조건을 지키지 않을 경우 HashMap, HashSet 등의 컬렉션 사용 시 문제가 생길 수 있다.

equals와 hashCode 정의에 대한 규칙을 알지 못한다면 차라리 정의하지 않는 게 좋다고 Effective Java 책에서 언급하고 있으니 주의하여 사용해야 하는데 예를 들면 다음과 같을 수 있다.

name, age 2 변수를 가지는 class가 있다. 2 변수 중 name만 equals 정의를 하거나, 의미 없는 값으로 hashCode를 정의하는 경우이다. 이 경우 age 값에 대한 검증과 hashCode는 무조건 5라는 숫자를 리턴한다. 이런 식의 정의는 Java Object의 equals와 hashCode 조건을 어기는 부분으로 차라리 정의하지 않는 편이 좋다.

다만 name이 동일하다면 hashCode도 5로 동일하니 동일한 객체임은 맞을 수 있으나, Response 코드를 보면 오류 임도 알 수 있다.

class SomethingResponse(
    val name: String?,
    val age: Int
) {

    override fun equals(other: Any?): Boolean {
        return this.name == (other as? String)
    }

    override fun hashCode(): Int {
        return 5
    }
}

이 코드에 대한 일반적인 정의는 31을 bit 연산할 수 있다. 다행히 data class를 활용한다면 자동으로 만들어준다.

추가로 31을 사용하는 이유는 Effective Java 책과 Stack overflow 소개 등이 있는데, 아래는 StackOverflow 내용을 가져와보았다.

The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i « 5) - i. Modern VMs do this sort of optimization automatically.

31을 이용하여 아래와 같이 hashCode를 정의할 수 있고, equals도 직접 정의할 수 있다.

class SomethingResponse(
    val name: String?,
    val age: Int
) {

    override fun hashCode(): Int {
        return ((name?.hashCode() ?: 0) * 31 + age) * 31
    }

    override fun equals(other: Any?): Boolean {
        return if (this !== other) {
            if (other is SomethingResponse) {
                val (name, age) = other
                return Intrinsics.areEqual(name, age)
            }
            false
        } else {
            true
        }
    }
}

이러한 규칙을 매번 작성하기는 어려우니 Java에서도 Google이 만든 AutoValue을 사용하여 해결할 수 있다. 이 부분은 Effective Java 책(한국어)에 소개되어 있으니 참고하시길.


그래서 data class 활용하면 좋다?

답은 그렇다. Kotlin in action 책을 통해 확인할 수 있지만, 결론은 data class를 활용하면 Kotlin 언어 수준에서 알아서 equals/hashCode 정의해 줄 테니 손대지 않는 편이 좋다고 권장하고 있다. 그러니 data class를 적극 활용하는 게 좋다는 결론이다.

data class를 활용하더라도, 아래와 같은 케이스가 발생한다.

data class SomethingResponse(
    val name: String?,
    val age: Int
) {
    var isSelected: Boolean = false
}

필자도 간혹 사용하는 부분인데, data class 생성자에 값을 초기화하는 것이 아닌, 생성자 밖에 정의하는 경우가 생긴다.

이렇게 해도 equals, hashCode에 포함되어 있을까? 답은 NO. 디컴파일을 통해 확인해보자.

public int hashCode() {
   String var10000 = this.name;
   return (var10000 != null ? var10000.hashCode() : 0) * 31 + this.age;
}

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

      return false;
   } else {
      return true;
   }
}

위 코드에서 확인할 수 있듯 data class 생성자 밖에 정의한 값은 hashCode와 equals에 포함하지 않는다.

이 경우 어떻게 적용할 수 있을까? equals와 hashCode 재정의 가능하다. 하지만 이 경우 잘 알고 재정의한다면 문제가 없겠지만 혹여나 실수가 생길 수 있다.

딱히 중요한 값이 아니라면 equals/hashCode 재정의 하지 않는 편이 좋다.

그럼 어떻게 하는 게 가장 좋을까? 결국 아래와 같이 정의하는 게 가장 좋은데 var로 정의해두고 추후 값을 변경하는 형태로 해도 충분하다.

data class는 값을 immutable로 처리하는 게 좋다. 꼭 필요한 게 아니라면 mutable보단 immutable 정의를 추천한다.

data class SomethingResponse(
    val name: String?,
    val age: Int,
    var isSelected: Boolean = false
)


실행 결과물을 보자

아래와 같이 동일한 값을 채워 넣었다.

@Test
fun test() {
    val somethingItemOne = SomethingResponse(
        name = "aaa",
        age = 50,
        isSelected = false
    )
    val somethingItemTwo = SomethingResponse(
        name = "aaa",
        age = 50,
        isSelected = false
    )
    println("somethingItemOne == somethingItemTwo ${somethingItemOne == somethingItemTwo}")
    println("somethingItemOne.hashCode ${somethingItemOne.hashCode()}")
    println("somethingItemTwo.hashCode ${somethingItemTwo.hashCode()}")
}

위 실행 결과는 equals가 동일하기 때문에 hashCode도 동일한 결과가 나옴을 확인할 수 있다.

참고로 hashCode가 동일하지만 실제 메모리를 비교하는 somethingItemOne === somethingItemTwo의 결과는 false이다. 이 부분에 대한 이유는 비교연산자 ==, ===을 살펴볼 때 알아보자.

somethingItemOne == somethingItemTwo true
somethingItemOne.hashCode 92566031
somethingItemTwo.hashCode 92566031

var로 정의한 isSelected를 수정하면 equals의 값이 달라졌으니 다른 결과가 나오게 된다.

somethingItemOne.isSelected = true
println("somethingItemOne == somethingItemTwo ${somethingItemOne == somethingItemTwo}")
println("somethingItemOne.hashCode ${somethingItemOne.hashCode()}")
println("somethingItemTwo.hashCode ${somethingItemTwo.hashCode()}")

isSelected의 값이 서로 다르니 false가 나오고, hashCode 역시 다른게 리턴된다.

somethingItemOne == somethingItemTwo false
somethingItemOne.hashCode 92566032
somethingItemTwo.hashCode 92566031


이번엔 copy를 활용해보자

그래도 data class이니 immutable이 좋지 않을까라는 생각을 해볼 수 있다. 필자도 동의한다.

mutable로 값을 변경하는 한 개 이상의 thread가 있다면 동시 접근에 대한 문제가 발생할 가능성이 있다.

그러므로 immutable 정의를 해도고, copy를 활용하여 처리하는 편이 좋다.

// isSelected는 아래에서 한번 만 변경하였다.
// somethingItemOne.isSelected = true 해당 코드는 생략한 상태이다.

val somethingItemOneNew = somethingItemOne.copy(isSelected = true)
println("somethingItemOne == somethingItemTwo ${somethingItemOne == somethingItemTwo}")
println("somethingItemOneNew == somethingItemOne ${somethingItemOneNew == somethingItemOne}")
println("somethingItemOneNew == somethingItemTwo ${somethingItemOneNew == somethingItemTwo}")
println("somethingItemOne.hashCode ${somethingItemOne.hashCode()}")
println("somethingItemOneNew.hashCode ${somethingItemOneNew.hashCode()}")
println("somethingItemTwo.hashCode ${somethingItemTwo.hashCode()}")

somethingItemOne과 somethingItemTwo의 값은 동일하므로 결괏값은 true. copy를 이용하여 isSelected 값만 변경한 somethingItemOneNew과 somethingItemOne의 결과는 false이다.

hashCode의 결과물을 통해 좀 더 쉽게 파악할 수 있는데, somethingItemOne과 somethingItemTwo의 hashCode는 동일하고, isSelected의 값을 변경하였기에 somethingItemOneNew는 다른 hashCode가 리턴된다.

somethingItemOne == somethingItemTwo true
somethingItemOneNew == somethingItemOne false
somethingItemOneNew == somethingItemTwo false
somethingItemOne.hashCode 92566031
somethingItemOneNew.hashCode 92566032
somethingItemTwo.hashCode 92566031

참고로 equals/hashCode를 재정의하지 않은 경우라면 equals가 서로 같은 값을 가지더라도 equals아 hashCode는 모두 서로 다른 값을 리턴한다.

이 부분을 아래 equals와 메모리 비교하기를 통해 자세히 알아본다.


equals와 메모리 비교하기

Java와 Kotlin은 equals와 메모리 비교 시 사용하는 용어의 차이가 발생한다.

Java에서는 아래와 같이 비교한다.

  • equals : 값이 동일한지 체크한다.
  • == : 메모리상 동일한 객체인지 체크한다.

kotlin에서는 아래와 같이 비교한다.

  • == : 값이 동일한지 체크한다.
  • === : 메모리상 동일한지 체크한다.


data class에서 적용한 부분 검증해 보기

위에서 equals만 검증하던 data class로 정의한 SomethingResponse 객체를 메모리 부분을 검증을 추가해보자.

결과는 어떻게 될까?

@Test
fun stringMemory() {
    val somethingItemOne = SomethingResponse(
        name = "name",
        age = 50,
        isSelected = false
    )
    val somethingItemTwo = SomethingResponse(
        name = "name",
        age = 50,
        isSelected = false
    )

    println("somethingItemOne == somethingItemTwo ${somethingItemOne == somethingItemTwo}")
    println("somethingItemOne === somethingItemTwo ${somethingItemOne === somethingItemTwo}")
    println("somethingItemOne hashCode ${somethingItemOne.hashCode()}")
    println("somethingItemTwo hashCode ${somethingItemTwo.hashCode()}")
}

data class의 equals(==)는 true이고, 메모리 비교(===)는 false이다.

somethingItemOne == somethingItemTwo true
somethingItemOne === somethingItemTwo false
somethingItemOne hashCode -1052833319
somethingItemTwo hashCode -1052833319


원시 타입(Primitive type)에서의 비교

원시 타입(Primitive type)의 경우는 equals와 메모리 비교에서 모두 true를 리턴하는지 확인해보자.

companion object {
    private const val TYPE_NAME = "name"
}

enum class Type(val type: String) {
    NAME("name")
}

@Test
fun stringMemory() {
    val somethingItemOne = SomethingResponse(
        name = "name",
        age = 50,
        isSelected = false
    )

    println("somethingItemTwo.name == TYPE_NAME ${somethingItemOne.name == TYPE_NAME}")
    println("somethingItemTwo.name === TYPE_NAME ${somethingItemOne.name === TYPE_NAME}")
    println("somethingItemOne.name.hashCode() ${somethingItemOne.name.hashCode()}")
    println("TYPE_NAME.hashCode() ${TYPE_NAME.hashCode()}")
    println("Type.NAME.type.hashCode() ${Type.NAME.type.hashCode()}")
}

원시 타입인 String을 비교한 결과는 모두 true를 return 한다. 추가로 enum으로 정의한 String에 대해서도 동일하다.

somethingItemTwo.name == TYPE_NAME true
somethingItemTwo.name === TYPE_NAME true
somethingItemOne.name.hashCode() 3373707
TYPE_NAME.hashCode() 3373707
Type.NAME.type.hashCode() 3373707

Java String 사용 시 주의점을 잘 정리해둔 블로그가 있어 링크로 대체한다.

Java String 사용 시 주의점 - DevNote 블로그


Java Object.java 확인

모든 class는 Java Object.java의 룰을 따르고, Kotlin은 최상위 Any.kt를 따른다.

The root of the Kotlin class hierarchy. Every Kotlin class has [Any] as a superclass.

모든 class는 equals 정의를 별도로 하지 않으면 아래 Java 코드에 따라 기본 동작한다. 기본 동작이 Java 메모리 비교라 비교 시 false가 리턴된다.

public boolean equals(Object var1) {
    return this == var1;
}

아래와 같이 data 제거 시 class만 정의하고 equals/hashCode 정의하지 않으면 Java Object의 equals에 따라 2개의 서로 다른 객체의 메모리를 비교하게 된다. 서로 다른 2개의 객체를 equals 비교하면 false 리턴한다.

class SomethingResponse(
    val name: String?,
    val age: Int,
    var isSelected: Boolean = false
)

결과적으로 동일한 데이터를 정의했다고 하더라도, 이들은 서로 다른 메모리에 생성되었기 때문에 객체 자체를 비교하면 false이고, 값을 하나하나 비교한다면 true가 리턴된다.

val somethingItemOne = SomethingResponse(
    name = "name",
    age = 50,
    isSelected = false
)
val somethingItemTwo = SomethingResponse(
    name = "name",
    age = 50,
    isSelected = false
)

// 2 객체 비교
println("somethingItemOne == somethingItemTwo ${somethingItemOne == somethingItemTwo}")
println("somethingItemOne === somethingItemTwo ${somethingItemOne === somethingItemTwo}")
println("somethingItemOne hashCode ${somethingItemOne.hashCode()}")
println("somethingItemTwo hashCode ${somethingItemTwo.hashCode()}")

// 2 String 변수 비교
println("somethingItemOne.name == somethingItemTwo.name ${somethingItemOne.name == somethingItemTwo.name}")
println("somethingItemOne.name === somethingItemTwo.name ${somethingItemOne.name === somethingItemTwo.name}")
println("somethingItemOne hashCode ${somethingItemOne.name.hashCode()}")
println("somethingItemTwo hashCode ${somethingItemTwo.name.hashCode()}")

위 결과는 아래와 같다. equals와 hashCode 정의가 없는 2 객체의 equals 결과는 false이고, 메모리는 이것과 무관하게 다를 테니 false이다. equals가 다르니 hashCode 역시 false가 리턴된다.

// 2 객체 비교
somethingItemOne == somethingItemTwo false
somethingItemOne === somethingItemTwo false
somethingItemOne hashCode 425918570
somethingItemTwo hashCode 2143192188

원시 타입(Primitive type)의 결과는 조금 다르다. 이는 위에 걸어두었던 링크를 참고하시면 좋을 듯하다.

// 2 String 변수 비교
somethingItemOne.name == somethingItemTwo.name true
somethingItemOne.name === somethingItemTwo.name true
somethingItemOne hashCode 3373707
somethingItemTwo hashCode 3373707

결국 equals와 hashCode 정의를 잘 해두면 매우 편하게 사용할 수 있고, 기본 Java Object 조건을 충족할 수 있다.


마무리

이번 글에서 equals/hashCode의 중요성을 정리해보았다. 더 나아가 코틀린에서 기본 제공하는 data class 활용성을 높여야 개발이 편해진다는 점도 익힐 수 있었다.

필자도 학습을 게을리하다 보니 이제서야 정리해두고, 좀 더 잘 알고 사용하면 몸과 마음이 편해질 수 있다.

이 글을 기반으로 다음 글에서 RecyclerView DiffUtil 할 때 값의 비교 부분을 알아보려고 한다.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments