본문 바로가기
Java & Kotlin/Kotlin

JPA entity의 VO로 Kotlin value class 사용하기

by devson 2022. 6. 1.

DDD와 같이 도메인에 집중하는 설계 방식에 관련된 책이나 블로그 글들을 보면 VO(value object)에 대해 이야기하는 것을 자주 볼 수 있을 것이다.

VO는 도메인에서 다루는 값을 나타내는 객체로 String이나 Int 등 범용적인 타입이 아닌 그 자체로 도메인의 값을 나타내며, 객체 안에 있는 값 또한 해당 도메인에서 사용하는 신뢰할 수 있는 값을 가지고 있다는 특징을 지닌다.

(예를 들어 Email이라는 VO는 {username}@{domain} 형태의 String 값임을 나타낸다)

 

이번 포스팅에서는 Kotlin을 사용할 때 JPA entity와 VO 사용에 대해서 특히 value class를 사용한 VO에 대해 알아보도록 하겠다.

 

(관련 코드는 여기에서 확인할 수 있다)


Kotlin과 JPA를 사용한다면 서비스의 사용자를 의미하는 User entity class를 만들 때,

일반적으로는 기본 타입을 사용하여 아래와 같은 코드를 짤 수 있을 것이다.

package com.tistory.devs0n.jpavo.user

import javax.persistence.*

/**
 * Without VO
 */
@Entity
@Table(name = "users")
class User(
    name: String,
    email: String,
) {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0

    @Column(name = "name")
    val name: String = name

    @Column(name = "email")
    val email: String = email

    init {
        validateUserName()
        validateUserEmail()
    }

    private fun validateUserName() {
        if (this.name.isBlank()) {
            throw IllegalArgumentException("invalid user name: ${this.name}")
        }
    }

    private fun validateUserEmail() {
        val usernameAndDomain = this.email.split('@')

        if (usernameAndDomain.size != 2) {
            throw IllegalArgumentException("invalid user email: ${this.email}")
        }
    }

    override fun toString(): String {
        return "User(id=$id, name='$name', email='$email')"
    }
}

 

이렇게 String, Int, Long, Boolean 등 기본 타입을 클래스의 프로퍼티로 사용하여 entity class를 만드는 코드가 아마 가장 보편적이고 자주 사용되는 방식이 아닐까 싶다.

 

하지만 이렇게 기본 타입을 사용한 코드에는 문제가 있을 수 있는데

 

1. 도메인의 값을 표현하기엔 언어가 제공하는 기본 타입은 너무 범용적인 타입이다.

예를 들어 "이메일"과 같은 값은 명확하게 그 형식과 의미하는 바가 존재하는 값인 반면, String type은 "이메일"이라는 값 이상을 아우를 수 있는 범용적인 타입이다.

그렇기 때문에 만약 값에 대한 validation을 하지 않는 경우 도메인에서 사용되어야하는 값이 아닌 엉뚱한 값이 사용되고 저장될 수 있다.

 

2. 생성자나 메서드에 동일한 타입의 인자가 여러 개 있는 경우, 인자를 넣는 순서에 민감하다.

(사실 이건 Entity에만 적용되는 내용은 아니다)

예를 들어 아래와 같이 어떤 entity가 다른 entity들의 ID를 참조해야하는 경우가 있을 수 있는데,

class SomeEntity(
    fooId: Long,
    barId: Long,
) {
    @Column(name = "foo_id")
    val fooId: Long = fooId
    
    @Column(name = "bar_id")
    val barId: Long = barId
}

 

 

같은 타입의 파라미터가 연속으로 정의되있는 경우 Entity 객체를 만드는데 큰 실수가 발생 할 수 있다.

// OK
SomeEntity(fooId, barId)

// Big NO-NO 🙀
SomeEntity(barId, fooId)

 

(named parameter 사용을 하면 이런 실수를 없앨 수 있다)

 

앞서 얘기한 문제점을 해결하기 위해 특정 값에 대해 우리만의 타입인 VO를 만드는 건 robust한 도메인 어플리케이션을 만드는데 있어 훌륭한 역할을 한다.

또한 POJO에서 뿐만 아니라 JPA를 사용한 객체에서도 @Embeddable, @Embedded를 통해 VO를 사용할 수 있게한다.

 

