Kotlin

[Kotlin] Data class 알아보기

Alsong 2024. 12. 12. 19:49

코틀린에는 자바에는 없는 다양한 클래스들이 존재합니다. data class는 그중 하나로, 굉장히 편리한 기능들을 제공하고 있지요. 저는 자바를 안 해봐서 data class가 얼마나 편리한 클래스인지 체감을 해보지는 않았지만, 코틀린의 강력한 기능임에는 분명합니다. 

 

우테코에 들어와서 첫 주차에 data class에 대한 강의가 있었습니다. 가장 처음에 배울 만큼 중요한 개념입니다. 그때에는 data class는커녕 class의 개념조차 완벽히 정립되어 있지 않았어서 굉장히 이해가 어려웠던 기억이 있네요.. 하지만 우테코를 수료한 지금은 누군가에게 data class를 설명할 수 있을 정도로 실력이 향상된 것 같습니다! 😄 그럼 한 번 data class를 알아보러 떠나봅시다. ㅎㅎ

 

1. data class란?

이름에서부터 짐작할 수 있듯, data class는 객체를 데이터로서 다루는 데에 특화된 클래스입니다. 이를테면 다음과 같은 경우가 있을 수 있습니다.

나: 자동차 경주 프로그램을 만들자! 그러기 위해서 여러 개의 자동차가 필요하고 이 자동차들의 속도를 비교하고 싶다.

 

이런 상황이 있을 경우, 코틀린에서는 다음과 같은 방법으로 data class를 정의하는 것이 매우 효율적입니다.

data class Car(
    val name: String,
    val speed: Int,
)

위의 코드는 Car라는 객체를 data class로 정의했고, namespeed라는 프로퍼티를 가지고 있습니다.

 

Car 객체를 data class로 정의함으로써 저는 자동차를 데이터처럼 쓸 수 있습니다. 그리고 이 객체의 프로퍼티인 name, speed 역시 Car 데이터가 가진 세부 데이터로서 사용할 수 있습니다. (이것이 무슨 의미인지는 아래에서 자세히 알아보겠습니다.)

 

물론 다음과 같이 일반 클래스로 정의할 수도 있습니다. 위 코드에서 "data" 키워드만 빠진 것이에요!

 

class Car(
    val name: String,
    val speed: Int,
)

물론 이렇게 객체를 정의하더라도 문제는 없습니다. 충분히 동작하는 프로그램을 만들 수 있죠. 하지만 이 클래스는 여러 작업을 함에 있어서 굉장히 불편하다 라는 문제가 있습니다.😭 그 말은 반대로 하면 data class가 굉장히 편하다는 것이기도 합니다. 그럼 어떤 작업에서 data class가 편한 건데?라는 생각이 들 것입니다. ㅎㅎ 그것은 아래에서 data class가 제공하는 4가지 기능에 대해서 설명하면서 알아보도록 하죠!

 

2. data class가 제공하는 기능들

data class는 인스턴스가 생성됨과 동시에 컴파일러가 다음의 5가지 함수를 자동으로 구현해 줍니다.

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

함수를 자동으로 구현해 준다는 말은, 개발자가 함수를 직접 재정의(override) 혹은 구현하지 않아도 데이터를 다루기에 적합한 동작으로 함수의 동작이 바뀐다는 것을 의미합니다.

 

좀 더 풀어서 설명하자면, toString(), equals(), hashcode()의 3가지 함수는 일반 클래스에도 존재하는데, data class를 사용하면 이 함수들은 일반 클래스에서처럼 동작하지 않고 데이터를 다루기 쉬운 동작으로 바뀌게 됩니다. 그리고 componentN()과 copy()는 일반 클래스에 존재하지 않아 새로 만들어지게 됩니다.

 

처음에는 이 말뜻이 잘 이해가 가지 않을 겁니다. 조급해하지 말고 자세한 것은 하나씩 알아보도록 합시다!

 

1. toString()

toString() 메서드는 어떠한 데이터를 String 타입으로 바꿔주는 메서드입니다. Int나 Float과 같은 값을 문자열로 변환하는 데 보통 많이 사용할 것입니다.

 

그리고 이 메서드는 클래스에도 사용할 수 있습니다! 다음 코드를 보도록 하죠.

class Car(
    val name: String,
    val speed: Int,
)

fun main() {
    val car = Car("알송", 5)
    println(car.toString()) // 출력: Car@3d494fbf
}

Car라는 클래스를 정의했고, main함수에서 인스턴스를 생성한 다음 toString() 메서드를 호출해 어떤 문자열이 출력되는지 확인해 보았습니다. 

 

