본문 바로가기

Kotlin

공식 문서로 배우는 코틀린 - 20. Data classes

스무번 째, 데이터 클래스입니다.

 

※ 데이터 클래스는 DTO, JPA의 Entity 등과 같이 데이터를 다룰 때 사용하는 클래스를 위한 것입니다. Java(16 이전)에서는 Lombok 같은 별도의 라이브러리를 통해서 기본적으로 필요한 메소드들을 추가했다면, Kotlin은 아예 언어 차원에서 다양한 기능성 메소드들을 추가해 주는 데이터 (전용) 클래스를 제공합니다. 성격상 매우 자주 사용될 수 밖에 없으므로 전체적인 내용에 대해서 정확히 이해하고 기억하면 좋습니다.

 

※ Java의 경우 14에서 프리뷰로 추가됐고, 16에서 정식 채택된 레코드(record) 클래스가 Kotlin의 데이터 클래스에 해당합니다.

 

Kotlin의 데이터 클래스는 주로 데이터를 담는데 사용합니다. 각각의 데이터 클래스를 위해서 컴파일러는 자동으로, 인스턴스를 가독성 있게 출력하고, 인스턴스끼리 비교하고, 복사하는 등등의 작업을 할 수 있는 멤버들을 생성합니다. 데이터 클래스는 data 로 표기합니다.

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

 

컴파일러는 주 생성자에 선언돼 있는 모든 프로퍼티로부터 다음과 같은 멤버들을 자동으로 유도해 냅니다.

  • .equals() / .hashcode()
  • User(name=John, age=42) 같은 형태의 .toString()
  • 프로퍼티들의 선언된 순서에 상응하는 .componentN() 함수
  • .copy()

생성된 코드의 일관성과 의미있는 동작을 위해, 데이터 클래스는 다음의 요구 사항을 만족해야 합니다.

  • 주 생성자는 적어도 하나의 매개변수를 가져야 합니다.
  • 모든 주 생성자의 매개변수는 var이나 val로 표기되어야 합니다.
  • 데이터 클래스에는 abstract, open, sealed, inner를 지정할 수 없습니다. 즉, 추상 클래스, 봉인된 클래스, 내부 클래스가 될 수 없으며, 상속에서 기반 클래스가 될 수 없습니다.

추가적으로, 데이터 클래스의 멤버 생성에는 멤버의 상속과 관련하여 다음의 규칙을 따릅니다.

  • 데이터 클래스 몸체에 명시적인 .equals(), .hashCode(), .toString() 구현이 있거나 수퍼 클래스에 final로 구현이 있는 경우에는 이러한 함수들은 생성되지 않고 기존의 구현을 사용합니다.
  • 수퍼 타입이 .componentN() 함수를 open으로 가지고 있고 호환되는 타입을 반환하는 경우에는 해당 함수를 오버라이딩합니다. 만약, 수퍼 타입의 함수가 호환되지 않는 시그니처나 final이 지정되어 오버라이딩 할 수 없는 경우에는 오류가 보고됩니다.
  • 명시적인 .componentN()과 .copy() 구현은 허용되지 않습니다.

데이터 클래스는 다른 클래스를 상속 받을 수 있습니다(예시는 여기를 참고하세요).

JVM에서, 생성된 클래스가 매개변수가 없는 생성자를 가져야 한다면, 모든 프로퍼티에 기본 값이 지정되야 합니다. 관련 내용은 생성자 부분에서 확인할 수 있습니다.

 

클래스 몸체에 선언된 프로퍼티

컴파일러는 자동으로 생성하는 함수들을 위해 주 생성자에 정의된 프로퍼티만 사용합니다. 생성되는 구현에서 프로퍼티를 제외하려면, 해당 프로퍼티는 클래스 몸체에 선언하면 됩니다.

data class Person(val name: String) {
    var age: Int = 0
}

 

아래의 예에서 보여주는 것처럼 기본적으로 name 프로퍼티만 toString(), .equals(), .hashCode(), .copy() 구현에 사용됩니다.  그리고, 단 하나의 컴포넌트 함수 .compoent1() 만이 존재합니다. age 프로퍼티는 클래스 몸체에 선언됐고, 그에 따라 이러한 구현들에서 제외 됩니다. 그러므로, name 이 값고 age가 다른 두 개의 Person 객체는 같게(동등하게) 간주됩니다. 왜냐하면, .equals()는 주 생성자에 있는 프로퍼티만 따지기 때문입니다.

data class Person(val name: String) {
    var age: Int = 0
}

fun main() {
    val person1 = Person("John")
    val person2 = Person("John")
    person1.age = 10
    person2.age = 20

    println("person1 == person2: ${person1 == person2}")
    // person1 == person2: true

    println("person1 with age ${person1.age}: ${person1}")
    // person1 with age 10: Person(name=John)

    println("person2 with age ${person2.age}: ${person2}")
    // person2 with age 20: Person(name=John)
}

 

복사

.copy()를 사용하여 일부 프로퍼티는 값을 변경하고 나머지는 그대로 유지하면서 객체를 복사할 수 있습니다. 위에서 예를 든 User 클래스의 .copy()는 다음과 같습니다.

fun copy(name: String = this.name, age: Int = this.age) = User(name, age)

 

다음과 같이 사용할 수 있습니다.

val jack = User(name = "Jack", age = 1)
val olderJack = jack.copy(age = 2) // age는 2변 변경하고 name 값은 그대로 유지하는 복사본을 만듭니다.

 

데이터 클래스와 구조 분해(destructuring) 선언

생성된 컴포넌트 함수는 데이터 클래스를 구조 분해 선언에 사용할 수 있도록 해 줍니다.

val jane = User("Jane", 35)
val (name, age) = jane
println("$name, $age years of age")
// Jane, 35 years of age

 

표준 데이터 클래스

표준 라이브러리는 PairTriple 클래스를 제공합니다. 그러나, 대부분의 경우 상황에 맞게 이름 지은 데이터 클래스를 사용하는 것이 더 나은 설계적 선택입니다. 이렇게 하면 프로퍼티들을 위해 보다 의미있는 이름을 제공하는 것이 되어 가독성을 높여 줍니다.