Kotlin에서 data class는 왜 사용할까

DoDoBest

·

2024. 2. 16. 16:57

자바에서는 클래스를 선언할 때, equals, hashCode, toString과 같은 메소드를 직접 선언해야 한다.

반면 코틀린에서는 컴파일러가 컴파일 단계에서 자동으로 생성해준다.

 

컴파일러가 자동으로 생성해주는 위 메소드들을 override 해야 되는 경우는 언제일까?

toString

기본 제공되는 toString 메소드에 의해 표현되는 문자열은 Client@ef32a8 과 같은 형식이다.

이 문자열을 클래스가 가지고 있는 데이터를 포함하는 의미있는 문자열로 변경하기 위해서 toString 메소드를 오버라이드해야 한다.

class Client(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Client (name=$name, postalCode=$postalCode)"
    }
}

 

equals

아래 코드의 실행 결과는 어떻게 될까?

val client1 = Client("Kotlin", 1)
val client2 = Client("Kotlin", 1)
println(client1 == client2)

 

false가 출력된다.

 

Kotlin의 == 는 컴파일 과정에서 자바의 equals로 변환된다. 자바의 equals는 원시 타입의 경우 두 피연산자의 값이 같은지 비교하지만, 참조 타입의 경우 두 피연산자의 주소가 같은지를 비교한다.

 

서로 다른 두 객체가 내부에 동일한 데이터를 포함하는 경우 그 둘을 동등한 객체로 간주하려면 어떻게 해야 할까?

이러한 상황에서 equals를 override 한다.

class Client(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Client (name=$name, postalCode=$postalCode)"
    }

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is Client) {
            return false
        }
        return name == other.name && postalCode == other.postalCode
    }
}

 

equals를 오버라이드 할 때, 반드시 hashCode도 함께 오버라이드해야 한다. 그 이유는 아래에서 설명한다.

 

hashCode

아래 코드의 실행 결과는 어떻게 될까?

 

val client1 = Client("Kotlin", 1)
val processed = hashSetOf(client1)
println(processed.contains(Client("Kotlin", 1)))

 

false가 출력된다.

 

processed 집합은 HashSet이다. HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시 코드를 비교하고 해시 코드가 같은 경우에만 실제 값을 비교한다. 방금 본 예제에서 두 Client 인스턴스는 해시 코드가 다르기 때문에 두 번째 인스턴스가 집합 안에 들어있지 않다고 판단한다.

 

Client가 가지고 있는 데이터에 따라 hashCode가 생성되도록 변경하면 아래와 같다.

class Client(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Client (name=$name, postalCode=$postalCode)"
    }

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is Client) {
            return false
        }
        return name == other.name && postalCode == other.postalCode
    }

    override fun hashCode(): Int {
        return name.hashCode() * 31 + postalCode
    }
}

 

data class는 왜 사용할까?

어떤 클래스가 데이터를 저장하는 역할만을 수행한다면 이전 Client 예시와 같이 toString, equals, hashCode를 반드시 오버라이드해야 한다.

Kotlin은 data 변경자를 class 앞에 붙이면 필요한 메소드를 컴파일러가 자동으로 생성해준다. 앞선 Client를 data class로 변경하면 아래와 같이 코드가 짧아진다.

data class Client(val name: String, val postalCode: Int)

 

주 생성자 밖에 정의된 프로퍼티는 equals와 hashCode를 계산할 때 고려의 대상이 아니라는 점에 주의하자.

data class Client(val name: String, val postalCode: Int) {
    val excludedData: String = "hello" // 이 값은 hashCode, equals 과정에서 고려되지 않는다.
}

 

data class의 property는 왜 불변이어야 하는가?

data class를 사용하다 보면 property를 불변 val로 사용하는 것이 권장된다는 말을 많이 볼 수 있다. 이유는 무엇일까?

 

Key로 사용하는 경우

HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우엔 불변성이 필수적이다.

데이터 클래스 객체를 key로 하는 값을 컨테이너에 담은 다음에 key로 쓰인 데이터 객체의 프로퍼티를 변경하면 예측하지 않은 결괏값을 얻을 수 있다.

data class Client(var name: String, var postalCode: Int)

fun main() {
    val versions = hashMapOf<Client, Int>()

    val kotlin = Client("Kotlin", 1)
    val java = Client("Java", 1)

    versions[kotlin] = 19
    versions[java] = 17

    kotlin.name = "Python"
    println(versions[kotlin]) // null

    java.name = "Kotlin"
    println(versions[java]) // null
}

 

다중 쓰레드

프로퍼티를 불변 객체로 관리할 경우, 프로그램 흐름을 쉽게 추론할 수 있다. 특히 다중스레드 프로그램의 경우 이런 성질은 더 중요하다. 불변 객체를 사용하면 스레드가 사용 중인 데이터를 다른 스레드가 변경할 수 없으므로 스레드를 동기화해야 할 필요가 줄어든다.

'학습 > CS' 카테고리의 다른 글

If I can use if, when to use when  (0) 2024.03.06
Sealed class vs Sealed interface vs Enum  (0) 2024.03.05