Kotlin을 사용한다면 유용하게 쓰이는 data class를 사용하여 VO를 만들 수 있을 것이다.

package com.tistory.devs0n.jpavo.user

import javax.persistence.*

/**
 * With VO - data class
 */
@Entity
@Table(name = "plans")
class User(
    name: UserName,
    email: UserEmail,
) {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0

    @Embedded
    val name: UserName = name

    @Embedded
    val email: UserEmail = email

    override fun toString(): String {
        return "User(id=$id, name='$name', email='$email')"
    }
}

@Embeddable
data class UserName(
    @Column(name = "name")
    val value: String
) {
    init {
        if (this.value.isBlank()) {
            throw IllegalArgumentException("invalid user name: ${this.value}")
        }
    }
}

@Embeddable
data class UserEmail(
    @Column(name = "email")
    val value: String
) {
    init {
        val usernameAndDomain = this.value.split('@')

        if (usernameAndDomain.size != 2) {
            throw IllegalArgumentException("invalid user email: ${this.value}")
        }
    }
}

도메인에서 사용되는 값에 대해서 class로 타입을 지정하고 그 값에 대한 유효성 검사를 하기 때문에,

해당 값을 사용하는데 있어 신뢰를 할 수 있고, 값에 대해서 타입을 걱정할 여지가 사라졌다.

 

하지만 data class를 사용하여 VO를 사용했을 때도 아쉬운 점이 있는데,

프로퍼티(필드)가 하나 뿐인데도 @Embeddable, @Embedded 어노테이션을 추가해야하니 손이 많이 간다.

또한 JPA 의존적인 코드기 때문에 이나 이메일 같은 공용적으로 쓰일만한 common VO로 빼기도 약간 애매하다. 🤔

(common은 프레임워크에 대한 내용이 아예 없거나 존재하여도 최소로 존재하는 편이 좋다)

 

라는 고민을 하는 우리에게 보다 좋은 선택지가 될 수 있는 방법이 있는데 그것은 바로 value class이다.

먼저 앞서 살펴본 코드의 VO에 value class를 적용한 코드부터 살펴보자.

package com.tistory.devs0n.jpavo.user

import javax.persistence.*

/**
 * With VO - value class
 */
@Entity
@Table(name = "plans")
class User(
    name: UserName,
    email: UserEmail,
) {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0

    @Column(name = "name")
    val name: UserName = name

    @Column(name = "email")
    val email: UserEmail = email

    override fun toString(): String {
        return "User(id=$id, name='$name', email='$email')"
    }
}

@JvmInline
value class UserName(
    val value: String
) {
    init {
        if (this.value.isBlank()) {
            throw IllegalArgumentException("invalid user name: ${this.value}")
        }
    }
}

@JvmInline
value class UserEmail(
    val value: String
) {
    init {
        val usernameAndDomain = this.value.split('@')

        if (usernameAndDomain.size != 2) {
            throw IllegalArgumentException("invalid user email: ${this.value}")
        }
    }
}

entity class인 User class를 보면 알 수 있듯, value class는 일반적인 타입과 동일하게 사용된다.

 

그렇기 때문에 value class는 단일 프로퍼티에 대해서 도메인의 값을 표현하는 VO로써 사용하기가 좋으며,
공통 코드로 분리하여도 프레임워크 의존도가 없으니 큰 부담이 없다.

 

value class가 뭐길래 이렇게 코드를 사용할 수 있는걸까?

value classinline class로써 하나의 특정한 프로퍼티를 한 겹 감싼 클래스라고 보면된다.

하지만 컴파일 시 이 클래스를 사용한 필드는 내부 프로퍼티 타입으로 사용된다.

말이 어렵지만 User class를 java로 컴파일된 결과를 보면 UserName, UserEmail type이 없고 단지 String type만 있는 것을 확인할 수 있다.

 

이처럼 단일 프로퍼티에 대해서는 일반 타입과 거의 같게 사용될 수 있지만,

내부적으로 유효성 검사를 할 수 있으며 또한 코틀린 코드에서는 명백하게 타입을 갖기 때문에 효용성에 있어서 코드를 작성하는데 큰 도움이 된다고 생각한다.

 

하나 변수를 VO로 감싸는데 부담을 느낀다면 어느정도 적절한 트레이드 오프를 가진 value class를 사용해보는건 어떨까싶다 :)

 
 

댓글