아이디 검증 로직 By - kotlin value class

박태현·2025년 6월 13일
0

예약 프로젝트

목록 보기
3/8

회원가입 시 사용자의 아이디의 유효성을 확인하기 위한 로직


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 값 자체가 직접 전달되거나 사용됩니다.

  • 목적: 불필요한 객체 래퍼 생성을 피하여 힙 메모리 할당을 줄이고, GC 부하를 감소시켜 성능을 향상시키는 것이 주된 목적입니다. 특히 작은 값 타입( ID, 이메일 주소.. )을 타입 안전하게 래핑하면서도 원시 타입과 유사한 성능을 얻고 싶을 때 유용합니다.

value class는 @JvmInline 어노테이션과 함께 사용해야만 함

[ 예상 동작 과정 ]

적어도 하나 이상의 특수 문자와 대문자, 숫자가 포함되도록 하되, 이러한 검증 전에 만약 공백이나 하이픈이 있다면 이를 제거하여 검증하도록 합니다.

  1. 클라이언트로부터 요청이 들어오면, data class의 필드인 val username: CheckUsername에 의해 해당 문자열이 CheckUsername 타입으로 역직렬화

  2. 이 과정에서 CheckUsername 클래스 내부의 @JsonCreator가 붙은 removeSpace() 함수가 호출되며, 입력 문자열을 인자로 받아 공백과 하이픈을 제거한 인자를 담아 CheckUsername(...)가 반환됩니다.

  3. 이 호출은 생성자를 통해 객체가 생성되며, 이때 init 블록이 실행되어 validateUsername() 메서드를 통해 사용자 아이디에 대한 유효성 검사가 수행되게 됩니다.

객체를 생성할 때 생성자로 생성할 시 removeSpace 검증을 거치지 않게 되므로 생성자를 private으로 변경하고, invoke를 오버로딩하여 객체를 생성할 수 있도록 하였습니다.

하지만, 이러한 동작 과정을 거칠 때, removeSpace() 함수가 작동하지 않는 문제가 발생

요청이 들어올 때, @JsonCreator 어노테이션을 사용한 함수로 데이터를 역직렬화하려고 했지만, 예상대로 작동하지 않았습니다.. ..

왜 그럴까 ??

Kotlin의 value class와 Jackson 라이브러리가 역직렬화를 처리하는 방식 때문입니다 !

CheckUsernameString을 래핑하는 value class이며, Jackson은 기본적으로 value class를 역직렬화할 때, 래핑된 기본 타입인 String으로 직접 역직렬화하려는 경향이 있는데, 이 경우 CheckUsername 내부에 정의된 @JsonCreator 함수를 건너뛰고 JSON 문자열을 CheckUsernameusername 필드에 바로 주입해버리기 때문

// 부가 설명
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 내에 관련된 확장 함수들을 모아두면 코드의 명확성을 높이고 재사용성을 높이며, 특히 공유 자원 관리에 큰 이점을 얻을 수 있습니다.

profile
꾸준하게

0개의 댓글