Java를 사용하면 고민할 필요가 없어 그냥 넘어가는데, Kotlin으로 JPA를 사용하면 한 번쯤 생각해봐야 하는 것이 있다.
나의 경우 PK인 id 프로퍼티를 어떻게 선언해둘지 고민하다 Spring Data JPA 내부 구현체를 살펴보고 새로운 entity를 어떻게 판단하는지 직접 확인해보며 정리해봤다.
Java냐 Kotlin이냐 상관 없이 공통적인 내용이 있어 잘 알아두는 게 좋을 것 같다.
테스트 환경
- Kotlin 1.9
- Spring Boot 3.2.3
- Spring Data JPA
- MySQL 8
- Testcontainers
Spring Data JPA 내부 구현체
package me.ramos.guide.repository
import me.ramos.guide.domain.Item
import org.springframework.data.jpa.repository.JpaRepository
interface ItemRepository : JpaRepository<Item, Long> {
}
우선 흔히 하는 방식대로 코드를 작성하면 위와 같다.
실제로 Entity의 save
, findById
등등의 행위는 어디에서 일어날까? 내부 구현체의 상속관계 다이어그램을 보면 아래와 같다.
여기서 실제 동작이 일어나는 SimpleJpaRepository
를 살펴보면 아래와 같이 구현되어 있다.
save시 새로운 엔티티를 구별하는 방법?
entityInformation.isNew(entity)
로 판단하는데, JPA를 사용하면 JpaPersistableEntityInformation
의 isNew(entity)
가 동작하게 된다.
entity.isNew()
를 타고 들어가면 Persistable<ID>
interface가 나타나게되고 하위에 추상 클래스인 AbstractPersistable
클래스가 나타난다.
실제 여기에서 PK가 null
인지의 여부로 새로운 엔티티를 구별하게 되는 것이다.
아래와 같이 entity를 정의하고 디버깅을 해보자.
package me.ramos.guide.domain
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
@Entity
class Item(
val name: String,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
) {
}
persist
를 한 뒤 id가 1부터 시작하게 된다.
반대로 Item
이라는 Entity
를 아래와 같이 작성하고 디버깅을 해보자.
package me.ramos.guide.domain
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
@Entity
class Item(
val name: String,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = 0L
) {
}
isNew()
로 판단하는 구간에서 이미 id가 0으로 초기화되어 있는 상태이기에 persist
가 아닌 merge
로 넘어가게 된다.
두 방법 모두 동작 자체는 정상적으로 넘어가며 PK 전략이 IDENTITY
대로 동작하는 것은 모두 같다.
Kotlin 사용 시 Id는 어떻게 선언하는게 좋을까?
생략된 프로퍼티는 많지만 일단, 일반 Java 클래스로 entity 매핑을 할 경우 아래와 같은 방식으로 할 것이다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item {
@Id
@GenerateValue(strategy = GenerationType.IDENTITY)
private Long id;
}
id를 Long 타입으로 단순하게 선언해두면 문제가 없다. 크게 고민할 부분이 없다.
하지만 Kotlin으로 JPA를 사용할 때 Kotlin 자체의 언어적 측면에서 null safe한 특징이 있어 고민해볼 사항이 많다.
- Kotlin의 null safe한 특성 vs JPA가 새로운 엔티티를 판단할 때 null을 기준으로 판단함
- 일반 class를 사용해야 하는지 data class를 사용해야하는지?
- all open, no args 등의 별도 설정이 필요함.
여기서는 Id를 어떻게 선언하면 좋을지에 대해서만 이야기해보자.
앞서 entity class를 두 가지 방식으로 정의했었다.
// 첫 번째 방식
package me.ramos.guide.domain
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
@Entity
class Item(
val name: String,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
) {
}
// 두 번째 방식
package me.ramos.guide.domain
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
@Entity
class Item(
val name: String,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = 0L
) {
}
디버깅한 스크린샷을 유심히 살펴보면, persist
를 수행하는지 merge
를 수행하는지를 눈치챘을 것이다.
이 두 메서드는 저장이냐 병합이냐의 차이인데, merge
의 경우 우선 DB를 호출해서 값을 확인하고, DB에 값이 없으면 새로운 엔티티로 인지하여 매우 비효율적인 방식이다. 0L
로 초기화 하는 것 보단, null
로 두고 persist
가 동작하도록 사용하는 것이 좀 더 올바른 방법이라 생각한다.
0L
로 초기화 했더라도 무시되고 생성된 ID 값으로 덮어 씌워지게 된다. 하지만 nullable이 아니기 때문에 데이터베이스에 삽입되는 동안 항상 ID가 존재하고 있는 상태다.
데이터베이스에서 자동 생성된 ID를 사용하면서도 entity가 영속성 컨텍스트에 저장되지 않았을 때 null로 초기화되어야 하는 상황을 고려해야하므로 첫 번째 케이스가 적절하다.