프로그래밍 언어/Kotlin

[코틀린 기초] kotlin data class

코딩하는흑구 2022. 9. 30. 20:31

데이터 클래스

데이터 클래스는 데이터를 보관하거나 전달하기 위한 목적을 가진 객체를 설계할 때 활용할 수 있습니다.(DTO)

class 키워드 앞에 data 를 선언하여 사용할 수 있습니다.

 

data class Room(var roomType: String, var price: Int)

 

이렇게 data 키워드를 사용하게 되면 코틀린 컴파일러가 아래의 함수들을 자동으로 생성해줍니다.

  • equals()
  • hashCode()
  • toString()
  • componentN()
  • copy()

기존 자바 개발자분들은 Spring Framework를 이용하여 웹 어플리케이션을 개발할 때, Lombok 라이브러리를 많이 이용하셨을텐데요. 거기에서 @Data 어노테이션과 비슷한 역할을 한다고 보시면 될 것 같습니다.

 

뿐만 아니라 JDK 15이상을 사용하시는 자바 개발자분들은 record라는 키워드와도 동일한 역할을 한다고 보면 될것 같습니다.

 

이렇게 클래스를 data class로 선언하게 된다면 별도의 구현없이 위의 5가지 함수들을 활용할 수 있습니다.

 


Why Data Class

데이터 클래스를 사용하는 이유는 equals, hashCode, toString을 통해 정보전달 혹은 저장을 위한 개발에 많은 기능적, 개념적 도움을 주기 때문입니다.

 

일반적인 클래스에서는 위 세가지 함수들을 직접 구현하거나 IDE를 통해 생성해야하는 불편함이 있는데, 데이터 클래스는 이에 대해서 판을 제대로 깔아줬다는 느낌이 들었습니다.

 

객체 동등성 비교 이슈(equals)

- 객체 동등성 비교를 하기 위해 equals 메소드를 재정의하게 됩니다.

- 데이터 클래스와 일반 클래스의 차이점을 확인해보자.

class Room(var roomType: String, var price: Int)
data class RoomData(var roomType: String, var price: Int)
fun main() {

    var room1: Room = Room("원룸", 300_000)
    var room2: Room = Room("원룸", 300_000)
    println(room1 == room2) // false

    var roomData1: RoomData = RoomData("원룸", 300_000)
    var roomData2: RoomData = RoomData("원룸", 300_000)
    println(roomData1 == roomData2) // true
}

해당 코드에서

첫번째 비교는 일반 클래스이기 때문에 equals 메소드가 재정의되어 있지 않아 Object 클래스의 equals 메소드를 사용하게 되어 서로 다른 인스턴스 이기 때문에 false를 리턴하게 됩니다.

 

두번째 비교는 데이터 클래스이기 때문에 equals 메소드가 각 변수별로 선언되어 있어 모든 클래스 변수가 같은 값을 가지면 true를 리턴하게 됩니다.

 

 

해시 코드 이슈(hashCode)

- equals 함수를 재정의할 때 반드시 재정의 해주어야하는 파트너 함수.

- JVM을 런타임 플랫폼으로 사용하는 언어라면 반드시 equals가 true를 리턴했다면 hashCode 또한 true를 리턴해야 한다.

fun main() {

    var room1: Room = Room("원룸", 300_000)
    var room2: Room = Room("원룸", 300_000)
    println(room1 == room2) // true -> equals를 구현했기 때문

    val map = hashMapOf<Room, Int>()
    map[room1] = 1
    val result = map.containsKey(room2)
    println(result) // false -> hashCode를 구현하지 않았기 때문.
}

 

equals, hashCode가 모두 구현되었다면..

class Room(var roomType: String, var price: Int) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as Room

        if (roomType != other.roomType) return false
        if (price != other.price) return false

        return true
    }

    override fun hashCode(): Int {
        var result = roomType.hashCode()
        result = 31 * result + price
        return result
    }
}
data class RoomData(var roomType: String, var price: Int)
fun main() {

    var room1: Room = Room("원룸", 300_000)
    var room2: Room = Room("원룸", 300_000)
    println(room1 == room2) // true -> equals를 구현했기 때문

    val map = hashMapOf<Room, Int>()
    map[room1] = 1
    val result = map.containsKey(room2)
    println(result) // true -> hashCode를 구현했기 때문.
}

이는 Hash가 들어가는 자료구조의 경우에만 이러한 이슈가 발생하기 때문에 내부적으로 Hash를 사용하는 컬렉션의 자료구조를 들여다 봐야합니다. hashmap 혹은 hashset의 경우에는 내부적으로 key를 탐색하기 위한 방법으로써 equals와 hashCode가 모두 사용되고 있습니다. 결과적으로 해당 hash 코드를 활용하는 컬렉션 구현체들은 equals 가 true 일 때, hashCode 또한 true라는 기본 전제를 토대로 구현되어 있기 때문에 이러한 결과가 발생하는 것 입니다.

 

하지만 data 클래스를 사용하게 된다면 내부적으로 컴파일러가 해당 함수들을 모두 내장시켜주기 때문에 해당 이슈에 대해서는 걱정할 필요가 덜합니다.

