ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Boot + Kotlin + JPA 적용하기 Entity 생성시 생각해볼 점들
    프로그래밍/서버 프로그래밍 2020. 1. 28. 16:23

    2020-05-12 우아한 형제들 기술 블로그 - 코틀린에서 하이버네이트를 사용할 수 있을까?에 나온 내용 추가합니다.

    • 4. data class 사용에 대해 본글에서 적은 순환참조 이슈 외에도 다른 이슈가 나와있어서 링크 추가합니다.

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


    최근 코틀린으로 스프링 부트 사용을 공부하고 있습니다.

    연휴에 이동욱님의

    스프링 부트와 AWS로 혼자 구현하는 웹 서비스

    를 코틀린으로 따라 해 보기를 도전하면서 JPA를 적용하는 방법과 JPA를 적용할 때 Entity 작성법에서 생각해볼 부분이 있어서 공유 겸 글을 작성합니다.

    정말 JPA를 프로젝트에 적용하는 것만 필요하신 분은 요약만 보셔도 됩니다!

    코틀린에 JPA 적용 요약

    • kotlin-jpa플러그인을 적용합니다. 해당 플러그인은 kotlin-noarg 플러그인을 사용하여, @Entity, @Embeddable, @MappedSuperClass 어노테이션이 붙어있는 클래스에 No-arg 생성자를 자동으로 생성해줍니다.

      • gradle 에서는 하단의 script를 적용해줍니다.

        plugins {
            id "org.jetbrains.kotlin.plugin.jpa" version "1.3.61"
        }
      • 자세한 설명은 링크를 참고해주세요.

      • kotlin-allopen 플러그인을 build path에 적용합니다. Hibernate에서는 final클래스를 사용하면 안 되기 때문에 해당 플러그인을 이용하여 자동으로 open 상태로 만들어 줍니다.

        • Gradle에서는 하단 script를 적용합니다.

          plugins {
              id "org.jetbrains.kotlin.plugin.allopen" version "1.3.61"
          }
          allOpen { 
              annotation("javax.persistence.Entity") 
              annotation("javax.persistence.MappedSuperclass")
              annotation("javax.persistence.Embeddable")
          }
          • 자세한 설명은 링크를 참고해주세요.
    • Entity클래스를 data class로 작성하는 방법은 추천하지 않습니다. Hibernate는 자동 생성된 equals/hashcode, toString 과 궁합이 좋지 않습니다.


    Hibernate의 Entity

    하이버네이트의 Hibernate_User_Guide에서는 Entity에 아래와 같은 요구사항이 있다고 말합니다.

    1. Entity는 반드시 javax.persistence.Entity어노테이션이 붙어야 한다.
    2. Entity는 반드시 public 혹은 protectedno-arg 생성자를 가지고 있어야 한다. 다른 추가적인 생성자도 가질 수 있다.
    3. Entity는 반드시 top-level클래스여야 한다.
    4. enum이나 interfaceEntity로 지정할 수 없다.
    5. Entityfinal 클래스일 수 없다. 모든 메소드와 엔티티 내에 persist되는 변수들 또한 final로 지정할 수 없다.
    6. Entity클래스가 detached상태로 사용되려면, Serializable 인터페이스를 구현해야 한다.
    7. 추상 클래스 혹은 구현 클래스 모두 Entity가 될 수 있다. 엔티티는 비-엔티티 객체와 엔티티인 객체 모두 상속할 수 있고, 그 반대도 가능하다.
    8. Entity의 상태 값은 JavaBean-style에 부합하는, 인스턴스 변수로 표현된다. 인스턴스 변수는 반드시 entity 자기 자신에 의해서만 접근해야 한다.

    이제 이런 요구사항들을 기반으로 코틀린으로 JPA 엔티티를 만들어 보겠습니다.


    코틀린에 적용

    1. Entity는 반드시 public 혹은 protected의 no-arg 생성자를 가지고 있어야 한다

    1-1. no-arg 플러그인으로 생성된 생성자의 접근자는 public인 상태로도 괜찮을까?

    Hibernate에서는 리플렉션으로 엔티티 객체를 생성하기 때문에 모든 Entity는 no-arg 생성자를 가지고 있어야 합니다. 그리고, 이는 public 혹은 protected 둘 중 하나의 visibility를 만족해야 합니다.

    자바에서 JPA를 사용할 때 저희는 대부분 Lombok을 사용했습니다. 또한 국내의 많은 개발자들이 visibility를 @NoArgsConstructor(access = AccessLevel.PROTECTED) 로 설정해서 Hibernate 자체에서는 리플렉션으로 기본 생성자를 사용하되, 외부에서 기본 생성자를 이용한 직접적인 객체 생성을 막고자 했습니다.

    하지만 코틀린을 JPA에 적용하면서 읽은 많은 게시글에서는 그냥 no-arg plugin 을 사용할 뿐 따로, 접근자를 protected로 설정을 해주지 않아서 의문이었습니다.

    계속해서 이유를 찾았는데, 찾다 보니 답은 kotlinlang에서 만든 레퍼런스에 있었습니다.

    The generated constructor is synthetic so it can’t be directly called from Java or Kotlin, but it can be called using reflection.
    This allows the Java Persistence API (JPA) to instantiate a class although it doesn't have the zero-parameter constructor from Kotlin or Java point of view (see the description of kotlin-jpa plugin

    코틀린의 no-arg 플러그인이 생성한 기본 생성자는 public이긴 하지만 리플렉션으로만 접근이 가능하고, 자바나 코틀린 코드에서는 접근이 불가능하기 때문에 Lombok을 사용해서 접근자를 제한하던 때보다 오히려 간결하고, 깔끔하게 외부 접근을 막고 있었습니다.

    플러그인은 아래 코드를 gradle에 작성하면 자동으로 Entity, Embeddable, MappedSuperClass 어노테이션이 붙어있는 클래스에 자동으로 no-arg 생성자를 생성해줍니다.

    plugins {
        id "org.jetbrains.kotlin.plugin.jpa" version "1.3.61"
    }

    1-2. no-arg 플러그인을 적용했는데 왜 자꾸 intelliJ에서 Warning을 띄울까?

    위에서 말한 no-arg플러그인이 잘 적용되었더라도, 인텔리제이에서 아래와 같이 에러 메시지와 함께 밑줄이 그어지는 경우가 있습니다.

    이런 경우에는 Gradle에서 Clean -> Build를 한 후 , gradle reimport를 하는 과정을 하다 보면, 사라집니다. 컴파일 과정에서 public constructor가 생성되는데 빌드가 아직 진행되지 않았거나, 빌드된 파일을 인식하지 못해서 발견되는 현상으로 보입니다.


    2. Entity는 final 클래스 일 수 없다. 모든 메소드와 엔티티 내에 persist 되는 변수들 또한 final로 지정할 수 없다.

    JPA에서는 런타임에 엔티티의 프록시 객체를 만들어서 Lazy-loading같은 기능을 작동하기 때문에 final로 작성하는 것은 대부분의 상황에서 금지되어있습니다.

    자바에서는 final 클래스를 생성하려면 final 키워드를 클래스에 붙여야 하는 반면, 코틀린에서는 기본적으로 클래스를 작성하면 final 클래스가 만들어집니다. 이러한 코틀린의 특성은 불변성을 보장하고, 무분별한 상속을 막을 수 있는 긍정적인 효과가 있지만, JPA를 적용하는 기에는 오히려 불편합니다.

    @Entity
    class User(username: String) {
        @Id
        @GeneratedValue
        var id: Long? = null
        var username: String = username
    }

    위 코드를 컴파일한 코드를 인텔리제이로 열면

    @javax.persistence.Entity public final class User public constructor(username: kotlin.String) {
        @field:javax.persistence.Id @field:javax.persistence.GeneratedValue public final var id: kotlin.Long? /* compiled code */
    
        public final var username: kotlin.String /* compiled code */
    }

    기존 클래스에서 저흰 final 키워드를 사용하지 않았지만, 코틀린에서는 기본이 final이기 때문에 컴파일되면서 final이 붙었습니다.

    final키워드 없이 상속이 가능한 상태로 Entity를 작성하려면, 생성할 때마다 모든 클래스와 변수에 open키워드를 적어줘야 합니다.

    하지만 이런 귀찮은 일을 위의 no-arg 플러그인처럼 자동으로 처리해주는 플러그인이 allOpen 플러그인입니다.

    plugins {
        id "org.jetbrains.kotlin.plugin.allopen" version "1.3.61"
    }
    
    allOpen { 
        annotation("javax.persistence.Entity") 
        annotation("javax.persistence.MappedSuperclass")
        annotation("javax.persistence.Embeddable")
    }
    

    위의 no-arg 같은 경우는 jpa플러그인이 알아서 필요한 어노테이션에 플러그인을 적용해줬지만, allOpen 플러그인은 직접 적용할 어노테이션을 명시해줘야 합니다.

    위와 같이 플러그인을 적용하고 같은 User 클래스를 다시 컴파일해보면

    @javax.persistence.Entity public open class User public constructor(username: kotlin.String) {
        @field:javax.persistence.Id @field:javax.persistence.GeneratedValue public open var id: kotlin.Long? /* compiled code */
    
        public open var username: kotlin.String /* compiled code */
    }

    일일이 open 키워드를 붙일 필요가 없이 자동으로 open 키워드가 붙었습니다.


    3. Entity의 상태 값은 JavaBean-style에 부합하는, 인스턴스 변수로 표현된다. 인스턴스 변수는 반드시 entity 자기 자신에 의해서만 접근해야 한다.

    문서에서 엔티티는 자기 자신이 가지고 있는 비즈니스 로직 혹은, 자신의 getter 혹은 setter로만 상태 값에 접근해야 한다고 말합니다.

    @Entity
    class User(username: String) {
        @Id
        @GeneratedValue
        var id: Long? = null
        var username: String = username
    }

    위와 같은 Entity를 가지고 코틀린에서 아래와 같은 코드를 실행해보겠습니다.

    val user = User(username = "junwoo")
    val username = user.username
    println(username)

    얼핏 보면 getter를 사용하지 않고 있기 때문에 위 조건을 어기고 있는 것 같아 보이지만, 실제로 바이트 코드를 다시 자바로 디컴파일해보면 아래와 같은 코드가 나옵니다.

    User user = new User("junwoo");
    String username = user.getUsername();
    boolean var3 = false;
    System.out.println(username);

    kotlin의 문법 때문에 getter를 사용하지 않고, 직접 상태 값에 접근하는 것 같이 보였지만, 실제로는 getter를 사용해서 접근하고 있다는 것을 확인할 수 있습니다.

    setter도 마찬가지로 user.username = "JUNWOO" 와 같이 작성하면 setter를 사용해서 상태 값을 변경하는 것을 확인할 수 있습니다.

    저 같은 경우, setter를 무분별하게 사용하지 않기 위해 자바에서 JPA를 사용할 때는 setter를 일부로 작성하지 않고 따로 update를 위한 메소드를 작성하여서 사용했었습니다. setter가 모든 필드에 대해 열려있을 경우, 변경 기능이 없거나 의도치 않은 상황에서의 변경을 막기 힘들고, 데이터 변경이 어디서 됐는지 추적하기도 매우 어려워집니다.

    그런데 현재 저희의 Entity는 모든 val로 선언하지 않는 이상 settergetter가 모두 public으로 기본으로 생성됩니다. 이를 막고, setter를 private으로 선언하기 위해 아래와 같이 작성하고 빌드를 해봅니다.

    @Entity
    class User(username: String) {
        @Id
        @GeneratedValue
        var id: Long? = null
        var username: String = username
            private set
    }

    Private setters are not allowed for open properties 에러가 발생하며 빌드가 되지 않습니다. 앞에서 붙여준 open키워드가 클래스뿐 아니라 property들에도 붙는데, open property에는 private setter를 지정할 수 없다고 합니다. 저는 setter를 public으로 계속 열어둘 수는 없으니 protected 레벨로 라도 작성해서 외부에서 접근이 제한되도록 작성하였습니다.

    @Entity
    class User(username: String, birthDate: LocalDate) {
        @Id
        @GeneratedValue
        var id: Long? = null
        var username: String = username            // 변경 가능성이 있는 필드
            protected set
        @Column(updatable = false)
        val birthDate : LocalDate = birthDate    // 변경 가능성이 없는 필드
    
        fun changeUsername(username: String) {
            this.username = username
        }
    }

    변경 가능성이 없는 필드의 경우 val 으로 선언하면 getter만 생기고, setter는 접근이 제한되기 때문에 따로 protected를 붙여줄 필요가 없었습니다.

    Hibernate에서는 final 필드를 선언하지 않아야 한다고 했는데 val 을 사용해도 되는 것인가? 에 대해서는 all-open 플러그인이 open으로 상속에 대해 열어주기 때문에 사용가능한 것으로 이해했습니다. 

    이렇게 protectdval 을 이용해서 자기 자신의 상태 값을 직접 수정하고 조회하는 entity를 생성할 수 있었습니다.

    4. data class의 사용

    코틀린을 JPA에 적용하면서 본 많은 예제들은 data class를 사용해서 Entity를 작성했습니다.

    코틀린의 data class는 Lombok의 @Data 어노테이션처럼 많은 메소드들을 자동으로 생성해줍니다.

    실제로 아래 코드를 자바 코드로 바꾸면 다음과 같이 변경됩니다.

    @Entity
    data class Book(
            var id: Long? = null,
            var name: String,
            var isbn: String
    )
    @Entity
    public final class Book {
       @Nullable
       private Long id;
       @NotNull
       private String name;
       @NotNull
       private String isbn;
    
      /* getter와 setter는 코드 길이상 생략 */
    
       public Book(@Nullable Long id, @NotNull String name, @NotNull String isbn) {
          Intrinsics.checkParameterIsNotNull(name, "name");
          Intrinsics.checkParameterIsNotNull(isbn, "isbn");
          super();
          this.id = id;
          this.name = name;
          this.isbn = isbn;
       }
    
       // $FF: synthetic method
       public Book(Long var1, String var2, String var3, int var4, DefaultConstructorMarker var5) {
          if ((var4 & 1) != 0) {
             var1 = (Long)null;
          }
    
          this(var1, var2, var3);
       }
    
       @Nullable
       public final Long component1() {
          return this.id;
       }
    
       @NotNull
       public final String component2() {
          return this.name;
       }
    
       @NotNull
       public final String component3() {
          return this.isbn;
       }
    
       @NotNull
       public final Book copy(@Nullable Long id, @NotNull String name, @NotNull String isbn) {
          Intrinsics.checkParameterIsNotNull(name, "name");
          Intrinsics.checkParameterIsNotNull(isbn, "isbn");
          return new Book(id, name, isbn);
       }
    
       // $FF: synthetic method
       public static Book copy$default(Book var0, Long var1, String var2, String var3, int var4, Object var5) {
          if ((var4 & 1) != 0) {
             var1 = var0.id;
          }
    
          if ((var4 & 2) != 0) {
             var2 = var0.name;
          }
    
          if ((var4 & 4) != 0) {
             var3 = var0.isbn;
          }
    
          return var0.copy(var1, var2, var3);
       }
    
       @NotNull
       public String toString() {
          return "Book(id=" + this.id + ", name=" + this.name + ", isbn=" + this.isbn + ")";
       }
    
       public int hashCode() {
          Long var10000 = this.id;
          int var1 = (var10000 != null ? var10000.hashCode() : 0) * 31;
          String var10001 = this.name;
          var1 = (var1 + (var10001 != null ? var10001.hashCode() : 0)) * 31;
          var10001 = this.isbn;
          return var1 + (var10001 != null ? var10001.hashCode() : 0);
       }
    
       public boolean equals(@Nullable Object var1) {
          if (this != var1) {
             if (var1 instanceof Book) {
                Book var2 = (Book)var1;
                if (Intrinsics.areEqual(this.id, var2.id) && Intrinsics.areEqual(this.name, var2.name) && Intrinsics.areEqual(this.isbn, var2.isbn)) {
                   return true;
                }
             }
    
             return false;
          } else {
             return true;
          }
       }
    
       public Book() {
          super();
       }
    }

    copy() , toString(), equals(), hashcode() 등 많은 코드들이 자동으로 생성되는 걸 알 수 있습니다.

    자바에서 Lombok을 사용해보신 분들은 알겠지만 @Data 어노테이션이 만들어준 toString()을 그대로 사용할 경우, 양방향 연관관계를 갖는 Entity들은 순환 참조 문제가 발생하면서 StackOverflow가 발생하는 것을 알고 계실 거라고 생각합니다.

    과연 코틀린은 어떤지 한번 샘플 코드를 작성해서 실험해보겠습니다.

    @Entity
    data class User(
            @Id
            @GeneratedValue
            var id: Long? = null,
            var username: String,
            @OneToMany
            val writtenPosts: MutableList<Post> = ArrayList()
    )
    
    @Entity
    data class Post(
            @Id @GeneratedValue
            var id: Long? = null,
            var title: String,
            @ManyToOne
            var author: User
    )
    

    위와 같은 엔티티들을 두고, 아래 테스트를 실행시켜봅니다.

    @Test
        internal fun test(){
            val user = User(username = "junwoo")
    
            val post = Post(author = user, title = "제목")
            user.writtenPosts.add(post)
    
            println(user)
        }

    println에서 user.toString() 이 실행되면서 둘의 순환 참조로 스택오버플로우가 발생합니다.

    이러한 이슈는 이외에도 자동으로 작성된 equalshashcode 가 의도치 않은 상황을 야기할 수 있으므로 entity에는 data class의 사용은 지양하자는 것이 개인적인 의견입니다.


    마무리

    최근 코틀린을 공부하면서 코드량이 줄고, 다양한 편의 메소드들이 있어서 코드 자체의 편의성이나 간결함이 훨씬 좋았습니다.

    하지만 한편으로는 자바 생태계에 얼마나 많은 양질의 레퍼런스와 좋은 게시글 혹은 Best Practices들이 있었는지 새삼 느끼게 되었습니다. 보통 코틀린에 관해 검색하면 대부분 안드로이드 관련이고, Spring과 코틀린에 관련한 정보는 정말 적어서, 자바에서 검색했으면 전 세계 어딘가 누군가는 나와 같은 문제를 겪고, 이를 기록해 놓았을 것 같은 믿음과 함께 검색을 하게 되지만 코틀린은 .................

    그래도 자바에서는 당연하게 다른 사람의 예제를 따라갔던 것에 비해 조금 더 주체적으로 고민을 하면서 공부를 할 수 있다는 것은 장점인 것 같기도 합니다! :D

    코틀린 JPA 관련 정보

    저는 이제 막 코틀린을 공부하고 있기 때문에, 제 의견이 틀렸거나 더 좋은 방법이 있을 수도 있습니다! 틀린 내용이나 더 좋은 내용이 있다면 공유해주시면 감사하겠습니다.

    댓글

Designed by Tistory.