코틀린 베이직 KB 23 data class — 한 줄이 만드는 다섯 가지
코틀린 베이직 · 23/26

KB 23 data class — 한 줄이 만드는 다섯 가지

gabury1고친 사람 github-actions[bot]

0. 같은 한 줄 클래스인데 출력이 다르다

자바로 값을 담는 클래스를 만들면 필드와 생성자 뒤에 equals·hashCode·toString 을 손으로 쓰거나, IDE 로 뽑거나, Lombok 의 @Data 를 붙입니다. 코틀린은 클래스 앞에 낱말 하나를 붙입니다.

두 클래스를 나란히 놓았습니다. 선언은 data 한 낱말만 다릅니다.

Kotlin
class Plain(val name: String, val age: Int)

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

fun main() {
    val p1 = Plain("Kim", 30)
    val p2 = Plain("Kim", 30)
    println(p1)        // Plain@2d8e6db6
    println(p1 == p2)  // false

    val u1 = User("Kim", 30)
    val u2 = User("Kim", 30)
    println(u1)        // User(name=Kim, age=30)
    println(u1 == u2)  // true
}

First.kt 로 저장해 코틀린 컴파일러 kotlinc 로 컴파일하고 kotlin 으로 돌렸습니다. kotlin 은 java 와 같은 일을 하되 코틀린 표준 라이브러리를 classpath 에 스스로 넣는 명령입니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.

터미널
$ kotlinc First.kt -d o_First
$ kotlin -cp o_First FirstKt

Plain 은 자바 클래스를 아무 메서드 없이 만든 것과 같습니다. Object 에서 물려받은 toString 이 클래스 이름과 @ 뒤 16진수를 찍고, equals 는 같은 객체인지만 봅니다. 두 객체는 따로 만들었으니 false 입니다.

User 는 필드 값을 찍고, 값이 같으니 true 입니다. data 한 낱말이 toString 과 equals 를 만들어 넣었습니다. 만들어진 것은 그 둘만이 아닙니다. 이 편은 다섯 가지를 하나씩 쓰면서, 편한 만큼 조심해야 할 대목 넷을 같이 봅니다.

1. 무엇이 생기나 — 다섯 가지를 차례로 부른다

data class 선언 하나에 컴파일러가 넣어 주는 함수는 다섯 종류입니다(공식 문서). 이 절에서는 0절의 User 에 다섯 가지를 한 번씩 불러 봅니다.

생기는 것 하는 일 자바로 치면
equals 주생성자 프로퍼티를 전부 비교 손으로 쓰던 equals
hashCode 주생성자 프로퍼티로 해시값 계산 손으로 쓰던 hashCode
toString User(name=Kim, age=30) 꼴로 찍기 손으로 쓰던 toString
copy 일부 값만 바꾼 새 객체 만들기 없음
component1·component2… 프로퍼티를 순서대로 꺼내기 없음

앞의 셋은 자바에서 손으로 쓰던 것이고, 뒤의 둘은 코틀린이 더한 것입니다.

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

fun main() {
    val u1 = User("Kim", 30)
    val u2 = User("Kim", 30)
    val u3 = User("Kim", 31)

    println(u1.equals(u2))       // true
    println(u1 == u3)            // false
    // ↓ true
    println(u1.hashCode() == u2.hashCode())
    // ↓ User(name=Kim, age=30)
    println(u1.toString())

    val older = u1.copy(age = 31)
    println(older)               // User(name=Kim, age=31)
    println(older == u3)         // true
    println(older === u3)        // false

    println(u1.component1())     // Kim
    println(u1.component2())     // 30
}
  • u1.copy(age = 31) — age = 31 처럼 파라미터 이름을 적어 넘기는 것을 명명인자라 부릅니다. 적지 않은 name 은 u1 의 값을 받습니다
  • === — 두 변수가 같은 객체를 가리키는지 봅니다. 자바의 == 에 해당합니다
  • component1() — 주생성자의 첫째 프로퍼티를 돌려줍니다. 둘째는 component2() 입니다
터미널
$ kotlinc Five.kt -d o_Five
$ kotlin -cp o_Five FiveKt

