Kotlin + Spring 시작하기(3) - Entity

EP·2022년 6월 11일
3

Kotlin + Spring

목록 보기
3/9
post-thumbnail

Entity


코틀린으로 프로젝트 환경 구축을 완료했으면 마음껏 코드를 짜면 된다. 하지만 우리는 자바로 스프링을 개발하면서 우리도 인지하지 못하고 있던 관습들이 있다. 이 관습들이 코틀린으로 개발을 하면서 문법적인 차이 때문에 인지를 하게 되고 이를 해결해야하는 상황을 마주한다.

생성자 정의


다음은 공식 문서에 담긴 엔티티 정의의 예시이다.

@Entity
class Article(
    var title: String,
    var headline: String,
    var content: String,
    @ManyToOne var author: User,
    var slug: String = title.toSlug(),
    var addedAt: LocalDateTime = LocalDateTime.now(),
    @Id @GeneratedValue var id: Long? = null)

@Entity
class User(
    var login: String,
    var firstname: String,
    var lastname: String,
    var description: String? = null,
    @Id @GeneratedValue var id: Long? = null)

코틀린은 생성자에서 필드를 정의한다. 또한 우리는 reflection을 사용하기 위해 기본 생성자를 플러그인을 통해 만들어줬다.(참조) 코틀린에서는 default 값을 설정하면 생성자에서 선택적 매개변수가 된다. 필수 매개변수는 순서를 명확하기 위해 상단에 정의하고 선택적 매개변수는 하단에 정의하는 것이 일반적이다. 코틀린은 named arguments를 지원하기 때문에 생성자에 큰 부담은 없을 것이다.

모든 생성자 파라미터에 디폴트 값을 지정하면 컴파일러가 자동으로 기본 생성자를 만들어준다. 그렇게 자동으로 만들어진 기본 생성자는 디폴트 값을 사용해 클래스를 초기화한다.
-코틀린 인 액션 4장

Entity와 data class


data class를 엔티티로 만들어주면 안된다. data class는 불변 객체로 정의하기 위한 클래스이며 equals(), hashCode(), toString(), copy() 등을 자동으로 만들어준다. Entity는 변경이 가능한 객체이며 해당 메서드를 사용하는 경우 문제가 발생한다. (엔티티에 롬복 @Data를 사용하지 않는 이유와 비슷하다.)

엔티티는 @Id로 식별자를 정의하므로 equals(), hashCode()를 하나의 식별자 필드로만으로 구현해야 한다. 그러다 data class는 주 생성자에 명시된 필드를 모두 이용해서 만들어주기 때문에 불필요한 로직을 가지게 된다. 또한 toString()의 경우 순환참조의 문제가 발생할 여지가 있다. 그래서 통상 해당 필드를 exclude 해줘야 한다. 하지만 data class에서는 toString()을 오버라이드해줘야 문제를 방지할 수 있다. 하지만 그러면 data class를 사용하는 의미가 없다.

@ToString(exclude = {"person"})

따라서 일반 class로 엔티티를 선언하고 equals, hashCode, toString 등의 메서드를 재정의 해줘야 한다.

다만 JPA가 아닌 다른 Spring Data의 데이터 접근 방식(Spring Data MongoDB, Spring Data JDBC, etc.)을 사용했을 때는 불변 객체를 다루는 data class를 사용할 수도 있다.

식별자


@Id

JPA에서는 엔티티의 id를 직접 할당, 자동 생성하는 방법을 사용한다. 직접 할당하는 방법에서 새로운 엔티티를 생성하고 save() 메서드를 호출했을 때, 이미 있는 엔티티가 있는지 확인하고 값이 없으면 새로운 엔티티로 인지하여 저장하게 된다. 이런 과정이 비효율적이라고 생각해서 Persistable를 사용해서 새로운 엔티티인지 확인하는 방법을 커스텀하는 방법이 있는데, 코틀린에서는 이 방법을 추천하지 않는다.

While Spring Data JPA makes it possible to use natural IDs (it could have been the login property in User class) via [Persistable](https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.entity-persistence.saving-entites), it is not a good fit with Kotlin due to KT-6653, that’s why it is recommended to always use entities with generated IDs in Kotlin.

JPA의 경우 엔티티를 생성할 때만 리플렉션으로 값을 넣어주기 때문에 setter를 사용하지 않는다. 또한 id는 pk를 사용하므로 값이 변경될 이유가 없기 때문에 val로 선언 해주는 것이 좋다. (공식문서에서는 var로 선언되어 있다)

또한 식별자에 @GeneratedValue(자동 생성 전략)을 사용하게 되면 db에 값을 insert하기 전에는 id 값이 null이기 때문에 nullable하게 정의 해줘야 한다. 엔티티를 생성할 때 id 값을 비즈니스 로직에서 넣어줄 필요가 없으므로 생성자에서 제거하고 default 값을 넣어주는 것이 좋다.

@Entity
class Entity {
    @Id
    val id: Long? = null
}

1:N (One To Many), N:N(Many To Many) 관계