출력된 문자열인 "Car@3d494fbf"는 클래스의 이름과 인스턴스의 해시코드를 16진수로 출력한 것입니다. 프로퍼티는 출력되지 않습니다.

 

그런데 사실 우리가 궁금한 건 이 객체의 메모리 주소가 아니지 않습니까?! 우리는 자동차라는 데이터가 궁금한 것이고, toString() 메서드를 통해 어떤 프로퍼티가 있고, 프로퍼티가 어떤 값을 갖고 있는지를 확인하고 싶은 것이죠. 그렇게 하려면 우리는 toString() 메서드를 override 해야 합니다.

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

fun main() {
    val car = Car("알송", 5)
    println(car.toString()) // 출력: Car(name=알송, speed=5)
}

toString() 메서드를 직접 override 해 줌으로써, 우리는 car의 toString() 메서드를 호출해 프로퍼티와 그 값들을 확인할 수 있습니다.

하지만 매번 toString()을 override 하는 것은 매우 귀찮은 일입니다.. 이럴 때 data class를 쓰면, 위와 같은 형태로 toString() 메서드를 자동으로 override 해줍니다!

data class Car(
    val name: String,
    val speed: Int,
)

fun main() {
    val car = Car("알송", 5)
    println(car.toString()) // 출력: Car(name=알송, speed=5)
}

toString()을 override 하지 않아도, 객체의 toString() 메서드를 호출해 데이터를 확인할 수 있는 것이랍니다! 😄

 

2. componentN

이 메서드는 data class의 프로퍼티를 순서대로 반환하는 데에 사용됩니다. 다른 말로, 구조분해라고도 합니다. 다음 코드를 봅시다.

data class Car(
    val name: String,
    val speed: Int,
)

fun main() {
    val alsongCar = Car("알송", 5)
    val (carName, carSpeed) = alsongCar

    println(carName) // 출력: 알송
    println(carSpeed) // 출력: 5
}

carNamecarSpeed 변수에 alsongCar 인스턴스의 프로퍼티 값이 자동으로 매핑되는 것을 확인할 수 있습니다. 위 코드는 내부적으로 다음과 같이 동작합니다.

val carName = alsongCar.component1()
val carSpeed = alsongCar.component2()

프로퍼티가 선언된 순서대로 숫자가 매겨지게 됩니다.

 

3. copy()

앞서 살펴본 toString()과 componentN() 메서드는 편리하기는 하지만 사실 잘 사용하지는 않는 메서드들입니다.. 😂 하지만 여기서부터 등장하는 메서드들은 매우 강력한 기능들이며, 자주 사용되기 때문에 더욱 중요합니다!! 더 집중해서 가봅시다.

 

이 메서드는 데이터클래스의 인스턴스를 부분적으로 깊은복사 하는 메서드입니다. 데이터의 복사에는 얕은복사(Shallow Copy)깊은복사(Deep Copy)가 있습니다.

 

얕은복사란 객체의 참조, 즉 메모리 주소를 복사하는 것입니다. 즉 객체를 얕은복사 할 경우 객체의 참조는 원본과 같습니다. 코틀린에서는 = 연산자를 사용하면 기본적으로 얕은복사가 됩니다.

data class Car(
    var name: String,
    var speed: Int,
)

fun main() {
    val original = Car("알송", 4)
    val copied = original // 얕은복사
    
    println(original) // 출력: Car(name=알송, speed=4)
    copied.speed = 5
    println(original) // 출력: Car(name=알송, speed=5)
}

위 코드에서 originalcopied는 같은 데이터뿐 아니라 참조마저 같습니다. 즉 메모리 주소가 같죠. 따라서 copied의 프로퍼티를 변경한다면, copied와 같은 메모리 주소를 참조하는 original의 프로퍼티 또한 같이 바뀌게 됩니다. 그래서 copied만 변경했는데도 original의 speed 또한 4에서 5로 바뀐 것을 확인할 수 있어요! 😮

 

깊은복사란 데이터만 복사하며, 메모리 주소는 복사하지 않습니다. 즉 깊은 복사를 수행한 두 인스턴스는 같은 값만 가질 뿐, 메모리 주소가 다른 별개의 인스턴스입니다.

data class Car(
    var name: String,
    var speed: Int,
)

fun main() {
    val original = Car("알송", 4)
    val copied = original.copy() // 깊은복사

    println(original) // 출력: Car(name=알송, speed=4)
    copied.speed = 5
    println(original) // 출력: Car(name=알송, speed=4)
}