위에서부터 보면, equals 와 == 는 같은 것을 부르니 값이 같으면 true, 나이가 다르면 false 입니다. 값이 같은 두 객체는 hashCode 도 같습니다. equals 가 true 인 두 객체는 hashCode 가 같아야 한다는 자바의 약속을 컴파일러가 지켜 줍니다.

copy 는 나이만 바꾼 새 객체를 만들었습니다. older == u3 는 값이 같으니 true, older === u3 는 따로 만든 객체라 false 입니다. 원본 u1 의 나이는 30 에서 바뀌지 않았습니다.

componentN 은 직접 부를 일이 드뭅니다. 4절의 구조분해가 이 함수들을 부르는 문법이라, 거기서 쓰임이 드러납니다.

붙일 수 없는 모양

data 는 아무 클래스에나 붙지 않습니다. 주생성자 인자가 없거나 val·var 없는 인자가 섞이면 컴파일이 멈춥니다.

Kotlin
data class A(x: Int)   // 컴파일 오류
data class B()         // 컴파일 오류
터미널
$ kotlinc NoVal.kt -d o_noval
NoVal.kt:1:14: error: primary constructor of data class must only have property ('val' / 'var') parameters.
data class A(x: Int)
             ^^^^^^
NoVal.kt:2:13: error: data class must have at least one primary constructor parameter.
data class B()
            ^^

첫 에러는 주생성자 인자가 모두 프로퍼티(val 또는 var)여야 한다는 뜻이고, 둘째는 인자가 적어도 하나 있어야 한다는 뜻입니다. 다섯 가지가 전부 주생성자 프로퍼티를 재료로 쓰니, 재료가 없으면 만들 수 없습니다.

정리하면, data 는 주생성자 프로퍼티를 재료로 다섯 가지를 만듭니다. 이 「주생성자 프로퍼티만」이 다음 절의 함정입니다.

2. 주생성자에 없는 프로퍼티는 빠진다

클래스 본문에도 프로퍼티를 선언할 수 있습니다. 그런데 다섯 가지의 재료는 주생성자 괄호 안뿐입니다. 이 절에서는 로그인 횟수를 본문에 둔 User 로 이게 어떤 결과를 내는지 봅니다.

Kotlin
data class User(val name: String) {
    var loginCount = 0
}

fun main() {
    val a = User("Kim")
    val b = User("Kim")
    a.loginCount = 5
    println(a)                   // User(name=Kim)
    println(a == b)              // true
    println(a.loginCount)        // 5
    println(a.copy().loginCount) // 0
}
  • var loginCount = 0 — 본문에 선언한, 다시 대입할 수 있는 프로퍼티입니다. 타입은 초깃값 0 에서 Int 로 정해집니다
  • a.copy() — 아무것도 바꾸지 않은 복사본입니다
터미널
$ kotlinc Body.kt -d o_Body
$ kotlin -cp o_Body BodyKt

a 는 로그인 횟수가 5, b 는 0 인데 a == b 가 true 입니다. toString 에도 loginCount 가 안 보입니다.

copy() 는 더 조용합니다. 복사본의 loginCount 는 5 가 아니라 0 입니다. copy 는 주생성자에 넘길 값만 옮기고 새 객체를 만드니, 본문 프로퍼티는 초깃값으로 다시 시작합니다.

이 동작은 쓰기에 따라 도구가 됩니다. 비교에서 일부러 빼고 싶은 값(캐시해 둔 계산 결과 같은 것)은 본문에 둡니다. 반대로 비교에 들어가야 할 값을 무심코 본문에 두면, 다른 두 객체가 같다고 나오고 로그에도 안 찍힙니다.

정리하면, 비교·출력·복사에 들어가야 할 값은 주생성자 괄호 안에 둡니다. 본문 프로퍼티는 다섯 가지에서 전부 빠집니다.

3. copy 는 얕은 복사다

copy 가 새 객체를 만든다는 것은 1절에서 봤습니다. 그러면 프로퍼티가 가리키는 리스트도 새로 만들까요. 이 절에서는 팀 이름과 팀원 목록을 담은 Team 을 복사해서 복사본에만 팀원을 더해 봅니다.

