자바 엔티티를 코틀린으로 옮기면서 전부 data class로 바꿨습니다. 롬복 애너테이션 네 줄이 단어 하나로 줄어드는 게 통쾌하더군요. 그런데 한참 뒤에 이상한 버그를 만났습니다. 저장한 엔티티를 Set에 담아 두고 나중에 꺼내려는데 contains가 false를 돌려줬습니다. 그 객체가 분명히 그 안에 들어 있는데도요.
원인을 찾는 데 시간이 꽤 걸렸고, 알고 나니 data class가 무엇을 생성하는지 몰랐던 게 문제였습니다. 지난 글과 같은 방식으로 디컴파일해서 확인했습니다. Kotlin 2.0.21, JDK 21 기준입니다.
한 단어가 자바 78줄이 됩니다
먼저 data class가 실제로 무엇을 만드는지부터 봤습니다.
data class Member(val id: Long, val name: String)
컴파일하고 시그니처를 찍으면 이렇게 나옵니다.
public kt.Member(long, java.lang.String);
public final long getId();
public final java.lang.String getName();
public final long component1();
public final java.lang.String component2();
public final kt.Member copy(long, java.lang.String);
public static kt.Member copy$default(kt.Member, long, java.lang.String, int, Object);
public java.lang.String toString();
public int hashCode();
public boolean equals(java.lang.Object);
한 줄짜리 선언이 메서드 열 개가 됐습니다. 필드가 하나 더 붙은 판을 디컴파일해 보니 주석과 패키지 선언을 뺀 본문이 78줄이었습니다. 자바에서 IDE로 생성하거나 롬복에 맡기던 코드가 그대로 나온 셈이라 여기까지는 반가웠습니다.
눈여겨볼 건 component1, component2입니다. 구조 분해에 쓰이는 메서드인데 선언 순서를 그대로 따릅니다. 나중에 생성자 인자 순서를 바꾸면 구조 분해하던 쪽이 조용히 다른 값을 받게 됩니다.
본문에 적은 프로퍼티는 빠집니다
문제는 equals가 무엇을 비교하느냐였습니다. 주문 엔티티를 이렇게 썼다고 해 보겠습니다.
data class Order(val id: Long, val items: MutableList<String>) {
var status: String = "NEW" // 본문에 선언했다
}
상태가 다른 두 주문을 만들어 비교했습니다.
status 가 다른 두 주문이 같은가 : true
해시도 같은가 : true
toString : Order(id=1, items=[사과])
결제가 끝난 주문과 방금 만든 주문이 같은 객체로 판정됩니다. toString을 찍어 봐도 status가 아예 없어서 로그만 보면 눈치채기 어렵습니다.
이유는 생성된 equals를 보면 바로 나옵니다. id와 items만 비교하고 status는 쳐다보지도 않습니다. 컴파일러가 주 생성자에 적힌 프로퍼티만 대상으로 삼기 때문이고, 코틀린 공식 문서에도 그렇게 적혀 있습니다. 읽고도 그냥 넘겼던 문장이었습니다.
판단 기준은 하나입니다. 이 값이 다르면 다른 객체인가. 그렇다면 주 생성자로 올리고, 아니면 본문에 두되 동등성에서 빠진다는 걸 알고 씁니다.
copy는 얕은 복사였습니다
copy도 이름 때문에 오해했습니다. 복사본의 리스트를 건드려 봤습니다.
copy 후 원본 items : [사과, 배]
두 items 가 같은 객체인가 : true
copy 후 status : NEW (원본 PAID)
복사본에 원소를 넣었는데 원본의 리스트가 늘었습니다. 두 객체가 같은 리스트를 가리키고 있으니 당연합니다. copy는 각 프로퍼티의 참조를 그대로 옮길 뿐입니다.
세 번째 줄은 앞 절의 연장입니다. 본문 프로퍼티는 copy에도 안 들어가서 복사본의 status가 초기값으로 돌아갑니다. 결제 완료 주문을 복사했더니 미결제 주문이 나옵니다.
이 둘을 한꺼번에 피하는 방법은 프로퍼티를 전부 불변으로 두는 겁니다. MutableList 대신 List, var 대신 val, 상태는 본문이 아니라 주 생성자에. 그러면 얕은 복사가 문제가 될 일이 없습니다. DTO와 값 객체에는 이게 자연스럽습니다.
엔티티에서 터진 지점
문제는 엔티티였습니다. 엔티티는 var를 쓸 수밖에 없고 식별자는 저장하면서 채워집니다. 그 조합이 사고를 만듭니다.
스프링 부트와 H2로 실제로 재현해 봤습니다. 엔티티를 Set에 넣고 저장한 뒤 해시를 비교했습니다.
val t = Tag(label = "새것")
val set = hashSetOf(t)
val before = t.hashCode()
tags.save(t)
data class: 저장 전후 해시가 같은가 : false
data class: Set 이 여전히 찾는가 : false
Set 안에 들어 있는 바로 그 객체를 Set이 못 찾습니다. 저장하면서 데이터베이스가 식별자를 채웠고, 식별자가 hashCode에 들어 있으니 해시가 바뀐 겁니다. 해시가 바뀌면 HashSet은 다른 칸을 뒤지니 찾을 리가 없습니다.
제가 만난 버그가 정확히 이거였습니다. 예외도 안 나고 로그도 깨끗한데 contains만 false를 돌려주니 한참 엉뚱한 곳을 팠습니다.
재현이 안 되던 시간
이 글을 쓰려고 재현하는 데도 한 번 헤맸습니다. 처음엔 식별자를 클래스 본문에 두고 시도했는데 해시가 안 바뀌더군요.
data class Tag(var label: String = "") {
@Id @GeneratedValue var id: Long? = null // 본문에 뒀다
}
한참 보다가 깨달았습니다. 둘째 절에서 확인한 그대로입니다. 본문 프로퍼티는 hashCode에 안 들어가니 식별자가 채워져도 해시가 그대로였던 겁니다.
식별자를 주 생성자로 올리니 바로 재현됐습니다. 그런데 이게 위안이 되진 않습니다. 본문에 두면 해시는 안정적이지만 이번엔 라벨만으로 동등성이 판정되니까요. 같은 라벨의 서로 다른 태그가 같은 객체가 됩니다. 어느 쪽에 둬도 엔티티에는 맞지 않습니다.
엔티티는 이렇게 씁니다
결국 평범한 class로 돌아가고 equals와 hashCode를 직접 씁니다.
@Entity
class Label(var text: String = "") {
@Id @GeneratedValue
var id: Long? = null
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Label) return false
return id != null && id == other.id
}
override fun hashCode(): Int = javaClass.hashCode()
}
같은 시나리오로 재 보면 이렇습니다.
직접 구현 : 저장 전후 해시가 같은가 : true
직접 구현 : Set 이 여전히 찾는가 : true
hashCode가 클래스 기준이라 저장해도 변하지 않습니다. equals는 식별자가 있을 때만 같다고 보고, 아직 식별자가 없는 새 객체는 자기 자신하고만 같습니다. 하이버네이트 문서가 권하는 형태이기도 합니다.
해시가 전부 같으면 성능이 나쁘지 않냐는 생각이 들 텐데, 한 Set에 같은 엔티티 타입을 수백 개씩 담는 경우가 아니면 체감되지 않습니다. 정합성을 먼저 챙기는 쪽이 맞습니다.
엔티티를 data class로 못 쓰는 이유가 하나 더 있습니다. 코틀린 클래스는 기본이 final이라 지연 로딩 프록시를 만들 수 없는데, data class에는 open을 붙일 수조차 없습니다. 이건 빌드 설정으로 푸는 문제라 다음에 따로 다루겠습니다.
어디에 쓰고 어디에 안 쓰나
정리하면 기준은 동등성을 무엇으로 판정하느냐 하나입니다. 값이 같으면 같은 객체로 봐도 되는 대상에는 쓰고, 식별자가 따로 있는 대상에는 쓰지 않습니다.
| 대상 | data class | 이유 |
|---|---|---|
| DTO, 응답 모델 | 쓴다 | 불변이고 동등성이 값 기준이면 맞다 |
| 값 객체(금액, 좌표) | 쓴다 | 같은 값이면 같은 객체가 맞다 |
| 설정 바인딩 | 쓴다 | val만으로 받고 기본 인자도 먹는다 |
| JPA 엔티티 | 안 쓴다 | 식별자가 나중에 채워지고 프록시가 필요하다 |
| 가변 상태를 들고 다니는 객체 | 안 쓴다 | copy가 얕은 복사라 새는 곳이 생긴다 |
마무리
data class는 좋은 기능입니다. 문제는 그게 무엇을 생성하는지 모른 채 “롬복 대체”로 받아들였던 제 쪽에 있었습니다.
규칙은 한 줄로 줄어듭니다. 값이 같으면 같은 객체인 것에는 쓰고, 식별자로 같고 다른 것에는 쓰지 않습니다. 엔티티는 후자입니다. 영속성 컨텍스트가 엔티티를 관리하는 방식을 떠올려 보면 왜 그런지가 더 분명해집니다.
지금 코드에 data class로 된 엔티티가 있다면 한 가지만 확인해 보시길 권합니다. 식별자가 주 생성자에 있는지, 그 엔티티를 Set이나 Map 키로 쓰는 곳이 있는지입니다. 둘 다 해당하면 이미 같은 버그를 안고 있을 가능성이 높습니다.
자바 개발자를 위한 코틀린
문법이 아니라 생성되는 코드를 봅니다
이 글에서 쓴 방식 그대로, 코틀린이 자바로 무엇이 되는지 디컴파일해서 확인한 책입니다. 널 안전성부터 코루틴·스프링 설정까지 여덟 장이고, 모든 수치와 오류 메시지는 실제로 돌려 얻었습니다. 확인에 쓴 코드는 도커로 그대로 재현됩니다.
공개 준비 중입니다. 나오면 알려 드릴 수 있도록 텔레그램 채널에 소식을 올리겠습니다.
이 주제를 더 깊게 보려면
인프런 자바 개발자를 위한 코틀린 입문 (Java to Kotlin Starter Guide)
이 링크를 통해 수강 신청하면 운영자에게 소정의 수수료가 지급될 수 있습니다. 강의 가격은 달라지지 않습니다.
핑백: 코틀린 스프링 부트 설정: 플러그인 없으면 앱이 안 뜹니다 - loghubs