위 코드에서는 data class의 copy() 메서드를 사용함으로써 original 인스턴스와 메모리 주소를 공유하지 않는, 완전히 별개의 인스턴스인 copied를 만들었습니다. copied를 변경하더라도 메모리 참조가 다르므로 original에는 영향을 미치지 않습니다. 👍

 

만약 어떤 인스턴스의 데이터를 복사하고 싶은데 얕은 복사를 해버린다면, 사본을 변경하더라도 원본이 따라서 바뀌어버리는 불상사가 일어납니다..! 이때 복사본과 원본을 아예 별개의 인스턴스로 사용하고 싶다면 copy() 메서드를 꼭 사용해야 하는 것이죠.

 

하지만 위에서 언급했듯 copy()는 부분적으로만 깊은복사를 해줄 뿐, 완벽한 깊은복사를 해 주지는 않습니다. 바로 data class의 프로퍼티가 원시타입(Primitive Type)으로만 이루어져 있는지, 참조타입(Reference Type)이 포함되어 있는지에 따라 다르죠.. (왤케 복잡해..? 😭)  이에 관해서는 할 말이 더 많기 때문에 따로 포스팅을 하도록 하겠습니다. data class를 처음 배우는 입장에서는 이 정도만 알아도 충분할 거예요!

 

 

4. equals(), hashCode()

이 두 메서드는 서로 밀접한 관계를 갖고 있기 때문에 묶어서 설명하도록 하겠습니다.

 

4-1. equals()

어떤 인스턴스가 같은 프로퍼티를 갖고 있는지를 비교하려면 어떻게 할까요? 다음의 코드를 한 번 봅시다.

class Animal(
    val name: String,
    val age: Int,
)

fun main() {
    val animal1 = Animal("철수", 5)
    val animal2 = Animal("철수", 5)
    println(animal1 == animal2) // false
}

animal1animal2는 같은 프로퍼티인 name="철수"와 age=5를 가지고 있습니다. 이때 animal1animal2의 프로퍼티가 일치하는지의 여부는 어떻게 판단해야 할까요?

 

단순히 생각해서 동등 연산자인 ==을 쓴다면 어떨까요? 위의 코드에서 animal1 == animal2를 통해 두 인스턴스의 동등성을 비교했더니 결과는 false가 출력되었습니다. 왜일까요? 일반 클래스에서는 동등성 비교를 할 때 메모리 주소가 같은지를 비교하기 때문입니다. 따라서 일반 클래스에서는 동등성 비교가 곧 동일성 비교이고, 실질적으로 동등성 비교를 하지 못한다고 할 수 있습니다.

동등성과 동일성을 모르는 독자를 위해.. 동등성(Equality)이란 두 객체가 같은 정보(데이터)를 담고 있는 것을 뜻하며, 동일성(Identity)란 같은 정보뿐만 아니라 메모리 주소마저 같은 것을 말합니다. 즉 두 객체가 동일하다면, 항상 동등합니다. 

(제가 쓴 https://alsongpink.tistory.com/29 포스팅을 참고해 주세요!)

 

하지만 일반 클래스에서도 동등성 비교를 할 수 있는 방법이 있는데, 바로 equals()와 hashCode() 메서드를 override 하는 것입니다.

 

 

인텔리제이 IDEA에서, 클래스의 body 부분에 커서를 대고 윈도우는 alt + insert, 맥은 cmd + n 단축키를 누르면 위와 같은 창이 뜨게 되는데, 여기서 첫 번째를 선택하면 equals()와 hashCode() 메서드를 자동으로 override 해 줍니다. (인텔리제이 편하죠? ㅋㅋ)

class Animal(
    val name: String,
    val age: Int,
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as Animal

        if (name != other.name) return false
        if (age != other.age) return false

        return true
    }

    override fun hashCode(): Int {
        var result = name.hashCode()
        result = 31 * result + age
        return result
    }
}

fun main() {
    val animal1 = Animal("철수", 5)
    val animal2 = Animal("철수", 5)
    println(animal1 == animal2) // true
}

equals()와 hashCode()를 override 하면 위와 같이 코드가 작성됩니다. 한 번 직접 IDE에서 해 보면 도움이 많이 되니 직접 해 봅시다!

 

위의 코드가 복잡해 보이지만, 사실 중요한 부분은 ===를 쓴 부분과 프로퍼티를 비교하는 부분이고 나머지는 몰라도 됩니다. 차근히 살펴봅시다.

 