Kotlin
data class Team(
    val name: String,
    val members: MutableList<String>,
)

fun main() {
    val blue = Team("blue", mutableListOf("Kim"))
    val red = blue.copy(name = "red")
    red.members.add("Lee")
    println(blue)
    println(red)
    println(blue.members === red.members)
}
  • MutableList<String> — 원소를 더하고 뺄 수 있는 리스트 타입입니다. 코틀린은 읽기 전용 List 와 바꿀 수 있는 MutableList 를 타입으로 가릅니다
  • mutableListOf("Kim") — Kim 하나를 담은 MutableList 를 만듭니다. 자바의 new ArrayList<>(List.of("Kim")) 쯤에 해당합니다
  • 마지막 인자 뒤의 쉼표 — 코틀린은 끝에 쉼표를 남겨도 됩니다

red 에만 Lee 를 더했습니다. blue 는 어떻게 찍힐까요.

터미널
$ kotlinc Shallow.kt -d o_Shallow
$ kotlin -cp o_Shallow ShallowKt
Team(name=blue, members=[Kim, Lee])
Team(name=red, members=[Kim, Lee])
true

원본 blue 에도 Lee 가 들어갔습니다. 마지막 줄의 true 가 까닭입니다. 두 팀의 members 는 같은 리스트 객체입니다.

copy 는 프로퍼티 값을 새 생성자에 넘길 뿐입니다. 리스트 프로퍼티의 값은 리스트 객체를 가리키는 참조이므로, 참조가 복사되고 리스트는 하나로 남습니다. 겉 객체만 새로 만들고 안쪽은 나눠 쓰는 이런 복사를 얕은 복사라 부릅니다.

이것을 피하는 길은 둘입니다. 하나는 프로퍼티를 읽기 전용 List 로 두고, 바꿀 때 새 리스트를 만들어 넘기는 것입니다.

Kotlin
data class Team(val name: String, val members: List<String>)

fun main() {
    val blue = Team("blue", listOf("Kim"))
    val red = blue.copy(
        name = "red",
        members = blue.members + "Lee",
    )
    // ↓ Team(name=blue, members=[Kim])
    println(blue)
    // ↓ Team(name=red, members=[Kim, Lee])
    println(red)
}
  • listOf("Kim") — 읽기 전용 List 를 만듭니다. add 가 없는 타입이라 위처럼 원소를 더할 수 없습니다
  • blue.members + "Lee" — 원소를 하나 더한 새 리스트를 돌려줍니다. 원래 리스트는 바뀌지 않습니다
터미널
$ kotlinc Deep.kt -d o_Deep
$ kotlin -cp o_Deep DeepKt

이번에는 blue 가 [Kim] 으로 남았습니다. 다른 하나는 MutableList 를 꼭 써야 할 때 members = blue.members.toMutableList() 처럼 복사본을 손으로 넘기는 것입니다.

정리하면, copy 는 한 겹만 새로 만듭니다. data class 의 프로퍼티를 읽기 전용 타입으로 두면 얕은 복사가 문제될 일이 거의 없습니다.

4. 구조분해 — 이름이 아니라 순서로 간다

componentN 은 이 문법을 위해 있습니다. 괄호 안에 변수 이름을 늘어놓으면 객체의 프로퍼티가 하나씩 들어갑니다. 이것을 구조분해(destructuring declaration)라 부릅니다. 이 절에서는 좌표 Point 를 풀어 보면서 변수 이름을 일부러 뒤집어 봅니다.

Kotlin
data class Point(val x: Int, val y: Int)

fun main() {
    val p = Point(1, 2)
    val (x, y) = p
    println("x=$x y=$y")        // x=1 y=2
    val (y2, x2) = p
    println("x=$x2 y=$y2")      // x=2 y=1
    val (_, onlyY) = p
    println(onlyY)              // 2
}
  • val (x, y) = p — val x = p.component1() 과 val y = p.component2() 두 줄을 한 줄로 쓴 것입니다
  • "x=$x" — 문자열 템플릿입니다. $x 에 변수 값이 끼워집니다
  • _ — 그 순서의 값은 버린다는 표시입니다. 둘째만 필요할 때 씁니다
