Kotlin KSP 내부 구조: 어노테이션이 생성 코드로 변환되는 과정
Kotlin KSP 내부 구조: 어노테이션이 생성 코드로 변환되는 과정
Jetpack Room을 사용하면 모든 @Dao 인터페이스가 완전한 데이터베이스 구현체로 변환됩니다. Hilt를 사용하면 @Inject 생성자가 의존성 그래프에 자동으로 연결됩니다. Moshi를 사용하면 @JsonClass가 JSON 어댑터를 생성합니다. 어노테이션 하나만 추가한 뒤 빌드를 실행하면, build/generated/ksp 디렉터리에 새로운 소스 파일이 나타나게 됩니다. 이 모든 과정을 가능하게 하는 핵심 엔진이 바로 KSP(Kotlin Symbol Processing)입니다.
이 글에서는 먼저 직접 작성할 수 있는 실용적인 프로세서 예제를 살펴본 뒤, KSP 파이프라인 내부를 추적합니다. Gradle이 프로세서를 탐색하는 방식, Resolver를 통해 코드베이스 전체를 심볼 트리(symbol tree)로 조회하는 원리, 다중 라운드 처리 루프가 생성된 파일 간 의존성을 해결하는 방법, 그리고 증분 빌드(incremental build) 시 어떤 파일을 재처리해야 하는지 추적하는 메커니즘까지 상세히 다루겠습니다.
근본적인 문제: KAPT가 느렸던 이유
KSP가 등장하기 전, 코틀린에서 어노테이션 처리를 수행하는 유일한 방법은 KAPT(Kotlin Annotation Processing Tool)였습니다. KAPT는 코틀린 소스 코드를 기반으로 자바 스텁(stub) 파일을 생성한 뒤, 해당 스텁을 표준 javac 어노테이션 처리 파이프라인에 전달하는 방식으로 동작합니다. 즉, 프로젝트 내 극히 일부 클래스만 어노테이션을 달고 있더라도, 코틀린 컴파일러는 모든 클래스, 인터페이스, 함수에 대해 완전한 자바 선언을 생성해야 합니다.
코틀린 파일이 수백 개에 달하는 프로젝트에서는 이러한 스텁 생성 과정만으로 빌드 시간이 20~30초 추가될 수 있습니다. 더욱 문제인 점은, 생성된 스텁 파일은 처리가 끝나면 폐기된다는 것입니다. 결국 순수한 오버헤드에 해당하는 셈입니다.
KSP는 이와 완전히 다른 접근 방식을 취합니다. 자바 스텁을 생성해서 javac를 거치는 대신, 코틀린 컴파일러가 자체적으로 구축한 심볼 트리를 직접 읽습니다. 프로세서는 KSClassDeclaration, KSFunctionDeclaration, KSPropertyDeclaration 등 실제 코틀린 프로그램 구조를 나타내는 객체를 받게 되며, 자바 스텁에서는 유실되는 nullable 타입, 확장 함수, sealed class, 기본 매개변수 값 등 코틀린 고유의 기능을 온전히 인식할 수 있습니다.
그 결과, KSP 프로세서는 동등한 KAPT 프로세서 대비 약 2배 빠르게 실행되며, 소스 코드를 보다 정확하게 표현할 수 있습니다.
좀 더 구체적인 예를 들어 보겠습니다. suspend 함수, 기본 매개변수 값, nullable 제네릭 타입을 가진 코틀린 data class가 있다고 가정해 보겠습니다. KAPT의 자바 스텁에서는 suspend 수정자가 Continuation 매개변수로 변환되고, 기본값은 오버로드로 대체되며, nullable 정보는 플랫폼 타입(platform type) 어노테이션으로 소실됩니다. 반면 KSP 프로세서는 이 모든 요소를 코틀린 고유의 일급 시민(first-class citizen) 구성요소로 직접 인식합니다. 이 차이가 중요한 이유는, Room 같은 라이브러리의 경우 DAO 함수가 suspend 함수인지 여부를 판단해야 올바른 코루틴 래퍼를 생성할 수 있기 때문입니다.
KSP 프로세서의 구조
KSP 프로세서는 두 개의 클래스로 구성됩니다. 프로세서를 생성하는 SymbolProcessorProvider와 실제 작업을 수행하는 SymbolProcessor입니다. Provider는 빌드 시점에 자바의 ServiceLoader 메커니즘을 통해 탐색됩니다.
다음은 @AutoFactory 어노테이션이 붙은 모든 클래스에 대해 팩토리 클래스를 생성하는 최소 프로세서 예제입니다.
class AutoFactoryProcessorProvider : SymbolProcessorProvider {
override fun create(
environment: SymbolProcessorEnvironment
): SymbolProcessor {
return AutoFactoryProcessor(
environment.codeGenerator,
environment.logger
)
}
}
SymbolProcessorEnvironment는 프로세서에 필요한 모든 요소를 제공합니다. 파일 생성을 위한 CodeGenerator, 진단(diagnostics) 메시지를 위한 KSPLogger, 빌드 스크립트에서 전달한 옵션 맵, 그리고 버전 정보가 여기에 포함됩니다.
프로세서 자체는 process라는 단일 메서드를 구현하며, Resolver를 받아 지연(deferred)된 심볼 목록을 반환합니다.
class AutoFactoryProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(
"com.example.AutoFactory"
)
val unprocessable = symbols.filter { !it.validate() }
symbols.filter { it.validate() }
.filterIsInstance<KSClassDeclaration>()
.forEach { generateFactory(it) }
return unprocessable.toList()
}
}