일단 equals() 함수는 other라는 파라미터를 받고 있습니다. 동등 연산자 ==는 실제로 호출하면 내부적으로 이 equals() 함수가 호출됩니다. animal1 == animal2animal1.equals(animal2)와 같은 것입니다. 따라서 이 함수는 동등성 비교를 하는 함수입니다. main() 함수의 마지막 라인에서 ==를 쓴 부분이 equals() 함수를 호출한 부분입니다.

 

다음으로 if (this === other) return true 부분을 봅시다. === 연산자는 동일성 비교 연산자입니다. 두 객체의 동일성을 비교했을 때 동일했다면 true를 반환하는 걸 볼 수가 있군요! 동일하면 동등하기 때문입니다.

 

그리고 아래로 내려와서, 이 부분을 봅시다.

if (name != other.name) return false
if (age != other.age) return false

return true

두 객체의 프로퍼티를 비교하고 있습니다! 그리고 만약 프로퍼티의 값이 다르다면 false를 반환하는군요.

 

그리고 모든 값이 동일하다면 마지막에 true를 반환하는 것을 확인할 수 있습니다. 생각보다 로직은 간단하죠? ㅎㅎ

 

 

4-2. hashCode()

좋아요! equals()를 이해했으니, 이제 hashCode()로 넘어가 봅시다.

 

먼저 해시코드가 무엇인지 알아야겠죠? 해시코드란 어떤 객체를 식별하는 고유한 정수값을 의미합니다. 객체를 식별한다는 말이 무슨 말일까요? 코틀린에는 HashMap, HashSet과 같은 자료구조들이 존재합니다. 이 자료구조는 새로운 데이터를 추가할 때 그 데이터가 이미 존재하는지를 판단해야 합니다. 이때 해당 데이터가 이미 존재하는지 아닌지를 해시코드를 통해 판단하게 됩니다. 먼저 해시코드를 이해하기 위해 다음의 코드를 봅시다.

fun main() {
    val mySet = HashSet<String>()
    mySet.add("alsong")
    println(mySet) // alsong
    mySet.add("alsong")
    println(mySet) // alsong
}

HashSet을 모르시는 분들을 위해 간략히 설명하자면, 중복된 데이터를 허용하지 않는 자료구조입니다. "alsong" 문자열을 mySet에 추가하면 mySet에는 "alsong"이 들어있게 되는데요, 다시 한번 "alsong"을 추가하면 이미 "alsong"이 있기 때문에 추가되지 않습니다.

 

여기서 HashSet은 기존의 "alsong"과 새로 추가하려고 한 "alsong"이 같다는 것을 어찌 아는 걸까요? 바로 해시코드를 비교하게 됩니다. 문자열 "alsong"은 항상 같은 해시코드를 가지기 때문에 HashSet은 두 문자열이 같다는 것을 알 수 있는 겁니다.

fun main() {
    println("alsong".hashCode()) // -1414663232
    println("alsong".hashCode()) // -1414663232
}

같은 문자열은 항상 같은 해시코드를 가집니다. 문자열뿐만 아니라 Int, Float, Boolean과 같은 Primitive Type들은 기본적으로 같은 해시코드를 가집니다.

 

그렇다면 객체는 어떨까요? 똑같은 프로퍼티를 가진 두 객체의 해시코드를 출력해 보면 어떻게 나올지 궁금하군요. 한 번 봅시다.

class Animal(
    val name: String,
    val age: Int,
)

fun main() {
    val animal1 = Animal("alsong", 5)
    val animal2 = Animal("alsong", 5)
    println(animal1.hashCode()) // 1028214719
    println(animal2.hashCode()) // 500977346
}

Animal이라는 일반 클래스가 있고 인스턴스로 똑같은 프로퍼티를 가진 animal1animal2를 만들었습니다. 그리고 해시코드를 출력해 보았습니다.

 

그리고 보시는 대로, 해시코드가 다르게 출력됩니다. 왜냐하면 일반 클래스는 메모리 주소를 기반으로 해시코드를 생성하기 때문이죠..! 😓

 

이 말은 일반 클래스에서 만들어진 인스턴스는 HashMap이나 HashSet을 사용할 때, 같은 메모리주소를 참조하는 인스턴스여야만 같은 데이터로 판단한다는 것입니다.

class Animal(
    val name: String,
    val age: Int,
)

