[JPA] 코틀린으로 엔티티 작성 시 고려할 점

2022. 2. 8. 03:14Spring/JPA

최근 코틀린에 빠져서 코틀린으로 기존의 자바 작업들을 바꿔서 해보는 중이다. 그러다 코틀린에서 JPA를 쓰려고 하니 여러 가지 고려할 점이 생겨나 글을 작성하게 되었다.
먼저 JPA의 구현체인 Hibernate의 유저 가이드를 살펴보자.
https://docs.oracle.com/javaee/5/tutorial/doc/bnbqa.html

 

Entities - The Java EE 5 Tutorial

Entities An entity is a lightweight persistence domain object. Typically an entity represents a table in a relational database, and each entity instance corresponds to a row in that table. The primary programming artifact of an entity is the entity class,

docs.oracle.com

위 문서에 따르면 Entity 클래스의 필요조건은 다음과 같다.
1. 클래스는 반드시 javax.persistence.Entity 애노테이션을 적용해야 한다.
2. 클래스는 반드시 public 또는 protected의 no-argument 생성자를 가지고 있어야 한다. 다른 생성자를 가지고 있을 수도 있다.
3. 클래스는 반드시 final을 선언하면 안 된다. 어떤 메소드나 인스턴스 변수도 final을 선언하면 안 된다.
4. 엔티티는 엔티티나 엔티티가 아닌 클래스를 상속할 수 있고 엔티티가 아닌 클래스는 엔티티 클래스를 상속할 수 있다.
5. 인스턴스 변수는 반드시 private, protected 또는 package-private으로 선언되어야 하며 엔티티 클래스의 메소드에 의해 접근되어야 한다. 클라이언트는 반드시 엔티티의 상태를 접근자나 비즈니스 메소드를 통해 접근해야 한다.
6. 엔티티 인스턴스가 세션 빈의 원격 인터페이스를 통해 준영속 상태의 객체로 값으로 전달되는 경우 Serializable 인터페이스를 구현해야 한다.

이제 하나하나씩 알아보도록 하자.

1. 코틀린의 빈 생성자

코틀린을 사용할 땐 거의 주 생성자를 통해 클래스를 생성한다. 따라서 NoArgs 생성자는 만드는 경우가 많이 없다. 자바에서도 마찬가지인데 자바에서 JPA를 사용할 땐 롬복을 적용하여 @NoArgsConstructor를 사용해서 주로 만들었다. 하지만 코틀린에서는 기본적으로 롬복을 사용하지 않으므로 이를 직접 만들어야 한다.

@Entity class Person(
    name: String = ""
) {
    @Id
    @GeneratedValue 
    var id: Long? = null 
    
    @Column(nullable = false) 
    var name = name
}

위 코드는 디폴트 파라미터를 활용해서 NoArgs 생성자를 만든 경우이다. 코틀린은 주 생성자 사용 시 자바처럼 완전히 비어있는 인스턴스를 만들 수가 없게 설계되어 있다. 주 생성자 없이 부 생성자로만 사용할 경우에도 생성자 위임을 통해 모든 필드를 초기화하도록 유도한다.

@Entity 
class Person {

    constructor(): this("")
    constructor(_name: String) { name = _name }
    
    @Id
    @GeneratedValue
    var id: Long? = null
    
    @Column(nullable = false)
    var name: String
}

그렇다면 우리는 모든 클래스에 대해 이런 식으로 코드를 작성해야 할까? 이를 코틀린 개발자들도 알고 있었고 플러그인을 통해 NoArgs 생성자를 만들 수 있도록 하였다. build.gradle.kts의 plugins에 다음 플러그인을 추가하면 된다. 그리고 noArg를 어느 어노테이션에 대해 지정할 지 적용할 수 있다.

plugins {
    id "org.jetbrains.kotlin.plugin.noarg" version "1.6.10"
}

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

