코틀린에서 JPA를 이슈없이 사용하는 방법

date
Mar 26, 2023
thumbnail
slug
kotlin-jpa
author
status
Published
tags
Kotlin
JPA
summary
코틀린으로 JPA를 사용할 때 Lazy Loading이 적용되지 않는 문제
type
Post
updatedAt
Mar 29, 2023 02:36 AM
그 동안 Python, Javascript, Typescript 진영의 기술만 접해오다가, 회사에 들어와서 JVM 생태계를 접하고 코틀린으로 JPA를 학습 하던 중 이슈가 발생했다.
@Entity
class Member(
    @Id val id: Int,
    val name: String,
    val age: Int,
)
JPA 학습을 위해 Spring Initializer를 사용하지 않고, gradle 프로젝트에 hibernate 관련한 의존성만 추가한 후 위와 같이 Entity를 정의하니 아래와 같은 에러가 발생했다.
  1. Class 'Member' should have [public, protected] no-arg constructor
  1. Persistent object class 'Member' should not be final
하지만 회사 프로젝트에서는 class에 open 을 명시하지도 않았고, 주 생성자만 정의된 상태지만 위 에러를 만날 수 없었다.
 
위같은 컴파일 에러가 뜨는 이유는 무엇인지, 어떻게 해결하는지 알아보면서 코틀린에서 JPA 사용 시 Lazy Loading과 관련된 이슈도 알아보자.

기본 생성자가 없으면?

hibernate Docs를 살펴보면 Entity 클래스는 기본 생성자를 가져야한다고 명시되어 있다.
• The entity class must have a public or protected no-argument constructor. It may define additional constructors as well.
기본 생성자가 없는 경우, 아래와 같이 DB에 조회 후 해당 데이터를 바탕으로 Entity 클래스를 인스턴스화할 수 없으므로 InstantiationException 이 발생한다.
fun logic(em:EntityManager){
    println("Member Entity 조회")
    try {
        val member = em.find(Member::class.javaObjectType, 1)
    } catch (e:Exception){
        println("예외 발생! ${e.message}")
    }
}
// CONSOLE
Member Entity 조회
Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.age as age2_0_0_,
        member0_.name as name3_0_0_,
        member0_.team_id as team_id4_0_0_ 
    from
        Member member0_ 
    where
        member0_.id=?
예외 발생! org.hibernate.InstantiationException: 
				No default constructor for entity:  : member.entity.Member
이를 해결하기 위해 아래와 같은 방식들을 고려해볼 수 있다.
  1. 기본 생성자를 직접 정의한다
  1. 모든 필드에 default 파라미터를 정의해서 기본 생성자가 생성되도록 한다
하지만 모든 Entity클래스에 위와 같은 방식을 적용하는 건 너무 불편한 보일러플레이트 코드다.

kotlin-noarg 플러그인

이런 문제를 해결하기 위해 코틀린은 kotlin-noarg 플러그인을 제공한다.
The no-arg compiler plugin generates an additional zero-argument constructor for classes with a specific annotation. The generated constructor is synthetic so it can't be directly called from Java or Kotlin, but it can be called using reflection.
설명을 읽어보면 다음과 같은 특징이 있다.
  • 컴파일러 플러그인이니, 컴파일 시 특정 annotation이 붙은 클래스에 대해 기본 생성자를 생성
  • 컴파일 시 인위적으로 생성해주므로 직접적으로 호출할 순 없지만, reflection을 이용해서 호출할 수 있다고 써있다.
이 플러그인을 @Entity 어노테이션에 적용하면, reflection을 이용해서 Entity 클래스를 인스턴스화할 수 있게 되므로 Entity 클래스는 기본 생성자가 필요하다는 컴파일 에러를 잡을 수 있게 된다.
// build.gradle
apply plugin: "kotlin-noarg"

noArg {
    annotation("jakarta.persistence.Enetity") // Java EE면 javax.persistence.Entity
}
위와 같이 적용하니 Class 'Member' should have [public, protected] no-arg constructor 에러가 사라졌다.

kotlin-jpa 플러그인

개발자라면 귀찮은 걸 못참기도 하고 설정을 까먹을 수도 있기에, 코틀린은 JPA를 위한 noArg 설정까지 자동으로 해주는 kotlin-jpa 플러그인을 따로 제공한다.
코틀린 스프링 부트 가이드에서도 이 플러그인을 추천하기도 한다.
In order to be able to use Kotlin non-nullable properties with JPA, Kotlin JPA plugin is also enabled. It generates no-arg constructors for any class annotated with @Entity@MappedSuperclass or @Embeddable.
이 플러그인을 추가해주는 것 만으로 코틀린에서 JPA를 위한 클래스들의 기본 생성자를 모두 처리해준다.
// build.gradle
apply plugin: "kotlin-jpa"
이로써 코틀린에서 JPA를 사용할 때 개발자가 따로 기본 생성자를 명시할 필요가 없어졌다.

