프레임워크(Framework)/Kopring(Kotlin + Spring)

[Kopring] 코틀린 스프링 환경에서 JPA Entity 정의하기

잇트루 2023. 7. 27. 23:16
반응형

Intro

JPA는 Java 진영의 표준 ORM 프레임워크로 자바 언어에 특화되어 있다. 코틀린 언어가 자바와 뛰어난 상호운용성을 가지고 있다고 하더라도 자바 언어에 특화된 하이버네이트를 적용시키다 보면 예상치 못한 문제 상황과 더 나은 엔티티를 정의하기 위해 고민해야 할 부분이 많았다.

따라서 코틀린 + 스프링 환경에서 JPA를 적용하기 위해 엔티티를 정의하는 단계에서 만난 문제와 이를 해결하기 위해 고민했던 내용을 정리하고자 한다.

 

 

코틀린 클래스는 기본이 final

코틀린에서 클래스, 함수, 프로퍼티는 기본적으로 final로 선언되어 상속이 불가능하다. 하이버네이트에서 제공하는 Entity 클래스는 final일 수는 있지만 지연 로딩(Lazy loading)을 위한 프록시 객체를 생성할 수 없다. 따라서 Entity의 클래스와 프로퍼티에 open 변경자를 붙여주어야 한다.

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

    ...
)

매번 엔티티를 정의할 때마다 open 변경자를 반복적으로 붙여주는 것은 불편한 작업이다. 이를 해결하기 위해 코틀린 플러그인의 도움을 받을 수 있다.

 

Kotlin Plugin

코틀린 플러그인은 Kotlin 언어를 개발 환경에서 효율적으로 사용할 수 있도록 도와주는 도구다. 이 플러그인들은 주로 Gradle 또는 Maven과 같은 빌드 도구를 통해 프로젝트에 추가하여 사용할 수 있다. 코틀린 플러그인은 다양한 종류가 존재하며, 개발 생산성 향상, 코드 품질 관리, 외부 라이브러리 연동 등에 활용한다.

 

코틀린 언어로 스프링 프로젝트를 생성하여 JPA를 사용하면 기본적으로 다음과 같은 플러그인을 제공해 준다.

plugins {
    ...
    kotlin("jvm") version "1.8.22"
    kotlin("plugin.spring") version "1.8.22"
    kotlin("plugin.jpa") version "1.8.22"
    ...
}
  • kotlin(”jvm”) : Kotlin 언어를 JVM에서 실행하기 위한 기본 플러그인이다.
  • kotlin("plugin.spring") : Kotlin과 스프링 프레임워크를 함께 사용하기 위한 플러그인으로 빈 설정, 의존성 주입 등의 기능과 확장 함수를 지원한다.
  • kotlin("plugin.jpa") : Kotlin 언어와 JPA를 함께 사용할 때 발생하는 문제를 해결하기 위한 플러그인이다.

 

kotlin("plugin.spring") 플러그인에 포함되어 있는 all-open 플러그인을 통해 엔티티 클래스를 정의할 때 open 변경자를 자동으로 추가하여 불편함을 해소할 수 있다.

 

build.gradle.kt

편리하게 엔티티 클래스를 정의하기 위해 @Entity, @Embeddable, @MappedSuperclass 어노테이션에 대하여 allOpen 설정한다.

plugins {
    ...
    kotlin("plugin.jpa") version "1.8.22"
    ...
}

...

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    ...
}

allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.Embeddable")
    annotation("jakarta.persistence.MappedSuperclass")
}
  • annotation() 함수를 통해 open으로 설정할 어노테이션의 패키지 경로를 작성하여 설정할 수 있다.

 

 

엔티티 정의하기

이제 코틀린 스프링 부트 환경에서 JPA 엔티티를 정의하는 방법에 대해서 알아보자. JPA는 Java 기반으로 개발된 ORM 프레임워크이기 때문에 코틀린스럽게 JPA 엔티티를 정의하는 것에 어려움이 있다.

코틀린의 생성자 정의, data class 등의 코틀린 언어의 특성에 알맞은 엔티티를 정의하다 보면 JPA의 특성과 부딪히는 문제를 경험하게 될 것이다.

 

data class와 Entity