터미널
$ kotlinc Destruct.kt -d o_Destruct
$ kotlin -cp o_Destruct DestructKt

val (y2, x2) = p 는 이름으로 봐서는 y2 에 y 가 들어갈 것 같습니다. 찍힌 것은 x=2 y=1, 뒤집힌 값입니다. 구조분해는 변수 이름을 보지 않고 첫째 변수에 component1(), 둘째에 component2() 를 넣습니다. kotlinc 는 이 줄에 경고도 내지 않았습니다.

이 함정은 나중에 더 조용하게 터집니다. 누가 Point 의 선언을 Point(val y: Int, val x: Int) 로 바꾸면, 모든 구조분해 줄이 컴파일 에러 없이 뜻이 뒤집힙니다. 두 프로퍼티의 타입이 같으면 타입 검사도 못 잡습니다.

그래서 구조분해는 순서가 뻔한 곳에 씁니다. 맵을 돌 때의 for ((key, value) in map) 이나, 함수가 돌려준 값 두 개를 받는 곳이 대표입니다. 프로퍼티가 서넛을 넘거나 같은 타입이 나란히 있으면 p.x·p.y 처럼 이름으로 꺼내는 편이 안전합니다.

정리하면, 구조분해는 선언 순서를 따릅니다. 변수 이름은 읽는 사람을 위한 것일 뿐 컴파일러는 보지 않습니다.

5. 자바 레코드와 대조

자바 16 에서 정식으로 들어온 레코드(record)도 한 줄로 값 클래스를 만듭니다. 이 절에서는 같은 Person 을 레코드로 만들어 출력을 보고, data class 에서 되던 두 가지를 레코드에 시켜 봅니다.

Java
public record Person(String name, int age) {
    public static void main(String[] args) {
        Person a = new Person("Kim", 30);
        Person b = new Person("Kim", 30);
        System.out.println(a);
        System.out.println(a.equals(b));
        System.out.println(a.name());
    }
}
터미널
$ javac -J-Duser.language=en -d jout Person.java
$ java -Duser.language=en -cp jout Person
Person[name=Kim, age=30]
true
Kim

-J-Duser.language=en 은 javac 의 메시지를 영어로 받는 옵션이고, java 쪽의 -Duser.language=en 도 같은 일을 합니다. equals 가 값을 비교하는 것은 같습니다. toString 은 괄호 대신 대괄호를 쓰고, 읽는 메서드는 getName() 이 아니라 name() 입니다.

data class 에서는 본문에 var loginCount 를 두고, var 프로퍼티도 쓸 수 있었습니다. 레코드에 본문 필드를 두면 이렇게 됩니다.

터미널
$ javac -J-Duser.language=en -d jout RecField.java
RecField.java:2: error: field declaration must be static
    int loginCount = 0;
        ^
  (consider replacing field with record component)
1 error

레코드는 본문에 static 이 아닌 필드를 못 둡니다. 레코드의 필드는 모두 헤더 괄호 안의 것이고, 모두 final 입니다.

반대로 data class 가 못 하는 것도 있습니다. 상속해서 쓸 수 있게 open 을 붙이면 막힙니다.

터미널
$ kotlinc OpenData.kt -d o_open
OpenData.kt:1:1: error: modifier 'open' is incompatible with 'data'.
open data class User(val name: String)
^^^^
OpenData.kt:1:6: error: modifier 'data' is incompatible with 'open'.
open data class User(val name: String)
     ^^^^

코틀린 클래스는 기본이 final 이라 상속을 허용하려면 open 을 붙입니다. data class 는 그것이 안 됩니다. 레코드도 암묵적으로 final 이라 상속받을 수 없습니다. 둘 다 「값을 담는 끝 클래스」로 설계됐습니다.

