시스템아 미안해

Chapter 6: Class design - Item 42. Respect the contract of equals 본문

책/Effective Kotlin

Chapter 6: Class design - Item 42. Respect the contract of equals

if else 2026. 3. 6. 17:31

Kotlin에서 모든 객체는 Any를 확장하며, Any에는 명확한 계약이 정의된 몇 가지 메서드가 있습니다.

  • equals
  • hashCode
  • toString

이 메서드들의 계약은 주석과 공식 문서에 명시되어 있습니다. 아이템 31에서 설명했듯이, 계약이 있는 타입의 모든 하위 타입은 그 계약을 준수해야 합니다. 이 메서드들은 Java 초기부터 정의되어 있었기 때문에 매우 많은 객체와 함수들이 이 계약에 의존하고 있습니다. 계약을 위반하면 일부 객체나 함수가 올바르게 동작하지 않을 수 있습니다. 이번 아이템과 다음 아이템에서는 이 함수들을 오버라이드하는 방법과 각각의 계약에 대해 살펴봅니다.


동등성(Equality)

Kotlin에서 동등성에는 두 가지 종류가 있습니다.

  • 구조적 동등성(Structural equality): equals 메서드 또는 == 연산자(!=의 반대)로 확인합니다. a == b는 a가 nullable이 아니면 a.equals(b)로, nullable이면 a?.equals(b) ?: (b === null)로 변환됩니다.
  • 참조적 동등성(Referential equality): === 연산자(!==의 반대)로 확인합니다. 양쪽이 동일한 객체를 가리킬 때만 true를 반환합니다.

equals는 모든 클래스의 슈퍼클래스인 Any에 구현되어 있기 때문에 어떤 두 객체든 동등성을 비교할 수 있습니다. 다만 서로 다른 타입의 객체 간에는 동등성 비교 연산자를 사용할 수 없습니다.

open class Animal
class Book
Animal() == Book()  // Error: Operator == cannot be
                    // applied to Animal and Book
Animal() === Book() // Error: Operator === cannot be
                    // applied to Animal and Book

동등성 비교는 같은 타입이거나, 한쪽이 다른 쪽의 하위 타입인 경우에만 허용됩니다.

class Cat : Animal()
Animal() == Cat()  // OK, because Cat is a subclass of Animal
Animal() === Cat() // OK, because Cat is a subclass of Animal

이 제약은 서로 다른 타입의 두 객체를 동등 비교하는 것이 의미 없다는 사실에서 비롯되며, equals의 계약을 설명하면서 그 이유가 더 명확해집니다.


equals가 필요한 이유

equals의 기본 구현은 참조적 동등성(===)과 동일하게, 다른 객체가 정확히 같은 인스턴스인지를 확인합니다. 즉, 기본적으로 모든 객체는 고유합니다.

 

class Name(val name: String)

val name1 = Name("Marcin")
val name2 = Name("Marcin")
val name1Ref = name1

name1 == name1    // true
name1 == name2    // false
name1 == name1Ref // true

name1 === name1    // true
name1 === name2    // false
name1 === name1Ref // true

이런 동작은 데이터베이스 연결, 레포지토리, 스레드처럼 각 객체가 고유한 존재인 활성 객체들에는 완벽하게 적합합니다. 그러나 보유한 데이터로 동등성을 표현해야 하는 경우도 있습니다. 대표적인 대안이 data 클래스 동등성으로, 모든 주 생성자 프로퍼티가 동일한지를 비교합니다.

 

data class FullName(val name: String, val surname: String)

val name1 = FullName("Marcin", "Moskata")
val name2 = FullName("Marcin", "Moskata")
val name3 = FullName("Maja", "Moskata")

name1 == name1 // true
name1 == name2 // true, because data are the same
name1 == name3 // false

name1 === name1 // true
name1 === name2 // false
name1 === name3 // false

이러한 동작은 자신이 보유한 데이터로 표현되는 클래스에 매우 적합합니다. 따라서 우리는 데이터 모델 클래스나 기타 데이터 보관 용도의 클래스에서 data 수정자(data modifier)를 자주 사용합니다.

 

또한 data class의 동등성(equality) 비교 기능은 일부 속성만 비교하고 싶을 때도 유용합니다. 예를 들어 캐시(cache)나 기타 중복된(redundant) 속성은 제외하고 나머지 값들만 비교해야 하는 경우가 있습니다.

 