코틀린의 data class는 게터와 세터, equals(), hashCode(), toString() copy() 함수 들을 자동으로 생성해 주는 편리한 클래스다. 하지만, data class를 이용하여 엔티티를 정의하게 되면 몇 가지 문제점이 발생할 수 있다.

  • JPA 엔티티는 setter를 지양하지만, setter의 개방을 막을 수 없는 문제
  • 연관관계 매핑으로 인해 발생할 수 있는 toString() 함수의 순환참조 문제
  • 동등한 엔티티에 대하여 예상치 못한 결과를 나타낼 수 있는 equals(), hashCode() 함수 문제

 

toString(), equals(), hashCode() 함수들을 오버라이딩하여 문제를 해결한다고 하더라도 data class를 사용하는 의미가 사라지게 되며, setter의 개방을 막을 수 없는 문제가 남게 된다.

따라서 data class로 엔티티를 정의하지 않는 것이 좋다.

 

식별자(id) 정의

여러 블로그나 깃허브에서 엔티티의 식별자를 다음과 같이 작성한 사례들을 많이 볼 수 있다.

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

위 코드를 보면서 가진 의문점은 nullable 타입과 기본 값을 null을 지정한다는 것이다. 코틀린에서는 nullable 타입과 non-null 타입을 구분하여 널 안정성을 강조한다.

코틀린의 특성을 유지하면서 JPA 엔티티를 작성할 수 없는지 판단하기 위해서는 JPA가 새로운 엔티티와 기존 엔티티를 어떻게 구분하는지 알아볼 필요가 있다.

 

Spring Data JPA에서 save 메서드는 다음과 같은 코드로 구현되어 있다.

@Transactional
@Override
public <S extends T> S save(S entity) {

    Assert.notNull(entity, "Entity must not be null");

    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity);
    }
}
  • entityInformation.isNew(entity)가 true이면 EntityManager의 persist 메서드를 호출하고, false이면 merge 메서드를 호출한다.
  • 이 코드는 entity가 새롭게 생성된 경우와 이미 존재하는 경우를 판단한다.

 

새로운 엔티티인지 판단하는 isNew() 메서드는 다음과 같이 구현되어 있다.

public boolean isNew(T entity) {

    ID id = getId(entity);
    Class<ID> idType = getIdType();

    if (!idType.isPrimitive()) {
        return id == null;
    }

    if (id instanceof Number) {
        return ((Number) id).longValue() == 0L;
    }

    throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}
  • 엔티티의 식별자가 기본 타입(primitive)인지, 숫자(Number) 타입인지 판단한다.
  • 즉, 엔티티의 식별자가 null이거나, 숫자 타입의 값이 0이면 새로운 엔티티로 판단한다.

 

따라서 엔티티가 새로운 식별자인지 판단하는 과정에서 기본 타입이 아닌 경우 null인지 판단하는 코드로 인해 nullable 타입으로 지정해야 한다. (코틀린의 Long 타입은 Wrapper 타입)

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

 

주 생성자 파라미터와 엔티티

코틀린에서 클래스를 정의할 때 주생성자 파라미터를 통해 프로퍼티를 선언할 수 있다. 이는 JPA 엔티티 클래스를 작성할 때도 적용할 수 있다.

 

다음은 주 생성자 파라미터에 식별자, 이메일, 이름, 생년월일, 성별을 선언한 회원 엔티티다.

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

    @Column
    val email: String,
    
    @Column
    var name: String,

    @Column
    val birthDate: LocalDate,

    @Column
    @Enumerated(EnumType.STRING)
    var gender: Gender
) {
    enum class Gender(
        val gender: String
    ) {
        NOT_CHECK("미선택"),
        MAN("남자"),
        WOMAN("여자")
    }
}
  • var(variable)과 val(value)로 프로퍼티를 선언하며 var로 선언하면 getter와 setter를 자동으로 생성해 주고, val로 선언하면 getter만 생성해 주지만 불변 상태가 된다.
  • JPA에서 setter는 안티패턴으로 setter의 사용을 지양하고 있으며, var로 선언하면 setter를 막을 방법이 없다.
  • val의 경우 setter를 생성해 주지 않지만, 불변 상태가 되어 값을 변경할 방법이 없다.

 

코틀린의 특징을 살리면서 JPA 엔티티를 정의하는 경우

만약, 어떻게든 코틀린의 특징을 살리고자 한다면 다음과 같이 엔티티를 정의할 수 있다.

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

    @Column
    var email: String = "",

    @Column
    var name: String = "",

    @Column
    var birthDate: LocalDate = LocalDate.now(),

    @Column
    @Enumerated(EnumType.STRING)
    var gender: Gender = Gender.NOT_CHECK
) {

    enum class Gender(
        val gender: String
    ) {
        NOT_CHECK("미선택"),
        MAN("남자"),
        WOMAN("여자")
    }
}
  • id를 제외한 모든 프로퍼티를 주 생성자 파라미터에 var로 선언하고 디폴트 파라미터를 활용한다.
  • getter와 setter 모두 자동으로 생성되지만, 반복적인 코드가 줄어들고 디폴트 파라미터로 인해 기본 생성자도 자동으로 생성된다.

 