Entity의 경우 1:N, N:N의 관계를 가진 프로퍼티를 포함하는 경우가 있다. 그런경우 Collection을 사용해서 프로퍼티를 정의하는데, 변할 수 있는 값이라 Mutable Collection로 선언을 해준다. 하지만 이 경우 참조된 Collection을 예상치 못한 곳에서 내부 값을 변경할 수 있는 가능성이 있어 이를 제한해야 한다.

@Entity
class Entity(
    var name: String
) {
    @Id @GeneratedValue
    val id: Long? = null

    @OneToMany(fetch = FetchType.LAZY)
    private var _people: MutableList<Person> = mutableListOf()
    val people: List<Person>
        get() = _people.toList()

    fun joinPerson(person: Person) {
        // _people.add(person)
        _people += person
    }
}

@Entity
class Person {
    @Id
    val id: Long? = null
}

이렇게 제공되는 메서드로만 값을 변경할 수 있도록 만들어주면 people 프로퍼티를 외부에서 변경하는 가능성을 제거할 수 있다. (참고로 collection은 연산자 오버라이딩을 통해 add를 사용할 수 있다) 코틀린에서는 Collection을 사용할 때 가능한 불변 객체 사용을 권장하고 있다.

private setter


자바에서 JPA를 사용할 때, Entity에 대해서 setter를 최대한 숨기는 것이 캡슐화를 위한 관행이었다. 따라서 엔티티의 필드 값을 변경하는 역할을 엔티티에게 넘겨 public한 메서드를 구현하고 setter를 숨기는 방식으로 개발을 해왔다. 엔티티의 상태를 변경할 수 있는 지점을 최대한 줄이는 것이다.

그래서 코틀린에서도 이와 같은 개념을 적용하려고 했다.

@Entity
class Admin(
    val email: String,
    val name: String,
) {
		@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
		val id: Long? = null
}

간단한 Entity가 있다. 현재 val로 선언이 되어있어 프로퍼티는 getter만 제공하고 있다. 이름을 바꾸는 기능을 만들기 위해 var로 바꿔준다. 또한 이름을 바꿔주는 메서드를 제공한다.

@Entity
class Admin(
    val email: String,
    var name: String,
) {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null

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

이름은 바꾸는 메서드를 정의했지만 프로퍼티 name의 setter는 public하게 노출이 되었다. setter의 노출을 막는 방법으로 3가지를 생각할 수 있다.

1. 프로퍼티를 private로 선언

@Entity
class Admin(
    val email: String,
    private var name: String,
) {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null

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

이 경우 name getter도 생성이 되지 않기 때문에 의도했던 방법이 아닐 수 있다.

2. setter만 private로 선언

@Entity
open class Admin(
		@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
		val id: Long? = null,
    val email: String,
    name: String,
) {

	var name: String = name
		private set

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

getter는 살려주고 setter는 private으로 가시성을 좁히려고 한다. 하지만 코드가 장황해지고 엔티티 필드 선언이 복잡해진다. 그래도 setter를 private로 막기 위해서 위 방법을 사용하면 가능할거라 생각했지만 사실 위 코드는 컴파일조차 되지 않는다.

JPA는 프록시 기반으로 Lazy 로딩을 하는데, Proxy는 상속 기반이므로 final이 붙은 클래스를 open 해줘야 한다. 그래서 JPA를 사용하는 경우 다음과 같은 설정을 추가한다.

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

클래스 뿐만 아니라 프로퍼티에도 open 키워드가 적용이 된다. (참조) open 프로퍼티에는 private set을 선언할 수 없다.

위와 같은 컴파일 에러 메시지가 나타난다.

3. setter를 protected로 선언

protected는 open 프로퍼티에도 적용할 수 있는 접근제어자(가시성)로 public하게 setter가 노출되는 것을 막아준다.

@Entity
class Admin(
    val email: String,
    name: String,
) {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null

    var name: String = name
        protected set

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

하지만 이 방법이 능사는 아니다. 사실 OOP에서 getter와 setter로 객체의 프로퍼티에 접근하는 것은 심각한 안티패턴이다. (엘레강트 오브젝트 - 3장 5절: 절대 getter와 setter를 사용하지 마세요) 코틀린이라는 언어자체가 getter와 setter의 개방을 어느정도 강제하고 있으므로 getter, setter를 제거하는 문제에 대해서는 많은 고민을 해야할 필요가 있다.

Reference


Kotlin + JPA 사용시 Entity 정의

[Spring Data JPA] Persistable (feat. 새로운 엔티티를 구별하는 방법)

코틀린에서 하이버네이트를 사용할 수 있을까? | 우아한형제들 기술블로그

Kotlin JPA Encapsulate OneToMany

kotlin(+JPA) entity 에서 setter 를 막을 수 있을까

왜 var로 선언된 property는 private set이 안 될까?

[Solved] Kotlin: data class private setter public getter - Local Coder

[LIVE 다시보기] 어디 가서 코프링 매우 알은 체하기! : 9월 우아한테크세미나

profile
Hello!

0개의 댓글