Kotlin으로 JPA 써보기

라모스·2023년 3월 28일
0

코프링 Best Practice

목록 보기
1/1
post-thumbnail

Kotlin과 JPA를 함께 사용할 때 주의해야 할 점 몇 가지를 정리해보고자 한다.

//아직은 찍먹 수준이라 공부하면서 추 후 내용을 더 추가해보고자 한다.

Setter

아래 예제 코드를 보면, 생성자 안의 var 프로퍼티가 있어 setter를 사용할 순 있으나, setter 대신 updateName()를 구현했다. 캡슐화를 위한 관행을 생각한다면 Java에서와 마찬가지로 setter를 외부에서 바로 호출하는 것 보다, 적절한 이름의 함수로 사용하는 것이 훨씬 좋기 때문이다.

@Entity
@Table(name = "users")
class User(
    var name: String,

    val age: Int?,

    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
    val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf(),

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

    init {
        if (name.isBlank()) {
            throw IllegalArgumentException("이름은 비어 있을 수 없습니다")
        }
    }

    fun updateName(name: String) {
        this.name = name
    }

    fun loanBook(book: Book) {
        this.userLoanHistories.add(UserLoanHistory(this, book.name, false))
    }

    fun returnBook(bookName: String) {
        this.userLoanHistories.first { history -> history.bookName == bookName }.doReturn()
    }
}

Setter를 완전히 막아보기

  1. backing property를 사용하는 방식
@Entity
@Table(name = "users")
class User(
    private var _name: String,

    //...
) {
	val name: String
    	get() = this._name
    
    //...
}

_name 프로퍼티를 만들고, 읽기 전용으로 추가 프로퍼티인 name을 만드는 방식이다.

  1. custom setter를 사용하는 방식
@Entity
@Table(name = "users")
class User(
    name: String, // 프로퍼티가 아닌, 생성자 인자로만 name을 받는다

    //...
) {
	val name = name
    	private set
    
    //...
}

User의 생성자에서 name을 프로퍼티가 아닌, 생성자 인자로만 받고 이 name을 변경가능한 프로퍼티로 넣어주되, name 프로퍼티에 private setter를 달아두는 것이다.

위 두 가지 방법은 프로퍼티가 많아지면 번거롭다는 단점이 있다.

생성자 안의 프로퍼티와 클래스 body 안의 프로퍼티

User 클래스 주생성자 안에 있는 userLoanHistoriesid는 꼭 주생성자 안에 있을 필요는 없다.

@Entity
@Table(name = "users")
class User(
    var name: String,

    val age: Int?,
) {

    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
    val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf(),

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

    fun updateName(name: String) {
        this.name = name
    }

    fun loanBook(book: Book) {
        this.userLoanHistories.add(UserLoanHistory(this, book.name, false))
    }

    fun returnBook(bookName: String) {
        this.userLoanHistories.first { history -> history.bookName == bookName }.doReturn()
    }
}

생성자냐 클래스 body안이냐는 큰 상관은 없다. 다음 두 가지 사항을 고려하여 적절하게 대응하는게 옳은 것 같다.

  • 모든 프로퍼티를 생성자에 넣는다.
  • 프로퍼티를 생성자 혹은 클래스 body 안에 구분해서 넣을 때 명확한 기준이 있어야 한다.

JPA와 data class

JPA Entity는 data class를 피하는 것이 좋다.

kotlin의 data class는 equals(), hashCode(), toString() 등의 함수를 자동으로 만들어준다. 연관관계의 상황에선 이 세 가지 함수들은 문제가 될 수 있는 경우가 존재한다.

위 그림 처럼, 1:N 연관관계를 맺고 있는 상황을 가정하면, User 쪽에 equals()가 호출된다면, User는 자신과 관계를 맺고 있는 UserLoanHistory의 equals()를 호출하게 되고, 다시 UserLoanHistory는 자신과 관계를 맺고 있는 User의 equals()를 호출하게 된다.

all-open

kotlin은 기본적으로 Class, 함수 모두 final이다. 이는 상속과 오버라이드가 막혀있다.

JPA를 사용할 때 Proxy Lazy Fetching을 완전히 사용하려면 클래스가 상속이 가능해야 한다. @OneToMany에 있어선 Lazy Fetching이 동작하지만, @ManyToOne에 대해선 Lazy Fetching 옵션을 명시적으로 주더라도 동작하지 않는다고 한다.

all-open 기능을 통해 @Entity 클래스들은 decompile 했을 때도 class가 열려 있게 처리해줘야 한다. 이를 위해 build.gradle에 다음 내용들을 추가하자.

plugins {
    id 'org.jetbrains.kotlin.plugin.allopen' version '1.6.21'
}

// plugins, dependencies와 같은 level (build.gradle 최상단에 추가)
allOpen {
    annotation("javax.persistence.Entity")
    annotation("javax.persistence.MappedSuperclass")
    annotation("javax.persistence.Embeddable")
}

References

profile
Step by step goes a long way.

0개의 댓글