본문 바로가기
  • 조금 느려도, 꾸준히
Spring

[spring boot / kotlin] kotlin으로 JPA entity 작성하기

by chan 2021. 11. 21.
반응형

Spring boot 프레임워크에서는 @Entiy 어노테이션을 사용해서 어플리케이션의 엔티티 객체와 데이터베이스 테이블을 매핑해 객체지향적으로 데이터베이스의 데이터를 관리할 수 있다.

 

JPA 엔티티를 매핑하는 것은 기본적인 작업이지만 어플리케이션 개발의 객체지향적 관점과 데이터베이스의 관계지향적 관점을 조율하는 것은 고려해야 할 사항이 많고 비즈니스와도 밀접한 관련이 있어 아주 중요한 작업이다. 따라서 단순히 JPA 엔티티를 데이터베이스 테이블과 매핑하는 것을 넘어서 엔티티 설계를 잘 해야 한다.

 

이번 글에서는 위에서 언급한 설계의 레벨은 제외하고 kotlin으로 JPA엔티티를 매핑하는 과정에서 어떻게 엔티티 클래스를 작성해야 할 지 고민했던 과정을 적으려고 한다.

 

Java 언어를 사용한 엔티티 작성 예제는 워낙 자료가 많고 검증된 모범 케이스가 존재한다.

kotlin 도 찾아보면 관련 자료가 나오지만 자료마다 구현 방식이 다양하기도 하고 상대적으로 java에 비해 정보가 부족함을 개발하면서 느끼게 되었다.

 

물론 kotlin 과 java는 둘 다 JVM 상에서 동작하고 서로 100% 호환이 가능하다고 하지만, 컴파일 단계에서 약간의 문법적 차이가 있고 필자가 겪은 시행착오는 이로 인해서 발생한 것이었다.

 

# Kotlin JPA entity 예시

우선 JPA Buddy (JPA 엔티티 코드 자동 생성 및 분석 등 JPA 관련 여러 기능을 지원하는 플러그인) 에서 소개하는 kotlin jpa entity 예시코드가 있는데, 아래와 같이 구현되어 있다.

/**
 * This is how to define an entity according to the JPA specification:
 *  1. default no-argument constructor;
 *  2. the class and all properties are open and non-final.
 */
@Table(name = "project")
@Entity
open class Project {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    open var id: Long? = null

    @Column(name = "name", nullable = false)
    open var name: String? = null

    /**
     * Use lateinit on fields that are guaranteed to be non-null in the DB.
     * This allows the use of Kotlin nullability mechanism without removing the no-arg constructor.
     * NOTE: lateinit does not work with primitive types such as Long
     */
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "client_id", nullable = false)
    open lateinit var client: Client

}

여기서 하나 짚고가야 할 점은 위의 예시에서 엔티티를 open class로 구현했다는 점과 속성 값들 역시 모두 open 이라는 것인데,

JPA 구현체로 많이 사용하는 Hibernate 나 그 외 많은 라이브러리들은 엔티티를 상속받은 프록시 객체를 통해 지연로딩, 레퍼런스 참조 등의 기능들을 제공한다.

따라서 엔티티 객체는 상속 가능해야 하며, kotlin에서는 모든 class 가 기본적으로 final 이기 때문에 open 클래스임을 명시해주어야 한다.

동일한 이유로 kotlin data class 는 사용하지 않는다. 관련글에서 이유에 대해 잘 설명해주시고 있다.

 

그리고 JPA 에서는 reflection 개념을 활용해 런타임 상에서 엔티티 객체에 접근하여 값을 할당하거나 얻어올 수 있는데, 이를 위해서 기본 생성자가 필요하다. Guide to Java reflection

 

실제 spring boot를 사용해서 개발할 때는 no-argsall-open 플러그인을 사용할 수 있어 build.gradle.kts 에 다음 설정만 추가해 주면 위 두가지 필요사항을 기본적으로 적용할 수 있다.

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

 

# 본론

그래서 위의 JPA entity 예시는 완벽한가? 라는 물음에 대해서는 개인적으로 '아니오' 라고 생각한다.

왜냐하면 위의 예시는 두가지 한계가 존재하는데 먼저 코드의 일부분을 가져와 보면 아래와 같다.

    @Column(name = "name", nullable = false)
    open var name: String? = null

 

 

첫번째는 데이터베이스 상에서 name 이라는 컬럼은 nullable 하지 않는데 어플리케이션 레벨에서는 name 프로퍼티에 대해 nullable 하지 않다는 것을 보장할 수 없다. 타입이 String? 으로 지정되어 있어 실제 엔티티 객체의 name 값이 null 이어도 이를 허용한다. 만약 이대로 엔티티 객체를 persist 한다면 데이터베이스 레벨에서 에러가 발생할 것이다.

 

두번째는 사실 한계점이라기 보다는 보완할 점에 가까운데, 프로퍼티가 private 하지 않다. 물론 kotlin 에서는 open 클래스에 대해 private 프로퍼티를 선언하는 것을 허용하지 않는다. 다만 java 에서 private 프로퍼티를 선언한 것 처럼 만들 수 있는 방법은 존재한다.