예를 들어, 다음과 같은 날짜와 시간을 표현하는 객체가 있다고 가정해 보겠습니다. 이 객체에는 asStringCache와 changed라는 속성이 있는데, 이러한 속성들은 동등성 비교(equality checking)에 포함되어서는 안 되는 값들입니다.

class DateTime(
    /** The millis from 1970-01-01T00:00:00Z */
    private var millis: Long = 0L,
    private var timeZone: TimeZone? = null
) {
    private var asStringCache = ""
    private var changed = false

    override fun equals(other: Any?): Boolean =
        other is DateTime &&
                other.millis == millis &&
                other.timeZone == timeZone

    //...
}

data 한정자를 사용해도 동일하게 구현할 수 있습니다.

data class DateTime(
    private var millis: Long = 0L,
    private var timeZone: TimeZone? = null
) {
    private var asStringCache = ""
    private var changed = false

    //...
}

이 경우 한 가지 유의할 점이 있습니다. copy 함수는 주 생성자(primary constructor)에 선언되지 않은 속성들은 복사하지 않습니다.

이러한 동작이 올바르게 작동하려면 추가로 존재하는 속성들이 실제로 중복된(redundant) 데이터일 때만 가능합니다. 다시 말해, 그 속성들이 사라져도 객체의 동작에 문제가 없어야 합니다.

 

코틀린에서는 기본 클래스의 동등성 비교(default equality) 와 data class의 동등성 비교라는 두 가지 방식이 있기 때문에, 대부분의 경우 직접 equality를 구현할 필요가 거의 없습니다.

 

하지만 직접 equality를 구현해야 하는 경우도 존재합니다. 대표적인 예는 특정 하나의 속성만 비교하고 싶을 때입니다.

 

예를 들어 User 클래스에서 두 사용자가 동일한지 여부를 id 값만으로 판단해야 한다는 규칙이 있을 수 있습니다. 이런 경우에는 id가 동일하면 두 User 객체를 같은 것으로 간주하도록 equality를 직접 구현할 수 있습니다.

 
class User(
    val id: Int,
    val name: String,
    val surname: String
) {
    override fun equals(other: Any?): Boolean =
        other is User && other.id == id

    override fun hashCode(): Int = id
}

정리하면, equals를 직접 구현해야 하는 경우는 다음과 같습니다.

  • 기본 로직과 다른 동등성 로직이 필요한 경우
  • 일부 프로퍼티만 비교해야 하는 경우
  • data 클래스로 만들고 싶지 않거나, 비교해야 할 프로퍼티가 주 생성자에 없는 경우

equals의 계약

다음은 Kotlin 1.9.0 기준으로 equals의 주석에 명시된 계약입니다.

  • 반사성(Reflexive): null이 아닌 모든 값 x에 대해 x.equals(x)는 true를 반환해야 합니다.
  • 대칭성(Symmetric): null이 아닌 모든 값 x와 y에 대해, x.equals(y)가 true를 반환하는 경우에만 y.equals(x)도 true를 반환해야 합니다.
  • 추이성(Transitive): null이 아닌 모든 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)가 true이면, x.equals(z)도 true를 반환해야 합니다.
  • 일관성(Consistent): null이 아닌 모든 값 x와 y에 대해, equals 비교에 사용된 정보가 수정되지 않는 한 x.equals(y)는 항상 동일한 결과를 반환해야 합니다.
  • null과는 절대 같지 않음(Never equal to null): null이 아닌 모든 값 x에 대해 x.equals(null)은 false를 반환해야 합니다.

추가로, equals, toString, hashCode는 빠르게 동작해야 합니다. 이는 공식 계약의 일부는 아니지만, 두 요소가 같은지 확인하는 데 몇 초씩 걸린다면 매우 당혹스러울 것입니다.

이 요구사항들은 매우 중요하며 JVM 플랫폼 초창기부터 가정되어왔기 때문에, 지금은 매우 많은 객체들이 이 가정에 의존하고 있습니다. 각 요구사항을 하나씩 살펴보겠습니다.

