[코틀린] 데이터 클래스

hee09·2021년 11월 23일
0
post-thumbnail

데이터 클래스의 개요

자바에서는 클래스가 equals, hashCode, toString 등의 메서드를 구현해야 합니다. 이 글에서 알아볼 데이터 클래스를 사용하면 코틀린 컴파일러는 위의 메소드들을 기계적으로 생성하는 작업(IDE를 통해 추가하거나 직접 작성하는 등..)을 보이지 않는 곳에서 해줍니다. 따라서 필수 메서드로 인해 코드가 지저분해지는 것을 방지해줍니다. 데이터 클래스를 사용하지 않고 직접 코드를 구현하며 데이터 클래스에 대해 알아보겠습니다.


모든 클래스가 정의하는 메서드

자바와 마찬가지로 코틀린 클래스도 toString, equals, hashCode 등을 오버라이드할 수 있습니다. 우선 이 메서드들은 각각 역할이 있는데 예시를 통해 알아보겠습니다.

데이터 저장 역할을 하는 Client 클래스

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

문자열 표현: toString()

자바처럼 코틀린의 모든 클래스도 인스턴스의 문자열 표현을 얻을 방법을 제공합니다. 주로 디버깅과 로깅 시 이 메서드를 사용합니다. 기본 제공되는 객체의 문자열 표현은 'Client@19dfb72a'와 같은 방식인데, 이 기본 구현을 변경하려면 toString() 메소드를 오버라이드 해야합니다.

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

fun main() {
    val client = Client("홍길동", 125)
    println(client)
}

결과 : Client(name=홍길동, postalCode=125)

위와 같이 toString()을 오버라이드하여 표현하면 기본 문자열 표현보다 많은 정보를 획득할 수 있습니다.


객체의 동등성: equals()

위와 같은 Client 클래스에서 서로 다른 두 객체가 내부에 동일한 데이터를 포함하는 경우 그 둘을 동등한 객체로 간주해야 할 경우가 있습니다. 이를 위해 equals를 오버라이드하여 두 객체의 데이터가 같은지를 판단해야 합니다.

equals() 오버라이드 전

val client = Client("홍길동", 125)
val client2 = Client("홍길동", 125)

// 코틀린의 == 연산자는 참조 동일섬을 검사하지 않고 객체의 동등성을 검사
// 따라서 ==연산은 equals를 호출하는 식으로 컴파일
println(client == client2)

결과: false

위의 예제는 equals를 오버라이드하지 않은 코드입니다. 그렇기에 동일한 데이터를 담아도 두 객체가 동일하지 않습니다. Client 클래스의 요구 사항을 만족시키고 싶다면 equals를 오버라이드해야합니다.

참조
자바는 ==를 원시타입과 참조타입을 비교할 때 사용합니다. 원시 타입의 경우 ==는 두 피연산자의 값이 같은지 비교합니다(동등성). 반면 참조 타입의 경우 ==는 두 피연산자의 주소가 같은지를 비교합니다(참조 비교). 따라서 자바에서는 두 객체의 동등성을 알려면 equals를 호출해야 합니다.

코틀린에서는 == 연산자가 두 객체를 비교하는 기본적인 방법입니다. ==는 내부적으로 equals를 호출해서 객체를 비교합니다. 따라서 클래스가 equals를 오버라이드하면 ==를 통해 안전하게 그 클래스의 인스턴스를 비교합니다. 참죠 비교를 위해서는 === 연산자를 사용할 수 있습니다. === 연산자는 자바에서 객체의 참조를 비교할 때 사용하는 == 연산자와 같습니다.

이제 equals를 오버라이드하여 두 객체의 데이터를 비교하겠습니다.

equals() 오버라이드

class Client(val name: String, val postalCode: Int) {
    // Any는 코틀린의 모든 클래스의 최상위 클래스
    override fun equals(other: Any?): Boolean {
        // other가 Client인지 검사
        if(other == null || other !is Client)
            return false

        // 두 객체의 프로퍼티가 같은지 검사
        return name == other.name &&
                postalCode == other.postalCode
    }

