들어가며
이전 편에서는 ValuedEnum 처리와 타입 안전성 확보에 대해 다뤘습니다.
이번 편에서는 @WithConverter 구현 과정에서 마주친 문제와 그 해결 방법을 공유하고자 합니다.
@WithConverter(converter: Kclass)를 통해 받은 클래스 리터럴을 통해 메타데이터에 접근할 수 없는 문제
@WithConverter는 다음과 같이 구현된 간단한 어노테이션입니다:
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class WithConverter(
val converter: KClass<out SqlType<*>>
)
하지만 이 간단한 어노테이션의 구현 과정에서 한 가지 어려운 점에 직면했습니다.
컴파일 타임 클래스 참조의 한계
바로, 컴파일 타임에 클래스 리터럴을 통해 클래스의 메타데이터에 접근할 수 없다는 것이었습니다. 예를 들어:
@Column("created_at")
@WithConverter(LocalDateTimeConverter::class)
val createdAt: LocalDateTime
이러한 코드에서 KSP는 컴파일 타임에 LocalDateTimeConverter 클래스의 실제 구현에 접근할 수 없습니다.
코드를 생성할 때 올바른 import를 위해 FQCN이 필요한데, 클래스 리터럴을 통해 클래스의 메타데이터에 접근하는 것은 런타임에 가능하기 때문입니다.
이 부분을 KSP와 클래스 리터럴의 동작 원리를 통해 자세히 설명 해보겠습니다.
KSP와 클래스 리터럴의 동작 원리
1. 클래스 리터럴의 동작 원리
@WithConverter(LocalDateTimeConverter::class) // 클래스 리터럴
- 클래스 리터럴(::class)은 실제로는 해당 클래스의 KClass 인스턴스를 반환합니다
- KClass는 JVM 클래스 파일이 로드된 후에야 접근할 수 있는 리플렉션 API입니다
- 따라서 qualifiedName, methods, properties 등의 메타데이터는 클래스가 로드된 런타임에만 접근이 가능합니다
2. KSP의 동작 원리
- KSP는 컴파일 과정 중 소스 코드 파싱 직후, 실제 컴파일 이전에 동작합니다
- 이 시점에는:
- 소스 코드가 AST(Abstract Syntax Tree)로 파싱된 상태입니다
- 아직 클래스 파일이 생성되지 않았습니다
- JVM 클래스 로딩도 일어나지 않은 상태입니다
3. 시점의 차이
// 소스코드
@WithConverter(LocalDateTimeConverter::class)
val createdAt: LocalDateTime
// KSP 처리 시점: 컴파일 전
- AST에서 WithConverter 어노테이션을 발견
- LocalDateTimeConverter::class는 단순히 "참조"일 뿐
- 실제 KClass 인스턴스나 메타데이터는 아직 존재하지 않음
// 런타임
- LocalDateTimeConverter 클래스가 로드됨
- KClass 인스턴스가 생성됨
- 이때 비로소 메타데이터 접근 가능
이것이 바로 KSP가 클래스 리터럴을 통해 전달된 클래스의 메타데이터에 접근할 수 없는 근본적인 이유입니다.
KSP는 컴파일 전에 동작하지만, 클래스 리터럴의 메타데이터는 런타임에만 존재하기 때문입니다..
해결 방법: Exception message의 Qualified Name을 활용
이 문제를 해결하기 위해 다음과 같은 접근 방식을 채택했습니다:
@OptIn(KspExperimental::class)
private val WithConverter.qualifiedName
get() = try {
this.converter.qualifiedName!!
} catch (e: ClassNotFoundException) {
e.message ?: throw e
} catch (e: KSTypeNotPresentException) {
val cause = e.cause
if (cause is ClassNotFoundException) {
cause.message ?: throw e
} else {
throw e
}
}
이 코드는 클래스의 Qualified Name을 추출하여 사용합니다. 실제 클래스 구현에 접근하는 대신, 예외에 접근해 클래스의 전체 경로를 문자열로 얻어 이를 코드 생성에 활용하는 방식입니다.
KTORM 테이블 스키마 생성에서의 활용
컨버터를 활용한 테이블 스키마 생성은 다음과 같이 구현했습니다:
private fun generateKtormSchemeCode(entity: KSClassDeclaration) {
columns.forEach { column ->
val propertySpec = PropertySpec.builder(
column.propertyName,
ClassName("org.ktorm.schema", "Column")
.parameterizedBy(column.propertyTypeClassName)
).apply {
when {
column.isConvertedColumn -> {
initializer(
"registerColumn(%S, %T)"
+ (if (column.isPrimaryKey) ".primaryKey()" else "")
+ "\n.bindTo { it.${column.propertyName} }",
column.name,
column.converterQualifiedName?.toClassName()
?: throw IllegalArgumentException(
"ConverterQualifiedName is null. " +
"Converted column must have converterQualifiedName"
)
)
}
// 다른 케이스 처리...
}
}.build()
schemeObject.addProperty(propertySpec)
}
}
실제 사용 예시
커스텀 JSON 컨버터 예시
data class UserPreferences(
val theme: String,
val notifications: Boolean
)
object UserPreferencesConverter : SqlType<UserPreferences>(Types.VARCHAR, "json") {
private val objectMapper = ObjectMapper()
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: UserPreferences) {
ps.setString(index, objectMapper.writeValueAsString(parameter))
}
override fun doGetResult(rs: ResultSet, index: Int): UserPreferences? {
return rs.getString(index)?.let {
objectMapper.readValue(it, UserPreferences::class.java)
}
}
}
@EntityDefinition(tableName = "users")
data class User(
@PrimaryKey
@Column("id")
val id: Long,
@Column("preferences")
@WithConverter(UserPreferencesConverter::class)
@DbString
val preferences: UserPreferences
)
학습한 점들
- 컴파일 타임의 제약 이해
- 컴파일 타임에서 클래스 메타데이터 접근의 한계를 이해하고 대안을 찾는 것이 중요했습니다.
- Qualified Name을 활용한 간접적인 접근 방식이 유용했습니다.
- 확장성 있는 설계
- 제네릭을 활용한 유연한 컨버터 시스템 설계가 가능했습니다.
- 새로운 타입의 컨버터를 쉽게 추가할 수 있는 구조를 만들었습니다.
마치며
@WithConverter 구현을 통해 컴파일 타임 코드 생성의 한계와 그 해결 방법을 배울 수 있었습니다.
다음 글에서는 '실전 적용과 회고' 를 마지막으로 persistence-code-generator 라이브러리 제작기를 마치도록 하겠습니다!