본문 바로가기
작디 작은 나만의 라이브러리/Persistence-Code-Generator

[신입 개발자의 세 번째 라이브러리] 타입 안전한 Enum 변환과 확장성 - persistence-code-generator 개발기(3)

by 시니성 2024. 12. 6.

들어가며

이번 편에서는 persistence-code-generator를 작성하며, 기본 기능 외에도 디테일한 표현이 가능하도록 신경을 쓴 부분인 ValuedEnum 처리와 컨버터 등록 어노테이션인 @WithConverter에 대해 다루어 보겠습니다.

ValuedEnum: 값을 가진 열거형

먼저 ValuedEnum의 개념과 그 필요성부터 이해해보겠습니다.
ValuedEnum은 실제 데이터베이스에 저장될 값을 가지는 형태의 enum입니다.


저는 키오스크나 포스와 같은 클라이언트 솔루션을 개발하는 개발자로서, 결제 데이터를 취합하고, 통계 데이터를 보여주는 서버(이하 '집계 서버')와의 API 통신 코드를 짤 때가 많았는데요.

이 때 ValuedEnum의 유용함을 느꼈습니다.

왜냐하면, 집계 서버는 단일 문자열 값으로 구분된 일명 "Flag"처리 된 값을 많이 사용하는 구조를 가지고 있었기 때문입니다. 그래서 매번 어떤 문자열이 어떤 의미를 가지는지 (예를 들어, POS의 형태가 "1"이면 선불형 "2"면 후불형) 찾아보고 매핑하기 위해 한정된 자원인 '집중력'을 낭비했어야 했습니다. 이런 문제를 해결하고자 값과 의미를 한번에 표기할 수 있게 고안된 열거형이 바로 ValuedEnum 입니다.

(ValuedEnum 자체를 제가 고안한 건 아니고, 사수분께서 만든 사내 라이브러리에 이미 존재하던 인터페이스였습니다.)

interface ValuedEnumClass<T> {
    val value: T
}

enum class UserRole(override val value: String) : ValuedEnumClass<String> {
    ADMIN("A"),
    USER("U")
}

이러한 ValuedEnum을 보일러 플레이트 코드 작성 없이 유연하게 처리하기 위해서는 다음과 같은 도전 과제들이 있었습니다:

  • 데이터베이스 값과 enum 간의 양방향 변환 컨버터 자동 생성
  • 컴파일 타임 타입 체크

컨버터 자동 생성 로직

ValuedEnumKtormConverterGeneratorProcessor는 이러한 도전 과제들을 해결하기 위한 핵심 컴포넌트입니다.

class ValuedEnumKtormConverterGeneratorProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        val properties = resolver.getSymbolsWithAnnotation(ValuedEnum::class.qualifiedName!!)
            .filterIsInstance<KSPropertyDeclaration>()
            .filter { it.isSubClassOf(ValuedEnumClass::class) }
            .distinctBy { it.type.resolve().toClassName() }

아래는 데이터 베이스 값과 enum을 전환하기 위한 컨버터를 자동으로 생성하는 로직입니다.

private fun generateValuedEnumConverter(property: KSPropertyDeclaration) {
    val enumClassName = property.type.resolve().toClassName()
    val converterObject = TypeSpec.objectBuilder(converterName)
        .superclass(ClassName("org.ktorm.schema", "SqlType")
        .parameterizedBy(enumClassName))

    val setParameterFun = FunSpec.builder("doSetParameter")
        .addModifiers(KModifier.OVERRIDE)
        .addParameter("ps", ClassName("java.sql", "PreparedStatement"))
        .addParameter("index", Int::class)
        .addParameter("parameter", enumClassName)
        .addStatement("ps.${column.type.toResultSetSetterFunctionName()}(index, parameter.value)")

타입 안전성 확보

컴파일 타임 검증

fun KSPropertyDeclaration.validPropertyTypeToColumnType() {
    val propertyType = this.type.resolve().unwrapTypeAlias()
    val valuedEnumClassType = propertyDeclaration.superTypes
        .map { it.resolve() }
        .find { it.declaration.qualifiedName?.asString() == 
            "org.imtsoft.core.types.base.ValuedEnumClass" }

    val valuedEnumTypeParameterType = valuedEnumClassType?.arguments
        ?.firstOrNull()?.type?.resolve()
        ?: throw IllegalArgumentException(
            "ValuedEnumClass must have a type parameter"
        )

    if (valuedEnumTypeParameterType.toClassName() != dbType.toKotlinType()) {
        throw IllegalArgumentException(
            "The value type $valuedEnumClassType is not compatible " +
            "with the column DB type $dbType"
        )
    }
}

유연한 컨버터 시스템

그 외에 데이터 베이스에 들어갈 값과 도메인 엔티티에서의 프로퍼티 타입이 다른 경우를 위한 커스텀 컨버터를 등록하는 어노테이션입니다.

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class WithConverter(
    val converter: KClass<out SqlType<*>>
)

실 사용례.

data class User(
    //... 기타 프로퍼티 ...
    @Column("preferences")
    @WithConverter(CustomJsonConverter::class)
    @DbString
    val preferences: UserPreferences
    //... 기타 프로퍼티 ...
)

사용 예시

도메인 모델 예시

@EntityDefinition(tableName = "orders")
data class Order(
    @PrimaryKey
    @Column("id")
    val id: Long,

    @ValuedEnum
    @DbString(limit = 1)
    val status: OrderStatus,

    @ValuedEnum
    @DbString(limit = 2)
    val paymentMethod: PaymentMethod
)

enum class OrderStatus(override val value: String) : ValuedEnumClass<String> {
    PENDING("P"),
    CONFIRMED("C"),
    SHIPPED("S"),
    DELIVERED("D")
}

생성된 컨버터 활용

// 생성된 KTORM 테이블 정의
object Orders_ : Table<Order_>("orders") {
    val status = registerColumn("status", OrderStatusConverter_)
    val paymentMethod = registerColumn("payment_method", PaymentMethodConverter_)
}

마치며

ValuedEnum 처리와 타입 안전성 확보는 persistence-code-generator의 디테일한 기능 중 하나입니다.
이를 통해 데이터 베이스에는 의미 있는 단순 값(일종의 코드)를 저장하더라도 도메인 레이어에서는 강력한 타입 안정성과 의미를 가지는 열거형으로 데이터를 다룰 수 있게 해줍니다.

원래는 다음 편을 마지막으로 이 라이브러리의 실제 프로젝트 적용 사례와 그 과정에서 얻은 교훈들을 공유하려고 하였는데요!
계획을 변경해 제가 이 라이브러리를 구현하면서 많이 고생했던, @WithConverter 구현에 대해 다루려고 합니다.
컴파일 시점에서는 클래스 리터럴을 참조하여 클래스의 메타데이터를 활용할 수 없기 때문에 코드 생성에 고생을 했던 부분이 있어, 이를 해결한 과정을 공유할 예정입니다.

 

그럼 다음편에서 봬요~~~

728x90