    override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

is는 자바의 instanceOf와 같습니다. 이 is를 통해 어떤 값의 타입인지를 검사하여 Client 타입인지 아닌지를 판단합니다. 그리고 두 객체 모두 Client 타입이면 두 객체의 프로퍼티를 비교하여 값이 같은지 아닌지를 판단하면 됩니다. 위와 같이 equals를 오버라이드하면 두 객체의 데이터가 같은지를 비교할 수 있습니다. 하지만 Client 클래스로 더 복잡한 작업을 수행하면 제대로 작동하지 않는 경우가 있습니다. 이는 hashCode가 없어서 제대로 작동하지 않는 것인데 이를 아래에서 추가로 설명하겠습니다.


해시 컨테이너: hashCode()

자바에서는 equals를 오버라이드할 때 반드시 hashCode도 함께 오버라이드해야 합니다.
hashCode는 일반적으로 각 객체의 주소값을 변환하여 생성한 객체의 고유한 정수값입니다. hashCode를 함께 오버라이드하는 이유는 오버라이드하지 않으면 값이 같은 프로퍼티를 가지는 두 객체를 생성하여 equals 메서드에 대한 결과가 true여도 주소는 다르기에 hashCode의 값은 달라 문제가 발생하기 때문입니다.

hashCode()를 구현하는 이유

val processed = hashSetOf(Client("홍길동", 4122))
println(processed.contains(Client("홍길동", 4122)))

결과: false

원소가 '홍길동'이라는 고객 하나인 집합을 만들었습니다. 그 후 새로 원래의 '홍길동'과 똑같은 프로퍼티를 포함하는 새로운 Client 객체를 만들어서 그 객체가 집합안에 들어있는지 검사하면 결과는 false가 나옵니다. hashSetOf 메서드는 HashSet 객체를 만드는데 이 HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시 코드를 비교하고 해시 코드가 같은 경우에만 실제 값을 비교합니다. 위에서 말했듯이 hashCode 메서드를 오버라이드하지 않으면 객체의 주소값을 변환하여 고유한 정수값을 반환합니다. 그런데 두 Client 객체는 서로 다른 객체기에 다른 주소를 가지고 있어서 해시코드 값이 다르게 나오기 때문에 결과가 false되는 것입니다.

JVM 언어에서는 hashCode가 지켜야 하는 "equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다"라는 제약이 있는데 이를 지키기 위해 hashCode를 오버라이드해야 합니다.

class Client(val name: String, val postalCode: Int) {
    // Any는 코틀린의 모든 클래스의 최상위 클래스
    override fun equals(other: Any?): Boolean {
        // other가 Client인지 검사
        if(other == null || other !is Client)
            return false

        // 두 객체의 프로퍼티가 같은지 검사
        return name == other.name &&
                postalCode == other.postalCode
    }

    // hashCode 오버라이드
    // 프로퍼티의 값을 비교해서 프로퍼티가 같은 경우는 해시코드또한 똑같게 나오도록 구현
    override fun hashCode(): Int = name.hashCode() * 31 + postalCode

    override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

이와 같이 hashCode를 구현하면 프로퍼티의 값이 같은 객체에 대해 equals() 메서드가 true를 반환하고 또한 hashCode() 메서드에 대해 같은 해시코드 값을 반환하기 때문에 제약을 만족하게 됩니다. 이때 주 생성자 밖에 정의된 프로퍼티는 equals나 hashCode를 계산할 때 고려의 대상이 아니라는 사실을 주의해야합니다.


데이터 클래스: 모든 클래스가 정의해야 하는 메서드 자동 생성

어떤 클래스가 단순히 데이터를 저장하는 역할만을 수행한다면 위에서 설명한 toString, equals, hashCode를 반드시 오버라이드해야 합니다. 이런 메서드를 코틀린에서는 data라는 변경자를 클래스 앞에 붙이면 필요한 메소드를 컴파일러가 자동으로 만들어줍니다. data 변경자가 붙은 클래스를 데이터 클래스라고 부릅니다.

data 클래스로 선언

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

이렇게 되면 클래스는 위에서 알아본 자바에서 요구하는 모든 메서드를 포함합니다.

  • 인스턴스 간 비교를 위한 equals
  • HashMap과 같은 해시 기반 컨테이너에서 키로 사용할 수 있는 hashCode
  • 클래스의 각 필드를 선언 순서대로 표시하는 문자열 표현을 만들어주는 toString

copy() 메서드

데이터 클래스의 프로퍼티가 꼭 val일 필요가 없고 원한다면 var를 사용해도 됩니다. 다만 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변(immutable) 클래스로 만들라고 권장합니다. 그 이유는 HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우에는 불변성이 필수적이기 때문입니다. 불변 객체를 주로 사용하는 프로그램에서는 사용 중인 데이터를 다른 스레드가 변경할 수 없으므로 스레드를 동기화해야 할 필요가 줄어듭니다.

데이터 클래스의 객체를 불변 객체로 쉽게 활용할 수 있게 코틀린 컴파일러는 한 가지 편의 메서드를 제공합니다. 그 메서드는 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 copy 메서드입니다. 객체를 메모리 상에서 직접 바꾸는 대신 복사본을 만들어 원본과 다른 생명주기를 가지게 하고, 복사본의 값을 바꾸거나 제거해도 원본에 영향을 끼치지 않게 할 수 있습니다.

copy() 메서드를 사용하여 객체 복사

val client1 = Client("홍길동", 4000)
val client2 = client1.copy(postalCode = 3000)
println(client1)
println(client2)

결과:
Client(name=홍길동, postalCode=4000)
Client(name=홍길동, postalCode=3000)


참조
Kotlin in Action

틀린 부분을 댓글로 남겨주시면 수정하겠습니다!!

profile
되새기기 위해 기록

0개의 댓글