한 가지 갈리는 것은 부모 쪽입니다. 레코드에 extends Base 를 붙이면 javac 가 '{' expected 를 냅니다. 레코드는 이미 java.lang.Record 를 물려받아 다른 클래스를 부모로 못 둡니다. data class 는 open 클래스를 부모로 둘 수 있습니다.

코틀린 data class 자바 record
equals·hashCode·toString 생긴다 생긴다
toString 모양 User(name=Kim, age=30) Person[name=Kim, age=30]
값을 읽는 메서드 getName(), 코틀린에서는 u.name name()
var 프로퍼티 된다 안 된다. 필드가 전부 final
본문 필드 되지만 다섯 가지에서 빠진다 static 만 된다
다른 클래스를 부모로 된다 안 된다
자신을 상속시키기 안 된다 안 된다
copy · 구조분해 있다 없다(자바 21 은 instanceof·switch 의 레코드 패턴으로 푼다)

자바에서 data class 를 부르면

같은 JVM 에서 도니 자바 코드도 data class 를 씁니다. 0절의 User 를 자바에서 불렀습니다.

Java
public class UseUser {
    public static void main(String[] args) {
        User u = new User("Kim", 30);
        // ↓ Kim
        System.out.println(u.getName());
        // ↓ 30
        System.out.println(u.component2());
        // ↓ User(name=Lee, age=30)
        System.out.println(u.copy("Lee", 30));
    }
}
터미널
$ kotlinc User.kt -d out
$ javac -J-Duser.language=en -cp out -d out UseUser.java
$ kotlin -cp out UseUser

getName() 으로 읽고, componentN 도 보통 메서드로 보입니다. copy 는 인자를 전부 넘겨야 합니다. 자바에는 기본인자(파라미터에 적어 두고 생략하면 쓰이는 기본값)가 없어서, 코틀린의 copy(age = 31) 처럼 일부만 넘기는 모양을 쓸 수 없습니다.

자바 쪽이 레코드로 받아야 한다면 @JvmRecord 를 붙입니다. @JvmRecord data class P(val x: Int, val y: Int) 를 -jvm-target 21(클래스 파일을 맞출 JVM 버전) 로 컴파일했더니 자바에서 p.x() 가 1 을, p instanceof Record 가 true 를 찍었습니다. 옵션 없이 컴파일하면 using @JvmRecord is only allowed with -jvm-target 16 or later 에러가 납니다.

정리하면, 둘 다 equals·hashCode·toString 을 만드는 값 클래스입니다. data class 는 var 와 copy·구조분해를 더 주고, 레코드는 필드를 전부 final 로 묶습니다.

6. HashSet 에 넣고 값을 바꾸면 못 찾는다

data class 에 var 를 쓸 수 있다는 것은 5절의 장점입니다. 이 절은 그 장점이 사고가 되는 경우입니다. 장바구니 Cart 를 해시 기반 집합에 넣고, 넣은 뒤에 품목 수를 바꿔 봅니다.

HashSet 은 해시테이블로 원소를 담습니다. 원소를 넣을 때 hashCode() 를 불러 그 값으로 들어갈 칸을 고르고, 찾을 때도 hashCode() 로 칸을 골라 그 칸만 봅니다.

Kotlin
data class Cart(val owner: String, var items: Int)

fun main() {
    val cart = Cart("Kim", 1)
    val set = hashSetOf(cart)
    println(set.contains(cart))   // true
    cart.items = 2
    println(set.contains(cart))   // false
    println(set.size)             // 1
    println(set.first() === cart) // true
    println(set.any { it == cart }) // true
}
  • hashSetOf(cart) — cart 하나를 담은 java.util.HashSet 을 만듭니다
  • set.first() — 집합의 첫 원소를 꺼냅니다
  • set.any { it == cart } — 원소를 처음부터 하나씩 훑어 조건에 맞는 것이 있는지 봅니다. { } 는 람다(함수 대신 넘기는 짧은 코드 조각), it 은 지금 보는 원소입니다
터미널
$ kotlinc Hash.kt -d o_Hash
$ kotlin -cp o_Hash HashKt

