코프링 실전 (1/5)

다음 편: [코프링] 2. 예외 처리 & Validation 전략

코프링 실전 시리즈 (1/5)

다음 편: 2 예외 처리 & Validation 전략

Kotlin으로 Spring Boot 프로젝트를 시작하면 가장 먼저 부딪히는 문제가 JPA 엔티티 설계다. Java에서는 자연스러웠던 것들이 Kotlin에서는 문제를 일으킨다. data class로 엔티티를 만들면 안 되는 이유, val vs var의 선택, lateinit의 함정, 연관관계 편의 메서드의 Kotlin 스타일 — 이런 것들을 정리하지 않으면 프로젝트 초반부터 삽질한다.

이 문서는 JPA와 Kotlin이 충돌하는 지점을 짚고, 실전에서 통하는 엔티티 설계 패턴을 다룬다.

JPA와 Kotlin의 충돌

JPA는 Java를 기반으로 설계된 스펙이다. 그래서 Kotlin의 철학과 부딪히는 지점이 여럿 있다.

첫째, JPA는 기본 생성자(no-arg constructor)가 필요하다. 엔티티를 리플렉션으로 생성할 때 인자 없는 생성자를 호출한다. 하지만 Kotlin의 data class나 일반 클래스는 모든 프로퍼티를 생성자에 선언하는 것이 관례이므로, 기본 생성자가 없다.

둘째, JPA는 프록시를 위해 클래스를 상속한다. 지연 로딩(lazy loading)을 구현하려면 엔티티 클래스를 상속한 프록시 객체를 만들어야 한다. 하지만 Kotlin의 클래스는 기본이 final이라 상속이 불가능하다.

셋째, Kotlin은 불변을 지향하지만, JPA는 가변 상태를 전제한다. val로 선언하고 싶지만, JPA가 프로퍼티에 값을 주입하려면 var이어야 한다.

이 충돌들을 하나씩 해결해보자.


필수 플러그인

Kotlin + JPA를 쓸 때 반드시 적용해야 하는 컴파일러 플러그인이 두 개 있다.

// build.gradle.kts
plugins {
    kotlin("plugin.jpa") version "2.1.0"      // no-arg 생성자 자동 생성
    kotlin("plugin.spring") version "2.1.0"    // open 키워드 자동 추가
}
  • kotlin-jpa 플러그인 — @Entity, @MappedSuperclass, @Embeddable이 붙은 클래스에 기본 생성자를 자동으로 생성한다. 이 생성자는 리플렉션 전용이며 코드에서 직접 호출할 수 없다.
  • kotlin-spring 플러그인 — @Entity, @Component, @Service 등 Spring/JPA 어노테이션이 붙은 클래스에 open을 자동으로 추가한다. 프록시 생성을 위한 상속이 가능해진다.

이 두 플러그인이 없으면 Kotlin + JPA는 제대로 동작하지 않는다. Spring Initializr에서 프로젝트를 생성하면 자동으로 포함되지만, 직접 설정할 때는 빠뜨리지 않도록 주의해야 한다.

allopen 추가 설정

kotlin-spring 플러그인은 Spring 어노테이션에만 open을 추가한다. JPA의 @Entity는 포함되지만, 커스텀 어노테이션에는 적용되지 않는다. 필요하면 allOpen 설정에 직접 추가할 수 있다.


엔티티 기본 구조

Kotlin JPA 엔티티의 기본 형태를 보자.

@Entity
@Table(name = "posts")
class Post(
    @Column(nullable = false)
    var title: String,

    @Lob
    @Column(nullable = false)
    var content: String,

    @Column(nullable = false)
    var isPublished: Boolean = false,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L
)

