신규 시스템을 개발하면서 코틀린과 하이버네이트를 함께 사용한 경험을 나누기 위해 작성해봅니다.


안녕하세요! 서비스플랫폼 팀에서 서버 개발을 하는 김지희, 김석홍입니다.

저희 팀은 이번에 사장님이 혜택을 관리할 수 있는 신규 프로젝트를 진행하면서 개발 언어로 코틀린을 사용하기로 했습니다. 코틀린은 JetBrain에서 만든 언어로 JVM에서 구동할 수 있으며, 자바와 상호운용이 가능한 정적 타입 지정 언어입니다. 간결한 표현과 NPE 방지, 함수가 1급 객체인 점 등 여러 장점이 있습니다. 또한, 스프링 프레임워크에서도 5.0 버전부터 코틀린을 정식으로 지원하기 시작했습니다.

ORM 프레임워크로는 Hibernate를 사용하기로 했습니다. Hibernate는 자바를 위한 ORM 프레임워크입니다. 물론 코틀린을 위한 ORM 프레임워크로 ExposedKtorm 등도 있습니다. 하지만 새로운 언어를 사용하는데 새로운 ORM 프레임워크까지 시도하기에는 시간이 부족했고 리스크가 크다고 판단했습니다. 그리고 코틀린이 자바와 상호 호환되기 때문에 새로운 프레임워크가 아닌 기존에 사용해본 Hibernate를 사용해도 괜찮을 것으로 판단했습니다.

이번 글에서는 코틀린과 Hibernate를 함께 사용하며 겪었던 경험을 나누고자 합니다.

코틀린은 처음이라

개발 언어만 코틀린으로 변경하고 기존에 사용했던 기술 스택을 그대로 사용했기에 개발하는 과정에서 코틀린의 장점을 반영하고자 했습니다. 코틀린은 불필요하거나 반복적으로 발생하는 코드를 제거하고 문법적으로 간단한 표현을 여럿 제공하고 있는데요. 이 중 data class 라는 것이 있습니다.

코틀린에서 data 는 변경자로서 class 앞에 붙여주면 아래의 함수들을 컴파일러가 자동으로 만들어줍니다.

  1. toString()
  2. equals()
  3. hashCode()

개발자가 (기계적으로) 다시 정의해야 했던 함수들을 컴파일러가 대신 해주는 겁니다. (훨씬 간단하겠죠?) 물론 직접 구현하여 사용할 수도 있습니다. 이외에도 인스턴스 복사를 할 수 있는 copy() 함수와 destructuring 등을 제공합니다.

destructuring을 사용하면 아래와 같이 인스턴스의 프로퍼티를 한 번에 가져올 수 있습니다. 비즈니스 로직 개발을 하면서 여러 프로퍼티의 값을 읽어올 때 편하게 사용할 수 있습니다.

data class Person(
    val name: String,  
    val age: Int
)

val person = Person("홍길동", 99)
val (name, age) = person

// val name = person.name
// val age = person.age

Hibernate 와 함께 사용하기

Hibernate 와 함께 사용하기 위해서는 data class에서 생성해주는 함수를 바로 사용할 수는 없고, 직접 구현이 필요한데요. toString 의 경우 연관 관계 매핑된 엔터티에 접근하면서 lazy loading exception이 발생하거나 양방향 매핑으로 인한 stack overflow가 발생할 가능성이 있습니다. 또한, Hibernate의 가이드 문서에 따르면 equals와 hashCode 함수는 엔터티의 ID(또는 natural-id 나 business-key)를 이용해서 구현하는 것이 권장됩니다.

A common initial approach is to use the entity’s identifier attribute as the basis for equals/hashCode calculations.

The final approach is to use a “better” equals/hashCode implementation, making use of a natural-id or business-key.

일반 class를 사용하더라도 동일하게 toString, equals, hashCode 함수는 구현해줘야 하므로, destructuring을 사용할 수 있다는 장점이 있는 data class를 이용해서 엔터티를 구현하기로 했습니다.

Hibernate와 data class를 함께 사용하는 코드 테스트를 위해 아래와 같이 샘플 코드를 작성해 보았습니다.

@Entity
data class Company(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    var name: String
) {
    constructor() : this(null, "")
}
@Entity
data class Employee(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    var name: String,

    @ManyToOne(fetch = FetchType.LAZY)
    var company: Company?
) {
    constructor() : this(null, "", null)
}

Hibernate에서는 Reflection을 사용해 엔터티를 만들어서 인자가 없는 기본 생성자가 필요해서 기본 생성자를 추가로 선언했습니다.

그리고 열심히 테스트하는데 문제가 발생했습니다.

프록시 객체를 만들고 싶어요

Hibernate와 함께 사용하기 위해 프록시 객체 생성이 가능한지 테스트를 해보았습니다. FetchType.LAZY로 설정 후 데이터 조회 시, fetch join으로 함께 조회하지 않은 데이터는 프록시 객체를 가져오는지 확인했는데요.