items 를 바꾸자 contains 가 false 가 됐습니다. 그런데 집합의 크기는 1 이고, 그 하나는 cart 와 같은 객체(=== 가 true)입니다. 하나씩 훑는 any 로는 찾아집니다. 집합 안에 있는데 contains 만 못 찾습니다.

까닭은 hashCode 가 items 를 재료로 쓰는 데 있습니다. 넣을 때의 해시값과 지금의 해시값이 달라져서, contains 는 다른 칸을 보고 없다고 답합니다.

flowchart TD
    A["넣을 때 · items=1<br/>hashCode → 칸 A"] --> SA["칸 A<br/>Cart(Kim, 1→2)"]
    B["items=2 로 바꾼 뒤<br/>hashCode → 칸 B"] --> SB["칸 B<br/>비어 있음"]
    SB --> R["contains → false"]

칸이 A 와 B 로 갈린 것은 해시값이 바뀌었기 때문입니다. 에러도 예외도 없이 false 만 돌아오니, 원소가 빠진 것처럼 보입니다. HashMap 의 키도 같은 방식으로 찾으니 똑같이 사라집니다.

그래서 해시 기반 컬렉션에 넣을 data class 는 주생성자 프로퍼티를 전부 val 로 둡니다. 값을 바꿔야 하면 copy 로 새 객체를 만들고, 집합에서는 옛 것을 빼고 새 것을 넣습니다.

정리하면, var 프로퍼티는 hashCode 를 바꿉니다. 해시 컬렉션에 들어가 있는 동안 해시값이 바뀌면 그 원소는 contains 로 못 찾습니다.

7. 성능 — hashCode 는 모든 필드를 훑는다

data class 의 equals 와 hashCode 는 부를 때마다 주생성자 프로퍼티를 처음부터 끝까지 봅니다. 계산해 둔 값을 기억하지 않습니다. 필드가 Int 나 String 몇 개면 신경 쓸 일이 없습니다. 이 절에서는 프로퍼티 하나가 원소 1000개짜리 리스트일 때 HashMap 조회 한 번이 무엇을 부르는지 셉니다.

Kotlin
var calls = 0

class Tag(val s: String) {
    override fun hashCode(): Int {
        calls++
        return s.hashCode()
    }
}

data class Key(val id: Int, val tags: List<Tag>)

fun main() {
    val tags = List(1000) { Tag("t$it") }
    val key = Key(1, tags)
    val map = hashMapOf(key to "v")
    calls = 0
    map[key]
    println(calls)    // 1000
    map[key]
    println(calls)    // 2000
}
  • override fun hashCode() — Tag 의 hashCode 를 다시 정의합니다. 불릴 때마다 전역 변수 calls 를 하나 올립니다
  • List(1000) { Tag("t$it") } — 원소 1000개짜리 읽기 전용 리스트를 만듭니다. it 은 0 부터 999 까지의 번호입니다
  • hashMapOf(key to "v") — key 를 키로, "v" 를 값으로 담은 HashMap 입니다. a to b 는 둘을 한 쌍으로 묶습니다
  • map[key] — map.get(key) 입니다
터미널
$ kotlinc Walk.kt -d o_Walk
$ kotlin -cp o_Walk WalkKt

조회 한 번에 Tag.hashCode() 가 1000 번 불렸고, 두 번째 조회에서 또 1000 번 불렸습니다. Key.hashCode() 는 tags 의 hashCode 를 부르고, 리스트의 hashCode 는 원소를 전부 훑어 계산합니다. 두 번째라고 줄지 않은 것은 어느 쪽도 결과를 기억해 두지 않기 때문입니다.

equals 도 같은 식입니다. 조회할 때 해시값이 맞는 칸을 찾으면, 같은 객체가 아닌 한 equals 로 프로퍼티를 하나씩 비교합니다. 큰 리스트끼리 비교하면 원소를 하나씩 맞춰 봅니다.

이것이 문제가 되는 조건은 셋이 겹칠 때입니다. data class 를 HashMap 의 키나 HashSet 의 원소로 쓰고, 그 프로퍼티에 큰 컬렉션이나 깊이 중첩된 객체가 있고, 조회를 반복문 안에서 자주 할 때입니다. 이때는 키를 id 같은 작은 값으로 좁혀서 따로 두는 편이 낫습니다. 셋 중 하나라도 아니면 무시해도 됩니다.