문제점

  • setter가 개방되며 막을 방법이 없어 엔티티의 안정성이 보장받지 못하게 된다.
  • 값이 변하지 않아야 할 email, birthDate 등의 프로퍼티 마저 var로 선언된다.

 

var로 선언하여 private set으로 setter를 막는 경우

값이 변할 가능성이 있는 프로퍼티를 주 생성자 파라미터가 아닌 클래스 내부에 선언하여 해결해 보자.

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

    @Column
    val email: String,

    name: String,

    @Column
    val birthDate: LocalDate,
    
    gender: Gender

) {

    @Column
    var name = name
        private set

    @Column
    @Enumerated(EnumType.STRING)
    var gender = Gender.NOT_CHECK
        private set
    
    enum class Gender(
        val gender: String
    ) {
        NOT_CHECK("미선택"),
        MAN("남자"),
        WOMAN("여자")
    }
}
  • 값이 변할 가능성이 없는 프로퍼티는 주 생성자 파라미터에 선언하고, 값이 변할 가능성이 있는 프로퍼티는 클래스 내부에 선언하여 setter를 막고자 시도하는 코드다.
  • 하지만 이 경우 allOpen 플러그인을 통해 클래스와 프로퍼티가 open이 된 상태로 private set을 사용할 수 없으며, 실행 시 컴파일 에러를 발생시킨다.
  • open 키워드는 하위 클래스에서 오버라이드될 수 있음을 의미하는데, open 프로퍼티의 setter에 private를 적용시키는 것을 허용하지 않기 때문이다.

 

var로 선언하여 protected set으로 setter를 막는 경우

완전히 setter를 막을 수 없으므로 외부로부터 보호하기 위해 protected set으로 막아야 한다.

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

    @Column
    val email: String,

    name: String,

    @Column
    val birthDate: LocalDate,
    
    gender: Gender

) {

    @Column
    var name = name
        protected set

    @Column
    @Enumerated(EnumType.STRING)
    var gender = Gender.NOT_CHECK
        protected set
    
    enum class Gender(
        val gender: String
    ) {
        NOT_CHECK("미선택"),
        MAN("남자"),
        WOMAN("여자")
    }
}
  • 앞서 발생한 문제들을 해결하면서 setter를 외부로부터 보호할 수 있게 되었다.
  • 하지만, 코드가 복잡해지고 반복적인 코드가 생겼다.
  • JPA 엔티티는 자바 리플렉션 API를 활용하기 때문에 private가 아닌 기본 생성자를 필요로 한다.
  • 위 코드와 같이 프로퍼티를 초기화하여 기본 생성자 생성하도록 유도하거나 noarg 플러그인을 사용해야 한다.

 

noArg 플러그인 사용 방법

allOpen 플러그인과 같이 build.gradle.kt에 다음과 같이 설정할 수 있다.

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

 

문제점

  • protected set을 사용하여 안정성을 최대한 살렸지만, 코틀린의 특징을 살리지 못했다.
  • 변경 가능한 프로퍼티가 증가할수록 protected set을 반복적으로 작성해야 한다.
  • 기본 값을 설정하지 않을 시 여러 가지 방식의 엔티티 인스턴스를 생성해야 할 때마다 보조 생성자를 새로 정의해 주어야 한다.

 

 

결론

코틀린의 특징을 살리면서 JPA 엔티티를 작성하기엔 여러 가지 문제가 있다. JPA 안티패턴을 막기 위한 코드를 작성하면 코틀린의 특징을 살릴 수 없다.

혼자 개발하는 경우 코틀린의 특징을 살리면서 엔티티를 작성하여 setter를 사용하지 않는 방법을 선택할 수도 있겠지만, 대부분의 경우에는 여러 사람과 협업하여 개발하기 때문에 안정성을 최대한 보장하는 코드를 작성하는 것이 좋을 것 같다.

어쩌면 JPA를 도입하는 것이 아닌 Exposed와 같은 Kotlin 언어에 특화된 ORM을 고려하는 것도 해결 방안이 될 수 있을 것 같다.

반응형