
Kotlin data class는 짧게 쓰기 편한 문법처럼 보이지만, 실제로는 equals(), copy(), componentN()이 함께 움직이는 값 중심 설계 도구에 가깝습니다. 이 글에서는 실무에서 자주 만나는 예제로 data class가 어디까지 편하고 어디서부터 주의가 필요한지 차분하게 정리해보겠습니다.
먼저 핵심부터 말하면 data class의 자동 생성 기능은 주 생성자 프로퍼티만 기준으로 동작하고, copy()는 shallow copy입니다. 이 두 줄을 정확히 이해하면 equals, copy, destructuring에서 생기는 대부분의 오해를 줄일 수 있습니다.
먼저 감으로 보기: Kotlin data class가 편한 장면
data class가 잘 맞는 대표 장면은 값 중심 모델입니다. 검색 조건, API 응답 DTO, 화면 상태, 설정값 묶음처럼 이 객체의 정체성이 id보다 값 자체에 가까운 경우입니다.
data class SearchCondition(
val keyword: String,
val page: Int,
val sort: String,
)
val firstPage = SearchCondition(
keyword = "kotlin",
page = 1,
sort = "recent",
)
val secondPage = firstPage.copy(page = 2)이 한 장면만 봐도 data class가 왜 편한지 감이 옵니다. 새 객체를 만들 때 전체를 다시 적지 않아도 되고, 값 비교도 자연스럽게 됩니다. 반대로 객체의 핵심이 값 자체보다 identity에 가까우면 자동 생성된 동등성 규칙이 의도와 어긋날 수 있습니다.
핵심은 data class가 모든 클래스의 기본형이 아니라 값 중심 모델에 최적화된 도구라는 점입니다.
Kotlin data class가 자동으로 만들어주는 것
Kotlin 공식 문서 기준으로 data class는 primary constructor 프로퍼티를 바탕으로 equals(), hashCode(), toString(), componentN(), copy()를 자동 생성합니다. 중요한 점은 이 기능들이 모두 같은 기준, 즉 주 생성자 프로퍼티 목록을 공유한다는 것입니다.
- equals() / hashCode(): 값 비교와 hash 기반 컬렉션 동작을 자연스럽게 만듭니다.
- toString(): 로그와 디버깅에서 읽기 좋은 문자열을 제공합니다.
- componentN(): 구조 분해 선언을 가능하게 합니다.
- copy(): 일부 값만 바꾼 새 객체를 간결하게 만들 수 있습니다.
equals가 편한 이유는 값 비교를 바로 할 수 있기 때문
일반 class에서는 값이 같아 보여도 같은 객체로 비교되지 않는 경우가 흔합니다. 하지만 data class는 주 생성자 프로퍼티 기준으로 equals()와 hashCode()를 만들어주기 때문에, 값 비교가 자연스럽습니다.
data class UserSummary(
val name: String,
val postCount: Int,
)
val a = UserSummary("BS", 10)
val b = UserSummary("BS", 10)
println(a == b) // true이 장점은 Set 중복 제거나 테스트 비교에서도 바로 체감됩니다. 즉, data class의 equals는 문법 편의보다 값 객체처럼 다루기 쉬운 기본 동작을 제공한다는 데 의미가 있습니다.
val users = setOf(
UserSummary("BS", 10),
UserSummary("BS", 10),
)
println(users.size) // 1가장 중요한 규칙: class body의 프로퍼티는 자동 생성 기준에서 빠진다
이 부분이 data class에서 가장 자주 놓치는 포인트입니다. 자동 생성되는 equals, hashCode, toString, copy, componentN은 주 생성자 프로퍼티만 봅니다. class body에 둔 프로퍼티는 포함되지 않습니다.
data class SessionTemplate(
val name: String,
) {
var timeoutSeconds: Int = 30
}
val a = SessionTemplate("default")
val b = SessionTemplate("default")
a.timeoutSeconds = 30
b.timeoutSeconds = 120
println(a == b) // true
println(a) // SessionTemplate(name=default)
println(b) // SessionTemplate(name=default)timeoutSeconds가 달라도 비교 결과는 같습니다. 이유는 name만 자동 생성 기준에 들어가기 때문입니다. 그래서 프로퍼티를 어디에 두는지는 단순 문법 문제가 아니라 동등성 규칙 설계의 문제입니다.
- 이 값이 동등성 비교 기준이어야 하는가?
- 이 값이 copy() 대상이어야 하는가?
- 이 값이 구조 분해 대상이어야 하는가?
- 이 값이 toString()에 드러나야 하는가?
copy는 편하지만 deep copy는 아니다
copy()는 data class를 실무에서 자주 쓰게 만드는 핵심 기능입니다. 일부 값만 바꾼 새 객체를 만들 때 정말 편합니다. 하지만 공식 문서 기준으로 copy()는 deep copy가 아니라 shallow copy입니다.
data class NotificationConfig(
val channel: String,
val title: String,
val urgent: Boolean,
)
val defaultConfig = NotificationConfig(
channel = "email",
title = "주간 리포트",
urgent = false,
)
val vipConfig = defaultConfig.copy(
title = "[VIP] 주간 리포트",
urgent = true,
)이 장면에서는 copy()가 거의 정답에 가깝습니다. 문제는 내부에 mutable reference가 들어오는 순간입니다.
mutable property가 들어오면 왜 조심해야 할까
data class UserProfile(
val name: String,
val tags: MutableList<String>,
)
val original = UserProfile(
name = "BS",
tags = mutableListOf("android", "kotlin"),
)
val copied = original.copy()
copied.tags += "mentor"
println(original.tags) // [android, kotlin, mentor]
println(copied.tags) // [android, kotlin, mentor]겉으로 보면 copied는 새 객체입니다. 그런데 내부 tags 리스트는 같은 참조를 공유합니다. 그래서 복사본을 수정했는데 원본도 함께 바뀝니다.
data class가 불변성을 보장하는 것은 아닙니다. val로 선언해도 그 안의 참조 대상이 mutable이면 충분히 흔들립니다.
필요하면 깊은 복사 의도를 직접 적어주는 편이 낫습니다. Kotlin이 어디까지 자동으로 해주고, 어디서부터는 내 설계 책임인지 구분하면 됩니다.
data class SeoOptions(
val keywords: MutableList<String>,
val title: String,
)
data class PostDraft(
val content: String,
val seo: SeoOptions,
)
fun PostDraft.deepCopy(): PostDraft = copy(
seo = seo.copy(
keywords = seo.keywords.toMutableList(),
),
)data class destructuring은 짧은 코드에서 특히 빛난다
구조 분해 선언은 componentN() 덕분에 동작합니다. 즉, data class가 자동 생성해준 component 함수가 있기에 가능한 문법입니다.
data class User(
val name: String,
val age: Int,
)
val user = User("Lee", 20)
val (name, age) = user
println(name) // Lee
println(age) // 20짧은 로컬 문맥에서는 꽤 읽기 좋습니다. 함수 안에서 잠깐 값을 꺼내 쓸 때나 for ((key, value) in map)처럼 구조가 익숙한 경우에 특히 잘 맞습니다.
destructuring을 남용하면 오히려 헷갈리는 이유
destructuring은 이름이 아니라 순서를 따라갑니다. 그래서 필드가 많아지거나 변수 이름을 순서와 다르게 적으면 오히려 읽기 어려워집니다.
data class User(
val username: String,
val email: String,
)
val user = User("alice", "alice@example.com")
val (email, username) = user
println(email) // alice
println(username) // alice@example.com변수 이름을 이렇게 둔다고 해서 자동으로 이름 매칭이 되지 않습니다. 실무에서는 값이 2~3개이고 문맥이 짧을 때만 구조 분해를 쓰고, 의미가 복잡한 모델은 객체 이름으로 접근하는 편이 더 안전합니다.
data class가 잘 맞는 경우와 덜 맞는 경우
아래 조건이 많을수록 data class가 잘 맞습니다. 값 비교가 자연스러워야 하고, 일부 값만 바꾼 새 객체를 자주 만들며, DTO나 UI state처럼 값 중심 역할일 때입니다.
- 값 비교가 자연스러워야 한다.
- 일부 값만 바꾼 새 객체를 자주 만든다.
- 로그 출력이 읽기 쉬우면 좋다.
- DTO, 응답 모델, query 객체, UI state처럼 값 중심 역할이다.
- 주 생성자 프로퍼티 기준 동등성이 자연스럽다.
data class ApiUserResponse(
val id: Long,
val name: String,
val email: String,
)
data class FilterOption(
val keyword: String,
val includeClosed: Boolean,
)반대로 객체의 정체성이 값보다 identity에 가깝거나, 내부 mutable state가 많거나, 자동 생성된 동등성 규칙이 의도와 충돌할 수 있으면 일반 class가 더 나을 수 있습니다. data class가 부족해서가 아니라, 문제 자체가 값 객체보다 복잡하기 때문입니다.
실무에서 기억하면 좋은 한 줄 기준
- 이 객체는 값처럼 비교되어야 하는가?
- 일부 값만 바꾼 새 객체를 자주 만드는가?
- 주 생성자 프로퍼티가 곧 이 객체의 핵심 상태인가?
- 내부 mutable 참조를 공유해도 괜찮은가?
앞의 세 개는 예인데 마지막은 아니오라면, data class + copy() 조합이 잘 맞을 가능성이 큽니다. 반대로 마지막 질문에서 불안하다면 immutable 설계나 explicit deep copy를 먼저 고민하는 편이 좋습니다.
함께 보면 좋은 글과 공식 문서
코틀린 디자인 패턴 (5) – Prototype 패턴과 data class copy 정리를 함께 보면 copy()를 패턴 관점에서도 이해하기 좋습니다. 더 넓은 범위의 생성 규칙과 선택 기준이 궁금하면 코틀린 data class 완전 정리도 이어서 읽어보세요.
공식 참고 자료는 Kotlin data classes 문서와 Kotlin destructuring declarations 문서입니다.
결론: data class는 편한 문법이면서 동시에 설계 규칙이다
Kotlin data class는 분명 편합니다. 하지만 진짜 장점은 짧게 쓸 수 있다는 데만 있지 않습니다. equals, copy, destructuring이 모두 주 생성자 프로퍼티를 기준으로 같은 방향을 보게 만든다는 점이 더 중요합니다.
- equals는 값 비교를 쉽게 만들어줍니다.
- copy는 편하지만 shallow copy입니다.
- destructuring은 편하지만 순서 의존적입니다.
- data class는 어디에나 쓰는 만능 문법이 아니라 값 중심 모델에 잘 맞는 설계 도구입니다.