copy 는 매번 새 객체다

copy 는 1절에서 본 것처럼 늘 새 객체를 만듭니다. 아무것도 안 바꾼 copy() 도 마찬가지입니다.

Kotlin
data class Point(val x: Int, val y: Int)

fun main() {
    val p = Point(1, 2)
    val same = p.copy()
    println(p == same)      // true
    println(p === same)     // false
}
터미널
$ kotlinc CopyNew.kt -d o_CopyNew
$ kotlin -cp o_CopyNew CopyNewKt

값은 같고(true) 객체는 다릅니다(false). 3절에서 봤듯이 안쪽 리스트까지 복사하지는 않으니, 한 번의 copy 가 만드는 것은 겉 객체 하나입니다. 짧게 쓰고 버리는 작은 객체는 JVM 의 가비지 컬렉션이 싸게 치웁니다.

신경 쓸 곳은 수백만 번 도는 반복문 안에서 상태를 한 칸씩 바꾸며 매번 copy 하는 경우 정도입니다. 그런 반복문이 느리다고 재 봤을 때 그때 var 를 쓰는 보통 클래스로 바꿔도 늦지 않습니다.

정리하면, hashCode·equals 는 프로퍼티 전부를 매번 훑고 copy 는 매번 객체 하나를 만듭니다. 큰 컬렉션을 든 키로 자주 조회하는 경우만 따로 신경 씁니다.

8. 한 장 요약

data class 는 주생성자 프로퍼티를 재료로 equals·hashCode·toString·copy·componentN 을 만든다. 본문 프로퍼티는 다섯 가지에서 전부 빠진다. copy 는 겉 객체만 새로 만드는 얕은 복사고, 구조분해는 이름이 아니라 선언 순서로 값을 넣는다. 해시 컬렉션에 넣을 거라면 프로퍼티를 val 로 둔다. hashCode·equals 는 부를 때마다 모든 프로퍼티를 훑으니, 큰 컬렉션을 든 객체를 키로 자주 조회할 때만 따로 신경 쓴다.

주제 무엇을 조심하나 확인한 방법
다섯 가지 copy·componentN 은 자바에 없던 것 실행 출력
붙일 수 없는 모양 주생성자 인자가 없거나 val·var 가 빠지면 에러 kotlinc 에러
본문 프로퍼티 ==·toString·copy 에서 빠진다 실행 출력
copy 얕은 복사. 안쪽 리스트를 나눠 쓴다 === 로 같은 객체 확인
구조분해 순서로 들어간다. 이름을 뒤집어도 경고 없음 실행 출력
자바 레코드 var·본문 필드·부모 클래스가 갈린다. 상속 불가는 같다 javac·kotlinc 에러
자바에서 부르기 getName() · copy 는 인자 전부 자바 쪽 실행
var 와 해시 값을 바꾸면 contains 가 못 찾는다 실행 출력
성능 hashCode 가 프로퍼티 전부를 매번 훑는다. copy 는 매번 새 객체 호출 횟수 · ===

관련 항목

data class 가 만들어 주는 멤버

equals · hashCode · toString · copy · componentN · 주생성자 · 프로퍼티

data class 와 같은 역할을 두고 겨루는 수단

레코드 · Lombok · 값 객체 · DTO · JvmRecord

data class 를 쓰다 터지는 것

얕은 복사 · 깊은 복사 · 구조분해 선언 · equals 와 hashCode 계약 · 가변 객체

data class 를 키와 원소로 담는 해시 컬렉션

해시 · 해시 함수 · 해시테이블 · 해시 충돌 · HashMap · HashSet

data class 의 동작을 가르는 코틀린 문법 요소

val · var · 명명인자 · 기본인자 · 읽기 전용 컬렉션 · MutableList · 동등성 · 참조 동등성

data class 를 열어 보고 부르는 환경

kotlinc · 컴파일러 · JVM · 참조 · 가비지 컬렉션