Entity 클래스는 final이면 안된다?

hibernate DocsEntity 클래스의 Final에 대해 아래와 같이 말한다.
• The entity class must not be final. No methods or persistent instance variables of the entity class may be final. • Technically Hibernate can persist final classes or classes with final persistent state accessor (getter/setter) methods. However, it is generally not a good idea as doing so will stop Hibernate from being able to generate proxies for lazy-loading the entity.
요약하면 아래와 같은 내용이다.
  • Entity 클래스와 클래스가 갖는 메소드, 프로퍼티 모두 open되어야 함 (JSR 338의 JPA 스펙)
  • 근데 open안해도 동작은 할거다. 하지만 Entity 객체를 lazy하게 가져올 수는 없을거다. (이는 hibernate가 제공하는 기능)
코틀린은 자바와 다르게 클래스를 정의하면 기본적으로 final 이고, 상속을 허용하려면 open 키워드가 필요하다. 클래스 뿐 아니라, 프로퍼티나 메서드들도 기본적으로 final이다.
그렇다면 Persistent object class 'Member' should not be final 라는 에러를 잡기위해 lazy 로딩을 위한 여러가지 테스트를 해보자.
 
테스트를 위해 hibernate entity-manager 정식 버전 중 가장 최신인hibernate-entitymanager:5.6.15.Final 를 사용하였고, Entity Manager Factory, transaction 등에 관련한 코드를 생략하고 실행되는 로직만 보면 아래와 같이 최대한 간단히 하였다.
fun logic(em: EntityManager){
    println("=============Member Entity 조회=============")
    val member = em.find(Member::class.javaObjectType, 1)
    println("=============이제 Team에 접근해 보겠음=============") // Break Point
    println(member.team.name) // lazy 하게 동작한다면 여기서 조회가 발생해야함
}
// 사용 DB: Mysql 8.0
// DB 데이터
Member 테이블 : (id: 1, name: 'leo', team_id: 1)
Team 테이블 : (id: 1, name: 'kakao')

CASE) Entity 클래스만 open하고 프로퍼티는 open하지 않은 경우

[ Entity 클래스 ]
@Entity
open class Member(
    @Id val id: Int,
    val name: String,
    val age: Int,
		@ManyToOne(fetch = FetchType.LAZY) val team:Team
)

@Entity
open class Team(
    @Id val id:Int,
    val name:String,
)
[ 실행 결과 ]
// CONSOLE
=============Member Entity 조회=============
Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.age as age2_0_0_,
        member0_.name as name3_0_0_,
        member0_.team_id as team_id4_0_0_ 
    from
        Member member0_ 
    where
        member0_.id=?
Hibernate: 
    select
        team0_.id as id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
=============이제 Team에 접근해 보겠음=============
kakao
notion image
  • 쿼리가 lazy 하게 실행되지 않았고, 연속으로 2번의 쿼리가 발생
  • team 객체가 Proxy객체가 아님
💡
심지어 특정 버전 이하의 hibernate에서는 프록시 객체는 생성되나, 값을 세팅하지 못해서 null이 나옴 (eg. 5.0.12.Final)

CASE) Entity 클래스와 프로퍼티 모두 open하는 경우

[ Entity 클래스 ]
@Entity
open class Member(
    @Id open val id: Int,
    open val name: String,
    open val age: Int,
		@ManyToOne(fetch = FetchType.LAZY) open val team:Team
)

@Entity
open class Team(
    @Id open val id:Int,
    open val name:String,
)
[ 실행 결과 ]
// CONSOLE
=============Member Entity 조회=============
Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.age as age2_0_0_,
        member0_.name as name3_0_0_,
        member0_.team_id as team_id4_0_0_ 
    from
        Member member0_ 
    where
        member0_.id=?
=============이제 Team에 접근해 보겠음=============
Hibernate: 
    select
        team0_.id as id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
kakao
notion image
  • team 객체에 접근할 때 LAZY하게 질의가 발생
  • team 객체가 Proxy 객체인 것을 볼 수 있고, 아직 접근하지 않았을 때는 질의가 실행되지 않아 기본값으로 설정된 것을 볼 수 있음

kotlin-allOpen 플러그인

위 테스트 결과처럼 코틀린에서 JPA가 지원하는 프록시를 통한 Lazy Loading을 위해선 클래스와 모든 프로퍼티에 open을 명시해야한다.
근데 Entity 클래스를 만들 때마다 모든 곳에 open을 적어주는 것은 상당한 노동이다. 이를 위해 코틀린에서 kotlin-allOpen 플러그인을 제공한다.
Kotlin has classes and their members final by default, which makes it inconvenient to use frameworks and libraries such as Spring AOP that require classes to be open. The all-open compiler plugin adapts Kotlin to the requirements of those frameworks and makes classes annotated with a specific annotation and their members open without the explicit open keyword.
즉 아래와 같이 특정 어노테이션이 붙은 클래스와 그 멤버들 모두 open으로 변경해준다.
// Member.kt
@Entity
class Member(
    @Id val id:Int,
    val name:String,
    val age:Int,
    @ManyToOne(fetch = FetchType.LAZY) val team:Team
)