data class RoomData(var roomType: String, var price: Int)
fun main() {
    var roomData1: RoomData = RoomData("원룸", 300_000)
    var roomData2: RoomData = RoomData("원룸", 300_000)
    println(roomData1 == roomData2) // true

    val map = hashMapOf<RoomData, Int>()
    map[roomData1] = 1
    println(map.containsKey(roomData2)) // true
}

 

 

객체를 문자열로 나열, toString()

toString을 재정의하지 않고 객체를 출력하고자한다면..?

var room: Room = Room("원룸", 300_000)
println(room.toString()) // Room@3054cb8

이러한 값이 출력됩니다. 알아볼 수 있는 부분은 Room 클래스 타입이라는 것 뿐입니다.

 

data 클래스의 객체 인스턴스를 출력한다면..?

var roomData: RoomData = RoomData("원룸", 300_000)
println(roomData.toString()) // RoomData(roomType=원룸, price=300000)

이런식으로 깔끔하게 객체를 문자열로 나열해줍니다. 로그를 남기거나 디버깅하기 위한 출력문으로 확인하기에 좋아보입니다.

 

 

데이터 복사(불변)

- 데이터 클래스의 copy() 메소드를 활용하여 값을 쉽게 복사할 수 있다.

- copy 함수를 통해 해당 데이터 클래스를 불변으로 유지할 수 있다. 하지만 내부 변수를 val로 선언해야 불변이라고 할 수 있다.

- 해당 data 클래스의 인스턴스가 불변이라는 점이 깨지게 된다면 hash 계열의 자료구조 구현체를 활용하게 되는 경우 의도치 않게 버그를 마주칠 수 있다.

 

우선 불변하지 않도록 변수를 var로 선언한 Room 클래스를 보자.

data class Room(var roomType: String, val price: Int)

price 변수는 불변, roomType 변수는 가변으로 선언해두었다. 이로 인해 roomType 값은 Room 클래스의 인스턴스 안에서 자유롭게 변경할 수 있게 됐다.

 

fun main() {

    var room: Room = Room("원룸", 300_000)
    var set = hashSetOf(room);

    println(set.contains(room)) // true

    // roomType 변수의 값을 변경
    room.roomType = "투룸"
    println(set.contains(room)) // false

}

roomType을 변경하는 순간. hashSet 자료구조에서는 컬렉션 내부에 키값으로 "원룸,300_000"으로 가지고 있게 됩니다. value로 해당 room의 인스턴스를 가지고 있습니다.

 

근데 해당 키값에 해당하는 hashCode가 "투룸,300_000" 변경되었으니 equals도 false, hashCode 값도 다르게 리턴되는 상황이 발생하게 되었습니다. 이러한 버그는 개발자가 의도하지 않았더라도 다른 개발자와의 협업에서 충분히 발생할 수 있는 이슈입니다.

 

또한 멀티스레드의 환경에서 객체의 불변을 유지하는 것은 동기화 처리를 줄여주고 안정성을 유지하기 위해 중요합니다. 유지보수 측면에서도 여러 소스에서 객체의 프로퍼티를 각각 변경하고 있으면 코드를 파악하기도 어렵습니다.

 

이런 전처로 기존 객체를 수정하기보다는 새로운 객체를 복사해서 사용하는 것이 좋습니다. 이를 위해 copy 함수를 활용합니다.(물론 내부 변수들은 val로 선언해두어야 합니다.)

 

copy 함수는 원하는 프로퍼티만 변경하여 복사할 수도 있습니다.(named argument 활용)

fun main() {

    var room: Room = Room("원룸", 300_000)
    var set = hashSetOf(room);

    println(set.contains(room)) // true

    // roomType 변수의 값을 변경하여 복사
    val roomTypeTwoRoom = room.copy(roomType = "투룸")
    println(set.contains(room)) // true
    println(roomTypeTwoRoom.roomType) // 투룸

}

 

 

내부 변수를 선언된 순서대로 가져오는 componentN 함수

componentN은 enum의 ordinal 처럼 선언된 내부 변수의 값을 순서대로 가져올 수 있습니다.

fun main() {
    var room: Room = Room("원룸", 300_000)
    println(room.component1()) // 원룸
    println(room.component2()) // 300_000
}

 

구조분해할당이라는 문법을 사용하여 더 쉽고 안전하게 변수를 선언할 수 있다.

val (roomType, price) = room
println("$roomType, $price") // 원룸, 300000

해당 코드가 동작할 때 room의 roomType변수에는 component1() 함수가, price 변수에는 component2() 함수가 사용되게 됩니다.

 

순서를 바꿔볼까요?

val (price2, roomType2) = room
println("$roomType2, $price2") // 300000, 원룸

선언된 변수의 갯수와 순서에 따라서 호출되는 componentN 함수가 일정한 것을 볼수 있습니다.

 

만약 선언된 변수의 갯수를 넘기면 어떻게 될까요?? title이라는 세번째 변수를 추가해보겠습니다.

// Destructuring declaration initializer of type Room must have a 'component3()' function
val (roomType, price, title) = room
println("$roomType, $price")

이처럼 component3() 함수가 없다면서 컴파일이 되지 않습니다.