KSP — 코틀린 심볼 프로세싱으로 코드를 자동 생성하는 방법
"매번 비슷한 보일러플레이트 코드를 작성하고 있다면, 어노테이션 하나로 자동 생성할 수는 없을까요?"
KSP(Kotlin Symbol Processing)는 코틀린 코드의 심볼(클래스, 함수, 프로퍼티 등)을 분석하고, 컴파일 타임에 코드를 자동 생성하는 도구입니다. 기존의 KAPT(Kotlin Annotation Processing Tool)보다 빠르고, 코틀린의 타입 시스템을 더 잘 이해합니다.
KAPT의 한계와 KSP의 등장
KAPT의 동작 방식
코틀린 소스 → (kotlinc) → 자바 스텁 생성 → (javac APT) → 코드 생성 → 최종 컴파일
KAPT는 코틀린 코드를 먼저 자바 스텁으로 변환한 뒤, 자바의 APT(Annotation Processing Tool)를 실행합니다. 이 과정에서 두 가지 문제가 있습니다.
- 느림: 자바 스텁 생성에 전체 빌드 시간의 약 1/3이 소요됩니다
- 정보 손실: 코틀린의 null safety, 확장 함수, sealed class 등의 정보가 자바 스텁으로 변환되면서 손실됩니다
KSP의 동작 방식
코틀린 소스 → (KSP) → 코틀린 심볼 직접 분석 → 코드 생성 → 최종 컴파일
KSP는 자바 스텁을 거치지 않고 코틀린 심볼을 직접 분석합니다. 덕분에 빌드 속도가 KAPT 대비 최대 2배 이상 빠르며, 코틀린의 모든 정보에 접근할 수 있습니다.
첫 번째 KSP 프로세서 만들기
간단한 예제로 @AutoFactory 어노테이션이 붙은 클래스의 팩토리 메서드를 자동 생성하는 프로세서를 만들어보겠습니다.
1단계: 프로젝트 구조
my-ksp-project/
├── annotation/ ← 어노테이션 정의 모듈
│ └── src/main/kotlin/
│ └── AutoFactory.kt
├── processor/ ← KSP 프로세서 모듈
│ ├── build.gradle.kts
│ └── src/main/kotlin/
│ └── AutoFactoryProcessor.kt
└── app/ ← 사용자 모듈
└── build.gradle.kts
2단계: 어노테이션 정의
// annotation 모듈
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoFactory
3단계: 프로세서 의존성
// processor/build.gradle.kts
plugins {
kotlin("jvm")
}
dependencies {
implementation(project(":annotation"))
implementation("com.google.devtools.ksp:symbol-processing-api:1.9.0-1.0.13")
}
4단계: SymbolProcessor 구현
// processor 모듈
class AutoFactoryProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
// @AutoFactory가 붙은 클래스 찾기
val symbols = resolver.getSymbolsWithAnnotation(
AutoFactory::class.qualifiedName!!
)
val unprocessed = mutableListOf<KSAnnotated>()
symbols.filterIsInstance<KSClassDeclaration>().forEach { classDecl ->
if (!classDecl.validate()) {
unprocessed.add(classDecl)
return@forEach
}
generateFactory(classDecl)
}
return unprocessed // 아직 처리하지 못한 심볼 반환
}
private fun generateFactory(classDecl: KSClassDeclaration) {
val packageName = classDecl.packageName.asString()
val className = classDecl.simpleName.asString()
val factoryName = "${className}Factory"
// 주 생성자의 파라미터 분석
val constructor = classDecl.primaryConstructor
?: run {
logger.error("@AutoFactory는 주 생성자가 있는 클래스에만 사용 가능", classDecl)
return
}
val params = constructor.parameters.map { param ->
val name = param.name?.asString() ?: return
val type = param.type.resolve().declaration.qualifiedName?.asString() ?: return
Pair(name, type)
}
// 코드 생성
val file = codeGenerator.createNewFile(
Dependencies(true, classDecl.containingFile!!),
packageName,
factoryName
)
file.writer().use { writer ->
writer.write("package $packageName\n\n")
writer.write("// 자동 생성된 팩토리 — 직접 수정하지 마세요\n")
writer.write("object $factoryName {\n")
writer.write(" fun create(\n")
params.forEachIndexed { index, (name, type) ->
val comma = if (index < params.size - 1) "," else ""
writer.write(" $name: $type$comma\n")
}
writer.write(" ): $className {\n")
writer.write(" return $className(\n")
params.forEachIndexed { index, (name, _) ->
val comma = if (index < params.size - 1) "," else ""
writer.write(" $name = $name$comma\n")
}
writer.write(" )\n")
writer.write(" }\n")
writer.write("}\n")
}
logger.info("${factoryName}이 생성되었습니다")
}
}
5단계: SymbolProcessorProvider 등록
class AutoFactoryProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return AutoFactoryProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger
)
}
}
서비스 파일 등록이 필요합니다.
// processor/src/main/resources/META-INF/services/
// com.google.devtools.ksp.processing.SymbolProcessorProvider
com.example.processor.AutoFactoryProcessorProvider
6단계: 사용
// app 모듈 — build.gradle.kts
plugins {
id("com.google.devtools.ksp") version "1.9.0-1.0.13"
}
dependencies {
implementation(project(":annotation"))
ksp(project(":processor"))
}
// 사용자 코드
@AutoFactory
data class User(
val name: String,
val email: String,
val age: Int
)
// 빌드 후 자동 생성됨
// UserFactory.create(name = "김코틀린", email = "kotlin@test.com", age = 28)
KSP의 핵심 API
Resolver — 심볼 조회
// 특정 어노테이션이 붙은 심볼 찾기
resolver.getSymbolsWithAnnotation("com.example.AutoFactory")
// 특정 이름으로 클래스 찾기
resolver.getClassDeclarationByName("com.example.User")
// 새로운 파일 찾기 (incremental processing)
resolver.getNewFiles()
KSClassDeclaration — 클래스 정보
classDecl.simpleName.asString() // 클래스 이름
classDecl.packageName.asString() // 패키지
classDecl.primaryConstructor // 주 생성자
classDecl.getAllProperties() // 모든 프로퍼티
classDecl.getAllFunctions() // 모든 함수
classDecl.superTypes // 부모 타입들
classDecl.modifiers // 수정자 (open, abstract 등)
classDecl.classKind // CLASS, INTERFACE, ENUM 등
classDecl.annotations // 어노테이션들
KSType — 타입 정보
val type = param.type.resolve()
type.declaration.qualifiedName // 정규화된 이름
type.isMarkedNullable // nullable 여부 (코틀린 특화!)
type.arguments // 제네릭 타입 인자
코틀린의 nullable 정보에 접근할 수 있다는 것이 KAPT와의 큰 차이점입니다.
CodeGenerator — 코드 출력
// 새 파일 생성
val file = codeGenerator.createNewFile(
dependencies = Dependencies(aggregating = true, sources),
packageName = "com.example",
fileName = "Generated"
)
// KotlinPoet과 함께 사용하면 더 편리
val fileSpec = FileSpec.builder("com.example", "Generated")
.addType(typeSpec)
.build()
file.writer().use { writer ->
fileSpec.writeTo(writer)
}
KotlinPoet과 함께 사용하기
코드를 문자열로 직접 작성하면 실수하기 쉽습니다. KotlinPoet을 사용하면 타입 안전하게 코틀린 코드를 생성할 수 있습니다.
// build.gradle.kts
dependencies {
implementation("com.squareup:kotlinpoet:1.15.0")
implementation("com.squareup:kotlinpoet-ksp:1.15.0")
}
val factorySpec = TypeSpec.objectBuilder(factoryName)
.addFunction(
FunSpec.builder("create")
.apply {
params.forEach { (name, type) ->
addParameter(name, type.toClassName())
}
}
.returns(classDecl.toClassName())
.addStatement(
"return %T(%L)",
classDecl.toClassName(),
params.joinToString { "${it.first} = ${it.first}" }
)
.build()
)
.build()
val fileSpec = FileSpec.builder(packageName, factoryName)
.addType(factorySpec)
.build()
fileSpec.writeTo(codeGenerator, Dependencies(true, classDecl.containingFile!!))
Incremental Processing
KSP는 변경된 파일만 다시 처리하는 incremental processing을 지원합니다.
// Dependencies 설정이 중요
codeGenerator.createNewFile(
dependencies = Dependencies(
aggregating = false, // false: isolating, true: aggregating
classDecl.containingFile!! // 이 파일이 변경되면 재생성
),
packageName,
fileName
)
- Isolating: 하나의 입력이 하나의 출력에 대응. 입력이 변경되면 해당 출력만 재생성
- Aggregating: 여러 입력을 모아 하나의 출력 생성. 하나라도 변경되면 재생성
대부분의 프로세서는 isolating 모드가 빌드 성능에 유리합니다.
KSP를 사용하는 대표 라이브러리
| 라이브러리 | 용도 | KSP 전환 효과 |
|---|---|---|
| Room | Android DB | 빌드 시간 약 2배 단축 |
| Moshi | JSON 직렬화 | KAPT 대비 빌드 시간 개선 |
| Koin Annotations | DI | 컴파일 타임 DI 검증 |
| Compose Destinations | 네비게이션 | 타입 안전한 라우팅 자동 생성 |
KAPT에서 KSP로 마이그레이션
// 변경 전 (KAPT)
plugins {
kotlin("kapt")
}
dependencies {
kapt("androidx.room:room-compiler:2.6.0")
}
// 변경 후 (KSP)
plugins {
id("com.google.devtools.ksp")
}
dependencies {
ksp("androidx.room:room-compiler:2.6.0")
}
대부분의 경우 kapt를 ksp로 바꾸기만 하면 됩니다.
KSP 프로세서 테스트
// compile-testing 라이브러리 사용
dependencies {
testImplementation("com.github.tschuchortdev:kotlin-compile-testing-ksp:1.5.0")
}
@Test
fun `AutoFactory 어노테이션이 팩토리를 생성한다`() {
val source = SourceFile.kotlin("User.kt", """
package com.example
@AutoFactory
data class User(val name: String, val age: Int)
""")
val result = KotlinCompilation().apply {
sources = listOf(source)
symbolProcessorProviders = listOf(AutoFactoryProcessorProvider())
}.compile()
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode)
// 생성된 파일 확인
val generatedFile = result.kspGeneratedSources()
.find { it.name == "UserFactory.kt" }
assertNotNull(generatedFile)
}
정리
- KSP는 KAPT를 대체하는 코틀린 네이티브 심볼 프로세서로, 자바 스텁 생성이 없어 빌드가 빠릅니다
- SymbolProcessor의
process()메서드에서Resolver로 심볼을 조회하고,CodeGenerator로 코드를 생성합니다 - 코틀린의 nullable 타입, sealed class 등의 정보에 직접 접근할 수 있습니다
- KotlinPoet과 함께 사용하면 타입 안전하게 코틀린 코드를 생성할 수 있습니다
- Room, Moshi, Koin 등 주요 라이브러리가 이미 KSP를 지원하며, KAPT에서 마이그레이션은 대부분
kapt→ksp로 바꾸기만 하면 됩니다
댓글 로딩 중...