동등성은 반사적이어야 합니다. 즉, x.equals(x)는 항상 true를 반환해야 합니다. 당연해 보이지만 위반될 수 있습니다. 예를 들어, 밀리초 단위로 비교하면서 현재 시각도 표현할 수 있는 Time 객체를 만든다면 다음과 같습니다.

 

// DO NOT DO THIS!
class Time(
    val millisArg: Long = -1,
    val isNow: Boolean = false
) {
    val millis: Long
        get() =
            if (isNow) System.currentTimeMillis()
            else millisArg

    override fun equals(other: Any?): Boolean =
        other is Time && millis == other.millis
}

val now = Time(isNow = true)
now == now // Sometimes true, sometimes false
List(100000) { now }.all { it == now }
// Most likely false

 

여기서 결과가 일관되지 않다는 점을 주목해야 합니다. 따라서 이것은 마지막 원칙 또한 위반하게 됩니다.

어떤 객체가 자기 자신과도 같지 않다면, contains 메서드로 확인했을 때 컬렉션 안에 실제로 존재하더라도 대부분의 컬렉션에서 해당 객체를 찾지 못할 수 있습니다.

또한 이런 객체는 대부분의 단위 테스트 assertion에서도 올바르게 동작하지 않습니다.

val now1 = Time(isNow = true)
val now2 = Time(isNow = true)
assertEquals(now1, now2)
// Sometimes passes, sometimes not

결과가 일관되지 않다면 그 결과는 신뢰할 수 없습니다.
우리는 그 결과가 정말 올바른 것인지, 아니면 단지 불일치로 인해 우연히 나온 결과인지 확신할 수 없게 됩니다.

 

그렇다면 Time 클래스를 어떻게 개선해야 할까요?
가장 단순한 해결 방법은 해당 객체가 현재 시각(current time)을 나타내는지 여부를 별도로 확인하는 것입니다. 그리고 현재 시각을 나타내는 객체가 아니라면, 그다음에는 타임스탬프가 같은지를 비교하면 됩니다.

 

다만 이런 방식은, Item 40에서 설명한 **“태그 클래스(tagged class)보다 클래스 계층(class hierarchy)을 선호하라”**는 내용처럼, 전형적인 태그 클래스의 예시에 해당합니다. 따라서 이 경우에도 태그 클래스를 사용하는 것보다 클래스 계층 구조로 표현하는 편이 더 바람직합니다.

sealed class Time
data class TimePoint(val millis: Long) : Time()
object Now : Time()

객체의 동등성(equality)은 대칭적(symmetric) 이어야 합니다.
즉, x == y의 결과와 y == x의 결과는 항상 동일해야 합니다.

 

하지만 equals 구현에서 다른 타입의 객체를 허용하면 이 원칙이 쉽게 깨질 수 있습니다.

 

예를 들어, 복소수(complex number)를 표현하는 클래스를 구현하면서 Double 타입과의 비교도 허용하도록 equality를 구현했다고 가정해 보겠습니다.

class Complex(
    val real: Double,
    val imaginary: Double
) {
    // DO NOT DO THIS, violates symmetry
    override fun equals(other: Any?): Boolean {
        if (other is Double) {
            return imaginary == 0.0 && real == other
        }
        return other is Complex &&
                real == other.real &&
                imaginary == other.imaginary
    }
}

문제는 Double은 Complex와의 동등성을 허용하지 않는다는 것입니다. 따라서 비교 순서에 따라 결과가 달라집니다.

Complex(1.0, 0.0).equals(1.0) // true
1.0.equals(Complex(1.0, 0.0)) // false

대칭성이 없으면 contains 메서드나 단위 테스트 검증에서 예상치 못한 결과가 나올 수 있습니다.

val list = listOf<Any>(Complex(1.0, 0.0))
list.contains(1.0) // Currently on the JVM this is false,
                   // but it depends on the collection's implementation
                   // and should not be trusted to stay the same

 

동등성이 대칭적이지 않을 때, 그리고 그 비교가 다른 객체에서 사용될 경우, 그 결과는 신뢰할 수 없습니다. 왜냐하면 결과가 x를 y와 비교하느냐, 아니면 y를 x와 비교하느냐에 따라 달라질 수 있기 때문입니다.

 

이러한 사실은 보통 문서에 명시되어 있지 않으며, 객체를 사용하는 사람들도 양쪽 비교가 동일하게 동작할 것(대칭성)을 당연히 가정합니다. 또한 개발자가 언제든지 리팩토링을 하면서 비교 순서를 바꿀 수도 있습니다.

 