// 컴파일 된 Member.class
@javax.persistence.Entity public open class Member public constructor(id: kotlin.Int, name: kotlin.String, age: kotlin.Int, team: member.entity.Team) {
    public open val age: kotlin.Int /* compiled code */
    @field:javax.persistence.Id public open val id: kotlin.Int /* compiled code */
    public open val name: kotlin.String /* compiled code */
    @field:javax.persistence.ManyToOne public open val team: member.entity.Team /* compiled code */
}

kotlin-spring 플러그인

코틀린 스프링 부트 가이드에는 아래와 같은 문구가 적혀있다.
In addition to the obvious Kotlin Gradle plugin, the default configuration declares the kotlin-spring plugin which automatically opens classes and methods (unlike in Java, the default qualifier is final in Kotlin) annotated or meta-annotated with Spring annotations. This is useful to be able to create @Configuration or @Transactional beans without having to add the open qualifier required by CGLIB proxies for example.
결국 CGLIB를 이용한 프록시를 위해 @Configuration, @Transactional 같은 어노테이션이 붙은 빈을 자동으로 open 해주는 거라고 써있다.
즉 allOpen 플러그인을 통해 수동으로 설정하지 않아도, 스프링에서 CGLIB를 이용한 프록시를 위해 @Configuration, @Transactional 같은 어노테이션이 붙은 빈을 자동으로 open 해준다.
적용되는 어노테이션들은 아래와 같다.
  • @Component 에도 적용되므로, 이를 포함하는 @Configuration, @Controller , @Service 등에도 적용

@Entity는 kotlin-spring 플러그인 적용 대상이 아니다

Spring Initilizer를 통해 코틀린 기반으로 Spring Data JPA 를 포함한 프로젝트를 생성해보면 아래와 같이 build.gradle 이 생성된다
// build.gradle
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id 'org.springframework.boot' version '3.0.5'
    id 'io.spring.dependency-management' version '1.1.0'
    id 'org.jetbrains.kotlin.jvm' version '1.7.22'
    id 'org.jetbrains.kotlin.plugin.spring' version '1.7.22'
    id 'org.jetbrains.kotlin.plugin.jpa' version '1.7.22'
}
...
위에서 살펴본 kotlin-jpa , kotlin-spring 플러그인을 통해 특정 어노테이션이 달린 클래스들에 대해 아래와 같은 처리가 자동으로 된다.
  • 기본 생성자 생성
  • 클래스와 멤버 모두에 대한 open
@Entity 에 대한 기본 생성자는 kotlin-jpa 플러그인이 처리해주므로, Entity 클래스의 reflection을 통한 인스턴스화는 가능하다.
하지만 kotlin-spring 플러그인에 @Entity 어노테이션에 대한 명시는 없었다. 즉 allOpen 처리가 적용되지 않을 거고, 이는 Hibernate 위 플러그인에 포함되어 있지 않다. 따라서 동작은 하지만, 프록시를 이용해서 LAZY하게 동작은 하지 않을 것이다.
따라서 build.gradle 에 아래와 같은 사항을 직접 명시할 필요가 있다.
// build.gradle
apply plugin: "kotlin-allopen"

allOpen {
    annotation("jakarta.persistence.Enetity") // Java EE면 javax.persistence.Entity
}
이런 이유에서 코틀린 스프링 부트 가이드Persistence with JPA 챕터에 아래와 같은 내용이 있다.
In order to make lazy fetching working as expected, entities should be open as described in KT-28525. We are going to use the Kotlin allopen plugin for that purpose.
// build.gradle.kts
plugins {
  ...
  kotlin("plugin.allopen") version "1.8.0"
}

allOpen {
  annotation("jakarta.persistence.Entity")
  annotation("jakarta.persistence.Embeddable")
  annotation("jakarta.persistence.MappedSuperclass")
}

allOpen 테스트

JPA 관련 어노테이션 allOpen 적용 - Proxy 객체를 이용해서 Lazy Loading이 가능
JPA 관련 어노테이션 allOpen 적용 - Proxy 객체를 이용해서 Lazy Loading이 가능
JPA 관련 어노테이션 allOpen 미적용 - 일반 Entity 객체를 이용해서 Lazy하게 불가능
JPA 관련 어노테이션 allOpen 미적용 - 일반 Entity 객체를 이용해서 Lazy하게 불가능
allOpen을 적용하지 않으면 Lazy Loading이 불가능 할 뿐 아니라, 쿼리도 연속해서 2번 날아가므로 차라리 Fetching 전략을 Eager로 했을 때보다 못한 성능을 낼 수도 있다.

References