들어가며
이전 편에서 소개한 것처럼, persistence-code-generator는 도메인 엔티티로부터 필요한 영속성 관련 코드를 자동으로 생성합니다. 이번 편에서는 실제 구현의 핵심이 되는 어노테이션 설계와 코드 생성 로직을 상세히 다뤄보겠습니다.
어노테이션 설계와 구현
먼저 사용자 관점에서 직관적이고 명확한 API를 제공하기 위해 다음과 같은 어노테이션 체계를 설계했습니다:
엔티티 레벨 어노테이션
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class EntityDefinition(
val tableName: String = "",
val ifNotExists: Boolean = true
)
컬럼 타입 어노테이션
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Integer(
val hasDefaultValue: Boolean = false,
val defaultValue: Int = 0
)
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class DbString(
val hasDefaultValue: Boolean = false,
val defaultValue: String = "",
val limit: Int = 0
)
// ... 기타 타입들
제약조건 어노테이션
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class PrimaryKey
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Unique
이러한 어노테이션들은 도메인 모델의 의도를 명확하게 표현하면서도, 필요한 메타데이터를 모두 캡처할 수 있도록 설계했습니다.
KTORM 엔티티/테이블 생성 로직
KTORM 코드 생성은 KtormEntityGeneratorProcessor와 KtormSchemeGeneratorProcessor가 담당합니다.
엔티티 생성
private fun generateKtormEntityCode(entity: KSClassDeclaration) {
val ktormEntityInterface = TypeSpec.interfaceBuilder(generatedEntityName)
.addSuperinterface(
ClassName(
"org.ktorm.entity",
"Entity"
).parameterizedBy(TypeVariableName(generatedEntityName))
)
// ... 프로퍼티 생성 로직
.addType(
TypeSpec.companionObjectBuilder()
.superclass(
ClassName("org.ktorm.entity", "Entity", "Factory")
.parameterizedBy(TypeVariableName(generatedEntityName))
)
.build()
)
}
테이블 스키마 생성
private fun generateKtormSchemeCode(entity: KSClassDeclaration) {
val schemeObject = TypeSpec.objectBuilder(generatedSchemeName)
.superclass(
ClassName("org.ktorm.schema", "Table")
.parameterizedBy(TypeVariableName(entity.simpleName.toGeneratedKtormEntityName()))
)
.addSuperclassConstructorParameter("%S", entity.tableName)
// ... 컬럼 정의 로직
}
DDL-DSL 생성 로직
DDL 생성은 TableGeneratorProcessor가 담당하며, 제가 제작한 또 다른 사내 라이브러리인 ddl-dsl의 문법에 맞는 코드를 생성합니다.
private fun generateTableCreationCode(classDeclaration: KSClassDeclaration) {
val propertySpec = PropertySpec.builder(generatedName, TableDefinition::class)
.initializer(
CodeBlock.builder()
.beginControlFlow("TableDefinition.create(%S)", tableName)
.apply {
columns.forEach { column ->
add(
"column(%S, %L)",
column.name,
column.type.toDdlDslColumnTypeCode()
)
// ... 컬럼 속성 추가
}
}
.endControlFlow()
.build()
)
}
코드 생성시 고려해야 할 점들
네이밍 전략
fun EntityClassName.toGeneratedKtormEntityName(): String = "${this.asString()}_"
fun EntityClassName.toGeneratedKtormSchemaName(): String = "${this.asString()}s_"
fun EntityClassName.toGeneratedDdlName(): String = "${this.asString().replaceFirstChar { it.lowercase() }}Ddl_"
생성된 코드의 네이밍이 기존 코드베이스와 충돌하지 않도록 접미사를 사용하고, 일관된 네이밍 컨벤션을 유지했습니다.
타입 안전성
fun KSPropertyDeclaration.validPropertyTypeToColumnType() {
val propertyType = this.type.resolve().unwrapTypeAlias()
val dbType = this.columnType
if (propertyType.toClassName() != dbType.toKotlinType()) {
throw IllegalArgumentException(
"Property type $propertyType is not compatible with column type $dbType"
)
}
}
컴파일 타임에 타입 불일치를 감지하여 런타임 에러를 방지합니다.
에러 처리
try {
entities.forEach { generateKtormEntityCode(it) }
logger.info("Processed ${entities.size} entities")
} catch (e: Exception) {
logger.error("Error: ${e.message}")
throw Exception("Error in ${currentProcessor}: ${e.message}")
}
명확한 에러 메시지를 제공하여, DB데이터 타입과 코틀린 데이터 타입등을 일치하지 않게 코드를 작성하는 등의 실수를 디버깅하기 용이하게 합니다.
기타 고려 사항
추가로 @ValuedEnum등을 사용하여 자동으로 컨버터를 만들어 줄 때에는 중복 코드 생성을 방지하도록 하였습니다.
마치며
코드 생성 라이브러리를 만들면서 가장 중요하게 생각한 것은 "개발자 경험"이었습니다.
- 어노테이션만으로도 의도를 명확하게 표현할 수 있어야 한다.
- 중복된 보일러 플레이트 코드 작성에 드는 노력을 현저히 낮추어 줄 수 있어야 한다.
- 도메인 엔티티에 맞게 칼럼을 잘 만들었는지, 데이터 타입은 잘 맞추었는지 등의 메타 정보에 집중력을 뺐기지 않고 비즈니스 로직에 집중할 수 있어야 한다.
이렇게 세 가지 포인트를 생각하며 라이브러리를 구상하고 제작했습니다.
다음 편에서는 이런 포인트들을 더 잘 준수하기 위해 ValuedEnum 처리와 같은 디테일한 부분들을 어떻게 해결했는지 다루도록 하겠습니다.
이번에도 긴 글 읽어주셔서 감사합니다.