만약 객체의 동등성이 대칭적이지 않다면, 구현에서 예상하지 못한 오류가 발생할 수 있으며, 이런 오류는 디버깅하기도 매우 어려운 경우가 많습니다.
따라서 equals를 구현할 때는 항상 대칭성(symmetric)을 고려해야 합니다.

 

일반적인 해결 방법은 서로 다른 클래스 간의 동등성 비교를 허용하지 않는 것입니다. 실제로 그렇게 하는 것이 합리적인 경우를 저는 본 적이 없습니다.

 

코틀린에서도 비슷해 보이는 값이라도 타입이 다르면 서로 같지 않습니다.

 

예를 들면 다음과 같습니다.

  • 1 은 1.0 과 같지 않습니다.
  • 1.0 은 1.0F 와도 같지 않습니다.

이들은 서로 다른 타입이며, 심지어 비교 대상 자체가 되지도 않습니다.

 

또한 코틀린에서는 Any 외에 공통 상위 타입이 없는 서로 다른 타입 두 개에 대해 == 연산자를 사용할 수 없습니다.

Complex(1.0, 0.0) == 1.0 // ERROR

동등성은 추이적이어야 합니다. 즉, null이 아닌 x, y, z에 대해 x.equals(y)와 y.equals(z)가 모두 true이면 x.equals(z)도 true여야 합니다. 추이성 위반은 서로 다른 종류의 프로퍼티를 검사하는 서로 다른 동등성을 구현할 때 가장 흔하게 발생합니다. 예를 들어 Date와 DateTime이 다음과 같이 구현되었다고 가정해봅시다.

open class Date(
    val year: Int,
    val month: Int,
    val day: Int
) {
    // DO NOT DO THIS, symmetric but not transitive
    override fun equals(o: Any?): Boolean = when (o) {
        is DateTime -> this == o.date
        is Date -> o.day == day &&
                o.month == month &&
                o.year == year
        else -> false
    }
}

class DateTime(
    val date: Date,
    val hour: Int,
    val minute: Int,
    val second: Int
) : Date(date.year, date.month, date.day) {
    // DO NOT DO THIS, symmetric but not transitive
    override fun equals(o: Any?): Boolean = when (o) {
        is DateTime -> o.date == date &&
                o.hour == hour &&
                o.minute == minute &&
                o.second == second
        is Date -> date == o
        else -> false
    }

    // ...
}

이 구현의 문제는 DateTime 두 개를 비교할 때는 Date 두 개를 비교할 때보다 더 많은 프로퍼티를 비교한다는 점입니다. 따라서 같은 날짜이지만 다른 시각인 두 DateTime 인스턴스는 서로 같지 않지만, 둘 다 같은 Date와는 같다고 판단됩니다. 결과적으로 추이성이 깨집니다.

val o1 = DateTime(Date(1992, 10, 20), 12, 30, 0)
val o2 = Date(1992, 10, 20)
val o3 = DateTime(Date(1992, 10, 20), 14, 45, 30)

o1 == o2 // true
o2 == o3 // true
o1 == o3 // false, so equality is not transitive

setOf(o2, o1, o3).size // 1 or 2?
                        // Depends on the collection's implementation

여기서 같은 타입의 객체만 비교하도록 제한하는 것만으로는 문제가 해결되지 않습니다. 왜냐하면 이 경우에는 상속(inheritance)을 사용하고 있기 때문입니다.

 

이러한 상속 구조는 리스코프 치환 원칙(Liskov Substitution Principle) 을 위반하게 되며, 따라서 사용해서는 안 되는 설계입니다.

 

이 경우에는 상속 대신 합성(composition)을 사용하는 것이 바람직합니다.
(참고: Item 36 — Prefer composition over inheritance)

 

또한 합성을 사용하게 되면 서로 다른 타입의 두 객체를 비교하지 않도록 해야 합니다.

 

이 클래스들은 데이터를 보관하는 객체의 전형적인 예이기 때문에, 이와 같은 방식으로 표현하는 것은 좋은 설계 선택이라고 할 수 있습니다.

 
data class Date(
    val year: Int,
    val month: Int,
    val day: Int
)