여기에는 의도적인 설계 결정이 여러 개 담겨 있다.

  • class를 사용한다data class가 아니다. 이유는 뒤에서 설명한다.
  • 변경 가능한 필드는 vartitle, content, isPublished는 비즈니스 로직에 의해 변경될 수 있다.
  • idval — 식별자는 한번 부여되면 변경되지 않는다.
  • id의 기본값은 0L — 새 엔티티 생성 시 ID 없이 만들고, 저장 후 JPA가 값을 채운다.
  • 생성자 파라미터로 필수 필드를 선언 — 객체 생성 시 필수 값을 강제한다.

data class를 쓰면 안 되는 이유

Java 개발자가 Kotlin으로 오면 "엔티티에 data class를 쓰면 equals, hashCode, toString을 안 만들어도 되니까 편하겠다"고 생각하기 쉽다. 하지만 이건 함정이다.

equals/hashCode 문제

data classequals()모든 프로퍼티를 비교한다. JPA에서 엔티티의 동등성은 ID로 판단해야 한다. 같은 레코드를 나타내는 두 객체가 title만 다르다고 다른 엔티티가 되어서는 안 된다.

더 심각한 문제가 있다. data classhashCode()도 모든 프로퍼티로 계산되는데, 엔티티의 프로퍼티가 바뀌면 hashCode()가 달라진다. HashSet이나 HashMap에 넣은 엔티티의 프로퍼티를 변경하면 컬렉션에서 찾을 수 없게 된다.

toString 문제

data classtoString()은 모든 프로퍼티를 출력한다. 지연 로딩된 연관관계가 있으면 toString() 호출 시 의도치 않게 추가 쿼리가 발생한다. 세션이 닫힌 상태에서 호출하면 LazyInitializationException이 터진다.

copy 문제

data classcopy()는 새 객체를 만든다. JPA 엔티티는 영속성 컨텍스트에 의해 관리되는 객체인데, copy()로 새 객체를 만들면 원본과 복사본이 같은 ID를 가진 채 서로 다른 영속 상태를 가지게 된다. 이건 JPA의 설계 원칙에 어긋난다.

결론은 간단하다. JPA 엔티티에는 data class를 쓰지 않는다. 일반 class를 쓰고, 필요하면 equals/hashCode/toString을 직접 구현한다.


ID 전략

엔티티의 ID를 어떻게 다룰지는 생각보다 고민이 필요한 부분이다.

기본 패턴 — 기본값 0L

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L

가장 간결한 패턴이다. 새 엔티티는 id = 0L로 생성되고, save() 후 JPA가 DB에서 생성된 ID를 채워준다. val로 선언할 수 있는 이유는 JPA가 리플렉션으로 값을 주입하기 때문이다.

이 패턴의 약점은 "이 엔티티가 저장 전인지 후인지" 구분하려면 id == 0L을 체크해야 한다는 점이다.


nullable ID 패턴

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null

저장 전에는 null, 저장 후에는 값이 있다. 의미적으로는 더 명확하다. 하지만 ID를 사용하는 모든 곳에서 null 체크가 필요해진다. 코드가 번거로워질 수 있다.

어느 쪽을 쓸지는 팀 컨벤션에 따라 정하면 된다. 중요한 것은 팀 내에서 일관성을 유지하는 것이다. 이 문서에서는 0L 패턴을 사용한다.


BaseEntity 설계

대부분의 엔티티에 공통으로 들어가는 필드(ID, 생성일, 수정일)를 MappedSuperclass로 추출하면 반복을 줄일 수 있다.

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L

    @CreatedDate
    @Column(updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.MIN
        protected set

    @LastModifiedDate
    var updatedAt: LocalDateTime = LocalDateTime.MIN
        protected set
}
  • @MappedSuperclass — JPA에게 "이 클래스의 필드를 상속받는 엔티티의 테이블에 포함시켜라"고 알린다.
  • @EntityListeners(AuditingEntityListener::class) — Spring Data JPA의 Auditing 기능을 활성화한다. @CreatedDate@LastModifiedDate가 자동으로 채워진다.
  • protected set — 외부에서 직접 수정할 수 없지만, 하위 클래스와 JPA는 접근할 수 있다.

