본문 바로가기

GAB STORY

일상 속 소소한 순간들과 개발 공부 과정에서의 다양한 경험들을 담아낸 공간입니다.
카테고리 없음

kotlin with JPA

by 갑스토리 2022. 4. 5.

코틀린 내부에서 JPA을 올바르게 사용하는 방법에 대해 기록합니다.

 

 

하나의 Post에 다수의 첨부파일이 존재하는 테이블 스키마 구조입니다.

CREATE TABLE Post (
  id int NOT NULL AUTO_INCREMENT,
  title varchar(255) NOT NULL,
  PRIMARY KEY (id)
)


CREATE TABLE Attachment (
  id int NOT NULL AUTO_INCREMENT,
  url varchar(255) NOT NULL,
  postId int NOT NULL,
  PRIMARY KEY (id)
)

 

 

Entity

@Entity
@Table(name = "Post")
data class Post(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    val id: Int = 0,

    @Column(name = "title", nullable = false)
    val title: String,

    @OneToMany(mappedBy = "Post")
    val attachments: MutableSet<Attachment> = mutableSetOf()
)


@Entity
@Table(name = "Attachment")
data class Attachment(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int = 0,

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "postId", nullable = false)
    val Post: Post? = null
)

 

 

@Test
fun `기본 JPA 테스트`() {
    val attachment = attachmentRepository.findById(1)
    assertTrue(attachment.isPresent)
}

특별한 이슈 없이 성공 PASS합니다.

 


LAZY 이지만 EAGER로 동작하는 JPA

사실 위에서 테스트한 코드는 PASS했지만 기대했던 방식으로 동작하지는 않았습니다. 로깅을 살펴보면 Lazy로 설정이 되어있지만 Eager로 한 번에 조회를 하고 있습니다.

 

Hibernate: 
    select
        attachment0_.id as id1_2_0_,
        attachment0_.postId as entryid3_2_0_,
        attachment0_.url as url2_2_0_ 
    from
        Attachment attachment0_ 
    where
        attachment0_.id=?


Hibernate: 
    select
        entry0_.id as id1_3_0_,
        entry0_.title as title2_3_0_ 
    from
        Post entry0_ 
    where
        entry0_.id=?

 

Hibernate는 Lazy loading을 위해 Entity을 상속하여 Proxy를 생성합니다. 하지만 코틀린에서는 클래스의 기본 상속 제어자가 final이기 때문에 Proxy를 만들지 못하는 문제가 있습니다.

data class는 기본적으로 상속이 불가능한 클래스입니다. 여기에 제약사항이 하나더 추가되어 data class에는 open 키워드도 사용할 수 없습니다. 즉, 언어에서 강제하는 완전 상속 불가 클래스입니다. 

 

 

실제로 build된 파일을 확인해보면 final data class로 작성된것을 확인할 수 있습니다.

@javax.persistence.Entity @javax.persistence.Table public final data class Post public constructor(id: kotlin.Int /* = compiled code */, title: kotlin.String, attachments: kotlin.collections.MutableSet<com.example.jpastudy.entity.Attachment> /* = compiled code */) {
    @field:javax.persistence.OneToMany public final val attachments: kotlin.collections.MutableSet<com.example.jpastudy.entity.Attachment> /* compiled code */


.. 이하 생략

 

문제 해결을 위해 kotlin allOpen을 사용하면 됩니다. 

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

 

 

이제 proxy을 제대로 사용하는지 테스트 코드를 돌려봅니다.

@Test
fun `Proxy을 통해 Lazy 로딩이 잘 되는지 확인`() {
    val attachment = attachmentRepository.findById(1).get()
    val entry = attachment.entry!!

    logger.info { "post class reference : ${Post::class.java}" }
    assertTrue(HibernateProxy::class.java.isAssignableFrom(Post::class.java))
}

 

 

순환 참조 stack overflow

Post, Attachment Entity는 서로 manyToOne, oneToMany 양방향 참조를 하고 있음을 참고하시고 아래의 테스트 코드를 실행해보겠습니다.

@Test
fun `Lazy 로딩 데이터 접근 (toString)`() {
    val attachment = attachmentRepository.findById(1).get()
    val Post = attachment.Post!!

    println(Post)

    logger.info { "Post class reference : ${Post::class.java}" }
    assertTrue(HibernateProxy::class.java.isAssignableFrom(Post::class.java))
}

 

Attachment, Post 데이터를 무한 반복하며 stack overflow에러가 발생합니다.

select
    attachment0_.id as id1_2_0_,
    attachment0_.postId as entryid3_2_0_,
    attachment0_.url as url2_2_0_ 
from
    Attachment attachment0_ 
where
    attachment0_.id=?


select
    entry0_.id as id1_3_0_,
    entry0_.title as title2_3_0_ 
from
    Post entry0_ 
where
    entry0_.id=?

... 무한 반복

이유는 Entity가 data class로 선언 되었기 때문입니다. 자세히 말하면 data 클래스의 특징 "toString(), hashCode(), equals(), copy(), componentN functions 메소드를 자동으로 만들어준다."때문입니다.
- data class인 Attachment가 toString 생성을 위해 Entry의 toString을 호출합니다.
- data class인 Entry는  자신의 toString 생성하기 위해 Attachment의 toString을 호출합니다.
- 반복합니다.
- stack overflow가 발생합니다.

 

문제 해결 방법은 3가지가 있습니다.

Post Entity의 toString을 override하여 재구성합니다. (간단해서 내용 생략)

주 생성자의 프로퍼티 구분
data 클래스는 생성자 프로퍼티로 지정된 값으로 toString, hashCode, equals등을 생성하니 이를 분리하여 선언합니다.

@Entity
@Table(name = "Attachment")
data class Attachment(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int = 0,

    @Column(name = "url", nullable = false)
    var url: String? = null,
){
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "postId", nullable = false)
    val Post: Post? = null
}

 

