package com.example.kotlin.member
@JvmInline
@JsonDeserialize(using = CheckUsernameDeserializer::class) // 커스텀 역직렬화 지정
value class CheckUsername private constructor (val username: String) {
companion object {
// 브라우저가 # 이후는 프래그먼트로 간주하여 서버에 전달하지 않음, 따라서 #은 허용하면 안됨
private val USERNAME_REGEX = Regex("^(?=.*[A-Z])(?=.*\\d)[A-Za-z\\d@*^]+$")
// 직접 CheckUsername 인스턴스를 생성할 때 사용할 수 있는 invoke 오버로딩
operator fun invoke(username: String): CheckUsername = CheckUsername(username)
}
init {
validateUsername(username)
}
private fun validateUsername(username: String) {
require(USERNAME_REGEX.matchEntire(username) != null) {
throw ReserveException(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_USERNAME)
}
}
}
// 커스텀 역직렬화 클래스 정의
class CheckUsernameDeserializer : JsonDeserializer<CheckUsername>(), Loggable {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): CheckUsername {
val username = p.valueAsString
log.info { "공백, 하이픈 제거" }
val cleanedUsername = username.removeSpacesAndHyphens()
return CheckUsername(cleanedUsername)
}
}
fun String.removeSpacesAndHyphens(): String {
if (this.contains(' ') || this.contains('-')) {
return this.replace("[\\s-]".toRegex(), "")
}
return this
}
/*
* companion object 내부에 invoke를 정의하면, 이 invoke 함수는 클래스 자체의 일부분으로 간주되어 private 생성자에 접근할 수 있음
* */
// 사용자 아이디 검증 로직 테스트
@PostMapping("/check/username")
fun checkUsername(@RequestBody request: CheckInfoUsername) {
println("유효한 아이디: ${request.username}")
}
...
data class CheckInfoUsername(val username: CheckUsername)
value class를 사용한 이유
value class는 단 하나의 불변 필드만을 가질 수 있고, JVM에서 컴파일 시 class를 벗겨내고 내부의 값으로 대체가 되기 때문에 primitive 타입의 값을 객체와 같이 다룰 수 있으며, 이를 통해 wrapper 클래스 사용 시의 오버헤드 문제를 해결할 수 있기 때문
인라인 작동 방식
CheckUsername("someString")
과 같은 value class
인스턴스는 JVM 바이트코드 수준에서 실제로는 String
타입처럼 다뤄질 수 있습니다. 즉, CheckUsername
객체가 생성되는 대신, 내부의 String
값 자체가 직접 전달되거나 사용됩니다.value class는 @JvmInline 어노테이션과 함께 사용해야만 함
[ 예상 동작 과정 ]
적어도 하나 이상의 특수 문자와 대문자, 숫자가 포함되도록 하되, 이러한 검증 전에 만약 공백이나 하이픈이 있다면 이를 제거하여 검증하도록 합니다.
객체를 생성할 때 생성자로 생성할 시 removeSpace 검증을 거치지 않게 되므로 생성자를 private으로 변경하고, invoke를 오버로딩하여 객체를 생성할 수 있도록 하였습니다.
하지만, 이러한 동작 과정을 거칠 때, removeSpace() 함수가 작동하지 않는 문제가 발생
요청이 들어올 때, @JsonCreator
어노테이션을 사용한 함수로 데이터를 역직렬화하려고 했지만, 예상대로 작동하지 않았습니다.. ..
왜 그럴까 ??
Kotlin의 value class
와 Jackson 라이브러리가 역직렬화를 처리하는 방식 때문입니다 !
CheckUsername
은 String
을 래핑하는 value class
이며, Jackson은 기본적으로 value class
를 역직렬화할 때, 래핑된 기본 타입인 String
으로 직접 역직렬화하려는 경향이 있는데, 이 경우 CheckUsername
내부에 정의된 @JsonCreator
함수를 건너뛰고 JSON 문자열을 CheckUsername
의 username
필드에 바로 주입해버리기 때문
// 부가 설명
Jackson은 value class를 만났을 때, "이건 그냥 String이나 다름없으니, JSON의 String을 그대로 얘한테 넣어주자"는
방식으로 최적화를 시도하려 합니다.
이 과정에서 개발자가 명시적으로 정의한 @JsonCreator 함수를 우회하여, JSON의 원본 문자열을 value class의 래핑된
기본 타입 필드에 바로 대입(또는 주 생성자로 직접 전달)하려고 하기 때문에, @JsonCreator 내부의 가공 로직이 실행되지
않는다는 의미입니다.
하지만, 현재 생성자를 private로 설정해놨으므로 Jackson의 직접 주입 시도는 실패하게 되고, 이는 역직렬화 오류가 발생함
이를 해결하기 위해, 역직렬화 로직을 커스텀하여 사용해야 합니다.
// 의존성 주입
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
package com.example.kotlinPro.member
...
import com.fasterxml.jackson.databind.annotation.JsonDeserialize // 이 임포트가 중요
import io.github.oshai.kotlinlogging.KotlinLogging
private val log = KotlinLogging.logger {}
@JsonDeserialize(using = CheckUsernameDeserializer::class) // 커스텀 역직렬화 지정
@JvmInline
value class CheckUsername private constructor (val username: String) {
companion object {
private val USERNAME_REGEX = Regex("^(?=.*[A-Z])(?=.*\\d)(?=.*[@#%*^])[A-Za-z\\d@#%*^]+$")
// 직접 CheckUsername 인스턴스를 생성할 때 사용할 수 있는 invoke 오버로딩
operator fun invoke(username: String): CheckUsername = CheckUsername(username)
}
init {
validateUsername(username)
}
private fun validateUsername(username: String) {
require(USERNAME_REGEX.matchEntire(username) != null) {
"유효하지 않은 아이디 형식입니다."
}
}
}
// 커스텀 역직렬화 클래스 정의
class CheckUsernameDeserializer : JsonDeserializer<CheckUsername>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): CheckUsername {
val username = p.valueAsString
log.info { "공백, 하이픈 제거" }
val cleanedUsername = username.removeSpacesAndHyphens()
return CheckUsername(cleanedUsername)
}
}
fun String.removeSpacesAndHyphens(): String {
if (this.contains(' ') || this.contains('-')) {
return this.replace("[\\s-]".toRegex(), "")
}
return this
}
커스텀 역직렬화를 정의하여, 역직렬화를 할 때 커스텀한 CheckUsernameDeserializer
를 사용하도록 설정하여 검증을 거치기 전 공백과 하이픈을 제거하는 로직을 거치게 됩니다.
class ValidUsernameTest {
@ParameterizedTest
@ValueSource(strings = ["A1@abc", "T9%Test", "Z1^xyz", "@A1B2C"])
fun `유효한 사용자명은 정상 생성`(input: String) {
val result = CheckUsername(input)
println("성공: $result")
}
@ParameterizedTest
@ValueSource(strings = [
"A1abc", // 특수문자 없음
"a1@abc", // 대문자 없음
"ABC@def", // 숫자 없음
"A1\$abc", // 허용되지 않은 특수문자
"A1@ bc", // 공백 포함
"A1@-bc", // 하이픈 포함
"abc" // 전부 없음
])
fun `유효하지 않은 사용자명은 예외가 발생`(input: String) {
assertThrows<IllegalArgumentException> {
CheckUsername(input)
}
}
}
확장 함수
확장 함수
fun String.removeSpacesAndHyphens()
를 최상위에 정의하여 문자열의 공백과 하이픈을 제거하도록 구현 ( 같은 패키지 내의 파일이 아니면 import 하여 사용 )
cf ) 확장 함수를 사용할 때, 최상위에 정의하여 사용하는 것도 가능하지만, object
내에 관련된 확장 함수들을 모아두면 코드의 명확성을 높이고 재사용성을 높이며, 특히 공유 자원 관리에 큰 이점을 얻을 수 있습니다.