이번 포스트에서는 Annotation Processor에 대해 정리해 보겠습니다.
Annotation Processor란?
Annotation Prcoessor
란 소스 코드에 붙어있는 어노테이션 정보를 읽어서 컴파일 단계에서 컴파일러가 java 파일 혹은 바이트 코드를 생성하여 새로운 소스 코드를 생성할 수 있는 기능을 제공하는 javac(Java Compiler)에 내장된 도구입니다. 여기서 컴파일될 때 어떤 프로세서가 동작할지 결정하는 것은 프로세서를 찾는 'Service Loader'라는 기능의 도움으로 수행됩니다.
Annotation Processing 원리
Annotation Processing
은 javac에 의해 여러 round에 걸쳐서 동작합니다. 첫 번째 라운드는 컴파일이 사전 컴파일 단계에 도달하면 시작됩니다. 이 round에서 새로운 파일이 생성되면 생성된 파일을 입력으로 다음 라운드가 시작하는 방식으로 동작합니다. 그리고 프로세서가 모든 파일을 처리할 때까지 계속됩니다.
- Annotation을 정의하고 Service Loader에서 처리할 Annotation Processor를 표시합니다.
- Javac 컴파일이 시작됩니다. 첫 round에서 Javac는 Service Loader를 통해서 처리할 Annotation Processor를 알고 있습니다.
- Javac은 첫 번째 round에서 '.class 파일을 생성합니다. 이때 javac는 '.java' 파일을 처리했으므로 처리한 Annotation을 알게 됩니다. 이제 후속 Annotation Processor에 제어권이 전달됩니다.
- 후속 Annotation Processor가 작업을 시작합니다. 여기서는 추가 '.java' 파일을 생성할 수도 있고 생성하지 않을 수도 있습니다. 하지만, 이전 Annotation Processor가 후속 Annotation Processor에서 처리해야 할 Annotation이 포함된 '.java' 클래스를 생성했다고 가정해 보겠습니다.
- 원본 '.java' 파일이 모두 컴파일되었지만, 이전 Annotation Processor에서 생성된 '.java' 파일로 인해 javac는 이를 감지하고 다음 round를 시작합니다.
- 이 과정이 반복되며 더 이상 '.java' 파일이 생성되지 않아서 javac에 의해 감지되지 않는다면 round는 종료됩니다.
Annotation Processor의 장단점
장점
컴파일 단계에서 수행되기 때문에 런타임 비용이 없음
-> Annotation Processor의 가장 큰 장점으로 컴파일 타임에 모든 동작을 수행하고, 런타임에는 사용만 하는 것이므로 추가적인 비용이 제로에 가깝습니다.
런타임 예외를 발생시키지 않음
-> 리플렉션도 런타임에 실행 중인 클래스의 정보, 메서드, 필드 등을 조작할 수 있지만 런타임에 예외가 발생할 수 있는 단점이 있는 반면에, 컴파일 타임에 소스 코드를 조작하는 Annotation Processor는 런타임 예외를 발생시키지 않습니다.
Boilerplate code를 줄일 수 있음
-> Annotation Processor를 이용하면 코드 생성을 자동화하여 반복적인 작업을 간소화할 수 있습니다.
단점
컴파일 속도가 느려질 수 있음
-> Annotation Processor는 컴파일 타임에 실행되므로 프로젝트의 크기가 커질수록 컴파일 속도가 느려질 수 있습니다.
Annotation Processor를 사용하는 라이브러리
Dagger(Hilt)
-> Dagger는 컴파일 타임 의존성 주입(Dependency Injection)을 지원하는 라이브러리로, @Module, @Component 등의 어노테이션을 처리하여 의존성 그래프를 자동으로 생성할 수 있습니다.
Room(Room Persistence)
-> Room은 Android에서 SQLite 데이터베이스의 ORM(Object Relational Mapping) 라이브러리이며 데이터베이스의 객체를 코틀린의 객체로 매핑해 주는 역할을 합니다.
Spring Framework
-> Spring Framework에서 사용되는 @Autowired, @RequestMapping 등의 어노테이션은 Spring 컨테이너 및 MVC 기능을 구성하는 데 사용되며 Annotation Processor를 통해 이러한 어노테이션을 처리하여 Spring 구성 및 요청 매핑을 자동화할 수 있습니다.
JPA(Java Persistence API)
-> JPA를 사용하는 경우, 엔터티 클래스를 정의할 때 사용되는 @Entity, @Column, @Id 등의 어노테이션들을 Annotation Processor로 처리하여 데이터베이스 테이블과의 매핑 및 쿼리 생성을 자동화할 수 있습니다.
Lombok
-> Lombok은 @Getter, @Setter, @ToString 등을 처리할 수 있는 보일러 플레이트 코드를 자동 생성하기 위해 사용하는 라이브러리입니다. 이러한 어노테이션을 Annotation Processor로 처리하여 반복적인 코드 작성을 피할 수 있습니다.
Builder 패턴을 자동으로 생성해 주는 커스텀 Annotation 만들어보기
클래스에 @Builder
라는 어노테이션을 달아주면 Builder 패턴의 보일러 플레이트 코드를 자동으로 생성을 해주는 어노테이션을 만들어보겠습니다.
프로젝트에 builder-annotation, builder-processor 모듈 생성
커스텀 Annotation을 생성할 builder-annotation 모듈
, Anntatoin Processor를 생성할 builder-processor 모듈
을 생성해 줍니다. 그리고, 만든 어노테이션 프로세서를 통해서 예제를 실행할 example 모듈을 생성해 주겠습니다.
모듈을 생성한 후에 각 모듈의 build.gradle 파일을 아래와 같이 작성해 줍니다.
build.gradle (Module: example)
apply plugin: 'kotlin-kapt'
dependencies {
...
compileOnly project(':builder-annotation')
kapt project(':builder-processor')
}
build.gradle (Module: builder-annotation)
apply plugin: 'kotlin'
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21"
testImplementation "junit:junit:4.12"
}
compileKotlin {
kotlinOptions.jvmTarget = "11"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "11"
}
build.gradle (Module: builder-processor)
apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'
repositories {
mavenCentral()
jcenter()
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21"
implementation "com.google.auto.service:auto-service:1.0-rc7"
kapt "com.google.auto.service:auto-service:1.0-rc7"
implementation "com.squareup:kotlinpoet:1.7.2"
implementation project(':builder-annotation')
testImplementation "junit:junit:4.12"
}
compileKotlin {
kotlinOptions.jvmTarget = "11"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "11"
}
이제 작성한 각각의 build.gradle에 대해서 설명하겠습니다.
먼저, example 모듈에는 builder-annotation, build-processor에 대한 의존성을 추가해 줍니다. 여기서, builder-processor는 Kotlin Annotation Processor를 사용하므로 kapt로 의존성을 추가해줍니다.
builder-processor 모듈에는 AutoService 라이브러리
와 KotlinPoet 라이브러리
의존성을 추가합니다.
AutoService
는 java.util.ServiceLoader의 서비스 프로바이더 설정을 자동 생성해 주는 라이브러리입니다.
일반적으로는 Annotation Processor가 동작하려면 Java에서 제공하는 Service Loader를 사용하기 위해 META-INF.services에 메타 데이터를 수동으로 생성하고 Annotation Processor를 등록해주어야 합니다.
여기서 Service Loader
란, 서비스 구현을 로드하는 기능입니다. 여기서 말하는 서비스란, interface 또는 추상 클래스(abstract class)의 집합입니다. Serivce Provider는 interface의 구현을 jar파일의 형태로 Java 플랫폼에 설치합니다. Service Provider는 resources 디렉토리에 META-INF/services에 파일 이름이 Provider의 인터페이스의 풀 패키지 경로로 된 파일을 생성합니다. 그리고 파일 내용에는 구현체의 풀 패키지 경로를 작성합니다. 즉, 원래라면 수동으로 작성해야 합니다.
하지만, Google에서 만든 AutoService 라이브러리를 사용하면 이러한 Service Loader가 하는 동작을 하는 파일을 @AutoService 어노테이션을 붙이는 것 만으로 컴파일 타임에 자동으로 생성해 줍니다.
https://github.com/google/auto
KotlinPoet
은 Square에서 만든 라이브러리로 Code Generation API를 제공해 주는 라이브러리입니다. KotlinPoet을 사용하면 Annotation Processing과 같은 작업을 수행할 때 유용하며 보일러 플레이트 코드를 작성할 필요 없이 Code Generation을 할 수 있도록 도와줍니다.
https://square.github.io/kotlinpoet/
builder-annotation 모듈에 커스텀 Annotation 작성
builder-annotation 모듈에 Builder라는 이름의 커스텀 어노테이션 클래스를 선언해 줍니다.
package com.seosh817.annotation
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
@MustBeDocumented
annotation class Builder
선언한 @Builder는 Annotation Processor를 동작하게 하므로 Retention Policy는 소스 코드에만 존재하게 하는 SOURCE, 타겟은 클래스에 지정할 것이므로 CLASS를 주도록 하겠습니다.
Annotation의 속성을 더 자세하게 알아보면 아래와 같습니다.
@Retention
@Retention
은 어노테이션을 어디까지 유지하게 할 것인가를 정합니다. 즉, 예를 들면 Annotation이 소스 코드에만 존재하게 할 것인지 혹은 클래스나 인터페이스의 바이너리 형태로 존재하게 할 것인지 등을 결정합니다.
Retention Policy는 아래와 같습니다.
SOURCE
-> Annotation은 컴파일된 바이트코드에 저장되지 않습니다. 즉, SOURCE 레벨에 존재한다는 것의 의미는 소스 코드에만 존재할 뿐, 바이트코드에 저장되지 않는다는 것을 의미합니다.
BINARY
-> Annotation이 컴파일된 클래스 파일에 저장되지만, 리플렉션에는 저장되지 않습니다. 즉, 해당 Annotation이 사용되는 컴파일된 바이트코드(*.class)에 존재하므로 바이트코드에서 직접 작동하는 기능으로 사용할 수 있지만, 리플렉션을 통해 런타임에 사용할 수 없습니다.
RUNTIME
-> *.class 파일에도 저장되고 런타임 시에 JVM에 로드되어 실행 중인 프로그램 내에서 리플렉션을 사용하여 검색할 수 있습니다.
@Target
@Target
은 어떠한 Element에 이 Annotation을 달 수 있는지 종류를 제한합니다. 만약, 지정한 Element가 아닌 곳에 Annotation이 달려있다면 정상적으로 동작하지 않을 수 있습니다.
Target을 몇 가지 적어보면 아래와 같습니다.
CLASS
-> class, interface, object, annotation class에 사용할 수 있습니다.
PROPERTY
-> 프로퍼티에 사용할 수 있습니다.
PROPERTY_GETTER, PROPERTY_SETTER
-> 프로퍼티의 getter, setter에 사용할 수 있습니다.
FIELD
-> 프로퍼티의 backing field도 상관없이 필드에 사용할 수 있습니다.
CONSTRUCTOR -> primary, secondary 상관없이 생성자에 사용할 수 있습니다.
FUNCTION
-> 함수에 사용할 수 있습니다.
@Repeatable
@Repeatable
는 단일 Element에 Annotation을 반복해서 쓸 수 있도록 해줍니다.
@MustBeDocumented
@MustBeDocumented
는 자동으로 문서화되어야 함을 나타냅니다. 즉, 해당 Annotation이 적용된 Element는 코드의 문서화가 필요하므로 개발자에게 해당 Element에 대한 문서화가 필요하다는 힌트를 제공합니다. Dokka 등을 사용하면 이 Annotation을 읽고 자동으로 공식 문서에 추가할 수 있습니다.
builder-processor 모듈에 Annotation Processor 작성
builder-processor 모듈에서 @Builder가 붙어있는 클래스를 처리하는 BuilderProcessor라는 이름의 Annotation Processor를 만들어보겠습니다. BuilderProcessor의 전체 코드는 아래와 같으며 하나씩 설명하겠습니다.
package com.seosh817.processor
import com.seosh817.annotation.Builder
import com.google.auto.service.AutoService
import com.squareup.kotlinpoet.*
import java.io.File
import java.util.Locale
import javax.annotation.processing.AbstractProcessor
import javax.annotation.processing.Processor
import javax.annotation.processing.RoundEnvironment
import javax.lang.model.SourceVersion
import javax.lang.model.element.Element
import javax.lang.model.element.ElementKind
import javax.lang.model.element.TypeElement
import javax.lang.model.type.DeclaredType
import javax.lang.model.type.PrimitiveType
import javax.lang.model.type.TypeMirror
import javax.lang.model.util.ElementFilter.fieldsIn
@AutoService(Processor::class)
class BuilderProcessor : AbstractProcessor() {
private val targetDirectory: String
get() = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION]
?: throw IllegalStateException("Unable to get target directory")
override fun getSupportedSourceVersion(): SourceVersion {
return SourceVersion.latestSupported()
}
override fun getSupportedAnnotationTypes(): MutableSet<String> {
return mutableSetOf(Builder::class.java.name)
}
override fun process(annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment): Boolean {
if (roundEnv.processingOver()) {
processingEnv.noteMessage { "This round will not be subject to a subsequent round of annotation processing" }
}
return processAnnotation(roundEnv)
}
private fun processAnnotation(roundEnv: RoundEnvironment): Boolean {
val elements = roundEnv.getElementsAnnotatedWith(Builder::class.java)
if (elements.isEmpty()) {
processingEnv.noteMessage { "Not able to find @${Builder::class.java.name} in this round $roundEnv" }
return true
}
for (element in elements) {
element as TypeElement
when (element.kind) {
ElementKind.CLASS -> writeForClass(element)
else -> processingEnv.errorMessage { "The annotation is invalid for the element type ${element.simpleName}. Please add ${Builder::class.java.name} either on Constructor or Class" }
}
}
return true
}
private fun writeForClass(element: TypeElement) {
val packageName = "${element.enclosingElement}".lowercase(Locale.getDefault())
val fileName = "${element.simpleName}Builder".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
processingEnv.noteMessage { "Writing $packageName.$fileName" }
val allMembers = processingEnv.elementUtils.getAllMembers(element)
val fieldElements = fieldsIn(allMembers)
processingEnv.noteMessage { "All members for $packageName : $fieldElements" }
val classBuilder = TypeSpec.objectBuilder(fileName)
val builderClass = ClassName(packageName, fileName)
for (field in fieldElements) {
val propertyName = field.toString()
val type = ClassName("${element.enclosingElement}", "String")
classBuilder.addProperty(createProperty(field, propertyName))
classBuilder.addFunction(createSetterMethod(field, propertyName, builderClass))
}
classBuilder.addFunction(createBuildMethod(element, fieldElements))
val file = FileSpec.builder(packageName, fileName)
.addType(classBuilder.build())
.build()
file.writeTo(File(targetDirectory))
}
private fun asKotlinTypeName(element: Element): TypeName {
return element.asType().asKotlinType()
}
private fun TypeMirror.asKotlinType(): TypeName {
return when (this) {
is PrimitiveType -> {
processingEnv.noteMessage { "TypeMirror is PrimitiveType" }
processingEnv.typeUtils.boxedClass(this).asKotlinClassName()
}
is DeclaredType -> {
processingEnv.noteMessage { "TypeMirror is DeclaredType" }
this.asTypeElement().asKotlinClassName()
}
else -> this.asTypeElement().asKotlinClassName()
}
}
/** private var name: kotlin.String? = null */
private fun createProperty(field: Element, propertyName: String): PropertySpec {
return PropertySpec
.builder(propertyName, asKotlinTypeName(field).copy(nullable = true))
.addModifiers(KModifier.PRIVATE)
.mutable()
.initializer("null")
.build()
}
/** public fun methodName(propertyName: String): AnimalBuilder = apply {
* this.propertyName = propertyName
* } */
private fun createSetterMethod(field: Element, propertyName: String, className: ClassName): FunSpec {
return FunSpec
.builder(propertyName)
.addParameter(name = propertyName, type = asKotlinTypeName(field).copy(nullable = false))
.returns(className)
.addCode(
StringBuilder()
.append("return apply {\n")
.append("\tthis.$propertyName = $propertyName\n")
.append("}")
.toString()
)
.build()
}
/** public fun build(): Builder */
private fun createBuildMethod(typeElement: TypeElement, fieldElements: List<Element>): FunSpec {
return FunSpec.builder("build").apply {
val fieldNullCheck = StringBuilder()
for (field in fieldElements) {
fieldNullCheck.append("requireNotNull($field)").appendLine()
}
addCode(fieldNullCheck.toString())
}.returns(ClassName("${typeElement.enclosingElement}", "${typeElement.simpleName}"))
.apply {
val code = StringBuilder()
val iterator = fieldElements.listIterator()
while (iterator.hasNext()) {
val field = iterator.next()
code.appendLine()
code.append("\t$field = $field")
code.append("!!")
if (iterator.hasNext()) {
code.append(",")
}
}
addCode(
"""
|return ${typeElement.simpleName}($code
|)
""".trimMargin()
)
}
.build()
}
/** Returns the [TypeElement] represented by this [TypeMirror]. */
private fun TypeMirror.asTypeElement() = processingEnv.typeUtils.asElement(this) as TypeElement
private fun TypeElement.asKotlinClassName(): ClassName {
val className = asClassName()
return try {
// ensure that java.lang.* and java.util.* etc classes are converted to their kotlin equivalents
Class.forName(className.canonicalName).kotlin.asClassName()
} catch (e: ClassNotFoundException) {
// probably part of the same source tree as the annotated class
className
}
}
companion object {
const val KAPT_KOTLIN_GENERATED_OPTION = "kapt.kotlin.generated"
}
}
위 코드를 보면, BuilderProcessor라는 이름의 클래스를 만들고 위에서 설명한 auto-service 라이브러리의 @AutoService 어노테이션을 달아주고 AbstractProcessor를 상속받습니다.
@AutoService 어노테이션은 아래와 같습니다.
@AutoService
-> 위에서 Service Loader에 대한 설명은 자세히 했으므로 생략하며, AutoService를 사용하면 META_INF.services 매니페스트 파일을 수동으로 생성할 필요 없이 컴파일 타임에 자동으로 생성해 줍니다.
이제 AbstractProcessor에 대해 알아보겠습니다.
Abstract Processor
AbstractProcessor
를 상속받는 것은 Annotation Processor를 구현하기 위한 가장 좋은 방법입니다. AbstractProcessor는 Processor 인터페이스를 구현할 때 필요한 기능들의 기본적인 메소드들을 어느 정도 구현해 놓은 추상클래스입니다.
여기서 오버라이딩 해주어야 할 메소드들은 아래와 같습니다.
getSupportedSourceVersion()
getSupportedSourceVersion()
은 이 프로세서가 지원하는 최신 소스 버전을 지정하는 메소드입니다.
getSupportedAnnotationTypes()
getSupportedAnnotationTypes()
는 이 프로세서가 어떤 어노테이션을 처리할 것인가를 지정하는 메소드이며 반드시 구현해야 합니다.
process()
process()
는 프로세서가 어노테이션이 달린 Element들을 처리하는 메소드입니다. 여러 round에 걸쳐서 Annotation을 처리하며 return 값이 ture가 반환되면 후속 프로세서는 해당 Annotation에 대한 처리를 하지 않도록 합니다.
BuilderProcessor가 process()에서 처리하는 과정을 간단하게 설명하면 아래와 같습니다.
- @Builder가 달려있는 Element들을 탐색합니다.
- 만약, 어노테이션이 Class에 달려있다면 해당 클래스에 대한 Builder 패턴 코드를 생성하는 함수인 writeForClass() 함수를 호출합니다.
- 해당 클래스${Builder}의 이름으로 파일을 생성합니다.
- 만든 파일에 KotlinPoet 라이브러리에서 제공하는 함수들(FileSpec, TypeSpec, PropertySpec, FunSpec)을 이용하여 Builder Pattern에 필요한 클래스, 프로퍼티, 메소드를 생성합니다.
example 모듈에 예제 코드 작성
이제 Annotation과 Annotation Processor를 전부 작성했으므로 example 모듈에 예제 코드를 작성해 보겠습니다.
Car 클래스와 Engine 클래스를 만들고 @Builder 어노테이션을 달아줍니다.
package com.seosh817.example
import com.seosh817.annotation.Builder
import com.seosh817.example.car.EngineBuilder
fun main() {
val car = CarBuilder
.name("seosh817 car")
.brand("hyundai")
.engine(
EngineBuilder
.name("diesel")
.fuel(123)
.build()
)
.build()
print("car name : ${car.name}")
print("car engine : ${car.engine.name}")
}
@Builder
data class Car(
val name: String,
val brand: String,
val engine: Engine,
) {
@Builder
data class Engine(
val name: String,
val fuel: Int,
)
}
그리고 터미널에서 ./gradlew clean build
명령어를 실행하여 빌드를 실행해 줍니다.
빌드를 실행하면 [build] - [generated] - [source] - [kaptKotlin] 디렉토리에 CarBuilder, EngineBuilder 코드가 생성이 된 것을 확인할 수 있습니다.
생성된 코드는 아래와 같습니다.
CarBuilder.kt
package com.seosh817.example
import kotlin.String
public object CarBuilder {
private var name: String? = null
private var brand: String? = null
private var engine: Car.Engine? = null
public fun name(name: String): CarBuilder = apply {
this.name = name
}
public fun brand(brand: String): CarBuilder = apply {
this.brand = brand
}
public fun engine(engine: Car.Engine): CarBuilder = apply {
this.engine = engine
}
public fun build(): Car {
requireNotNull(name)
requireNotNull(brand)
requireNotNull(engine)
return Car(
name = name!!,
brand = brand!!,
engine = engine!!
)
}
}
EngineBuilder.kt
package com.seosh817.example.car
import com.seosh817.example.Car.Engine
import kotlin.Int
import kotlin.String
public object EngineBuilder {
private var name: String? = null
private var fuel: Int? = null
public fun name(name: String): EngineBuilder = apply {
this.name = name
}
public fun fuel(fuel: Int): EngineBuilder = apply {
this.fuel = fuel
}
public fun build(): Engine {
requireNotNull(name)
requireNotNull(fuel)
return Engine(
name = name!!,
fuel = fuel!!
)
}
}
마무리
Annotation Processor를 활용한다면 반복적인 작업들을 컴파일 타임 Code Generation을 통해 최소화시킬 수 있으므로 잘 활용한다면 생산성에 많은 도움이 될 것 같습니다.
2022년 Kotlin Lombok compiler plugin의 1.8.0 버전부터 @Builder 어노테이션을 제공하고 있다고 합니다. 포스트에서 만든 @Builder 어노테이션과는 당연히 다르지만 비슷한 동작을 하는 프로세서를 만들어 보았는데, Annotation Processor의 동작 원리를 생각한다면 Lombok의 @Builder 뿐만 아니라 Kotlin 혹은 여러 라이브러리에서 제공하는 어노테이션들이 어떠한 방식으로 동작하는지 대략적으로 이해하면서 사용할 수 있을 것 같습니다.
References
https://github.com/google/auto/tree/main
https://square.github.io/kotlinpoet/
https://kotlinlang.org/docs/kapt.html