또는 다음과 같은 플러그인을 추가해도 된다. 아래 플러그인은 코틀린에서 만들어 제공하는 플러그인으로 @Entity, @Embeddable, @MappedSuperclass에 대해 noArg를 적용해준다.

kotlin("plugin.jpa") version "1.6.10"

https://kotlinlang.org/docs/no-arg-plugin.html#gradle

 

No-arg compiler plugin | Kotlin

 

kotlinlang.org

이 플러그인을 적용하고 나서 바이트코드를 디컴파일하면 자바에서 사용했던 대로 NoArgs 생성자가 생겨 있는 것을 확인할 수 있다.

이로써 2번 문제는 해결했다.

2. 코틀린의 클래스는 기본적으로 final이다.

코틀린은 상속의 위험성을 줄이기 위해(이펙티브 자바의 저자인 조슈아 블로크의 철학에 따라) 기본적으로 모든 클래스는 final이 선언되어 있다. 그러나 JPA는 런타임에 프록시 기술로 지연 로딩을 지원하고 이는 기반 클래스를 상속하여 만들기 때문에 final로 선언하면 안 된다. 따라서 엔티티 클래스는 open을 통해 상속 가능하게 변경해야 한다. 그러나 엔티티가 많아질 경우 오히려 자바로 작성할 때보다 문제가 생긴다. 이를 위해 지원하는 플러그인이 allOpen이다. 이 플러그인을 사용하면 지정한 어노테이션에 붙은 클래스를 open 클래스로 컴파일해준다.

plugins {
    id "org.jetbrains.kotlin.plugin.allopen" version "1.6.10"
}

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

https://kotlinlang.org/docs/all-open-plugin.html

 

All-open compiler plugin | Kotlin

 

kotlinlang.org

스프링에서도 이러한 프록시 기술을 사용하기 때문에 코틀린에서는 스프링 편의 플러그인을 만들었다.

plugins { id "org.jetbrains.kotlin.plugin.spring" version "1.6.10" }

이 플러그인을 적용하면 @Component, @Async, @Transactional, @Cacheable, @SpringBootTest가 적용된 클래스에 대해 open 클래스로 컴파일한다. 따라서 @Component가 적용된 @Controller, @RestController, @Service, @Repository 등도 함께 적용된다.

위에서 소개한 plugin.jpa와 plugin.spring은 Intellij로 스프링 프로젝트를 생성하면 자동으로 추가된다.

이로써 3, 4, 5번 문제가 해결되었다. 하지만 아직 문제가 끝난 것은 아니다.

3. 엔티티 멤버의 세터에 대한 가시성

자바로 개발할 때 많이 들었던 조언은 엔티티에 대해서는 세터를 열어두지 말고 Dto를 따로 만들어서 이용하라는 것이었다. 엔티티에 세터를 열어놓으면 데이터의 변경 추적이 어렵다는 이유였다. 그러나 코틀린에서 open 클래스를 사용할 때는 멤버에도 이것이 적용되어 open 멤버에 대해 private set이 불가능하다.

package wscrg.learnjpakotlin.entity

import javax.persistence.*

@Entity
class Person(
    _name: String = ""
) {
    @Id
    @GeneratedValue
    var id: Long? = null
    
    @Column(nullable = false)
    var name = _name
    private set // ERROR!! 컴파일 오류 
}

위와 같이 세터를 막으면 다음과 같이 컴파일 오류가 발생한다.

그렇다면 이건 어떻게 처리해야 할까? 아쉽게도 깔끔한 방법은 아직 찾지 못했다. 최선은 가시성을 protected로 지정해 이 세터를 쓰지 말라는 의도를 말하는 것이다.

package wscrg.learnjpakotlin.entity

import javax.persistence.*

@Entity
class Person(
    _name: String = ""
) {
    @Id
    @GeneratedValue
    var id: Long? = null
    
    @Column(nullable = false)
    var name = _name
        protected set
}

4. data class

