Kotlin + Spring Boot 에서 data class 구현으로 Validation 로직 작성하기
Kotlin + Spring Boot를 개발시 클라이언트와 혹은 레이어간 데이터를 주고받을 때 발생할 수 있는 문제와 이를 해결하는 방법에 대해 알려진 방법인 Annotation을 사용하지 않고 다른 방법은 없을까에 대한 방법 제시에 대한 글입니다.
Kotlin의 데이터 클래스(Data class)를 이용하여 어떻게 데이터를 검증할 수 있는지에 대해 소개하겠습니다.
Data class 가 아닌 Value class 로 구현을 해도 되겠지만... Value class 는 다른 라이브러리들과 충돌이 다소 있다.
"나는 성능이 무조건적인 최우선 순위이며 발생하는 문제점들을 해결할 수 있는 일정이 있다" 가 아니라면 신중한 선택이 필요하다.
데이터 클래스(Data class)란 무엇인가요?
Kotlin의 데이터 클래스는 주로 데이터 보관에 특화된 클래스를 만들 때 사용됩니다. 일반 클래스와 달리 일부 기본 메서드(equals, hashCode, toString 등)가 자동으로 생성됩니다. 즉, 우리가 데이터를 주고받을 때 데이터 클래스를 사용하면 좀 더 간결하고 읽기 좋은 코드를 작성할 수 있습니다. 이러한 점은 정적 타이핑 언어에서 더욱 빛을 발할수 있습니다.
밸리데이션(Validation)이란?
밸리데이션은 데이터가 우리가 원하는 조건을 만족하는지를 확인하는 과정입니다. 사용자로부터 입력받은 데이터가 올바른 형식인지, 범위를 벗어나지 않는지 등을 확인하여 잘못된 데이터가 시스템에 들어가지 않도록 막아줍니다.
데이터 클래스와 밸리데이션 구현하기
본격적으로 데이터를 검증하는 예제를 살펴보겠습니다. 우리는 사용자의 이메일, 이름, 나이를 밸리데이션하는 간단한 예제를 만들어보겠습니다.
데이터 클래스 정의하기
먼저 필요한 데이터 클래스를 정의합니다.
data class UserCreationRequestDto(
val email: String,
val name: String,
val age: Int
)
data class UserCreationRequest(
val email: UserEmail,
val name: UserName,
val age: PositiveOrZeroInt,
)
필드 타입 감싸기
특정 필드 값에 대한 밸리데이션을 추가하려면 어떻게 해야 할까요?
여기에서는 유저의 이메일, 이름, 나이 각각에 대해 별도의 클래스를 만들어보겠습니다.
data class UserEmail(val value: String) {
fun validate(): Boolean {
return innerValidate(this.value)
}
companion object {
private val USER_EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()
const val INVALID_MESSAGE = "UserEmail must has valid format"
private fun innerValidate(value: String): Boolean {
return USER_EMAIL_REGEX.matches(value)
}
}
}
data class UserName(val value: String) {
fun validate(): Boolean {
return innerValidate(this.value)
}
companion object {
private val USER_NAME_REGEX = "^[A-Za-z]{2,}$".toRegex()
const val INVALID_MESSAGE = "UserName must has valid format"
fun innerValidate(value: String): Boolean {
return USER_NAME_REGEX.matches(value)
}
}
override fun toString(): String {
return "UserName(value=<hidden>)"
}
}
data class PositiveOrZeroInt(val value: Int) {
fun validate(): Boolean {
return innerValidate(this.value)
}
companion object {
const val INVALID_MESSAGE = "PositiveOrZeroInt must be positive or zero"
fun innerValidate(value: Int): Boolean {
return value >= 0
}
}
}
데이터 클래스 변환
클라이언트에서 받은 데이터를 서비스 레이어로 보내기 전에 정의한 Mapper 를 통해서 변경하는 로직을 구현 합니다.
fun UserCreationRequestDto.toModel() = validateModelOf {
UserCreationRequest(
email = UserEmail(this.email),
name = UserName(this.name),
age = PositiveOrZeroInt(this.age)
)
}
fun Int.toUserId() = validateTypedOf {
UserId(this)
}
데이터 클래스의 밸리데이션 함수
데이터 클래스 혹은 타입에 대해서 각 필드들을 검증하는 공용 로직을 구현 합니다.
object ModelValidator {
fun validateModel(model: Any): ValidationResult {
val result = ValidationResult()
model::class.declaredMemberProperties.forEach { property ->
validateFieldOfCustomTyped(property.name, property.getter.call(model), result)
}
return result
}
fun validateTyped(type: Any): ValidationResult {
val result = ValidationResult()
validateFieldOfCustomTyped(type::class.java.simpleName, type, result)
return result
}
private fun validateFieldOfCustomTyped(fieldName: String, field: Any?, result: ValidationResult) {
when (field) {
is PositiveOrZeroInt -> {
if (!field.validate()) {
result.addError(fieldName, "${PositiveOrZeroInt.INVALID_MESSAGE}: ${field.value}")
}
}
is UserEmail -> {
if (!field.validate()) {
result.addError(fieldName, "${UserEmail.INVALID_MESSAGE}: ${field.value}")
}
}
is UserName -> {
if (!field.validate()) {
result.addError(fieldName, "${UserName.INVALID_MESSAGE}: ${field.value}")
}
}
is UserId -> {
if (!field.validate()) {
result.addError(fieldName, "${UserId.INVALID_MESSAGE}: ${field.value}")
}
}
}
}
}
fun <T> validateModelOf(function: () -> T): T {
return function.invoke().also {
ModelValidator.validateModel(it as Any).throwIfError()
}
}
fun <T> validateTypedOf(function: () -> T): T {
return function.invoke().also {
ModelValidator.validateTyped(it as Any).throwIfError()
}
}
검증 결과 처리하기
밸리데이션 결과를 처리하는데 필요한 클래스를 살펴보겠습니다.
data class ValidationResult(
private val _errors: MutableList<FieldValidation> = mutableListOf()
) {
data class FieldValidation(
val field: String,
val message: String
)
val errors get() = _errors.toList()
val isValid: Boolean
get() = errors.isEmpty()
val hasError: Boolean
get() = errors.isNotEmpty()
fun addError(field: String, message: String) {
_errors.add(
FieldValidation(field, message)
)
}
fun throwIfError() {
if (hasError) {
throw IllegalArgumentException("Invalid request: $this")
}
}
}
Exception은 서버의 추가적인 비용을 발생시키며 APM 등 모니터링 연동시 효과적인 구성을 하는데 방해한다.
에러 레이즈를 해도 되지만 레이즈 하지 않은채로 프론트 개발자와 협의를 통해 응답에만 포함하여 주도록 구현할 수도 있을 것이다.
Controller - Service - Repository
이제 유효성 검사가 구현된 코드를 각 레이어에서 사용해보겠습니다.
@RestController
@RequestMapping("/users")
class UserController(
private val userService: UserService,
) {
@PostMapping("")
fun createUser(
@RequestBody requestDto: UserCreationRequestDto,
): ResponseEntity<UserEntity> {
return userService.createUser(
requestDto.toModel()
).let {
ResponseEntity.ok(it)
}
}
@GetMapping("/{userId}")
fun getUserById(
@PathVariable userId: Int
): ResponseEntity<UserEntity> {
return userService.getUser(
userId.toUserId()
).let {
ResponseEntity.ok(it)
}
}
}
@Service
class UserService(
private val userRepository: UserRepository,
) {
fun createUser(request: UserCreationRequest): UserEntity {
return UserEntity(
id = 1,
email = request.email.value,
name = request.name.value,
age = request.age.value,
)
}
fun getUser(userId: UserId): UserEntity? {
return userRepository.findById(userId.value)
?: throw IllegalArgumentException("User not found")
}
fun getUserByUserName(userName: UserName): UserEntity? {
return userRepository.findByUserName(userName.value)
?: throw IllegalArgumentException("User not found")
}
}
@Component
class UserRepository {
private val usersOnMemory by lazy {
listOf(
UserEntity(
id = 1,
email = "abc@dev.com",
name = "abc",
age = 10
),
UserEntity(
id = 2,
email = "bbb@dev.com",
name = "bbb",
age = 20
)
)
}
fun findById(id: Int): UserEntity? {
return usersOnMemory.find { it.id == id }
}
fun findByUserName(name: String): UserEntity? {
return usersOnMemory.find { it.name == name }
}
}
결론
지금까지 Kotlin의 데이터 클래스를 통해서 밸리데이션을 어떻게 구현할 수 있는지 살펴보았습니다.
데이터 클래스는 간결하고 읽기 쉬운 코드를 작성하게 해주며, 별도의 Wrapping 클래스를 통하여 유효성 검사를 쉽게 추가할 수 있습니다.
이 포스트를 통해 데이터를 유효성 검사를 하고 잘못된 데이터가 시스템에 유입되는 것을 방지하는 방법에 대해 더욱 이해하시길 바랍니다. 읽어주셔서 감사합니다!
이상 코드는 아래 주소에서 확인하실수 있습니다.
https://github.com/sanggi-wjg/kotlin-spring-boot-demos/tree/main/vo-validation
'Kotlin & Java > Spring' 카테고리의 다른 글
Pessimistic Locking in JPA (0) | 2024.02.06 |
---|