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 class는 inline class로써 하나의 특정한 프로퍼티를 한 겹 감싼 클래스라고 보면된다.
하지만 컴파일 시 이 클래스를 사용한 필드는 내부 프로퍼티 타입으로 사용된다.
말이 어렵지만 User class를 java로 컴파일된 결과를 보면 UserName, UserEmail type이 없고 단지 String type만 있는 것을 확인할 수 있다.
이처럼 단일 프로퍼티에 대해서는 일반 타입과 거의 같게 사용될 수 있지만,
내부적으로 유효성 검사를 할 수 있으며 또한 코틀린 코드에서는 명백하게 타입을 갖기 때문에 효용성에 있어서 코드를 작성하는데 큰 도움이 된다고 생각한다.
하나 변수를 VO로 감싸는데 부담을 느낀다면 어느정도 적절한 트레이드 오프를 가진 value class를 사용해보는건 어떨까싶다 :)
'Java & Kotlin > Kotlin' 카테고리의 다른 글
reified를 통해 generic type 정보 가져오기 (0) | 2023.01.16 |
---|---|
[MockK] mock 객체가 호출되지 않았음을 검증하기 (1) | 2022.10.21 |
[MockK] 메서드 호출 순서 검증하기 (0) | 2022.04.15 |
[MockK] 인자 값 그대로 리턴하기 (0) | 2022.04.14 |
observable, vetoable를 통한 프로퍼티 변경 감지 (0) | 2021.11.06 |
댓글