위 조건들을 보면 엔티티는 자바빈 규약을 따른다는 것을 알 수 있다. 코틀린에서는 자바빈 클래스를 위해 data class를 제공한다. data class를 만들면 equals(), hashCode(), toString()을 컴파일러가 모든 필드를 사용하여 자동으로 만들어준다. 뭔가 쎄한 느낌이 들 것이다. JPA를 사용하여 연관관계를 맺으면 List나 Set 컬렉션으로 참조를 하는 경우가 있는데 참조된 값이 엔티티끼리 서로 물리게 되면 순환 참조 문제가 발생한다. 따라서 data 클래스는 사용하지 않는 편이 낫다고 생각한다. 일반 클래스에 직접 오버라이드해서 필요한 멤버만을 가지고 사용하는 편이 더 나아보인다.

이쯤되면 그냥 엔티티는 자바로 작성하고 싶은 욕구가 샘솟는다. 필자도 이를 참지 못하고 자바로 엔티티를 작성해서 테스트를 돌려보았다. 그런데 여기서 또 다른 문제가 발생한다. 다음은 Person 엔티티를 자바로 작성한 코드이다.

package wscrg.learnjpakotlin.entity;

import lombok.*;
import javax.persistence.*;

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Entity
public class Person {

    @Id
    @GeneratedValue
    private Long id;
    
    @Column
    private String name;
}

그리고 다음과 같이 테스트 코드를 작성하고 실행하였다.

@DataJpaTest
class LearnJpaKotlinApplicationTests
    @Autowired constructor(val personRepository: PersonRepository) {
    
    @Test
    fun jpaTest() {
        val person = Person(1L, "James")
        personRepository.save(person)
        
        val findPerson = personRepository.findByIdOrNull(1L) ?: Person(-1L, "Anonymous")
        
        Assertions.assertThat(findPerson.id).isEqualTo(1L)
        Assertions.assertThat(findPerson.name).isEqualTo("James") 
    }
}

당연히 성공할거라고 생각했지만 예상하지 못한 오류가 발생했다.

이는 코틀린 코드와 롬복이 적용된 자바 코드가 혼용될 때 발생하는 문제다. 이 문제를 네이버 팀에서도 발견했었고 이를 문서로 공유하였다.
https://d2.naver.com/helloworld/6685007
핵심은 컴파일 순서의 차이이다. 코틀린과 자바가 함께 사용된 프로젝트는 코틀린이 먼저 컴파일되고 함께 사용된 자바 파일이 함께 로딩된다. 그 후에 자바 파일들이 컴파일된다. 위 글을 요약하면 이미 컴파일된 코틀린 코드에 어노테이션 프로세서가 적용된 코드를 사용할 수 없다는 것이다. 이를 해결하려면 네이버 팀에서 했던 것처럼 빌드 순서를 자바가 먼저 되도록 하는 방법이 있지만 그러면 자바 코드에서 코틀린 코드를 쓰지 못하는 불상사가 발생한다.. 아니면 롬복을 쓰지 않고 전부 자바 코드로 작성하면 되는데.... 그만 알아보자.

결론

엔티티를 작성할 때는 noArg와 allOpen 플러그인을 사용하고 세터는 protected로 지정하는 것이 현재로서는 최선인 듯 하다. 앞으로도 간간히 글을 올리도록 하겠다.

참고글

https://blog.junu.dev/37
https://d2.naver.com/helloworld/6685007
https://effectivesquid.tistory.com/entry/Kotlin-JPA-%EC%82%AC%EC%9A%A9%EC%8B%9C-Entity-%EC%A0%95%EC%9D%98

'Spring > JPA' 카테고리의 다른 글

[JPA] Fetch - Eager, Lazy  (0) 2021.12.04
[JPA] 연관관계[2/2] - 단방향  (0) 2021.12.03
[JPA] 연관관계[1/2] - 양방향  (0) 2021.12.03
[JPA] JPA 사용 시 주의할 점  (0) 2021.12.03
[JPA/Hibernate] Hibernate 기초  (0) 2021.10.12