data class 대신 일반 class로 Entity을 구현합니다. (이게 포인트! 이후 왜 Entity는 일반 클래스가 적합한지 설명하겠습니다.) 

 

 

data class로 Entity을 작성하는것이 적합한가?

 

저의 생각은 아니요. 입니다.
이유는 만든사람이 만들때 고려하지 않았고 적합하지 않으니 쓰지 않는게 좋겠다고 하니 안쓰는게 맞지 않을까요?

 

"Here we don’t use data classes with val properties because JPA is not designed to work with immutable classes or the methods generated automatically by data classes. If you are using other Spring Data flavor, most of them are designed to support such constructs so you should use classes like data class User(val login: String, …​) when using Spring Data MongoDB, Spring Data JDBC, etc."   

"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."

 


JPA는 data class가 제공하는 메서드들을 염두하고 작성되지 않았습니다.
Hibernate의 가이드 문서에 따르면 equals와 hashCode 함수는 엔터티의 ID(또는 natural-id 나 business-key)를 이용해서 구현하는 것이 권장됩니다.

 

 

일반 클래스와 data 클래스를 비교해보겠습니다.  data 클래스의 장점 단점을 살펴보면 자연스럽게 일반 클래스의 단점 장점을 파악할 수 있겠네요.

 

data class

  • 장점 
    • copy을 쉽게 사용할 수 있다.
    • destructuring을 사용할 수 있다. 
    • 양방향 연관관계시 어차피 toString, equals, hashCode는 구현해야하니 data class의 장점을 활용할 수 있다.
    • 장점은 아니지만 대응책으로 Lazy를 위한 open은 kotlin allOpen 플러그인으로 대응할 수 있다.

 

  • 단점
    • 자동으로 toString, equals, hashCode을 생성하기 때문에 개발자가 이를 인식하지 않는다면 문제 발생 확률이 높다.
    • 그리고 toString, equals, hashCode을 override해줘야한다면 data class의 장점이 있는가?
    • data class 불변클래스로 사용하는 것이 권장되는데 이 경우 JPA 의 dirty checking 기능을 사용할 수 없다. 물론 data class 를 가변클래스로(mutable var) 사용할 수도 있겠지만 가변클래스로 사용할 거라면 굳이 data class을 사용해야 할까요?

 

결론은? 

그래서 결론은 data class 대신 일반 클래스로 Entity을 작성하는게 좋겠습니다.
완성된 코드는 이런 모양일까요?

양방향 연관관계가 필요한 경우 https://github.com/consoleau/kassava 라이브러리를 이용하여 toString, equals, hashCode을 override한다.

@Entity
@Table(name = "Post")
class Post(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Int = 0,

    val title: String,

    @OneToMany(mappedBy = "Post")
    val attachments: MutableSet<Attachment> = mutableSetOf()
){
    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(Post::id)
        private val toStringProperties = arrayOf(
                Entry::id,
                Entry::title
        )
    }
}

@Entity
@Table(name = "Attachment")
data class Attachment(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int = 0,

    var url: String? = null,

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "postId", nullable = false)
    val Post: Post? = null
){
    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(Attachment::id)
        private val toStringProperties = arrayOf(
                Attachment::id,
                Attachment::url
        )
    }
}

 

복합키를 사용하는 경우

data class EntryPK(
    val blogId: Int = 0,
    val postId: Int = 0
) : Serializable

@Entity
@Table(name = "Post")
@IdClass(value = EntryPK::class)
class Post(
    @Id
    val blogId: Int = 0,

    @Id
    val postId: Int = 0,

    val title: String
)


단일키로 사용하는 경우

@Entity
@Table(name = "Post")
@IdClass(value = EntryPK::class)
class Post(
    @Id
    val blogId: Int = 0,

    val postId: Int = 0,

    val title: String
)


Entity의 특정 Key로 고유한 Entity 식별이 필요한 경우

@Entity
@Table(name = "Post")
@IdClass(value = EntryPK::class)
class Post(
   
    val blogId: Int = 0,

    @Id 
    val postId: Int = 0,

    val title: String
){

    companion object {
        private val equalsAndHashCodeProperties = arrayOf(Post::id)
        private val toStringProperties = arrayOf(
                Entry::id,
                Entry::title
        )
    } 
     override fun toString() = kotlinToString(properties = toStringProperties)

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

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

}



동등 비교가 필요한 경우는 일반 클래스의 Entity에 필요가 있을때 equals, hashCode을 override해서 구성합니다.

댓글