|

코틀린 data class: 실무에서 어디까지 쓸까

코틀린 data class equals copy destructuring 정리 대표 이미지
코틀린 data class 핵심 동작과 주의점 정리

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만 자동 생성 기준에 들어가기 때문입니다. 그래서 프로퍼티를 어디에 두는지는 단순 문법 문제가 아니라 동등성 규칙 설계의 문제입니다.

  1. 이 값이 동등성 비교 기준이어야 하는가?
  2. 이 값이 copy() 대상이어야 하는가?
  3. 이 값이 구조 분해 대상이어야 하는가?
  4. 이 값이 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가 부족해서가 아니라, 문제 자체가 값 객체보다 복잡하기 때문입니다.


실무에서 기억하면 좋은 한 줄 기준

  1. 이 객체는 값처럼 비교되어야 하는가?
  2. 일부 값만 바꾼 새 객체를 자주 만드는가?
  3. 주 생성자 프로퍼티가 곧 이 객체의 핵심 상태인가?
  4. 내부 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는 어디에나 쓰는 만능 문법이 아니라 값 중심 모델에 잘 맞는 설계 도구입니다.

함께보면 좋은 글