Kotlin의 Data Class와 Copy
1. 개요
data class는 Kotlin이 Java와 비교해 훨씬 간결한 코드를 작성하도록 도와주는 기능 중 하나입니다. 데이터만 담는 클래스(DTO, 모델, 값 객체)를 다룰 때 특히 유용합니다. 이 글에서는 Kotlin이 data class를 위해 생성하는 것들, copy() 함수로 몇 가지 필드를 수정한 복사본을 만드는 방법, 그리고 비교 시 실수, data class에서 var 사용, data class 상속 같은 일반적인 함정들을 살펴봅니다.
2. Data Class란?
data 키워드로 클래스를 선언하면, Kotlin 컴파일러는 primary constructor에 선언된 프로퍼티를 기반으로 자동으로 여러 함수를 생성합니다.
data class Customer(
val id: Long,
val name: String,
val email: String
)
이 4줄만으로 Kotlin이 생성하는 것:
id,name,email을 기반으로 하는equals()/hashCode().Customer(id=1, name=..., email=...)형식의toString().- 일부 필드를 변경할 수 있는 복사본을 만드는
copy(). - destructuring declaration을 지원하는
componentN().
Java와 비교하면, 동일한 기능을 위해 constructor, getter, equals, hashCode, toString이 포함된 클래스를 작성해야 하는데, 이는 약 40-50줄 정도 됩니다.
2.1. 기본 toString()
fun main() {
val customer = Customer(1, "An Nguyen", "an@nonladev.com")
println(customer)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
자동으로 생성되는 toString()은 로깅이나 디버깅할 때 매우 편리합니다. 직접 작성할 필요가 없습니다.
3. 값을 기반으로 한 equals()와 hashCode()
data class와 일반 클래스 간의 가장 큰 차이점은 equals()가 값을 기반으로 비교한다는 것입니다(structural equality).
fun main() {
val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
val c2 = Customer(1, "An Nguyen", "an@nonladev.com")
println(c1 == c2) // 값 비교
println(c1 === c2) // 참조 비교
}
true
false
==는equals()를 호출하며, 두 객체가 같은 필드를 가지면 다른 참조이더라도 같다고 판단합니다.===는 두 변수가 메모리에서 동일한 객체를 가리키는지 확인합니다.
주의: 일반 클래스(data class가 아닌)의 경우, 기본적으로
==는equals()를 재정의하지 않는 한===와 같게 참조를 비교합니다.
hashCode()도 프로퍼티를 기반으로 생성되므로, data class를 HashMap의 키나 HashSet의 요소로 안전하게 사용할 수 있습니다.
fun main() {
val set = hashSetOf(
Customer(1, "An Nguyen", "an@nonladev.com"),
Customer(1, "An Nguyen", "an@nonladev.com")
)
println(set.size)
}
1
값이 완전히 같은 두 객체는 같은 것으로 간주되므로, HashSet은 하나의 요소만 유지합니다.
4. copy() 함수 — 불변 복사본 만들기
프로퍼티에 val을 사용하면 Customer는 불변입니다. 생성 후에는 필드를 직접 수정할 수 없습니다. 몇 가지 값을 “변경”하려면, Kotlin이 생성한 copy() 함수를 사용하여 새 객체를 만들고, 전달되지 않은 필드는 유지합니다.
fun main() {
val original = Customer(1, "An Nguyen", "an@nonladev.com")
val updated = original.copy(email = "an.new@nonladev.com")
println(original)
println(updated)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
Customer(id=1, name=An Nguyen, email=an.new@nonladev.com)
original은 전혀 변경되지 않습니다. 이것이 불변성의 본질입니다.updated는 새 객체이며,email만 다르고 나머지 필드는 복사됩니다.
이 패턴은 UI의 state를 다룰 때(Compose, MVI) 또는 비즈니스 로직에서 entity를 업데이트할 때 직접 mutate하지 않기 위해 매우 일반적입니다.
4.1. Named Arguments를 사용한 copy()
여러 파라미터를 동시에 전달할 수 있으며, 순서 혼동을 피하기 위해 이름으로 지정합니다:
val promoted = original.copy(name = "An Nguyen (VIP)", email = "vip@nonladev.com")
이름 없이 위치로 전달하면, 같은 타입의 문자열 필드가 많을 때 실수하기 쉽습니다.
5. Shallow Copy — 내부의 Mutable 객체가 있을 때의 함정
copy()는 shallow copy만 생성합니다. 필드의 참조를 복사하지만, 내부의 복잡한 객체는 깊게 복사하지 않습니다.
data class Order(
val id: Long,
val items: MutableList<String>
)
fun main() {
val order1 = Order(1, mutableListOf("Book"))
val order2 = order1.copy()
order2.items.add("Pen")
println(order1.items) // 영향을 받습니다!
println(order2.items)
}
[Book, Pen]
[Book, Pen]
order2 = order1.copy()는 새Order객체를 만들지만,items필드는 여전히 같은MutableList를 가리킵니다.order2.items를 수정하면order1.items도 변경됩니다.data class에 mutable collection을 사용할 때 매우 흔한 버그입니다.
해결 방법: 불변 List(toList())를 사용하거나 constructor에서 collection을 직접 복사합니다.
data class Order(
val id: Long,
val items: List<String>
)
fun main() {
val order1 = Order(1, listOf("Book"))
val order2 = order1.copy(items = order1.items + "Pen")
println(order1.items)
println(order2.items)
}
[Book]
[Book, Pen]
불변 List<String>을 사용하고 + 연산자로 새 list를 만들어서 mutate 대신 복사합니다. 이는 data class의 불변성 정신에 맞습니다.
주의: 실제로 깊은 복사가 필요한 경우(data class가 다른 data class를 포함), 재귀적 복사 로직을 직접 작성해야 합니다. Kotlin은 깊은 복사를 자동 생성하지 않습니다.
6. Destructuring Declaration
자동 생성된 componentN()덕분에, data class를 여러 개별 변수로 “분해”할 수 있습니다:
fun main() {
val customer = Customer(1, "An Nguyen", "an@nonladev.com")
val (id, name, email) = customer
println("$id - $name - $email")
}
1 - An Nguyen - an@nonladev.com
변수의 순서는 constructor의 프로퍼티 순서에 해당하며, 이름이 아닙니다. 프로퍼티 선언 순서를 바꾸면 destructuring이 잘못될 수 있으므로 refactor할 때 주의해야 합니다.
팁:
data class가 필드가 적을 때(2-3개)만 destructuring을 사용하는 것이 좋습니다. 필드가 많은 클래스는 프로퍼티 이름을 직접 사용하는 것이 더 읽기 쉽습니다.
7. Data Class의 한계
7.1. 상속할 수 없음
data class는 다른 data class를 상속할 수 없습니다. 자동으로 필드를 상속하는 방식으로 open과 data를 함께 사용할 수 없습니다:
// 이렇게 하려고 하면 컴파일 오류가 발생합니다:
// data class VipCustomer(val tier: String) : Customer(...)
Kotlin은 data class가 interface를 구현하거나 primary constructor에 프로퍼티가 없는 일반 클래스를 상속하는 것만 허용합니다. 이유는 equals()/copy()가 자동 생성되면 다단계 상속 필드가 있을 때 올바르게 정의하기가 매우 어렵기 때문입니다.
일반적인 해결책: 상속 대신 composition을 사용합니다.
data class VipCustomer(
val customer: Customer,
val tier: String
)
7.2. Primary Constructor는 최소 1개의 프로퍼티 필요
// 오류: data class는 primary constructor에 최소 1개의 파라미터 필요
// data class Empty
7.3. Constructor의 프로퍼티만 계산됨
data class Product(val name: String) {
var discount: Int = 0
}
fun main() {
val p1 = Product("Laptop").apply { discount = 10 }
val p2 = Product("Laptop").apply { discount = 20 }
println(p1 == p2) // true, discount는 equals()에 참여하지 않음
}
true
discount는 body에 선언되었으며, primary constructor 외부이므로 equals(), hashCode(), toString(), copy()에 포함되지 않습니다. 이것이 혼동을 일으킬 수 있는 오류입니다. 비교에 중요한 필드는 primary constructor에 있어야 합니다.
8. JUnit 5와 AssertJ를 사용한 테스트
필요한 의존성(Gradle Kotlin DSL):
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.assertj:assertj-core:3.26.0")
copy()와 equals()의 동작을 검증하는 테스트 작성:
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class CustomerTest {
@Test
fun `copy는 변경되지 않은 필드는 유지하고 지정된 필드는 업데이트해야 함`() {
val original = Customer(1, "An Nguyen", "an@nonladev.com")
val updated = original.copy(email = "an.new@nonladev.com")
assertThat(updated.id).isEqualTo(original.id)
assertThat(updated.name).isEqualTo(original.name)
assertThat(updated.email).isEqualTo("an.new@nonladev.com")
assertThat(original.email).isEqualTo("an@nonladev.com")
}
@Test
fun `같은 필드를 가진 두 customer는 같아야 함`() {
val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
val c2 = Customer(1, "An Nguyen", "an@nonladev.com")
assertThat(c1).isEqualTo(c2)
assertThat(c1).isNotSameAs(c2)
}
}
isEqualTo는 값을 기반으로 검증하며,data class의equals()의 본질과 일치합니다.isNotSameAs는c1과c2가 메모리의 다른 객체임을 확인하지만, 값은 같습니다.- 첫 번째 테스트는
copy()가 올바르게 작동함을 확인합니다: 지정되지 않은 필드는 유지되고, 지정된 필드는 업데이트되며, 원본 객체는 변경되지 않습니다.
9. Data Class를 사용하면 안 되는 경우
- JPA/Hibernate Entity:
equals()/hashCode()가 모든 필드를 기반으로 하면(id 같은 generated 필드 포함), persist 전후로HashSet또는 entity 비교에서 쉽게 오류가 발생할 수 있습니다. Entity에는id를 기반으로equals()/hashCode()를 수동으로 작성해야 합니다. - 복잡한 비즈니스 로직이 있는 클래스:
data class는 data holder용이며, 많은 행동이 필요한 클래스나 다단계 상속에는 적합하지 않습니다. - 많은 mutable 중첩 필드로 메모리 최적화가 필요한 클래스: shallow copy가 어려운 제어 위험을 줄 수 있으면, builder pattern이나 일반 클래스를 사용하는 것을 고려하세요.
10. 결론
data class는 Kotlin에서 DTO/모델을 다룰 때 boilerplate code를 제거합니다. 자동으로 equals(), hashCode(), toString(), copy()를 생성하고 destructuring을 지원합니다. copy() 함수는 불변성을 유지하는 강력한 도구이지만, shallow copy임을 기억해야 합니다. 내부의 collection이나 mutable 객체가 있는 필드에 주의해야 합니다. JPA entity나 복잡한 비즈니스 로직이 있는 클래스에는 data class를 사용하지 마세요. 그리고 equals()/copy()가 예상대로 작동하려면 항상 중요한 필드를 primary constructor에 포함시켜야 합니다.