Auditing을 사용하려면 설정 클래스에 @EnableJpaAuditing을 추가해야 한다.

@Configuration
@EnableJpaAuditing
class JpaConfig

엔티티에서 상속받아 사용한다.

@Entity
@Table(name = "posts")
class Post(
    @Column(nullable = false)
    var title: String,

    @Lob
    @Column(nullable = false)
    var content: String,

    @Column(nullable = false)
    var isPublished: Boolean = false
) : BaseEntity()

id, createdAt, updatedAt을 매번 선언하지 않아도 된다.


equals와 hashCode

data class를 쓰지 않으므로, 필요한 경우 equalshashCode를 직접 구현해야 한다. JPA 엔티티에서는 ID 기반으로 동등성을 판단한다.

@Entity
class Post(
    var title: String,
    var content: String
) : BaseEntity() {

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Post) return false
        if (id == 0L) return false  // 저장 전 엔티티는 동등 비교 불가
        return id == other.id
    }

    override fun hashCode(): Int = id.hashCode()
}
  • id == 0L이면 false를 반환한다. 아직 저장되지 않은 엔티티끼리 비교하는 것은 의미가 없다.
  • hashCode()id 기반이다. 엔티티의 다른 필드가 변해도 해시값이 바뀌지 않는다.

모든 엔티티에 이걸 구현할 필요는 없다. Set에 넣거나 동등 비교가 필요한 엔티티에만 구현하면 된다. 실무에서는 BaseEntity에 구현해두는 경우도 있지만, ID가 0인 상태에서의 동작이 예상과 다를 수 있으므로 주의가 필요하다.


연관관계 설계

JPA 연관관계는 Kotlin에서도 Java와 크게 다르지 않다. 하지만 Kotlin의 문법을 활용하면 더 깔끔하게 표현할 수 있다.

일대다 / 다대일

@Entity
class Post(
    var title: String,
    var content: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    val author: User
) : BaseEntity() {

    @OneToMany(mappedBy = "post", cascade = [CascadeType.ALL], orphanRemoval = true)
    val comments: MutableList<Comment> = mutableListOf()

    fun addComment(comment: Comment) {
        comments.add(comment)
        comment.post = this
    }
}
  • authorval이다. 글의 작성자가 바뀌는 경우는 거의 없으므로 불변으로 선언한다.
  • commentsval이다. 리스트 참조는 바뀌지 않고, 리스트 내용만 변한다. MutableList이므로 요소 추가/삭제는 가능하다.
  • addComment() — 양방향 연관관계 편의 메서드. 양쪽 관계를 한 번에 설정한다.

연관관계 편의 메서드의 패턴은 Java와 동일하다. 다만 Kotlin에서는 comment.post = this 처럼 프로퍼티 접근 문법으로 쓸 수 있어서 조금 더 자연스럽다.


다대일 쪽

@Entity
class Comment(
    var content: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    var post: Post
) : BaseEntity()

postvar이다. addComment()에서 양방향 관계를 설정할 때 값이 변경되어야 하기 때문이다.

연관관계의 fetch 전략

JPA의 @ManyToOne은 기본이 FetchType.EAGER다. 반드시 FetchType.LAZY로 변경해야 한다. N+1 문제의 가장 흔한 원인이다.


Embeddable 활용

값 객체(Value Object)는 @Embeddable로 표현한다. Kotlin의 간결한 클래스 문법과 잘 어울린다.

@Embeddable
data class Address(
    @Column(nullable = false)
    val city: String,

    @Column(nullable = false)
    val street: String,

    @Column(nullable = false)
    val zipCode: String
)

여기서는 data class를 쓴다. @Embeddable은 엔티티가 아니라 값 객체이므로, data classequals/hashCode(모든 필드 비교)가 오히려 적절하다. 값 객체는 모든 필드가 같으면 같은 객체이기 때문이다.

