본문 바로가기

백엔드 개발/코틀린(Kotlin)

코틀린(Kotlin) data class를 JPA에서 사용시 주의할 점

반응형

코틀린(Kotlin) data class를 JPA에서 사용시 주의할 점

들어가며

코틀린(Kotlin)회사 웹 서비스에 도입하기 위해 공부하던 중 kotlin의 data class를 spring data jpa에서 사용시 주의할 점을 알게 되었다.

data class란 무엇인가?

lombok에서 사용하는 @Data 어노테이션과 비슷한 기능을 코틀린(Kotlin) 언어 자체에서 지원한다고 보면 된다.
지원 기능은 대표적으로 아래와 같다.

  1. equals() / hashCode() 자동 선언
  2. toString() 자동 선언
  3. copy() 함수 구현

반복되는 코드 구현을 줄여주는 좋은 기능이다.
실제로 웹상의 많은 예제에서 data class의 장점으로 spring data jpa의 entity를 예시로 들었다. 아래는 같다.

@Entity
data class Account(
        @Id @GeneratedValue
        @Column(updatable = false, insertable = false)
        var id: Long? = null,
        var email: String,
        var password: String,

        @Enumerated(EnumType.STRING)
        @ElementCollection(fetch = FetchType.EAGER)
        var roles: MutableSet<AccountRole>,

        @CreationTimestamp
        var createDt: LocalDateTime = LocalDateTime.now()
)

enum class AccountRole {
    ADMIN, USER
}

코틀린(Kotlin)의 기본 생성자로 JPA 엔티티(Entity)가 아주 간결하게 선언되었다.

참고로 위의 클래스는 Kotlin으로 Spring Boot Security - Login 맛보기에서 참고한 클래스이다. Kotlin으로 Spring Security을 연습할 때 아주 유익한 글이다.

유익하지만 주의해야할 점은?

결론부터 말하자만 data class의 기능 중 hashCode() 함수 자동 구현 때문에 그렇다.
data class는 클래스의 모든 프로퍼티(property)를 이용해 hashCode()를 구현한다.
새로 생성한 Account 객체를 Kotlin Collection 중 HashSet에 넣은 다음 영속화했다고 생각해보자.

    @Test
    fun `account를 hashSet에 넣고 영속화하면 hashSet에서 조회 불가능`() {
        val account = Account(email = "asdf@test.com",
                              password = "asdf",
                              roles = mutableSetOf(AccountRole.USER))
        val hashSet = hashSetOf(account)
        accountRepository.save(account)
        Assertions.assertFalse(hashSet.contains(account))
    }

위의 경우 테스트가 통과한다.
account가 hashSet에 들어갈때는 account의 id는 null이다. 그러나 accountRepository에 의해서 영속화되면서 account의 id에 자동생성된 값이 들어가면서 hashCode()의 반환값이 달라진다. 왜냐하면 코틀린에서는 data class의 모든 프로퍼티를 이용해 hashCode를 생성하기 때문이다.
사실 이 문제는 코틀린만의 문제가 아닌 자바(JAVA) 객체의 일반적인 문제라고 볼 수 있다.

그래서 해결책은?

첫 번째, Natural key(혹은 business key)를 사용하라.
Natural key란 데이터베이스 바깥 영역의 실제 세계에 존재하는 unique key이다. 대부분 편의상 DBMS의 설정으로 Auto Increment되는 인공키를 테이블(Table)의 기본키(Primary Key)로 사용한다. 이때 후보키(Candidate Key)가 존재하면 Natural key일 가능성이 높다.
예를 들자면 이메일, 홈페이지 주소, 사업자번호가 있을 것이다.

Account를 class로 바꾸고 Natural Key로 HashCode()를 Override 해보겠다.

    @Entity
    class Account(
            @Id @GeneratedValue
            @Column(updatable = false, insertable = false)
            var id: Long? = null,
            var email: String,
            var password: String,

            @Enumerated(EnumType.STRING)
            @ElementCollection(fetch = FetchType.EAGER)
            var roles: MutableSet<AccountRole>,

            @CreationTimestamp
            var createDt: LocalDateTime = LocalDateTime.now()
    ){
        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (javaClass != other?.javaClass) return false

            other as Account

            if (email != other.email) return false

            return true
        }

        override fun hashCode(): Int {
            return email.hashCode()
        }
    }

Natural Key인 email로 함수를 Override했다. 아래 테스트를 돌려보면 통과할 것이다.

    @Test
    fun `Natural Key로 hashCode()를 재정의한 객체를 hashSet에 넣고 영속화하면 hashSet에서 조회 가능`() {
        val account = Account(email = "asdf@test.com",
                password = "asdf",
                roles = mutableSetOf(AccountRole.USER))
        val hashSet = hashSetOf(account)
        accountRepository.save(account)
        Assertions.assertTrue(hashSet.contains(account))
    }

두 번째, 객체를 hashSet에 넣기 전에 영속화한다.
이렇게 하면 hashCode가 hashSet에 넣기 전과 후에도 일치한다.
Account를 다시 data class로 되돌리고 아래 테스트를 실행해보자.

    @Test
    fun `account를 hashSet에 넣기 전에 영속화하면 hashSet에서 조회 가능`() {
        val account = Account(email = "asdf@test.com",
                password = "asdf",
                roles = mutableSetOf(AccountRole.USER))
        accountRepository.save(account)
        val hashSet = hashSetOf(account)
        Assertions.assertTrue(hashSet.contains(account))
    }

Natural Key가 없어서 @Id로 정의한 엔티티 식별자를 사용하는 경우에는 두가지 해결 방법이 있다.

첫 번째, hashCode()이 상수값을 반환하도록 해서 entity가 영속화되기 전과 후의 hashCode()의 반환값이 같도록 한다.

 override fun hashCode(): Int {
        return 31
    }

두 번째, 영속화된 엔티티에 대해서만 동등성 검사를 한다.
이 경우 영속화된 엔티티에 대해서만 equals()와 hashCode()함수를 사용하므로 안전하다.

결론

이 글을 통해서 data class 사용시 주의할 점에 대해서 알아보았다.

JPA에서 detach된 객체를 다시 영속화한다든지 HashSet에 영속화되지 않은 객체를 넣은 다음에 영속화하는 상황은 일반적이지는 않다고 생각한다.

그래서 data class를 사용한 경우 문제없이 돌아갈 가능성도 있다.

그러나 어떤 기술을 도입할 때 주의점과 이 것이 미칠 영향을 모르고 도입하는 것과 알고 도입하는 것의 차이는 크다고 생각한다.

문제가 생기고 부랴부랴 찾는 것과 알고 바로 고치는 것은 다르니 말이다.


아래 링크는 이 글을 쓰는데 엄청 도움이 된 글들이다.
필자의 글이 미흡하다고 생각 된다면 아래 링크를 참고하면 도움이 될 것이다.

 

HIBERNATE WITH KOTLIN – POWERED BY SPRING BOOT
코틀린을 Hibernate에 사용할 때 주의할 점이 전반적으로 정리된 글이다.  

  

Implementing equals() and hashCode()
Hibernate User guide이다. JPA의 구현체인 Hibernate가 제안하는 equals()/hashCode()의 구현 방법을 알 수 있다.  

  

4.3. IMPLEMENTING EQUALS() AND HASHCODE()

반응형