INSERT INTO company (name) VALUES('a-company');
INSERT INTO employee (name, company_id) VALUES ('고길동', '1');
@Test
internal fun findById() {
    // when
    val employee = employeeJpaRepository.findByIdOrNull(1L)
  
    // then
    assertEquals(employee!!.id, 1L)     // break point
    assertEquals(employee.name, "고길동")
}

프록시 생성 실패

당당하게 존재하는 Company….


FetchType.LAZY를 사용하기 때문에 company에는 프록시 객체가 있을 것으로 기대했지만, 프록시 객체가 아닌 실제 객체가 들어 있는 것을 확인할 수 있습니다.

로그를 확인해보니 실제로 쿼리가 두 번 날아간 것을 확인할 수 있습니다.

데이터에 접근하지 않아도 쿼리 발생


어떻게 된 일일까요?

Java EE 5 tutorial 문서에 있는 entity class의 요구사항 중 아래와 같은 내용이 있습니다.

Requirements for Entity Classes

An entity class must follow these requirements:

  • The class must not be declared final. No methods or persistent instance variables must be declared final.

하지만 테스트 코드에서 보았듯이 final이어도 동작은 합니다. 왜 그럴까요? Hibernate의 가이드 문서를 참조하면 엔터티 클래스는 final일 수 있지만 lazy loading을 위한 프록시를 생성할 수 없다고 되어 있습니다.

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.

코틀린의 클래스와 프로퍼티, 함수는 기본적으로 final 이며 상속이 불가능합니다. 상속하기 위해선 open 키워드를 사용해야 합니다. 또한 클래스에 open 키워드를 붙인다고 해당 클래스의 프로퍼티와 함수도 open 되는 것이 아니기 때문에 상속을 허용하는 프로퍼티와 함수에도 open 키워드를 추가로 붙여주어야 합니다. (spring boot kotlin tutorial 에서는 allopen 플러그인을 사용하라고 권장합니다.)

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.

하지만, data classopen이 되지 않습니다.

Data classes cannot be abstract, open, sealed or inner;


따라서 Hibernate의 Lazy Loading을 사용하기 위해서는 Data class를 사용할 수 없습니다.

일반 class 사용하기

다시 일반 클래스를 사용하기로 합니다. 일반 클래스 사용시 반복적인 작업들을 줄이기 위해 플러그인과 라이브러리를 사용하겠습니다.

allopen, noarg 플러그인

위에서 언급했다시피 엔터티의 클래스, 프로퍼티와 함수를 open 해 주어야 합니다. 매번 클래스와 프로퍼티에 open 키워드를 넣는건 반복작업이고 실수를 야기할 수 있기 때문에 spring boot kotlin tutorial에서도 권장한 allopen 플러그인을 사용합니다. allopen 플러그인은 자동으로 모든 클래스(프로퍼티, 함수까지)를 open 시켜줍니다.

all-open 플러그인을 사용하는 경우 data class에도 open이 적용되는 것을 확인했습니다. 다만, 공식적으로 문서에 명시된 내용을 찾지 못했으며 위 내용과 같이 kotlin 공식 문서의 data class 스펙에 open이 될 수 없는 것으로 나와 있으므로, 사용하지 않는 방향으로 결정했습니다.

하지만 아직 반복적인 작업이 있습니다. 바로 no-arg constructor입니다. 위에서도 설명했지만 Hibernate에서는 인자가 없는 생성자가 필요합니다. 때문에 자동으로 기본 생성자를 만들어주는 noarg 플러그인을 사용합니다.

The entity class must have a no-argument constructor, which may be public, protected or package visibility. It may define additional constructors as well

플러그인을 반영한 코드는 아래와 같습니다.

build.gradle.kts

plugins {
		...
    kotlin("plugin.allopen") version "1.3.71"
    kotlin("plugin.noarg") version "1.3.71"
}

allOpen {
    annotation("javax.persistence.Entity")
}

noArg {
    annotation("javax.persistence.Entity")
}
@Entity
class Company(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    var name: String
)
@Entity
class Employee(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    var name: String,

    @ManyToOne(fetch = FetchType.LAZY)
    var company: Company?
)


이제 테스트 코드를 다시 확인해보면 정상적으로 lazy loading이 되는 것을 확인할 수 있습니다.

@Test
internal fun `findById - lazy loading`() {
    // when
    val employee = employeeJpaRepository.findByIdOrNull(1L)

    // then
    log.info("employee 데이터를 조회한 이후")
    assertEquals(employee!!.id, 1L)
    assertEquals(employee.name, "고길동")
        
    log.info("company 데이터에 접근하기 전")
    assertEquals(employee.company!!.name, "a-company")

    log.info("company 데이터에 접근한 후")
}

프록시 객체 생성

데이터 접근 시점에 쿼리

