들어가며
소프트웨어 개발에서 보일러플레이트 코드는 필연적으로 발생합니다.
특히 영속성 계층을 다룰 때는 더욱 그렇습니다.
DDL 문의 작성을 더 쉽게 하기 위해 DDL-DSL 이라는 플루언트 DSL 라이브러리를 만들었지만, 여전히 많은 보일러 플레이트 코드가 발생하였습니다.
(DDL - DSL은 제가 만든 두 번째 라이브러리로, 아래 링크에서 제작기를 확인하실 수 있습니다.)
[신입 개발자의 두 번째 라이브러리 개발기] 도메인 특화 언어(DSL)을 만들어 보자! DDL-DSL 개발기 -
개발 배경이번에 저희 회사에서 멀티플랫폼을 지원하는 POS를 개발하게 되었는데요.IOS를 제외한 Android와 Windows 플랫폼을 지원하는게 저희의 목표입니다.저는 이번 프로젝트 에서 아키텍쳐 설계
shin-e-dog.tistory.com
도메인 엔티티 하나를 정의할 때마다 ORM 엔티티, 테이블 정의, DDL SQL 문 등 여러 계층의 반복적인 코드를 작성해야 하죠.
또 도메인 엔티티의 프로퍼티 하나가 추가되거나 삭제되는 등 수정될 때마다, 여러 계층의 코드를 수정해 주어야 합니다.
이러한 반복 작업은 개발자의 생산성을 저하시킬 뿐만 아니라, 휴먼 에러의 여지도 많이 남깁니다.
이 글에서는 Kotlin Symbol Processing(KSP)을 활용해 이러한 보일러플레이트와 어떻게 싸웠는지,
persistence-code-generator의 개발 여정을 공유하고자 합니다.
개발 배경
모든 개발자들이 공감하시겠지만, 앞서 말씀드렸듯이 반복적인 보일러플레이트 코드 작성은 개발 생산성을 저하시키는 주요 요인 중 하나입니다.
우리 팀은 이번 멀티플랫폼 POS 프로젝트에서 KTORM을 사용해 데이터베이스 계층을 구현하기로 결정했습니다.
또, 이번 프로젝트의 설계를 제가 도맡게 되면서 도메인 주도 설계(DDD) 원칙을 적용하다 보니, 다음과 같은 패턴이 반복되었습니다:
- 도메인 엔티티 정의
- KTORM 영속성 엔티티 생성
- KTORM 테이블 스키마 정의
- DDL 생성을 위한 정의
예를 들어, 간단한 User 엔티티 하나를 추가하는데도 다음과 같은 코드들을 모두 작성해야 했습니다:
// 1. 도메인 엔티티
data class User(
val id: Long,
val name: String,
val email: String
)
// 2. KTORM 엔티티
interface UserEntity : Entity<UserEntity> {
companion object : Entity.Factory<UserEntity>()
var id: Long
var name: String
var email: String
}
// 3. KTORM 테이블 스키마
object Users : Table<UserEntity>("users") {
val id = long("id").primaryKey()
val name = varchar("name")
val email = varchar("email")
}
// 4. DDL 정의
val userTable = TableDefinition.create("users") {
column("id", ColumnType.BigInt) {
primaryKey = true
}
column("name", ColumnType.String(255))
column("email", ColumnType.String(255))
}
이러한 반복 작업은 시간 소모적일 뿐만 아니라, 다음과 같은 문제들을 야기했습니다:
- 실수로 인한 불일치 발생 가능성
- 도메인 모델 변경 시 여러 파일 수정 필요
- 개발자 피로도 상승
저는 이러한 반복 작업을 자동화하고 저희 팀의 개발 생산성을 향상시키기 위해 KSP를 활용하여 Persistence Code Generator 라이브러리를 개발하게 되었습니다.
KSP(Kotlin Symbol Processing)란?
KSP의 개념
KSP(Kotlin Symbol Processing)는 Kotlin 전용 코드 생성 도구입니다. KAPT(Kotlin Annotation Processing Tool)의 후속작으로, Kotlin의 특성을 더 잘 활용할 수 있도록 설계되었습니다.
KSP 선택 이유.
- 컴파일 시점 처리: 코드 생성이 컴파일 시점에 이루어져 런타임 오버헤드가 없습니다. 처음에 고려된 리플렉션을 활용한 방식에서 KSP를 선택한 가장 큰 이유였습니다.
- Kotlin 친화적: Kotlin의 특징(널 안정성, 확장 함수, 타입 별칭 등)을 완벽히 지원합니다.
- 빠른 처리 속도: KAPT 대비 2배 이상 빠른 처리 속도를 보입니다.
KSP의 동작 방식
KSP는 다음과 같은 순서로 동작합니다:
- 심볼 수집: 소스 코드에서 클래스, 함수, 프로퍼티 등의 심볼 정보를 수집
- 어노테이션 처리: 특정 어노테이션이 붙은 요소들을 찾아 처리
- 코드 생성: 수집된 정보를 바탕으로 새로운 코드를 생성
예를 들어, 우리 라이브러리에서는 다음과 같은 처리가 이루어집니다:
class TableGeneratorProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
// @EntityDefinition 어노테이션이 붙은 클래스들을 찾음
val entities = resolver.getSymbolsWithAnnotation(
EntityDefinition::class.qualifiedName!!
).filterIsInstance<KSClassDeclaration>()
// 각 엔티티에 대해 테이블 정의 코드를 생성
entities.forEach { generateTableCreationCode(it) }
return emptyList()
}
}
핵심 기능
1. 어노테이션 기반 코드 생성
라이브러리의 핵심은 도메인 엔티티에 어노테이션을 추가하면 KSP(Kotlin Symbol Processing)를 통해 필요한 코드를 자동으로 생성하는 것입니다.
예를 들어, 다음과 같은 도메인 엔티티가 있다고 가정해보겠습니다:
@EntityDefinition(tableName = "users")
data class User(
@Column
@PrimaryKey
val id: Long,
@Column
@DbString(limit = 100)
val name: String,
@Column
@ValuedEnum
@DbString(limit = 1)
val role: UserRole
)
2. ValuedEnum 지원
특별히 주목할 만한 기능 중 하나는 ValuedEnum의 자동 변환입니다. 다음과 같은 값을 가지는 열거형을 정의했다고 가정해보겠습니다:
enum class UserRole(override val value: String) : ValuedEnumClass<String> {
ADMIN("A"),
USER("U")
}
@ValuedEnum
과 @DbString
어노테이션을 함께 사용하면, 라이브러리는 자동으로 적절한 컨버터를 생성합니다.
3. 생성되는 코드 예시
라이브러리는 다음과 같은 코드들을 자동으로 생성합니다:
// KTORM 엔티티
interface User_ : Entity<User_> {
companion object : Entity.Factory<User_>()
var id: Long
var name: String
var role: UserRole
}
// KTORM 테이블 스키마
object Users_ : Table<User_>("users") {
val id = long("id").primaryKey()
val name = varchar("name")
val role = registerColumn("role", UserRoleConverter_)
}
// DDL 정의
val userDdl_ = TableDefinition.create("users") {
val id = column("id", ColumnType.BigInt)
column("name", ColumnType.String(100))
column("role", ColumnType.String(1))
key {
primaryKey(id)
}
}
// 컨버터
object UserRoleConverter_ : SqlType<UserRole>(Types.VARCHAR, "GenUserRoleValuedEnum") {
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: UserRole) {
ps.setString(index, parameter.value)
}
override fun doGetResult(rs: ResultSet, index: Int): UserRole? {
return fromValue<UserRole, String>(rs.getString(index))
}
}
도입 효과
이 라이브러리를 도입함으로써 다음과 같은 효과를 얻을 수 있었습니다:
- 보일러플레이트 코드 작성 시간 단축
- 휴먼 에러 감소
- 일관된 코드 스타일 유지
- 개발자가 비즈니스 로직에 더 집중할 수 있는 환경 조성
앞으로의 시리즈 구성
이어지는 포스팅에서는 다음과 같은 내용들을 다룰 예정입니다:
1편: "KSP로 보일러플레이트와 싸우기 - persistence-code-generator 개발기(1)"
2편: "도메인 엔티티에서 영속성 코드 생성하기 - persistence-code-generator 개발기(2)"
3편: "타입 안전한 Enum 변환과 확장성 - persistence-code-generator 개발기(3)"
4편: "실전 적용과 회고 - persistence-code-generator 개발기(4)"
각 포스팅에서는 구현 과정에서 마주친 기술적 도전과 해결 방법, 그리고 실제 코드를 상세히 다룰 예정입니다.
마치며
코드 생성기를 만드는 것은 초기에 상당한 투자가 필요한 작업이지만, 장기적으로 봤을 때 개발 생산성 향상에 크게 기여할 수 있습니다.
특히 Kotlin의 강력한 타입 시스템과 KSP를 활용하면, 타입 안전성을 보장하면서도 보일러플레이트를 효과적으로 제거할 수 있습니다.
다음 포스팅에서는 영속성 코드를 생성하는 부분의 구현을 집중적으로 다루어 보겠습니다.