fun main() {
    val animal1 = Animal("alsong", 5)
    val animal2 = Animal("alsong", 5)

    val mySet = HashSet<Animal>()
    mySet.add(animal1)
    println(mySet) // [Animal@3d494fbf]
    mySet.add(animal2)
    println(mySet) // [Animal@3d494fbf, Animal@1ddc4ec2]
}

위에서 보시다시피 같은 프로퍼티를 가진 인스턴스라 하더라도 HashSet은 다른 데이터로 판단하고 있음을 알 수 있죠? 다른 데이터로 판단하고 있기 때문에 새로 추가가 된 것입니다. 같은 데이터라면 추가가 안되었겠죠~

 

그런데 만약 우리가 객체를 데이터로서 다루고 싶어서, 같은 프로퍼티를 가지고 있으면 같은 데이터로 취급하고 싶다고 해봅시다. 그럼 어떻게 하면 될까요? equals()에서 본 것과 마찬가지로 hashCode() 메서드를 override 해주면 가능합니다.

class Animal(
    val name: String,
    val age: Int,
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as Animal

        if (name != other.name) return false
        if (age != other.age) return false

        return true
    }

    override fun hashCode(): Int {
        var result = name.hashCode()
        result = 31 * result + age
        return result
    }
}


fun main() {
    val animal1 = Animal("alsong", 5)
    val animal2 = Animal("alsong", 5)

    val mySet = HashSet<Animal>()
    mySet.add(animal1)
    println(mySet) // [Animal@ca108445]
    mySet.add(animal2)
    println(mySet) // [Animal@ca108445]
}

hashCode() 메서드 부분에서 해시코드를 계산하고 있는 걸 보실 수 있는데요, 자세한 계산 로직은 중요하지 않으니, 넘어갑시다.

 

main() 함수 부분에서 animal1animal2를 똑같이 생성하고 HashSet에 넣었더니 같은 데이터로 취급해 한 번만 추가가 된 것이 보이시나요? hashCode() 메서드를 override 함으로써 메모리주소 기반 해시코드에서 프로퍼티 값 기반 해시코드로 바뀌었기 때문입니다.

 

 

자 이제 equals()와 hashCode() 두 메서드 모두 이해하셨습니다. 일반 클래스에서 인스턴스를 데이터처럼 다루고 싶다면 저 두 메서드를 override 해야 한다는 것도요.

 

그리고 data class를 쓴다면 저 두 메서드를 자동으로 구현해 준다고 했었죠? data class를 써볼까요?

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

fun main() {
    val animal1 = Animal("alsong", 5)
    val animal2 = Animal("alsong", 5)

    println(animal1 == animal2) // true

    val mySet = HashSet<Animal>()
    mySet.add(animal1)
    println(mySet) // [Animal(name=alsong, age=5)]
    mySet.add(animal2)
    println(mySet) // [Animal(name=alsong, age=5)]
}

equals(), hashCode()를 override 하지 않았는데도 ==연산자를 통한 동등성 비교도 가능해졌고, 같은 해시코드를 가지게 되었음을 알 수가 있네요! data class.. 이 녀석 정말 강력하죠? 😊

 

equals()와 hashCode()를 묶어서 같이 설명한 이유는 둘 사이에 몇 가지 규칙이 있기 때문입니다.

1. 클래스에서 equals()를 override 했다면, 반드시 hashCode()도 override 해야 한다.
2. 두 개의 인스턴스의 equals()가 true이면 두 인스턴스의 hashCode()는 동일해야 한다. 단, hashCode()가 동일해도 equals()는 false일 수 있다.

 

 

3. 그 외

어때요, data class를 왜 쓰는지 이제 이해하셨나요? 사실 코틀린 입문자가 data class를 완벽하게 이해하는 것은 어려울 거라고 생각합니다. 저도 완벽하게 이해하는 데에 10개월은 걸린 것 같아요.. data class를 완벽하게 이해하기 위해서 필요한 코틀린의 다른 개념들이 많이 존재합니다. 만약 이해가 어렵다면 일단은 인스턴스를 데이터처럼 다룰 수 있는 클래스 정도로 이해하고, 넘어가시기 바랍니다. 나중에 코틀린을 더 공부하고 난 후에 다시 이 글을 읽는다면 "아 이게 그 말이었구나!" 하고 이해가 되실 거예요!

 

그리고 위의 data class의 모든 기능은 생성자 안에서 선언된 프로퍼티에만 적용된다 라는 점 또한 기억해 주시기 바랍니다.

 

마지막으로 Chat GPT에서 좋은 표를 만들어 주었길래 첨부합니다 ㅎㅎ! 이해에 도움이 되었으면 좋겠습니다!