(쿼리가 언제 날아가는지 보기 위해서 중간중간 로그를 남겼습니다.)

kassava 라이브러리

이어서 toString(), equals(), hashCode() 를 구현하겠습니다. 좀 더 편하게 구현하기 위해 kassava 라이브러리를 사용하기로 합니다.

build.gradle.kts

dependencies {
  	...
    implementation("au.com.console:kassava:2.1.0-rc.1")
  	...
}

kassava 라이브러리는 toString(), equals(), hashCode() 구현을 위해 사용할 라이브러리입니다. kassava 2.1.0-rc.1 버전에 kotlinHashCode 함수가 추가되었습니다. 2.0.0 버전을 사용하는 경우 별도로 hashCode 함수 구현이 필요합니다.

...
import au.com.console.kassava.kotlinEquals
import au.com.console.kassava.kotlinHashCode
import au.com.console.kassava.kotlinToString
...

@Entity
class Employee(
        ...
) {
    override fun toString() = kotlinToString(properties = toStringProperties)

    override fun equals(other: Any?) = kotlinEquals(other = other, properties = equalsAndHashCodeProperties)

    override fun hashCode() = kotlinHashCode(properties = equalsAndHashCodeProperties)


    companion object {
        private val equalsAndHashCodeProperties = arrayOf(Employee::id)
        private val toStringProperties = arrayOf(
                Employee::id,
                Employee::name
        )
    }
}
...
import au.com.console.kassava.kotlinEquals
import au.com.console.kassava.kotlinHashCode
import au.com.console.kassava.kotlinToString
...

@Entity
class Company(
        ...
) {
        override fun toString() = kotlinToString(properties = toStringProperties)

        override fun equals(other: Any?) = kotlinEquals(other = other, properties = equalsAndHashCodeProperties)

        override fun hashCode() = kotlinHashCode(properties = equalsAndHashCodeProperties)


        companion object {
                private val equalsAndHashCodeProperties = arrayOf(Company::id)
                private val toStringProperties = arrayOf(
                        Company::id,
                        Company::name
                )
        }
}

각각 toString(), equals(), hashcode() 에서 사용할 프로퍼티를 지정해서 우리가 원하는 대로 동작하게 오버라이드 할 수 있습니다.

EmployeetoString() 에선 연관관계인 company 프로퍼티는 보이지 않게 설정했고 EmployeeCompany 모두 equals(), hashcode() 에서는 id 프로퍼티만 확인하게 변경했습니다.


테스트 코드를 통해 원하는 대로 동작하는 것을 확인합니다.

internal class EmployeeTest {

    @Test
    fun `toString - 연관되어있는 객체는 노출되지 않는다`() {
        // given
        val company = Company(name = "heebongpany")

        val employee = Employee(id = 10L, name = "heebong", company = company)

        // expect
        assertEquals(employee.toString(), 
                     "Employee(id=${employee.id}, name=${employee.name})")
    }

    @Test
    fun `equals - id값이 같으면 같은 객체로 인지 한다`() {
        // given
        val employeeId = 1L

        val employee1 = Employee(employeeId, "hee", null)
        val employee2 = Employee(employeeId, "bong", null)

        // expect
        assertEquals(employee1, employee2)
    }

}

internal class CompanyTest {

    @Test
    fun `toStringTest`() {
        // given
        val company = Company(id = 1L, name = "heebongpany")

        // expect
        assertEquals(company.toString(), "Company(id=${company.id}, name=${company.name})")
    }

    @Test
    fun `equals - id값이 같으면 같은 객체로 인지 한다`() {
        // given
        val companyId = 1L
        
        val company1 = Company(id = companyId, name = "heebongpany1")
        val company2 = Company(id = companyId, name = "heebongpany2")

        // expect
        assertEquals(company1, company2)
    }
}

결론

  • Hibernate에서 엔터티 클래스는 final일 수 있지만 lazy loading을 위한 프록시를 생성할 수 없습니다.
  • 코틀린의 모든 클래스는 final 이며, 상속을 위해서는 명시적으로 open을 붙여주어야 합니다.
  • data class는 open 할 수 없습니다. (allopen 플러그인을 이용하면 가능하나, 명시된 내용은 찾지 못했습니다.)
  • open, no-arg constructor를 위한 반복 작업은 플러그인을 사용하면 편하게 할 수 있습니다.
  • toString(), equals(), hashcode() 구현은 kassava 라이브러리를 사용하여 구현할 수 있습니다.
  • 코드는 여기서 확인 할 수 있습니다.


지금까지 코틀린에서 하이버네이트 적용기였습니다. 이 글이 저희와 같은 고민을 하는 분들에게 조금이나마 도움이 되길 바랍니다. (같은 삽질은 하지 않으시겠죠? 다른 삽질 부탁드려요🙇‍♂️)


읽어주셔서 감사합니다! 끗

참고