엔티티에서 사용할 때는 이렇게 된다.

@Entity
class User(
    var name: String,

    @Embedded
    var address: Address
) : BaseEntity()

심화 분석

protected 기본 생성자의 대안

kotlin-jpa 플러그인이 생성하는 기본 생성자는 public이 아니라 protected다(정확히는 바이트코드 레벨에서 리플렉션 전용). 코드에서 직접 호출할 수 없으므로, 실수로 빈 엔티티를 만드는 것을 방지한다.

만약 플러그인 없이 직접 기본 생성자를 만들어야 한다면, protected constructor()를 추가하는 방법이 있다. 하지만 플러그인을 쓰는 것이 훨씬 깔끔하므로 권장하지 않는다.

엔티티 변경 메서드

엔티티의 필드를 var로 노출하면, 아무 곳에서나 값을 바꿀 수 있다. 이것이 불안하다면 비즈니스 의미가 있는 메서드를 통해 변경하도록 설계할 수 있다.

@Entity
class Post(
    title: String,
    content: String
) : BaseEntity() {

    @Column(nullable = false)
    var title: String = title
        private set

    @Lob
    @Column(nullable = false)
    var content: String = content
        private set

    @Column(nullable = false)
    var isPublished: Boolean = false
        private set

    fun update(title: String, content: String) {
        this.title = title
        this.content = content
    }

    fun publish() {
        this.isPublished = true
    }
}
  • 생성자 파라미터에서 var/val을 빼고, 프로퍼티를 클래스 본문에 선언했다.
  • private set으로 외부에서 직접 수정을 막는다.
  • update(), publish() 같은 의미 있는 메서드로만 변경이 가능하다.

이 패턴은 도메인 모델의 캡슐화를 강화한다. 다만 코드량이 늘어나므로, 프로젝트 규모와 팀의 판단에 따라 적용 범위를 정하면 된다. 간단한 CRUD 프로젝트에서는 var로 직접 노출해도 충분하다.

Enum 매핑

Kotlin의 enum class는 Java의 enum과 동일하게 JPA에서 사용할 수 있다.

enum class PostStatus {
    DRAFT, PUBLISHED, ARCHIVED
}

@Entity
class Post(
    var title: String,

    @Enumerated(EnumType.STRING)
    var status: PostStatus = PostStatus.DRAFT
) : BaseEntity()

@Enumerated(EnumType.STRING)은 반드시 지정해야 한다. 기본값인 ORDINAL은 enum 순서가 바뀌면 데이터가 깨진다.


자주 하는 실수

data class로 엔티티 만들기

앞에서 설명했지만 다시 강조한다. JPA 엔티티에 data class를 쓰면 equals/hashCode/toString/copy 모두 문제가 된다. @Embeddable 값 객체에는 data class가 적합하지만, @Entity에는 일반 class를 써야 한다.

lateinit으로 연관관계 선언

// 나쁜 예
@Entity
class Comment(
    var content: String
) : BaseEntity() {
    @ManyToOne(fetch = FetchType.LAZY)
    lateinit var post: Post  // 초기화 안 하고 사용하면 UninitializedPropertyAccessException
}

lateinit은 "나중에 반드시 초기화하겠다"는 약속이다. 하지만 JPA에서는 리플렉션으로 객체를 생성하기 때문에, 개발자가 초기화를 깜빡하면 런타임에서야 에러가 난다. 연관관계는 생성자에서 받는 것이 안전하다.

nullable 컬렉션

// 나쁜 예
@OneToMany(mappedBy = "post")
var comments: MutableList<Comment>? = null

// 좋은 예
@OneToMany(mappedBy = "post")
val comments: MutableList<Comment> = mutableListOf()

컬렉션 타입의 연관관계는 null이 아니라 빈 컬렉션으로 초기화해야 한다. null이면 사용할 때마다 null 체크가 필요하고, JPA도 빈 컬렉션을 기대한다.