data class DateTime(
    val date: Date,
    val hour: Int,
    val minute: Int,
    val second: Int
)

val o1 = DateTime(Date(1992, 10, 20), 12, 30, 0)
val o2 = Date(1992, 10, 20)
val o3 = DateTime(Date(1992, 10, 20), 14, 45, 30)

o1.equals(o2) // false
o2.equals(o3) // false
o1 == o3      // false

o1.date.equals(o2) // true
o2.equals(o3.date) // true
o1.date == o3.date // true

동등성은 일관적이어야 합니다. 즉, 비교에 사용된 정보가 수정되지 않는 한 동일한 두 객체에 대해 메서드를 여러 번 호출해도 항상 같은 결과를 반환해야 합니다. 불변 객체에서는 결과가 항상 같아야 합니다. 즉, equals는 순수 함수여야 하며, 결과는 항상 입력과 수신자의 상태에만 의존해야 합니다. 앞서 본 Time 클래스가 이 원칙을 위반하는 예시입니다.


java.net.URL의 equals 문제

equals가 잘못 설계된 대표적인 사례가 java.net.URL입니다. 두 java.net.URL 객체의 동등성은 네트워크 연산에 의존합니다. 두 호스트명이 동일한 IP 주소로 변환될 수 있으면 동등하다고 판단하기 때문입니다.

import java.net.URL

fun main() {
    val enWiki = URL("https://en.wikipedia.org/")
    val wiki = URL("https://wikipedia.org/")
    println(enWiki == wiki)
}

이 코드는 true를 반환해야 할 것처럼 보이지만, 결과가 일관되지 않습니다. 인터넷이 연결된 환경에서는 두 URL의 IP 주소가 같은 것으로 확인되어 true를 출력하지만, 인터넷이 끊긴 환경에서는 false를 출력합니다. 이것은 심각한 설계 오류입니다. 동등성은 네트워크 상태에 의존해서는 안 됩니다.

이 설계의 주요 문제점은 다음과 같습니다.

  • 동작이 일관되지 않습니다. 인터넷 연결 여부에 따라 같은 두 URL이 같을 수도, 다를 수도 있습니다. IP 주소도 시간이 지남에 따라 변할 수 있습니다.
  • equals와 hashCode가 느릴 수 있습니다. URL이 리스트에 있는지 확인하면 각 요소마다 네트워크 호출이 필요합니다. Android처럼 메인 스레드에서 네트워크 작업이 금지된 플랫폼에서는 URL을 Set에 추가하는 것조차 별도의 스레드에서 처리해야 합니다.
  • HTTP에서의 가상 호스팅과 일관성이 없습니다. IP 주소가 같다고 해서 콘텐츠가 같은 것은 아닙니다. 가상 호스팅을 사용하면 서로 관련 없는 사이트들이 같은 IP 주소를 공유할 수 있기 때문입니다.

Android에서는 이 문제가 Android 4.0(Ice Cream Sandwich)에서 수정되었습니다. 그 이후로 URL은 호스트명이 동일한 경우에만 같은 것으로 판단합니다. Kotlin/JVM을 다른 플랫폼에서 사용할 때는 java.net.URL 대신 java.net.URI를 사용하는 것을 권장합니다.


equals 구현하기

특별한 이유가 없다면 직접 equals를 구현하지 않는 것을 권장합니다. 대신 기본 구현이나 data 클래스 동등성을 활용하세요. 커스텀 동등성이 필요하다면, 구현이 반사적, 대칭적, 추이적, 일관적인지 반드시 확인해야 합니다. 전형적인 equals 구현은 다음과 같습니다.

override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (other !is MyClass) return false
    return field1 == other.field1 &&
            field2 == other.field2 &&
            field3 == other.field3
}
// or
override fun equals(other: Any?) =
    this === other ||
    (other is MyClass &&
    field1 == other.field1 &&
    field2 == other.field2 &&
    field3 == other.field3)

커스텀 equals를 구현하는 클래스는 final로 선언하거나, 하위 클래스에서 동등성 동작을 변경하지 않도록 주의해야 합니다. 상속과 커스텀 동등성을 동시에 올바르게 구현하는 것은 매우 어렵습니다. 일부에서는 불가능하다고까지 말합니다. 이것이 data 클래스가 final인 이유 중 하나입니다.