kotlin은 변수 선언과 동시에 gettersetter 가 자동으로 생성되는데, java 처럼 getName, setName 등 메서드가 따로 만들어지는 것이 아니라 마치 변수에 접근하는 것 처럼 동작한다.

Project project = Project()
project.name = "project 1" // equal project.setName("project 1") in java

물론 java 에서 프로퍼티에 직접 접근하는 것과는 동작 방식이 다르다. 하지만 개발자 경험의 관점에서 볼 때 저렇게 프로퍼티를 수정할 수 있게 놔두면 추후 실수의 여지가 있고, 애초에 모든 프로퍼티에 대해 setter 를 만드는 것은 좋은 방법이 아니다.

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    open var id: Long? = null

보면 id 값도 마음만 먹으면 바꿀 수 있을 것처럼 보인다. id, 식별자는 처음 생성되어 DB에 반영될 때를 제외하고는 절대 불변해야 한다. 따라서 id는 var 대신 val 로 선언하는 것이 더 나을 것이다. kotlin에서 val 로 선언하면 기본적으로 읽기 전용이기 때문에 setter는 생성되지 않는다.

 

# 첫번째 개선

kotlin에서는 프로퍼티 선언 시 초기화를 반드시 해주어야 한다. 여기서 발생하는 문제는 프로퍼티를 nullable하지 않게 선언하면 초기값으로 null 이 아닌 값을 넣어줘야 한다. String 타입의 경우 "" 를 넣어줄 수 있겠지만 Long 타입 등은 사실 애매한 부분이 있다. 

이러한 경우에 kotlin 에서 지원하는 문법 설탕을 사용할 수 있는데, 바로 생성자에서(primary constructor) 프로퍼티 선언 및 초기화를 해줄 수 있다. 그렇게 위 예시 코드를 바꾸어보면 다음과 같다.

@Table(name = "project")
@Entity
open class Project(
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    open val id: Long,

    @Column(name = "name", nullable = false)
    open var name: String,
) {
	
	@ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "client_id", nullable = false)
    open lateinit var client: Client
}

이제 id 와 name 프로퍼티를 nullable로 설정하지 않아도 되게 되었다. id 프로퍼티는 val 로 설정하여 추후 수정될 수 없도록 했다.

 

하지만 위 코드도 name 에 대한 setter 문제, 그리고 생성자에 id 프로퍼티를 선언했더니 Project 객체를 생성할 때 id 값을 넣어줄 수 있는 치명적인 문제가 존재한다. 바꿔야 한다.

참고로 생성자에서 프로퍼티의 getter 나 setter를 커스텀할 수 없다. 생성자에서 프로퍼티를 초기화하는 방법 자체가 문제일 수 있다.

 

# 두번째 개선

생성자에서는 실제 Project 객체를 생성할 때 필요한 정보를 받도록 하고, 그 정보들을 프로퍼티의 초기값으로 넘겨주는 방식으로 개선하는 방법이 있다. 이렇게 하면 nullable 문제도 해결되고, setter 문제도 해결될 수 있을 것이다. 수정한 결과는 아래와 같다.

@Table(name = "project")
@Entity
open class Project(
	name: String
) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    open val id: Long = 0

    @Column(name = "name", nullable = false)
    open var name: String = name
    	protected set

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "client_id", nullable = false)
    open lateinit var client: Client

}

id 는 DB 상에서 존재하지 않는 pk값인 0으로 초기화를 시켜주었고, name은 생성자를 통해 값을 할당받게 하는 동시에 protected set 을 통해 객체 자기 자신과 자신을 상속받은 객체에서만 수정할 수 있고 외부에서는 수정할 수 없게 하였다.

id 는 저렇게 설정해두어도 reflection을 통해 pk 값을 부여받기 때문에 DB에 저장되거나 select 되어 불러와지는 경우 정상적으로 자기 자신의 pk 값을 부여받게 되지만, 사실 0으로 값을 초기화시킨다는 것이 애매해서, 실제 프로젝트에서는 그냥 null 값으로 초기화 하는 방법을 사용하기는 했다.

 

그 외 기타 프로퍼티들은 초기화 될 때만 생성자를 통해 값을 부여받도록 설정하고, 만약 update 해야 되는 상황이 생기면 관련 함수를 만들어서 사용하는 방향으로 진행하면 될 것이다.

 

아직은 '그래서 저 방법이 완벽한가?' 에 대한 물음에 그렇다고 할 수 없고, 필자 역시 kotlin 문법과 jpa 에 대해 계속 공부하고 있는 입장이라서, 추후에 개선안이 또 추가될 수도 있을 것 같다.

 

 

반응형

'Spring' 카테고리의 다른 글

[Spring boot / kotlin dsl] kotlin Querydsl 초기설정  (0) 2021.11.16

댓글