[Kotlin] Annotation & Annotation 처리에 관하여
Annotation을 사전에서 찾아보면 주석이다.
이 주석을 어디에 어떻게 추가하는지 알아보도록 하자.
Kotlin Reflection interface의 hierarchy의 최상단은 annotatedElement 이다.
해당 인터페이스를 살펴보면 다음과 같다.
어노테이션을 기록하는 역할을 하는 것을 알 수 있다.
Reflection interface의 최상단 interface가 위와 같다는 것은 reflection으로
다루던 모든 것이 annotation의 대상이 될 수 있다는 것을 알 수 있다.
아래는 코틀린 클래스의 annotation을 가져와 출력하는 예시이다.
fun main() {
val kClass = Playground::class
kClass.annotations.forEach { annotation ->
println(annotation)
}
}
Java에서 정의한 Annotation을 사용할 때는 자바 기준으로 동작하기 때문에
프로퍼티와 같이 자바와 다른 형태를 가진 것에는 use-site를 지정해주는 것이 좋다.
[ Property = Field + Getter + Setter ]
class MyClass {
@get:MyAnnotation
val p1 = Employee()
@field:MyAnnotation
val p2 = 3
@set:MyAnnotation
var p3 = null
}
annotation class MyAnnotation()
Meta-Annotation
annotation에 적용하는 annotation을 meta-annotation이라고 하며 이들은
compiler가 annotation을 처리하는 과정을 제어하는 기능을 제어 한다.
@Target
해당 annotation의 사용을 어디로 제한할지 결정
- CLASS: class, interface, object, annotation class 에 사용 가능하도록 제한
- FUNCTION: 생성자를 제외한 함수들에 사용 가능하도록 제한
- FIELD: backing field 를 포함한 field 들에만 사용 가능하도록 제한
- TYPE: 모든곳에 사용 가능하도록 제한
- https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.annotation/-annotation-target/
@Retention
해당 Annotation의 Scope 결정 ( Default - RUNTIME )
- SOURCE: compile time 때만 사용, .class 파일에는 포함하지 않는다.
- BINARY: compile time 때 사용 및 .class에 포함한다.
- RUNTIME: Binary에 runtime scope가 추가된다.
@Repeatable
해당 Annotation을 중복 적용할 수 있도록 허용
@MustBeDocumented
해당 Element의 문서화가 필요하다고 개발자에게 알리는 역할을 한다.
Dokka와 같은 도구를 사용하면 문서화를 자동화할 수 있다.
이후에는 기록한 주석을 통해서 무엇을 할 수 있는지 알아보자.
Annotation Runtime 제어
위에서 말한 것처럼 annotation은 자체적으로 기능을 수행하지 못한다.
따라서, Runtime에서 Annotation을 통해 연산을 진행하고 싶으면 먼저 annotation을 찾아야 한다.
그리고 이를 위해서 Reflection을 사용한다.
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class MyIntRange(
val from: Int,
val to: Int
)
class AnnotationTest(
@MyIntRange(0, 100)
val p: Int
)
fun isValidateMyIntRange(value: Int, from: Int, to: Int): Boolean {
return value in from..to
}
fun main() {
val p = 101
val kClass = AnnotationTest::class
val primaryConstructor = kClass.constructors.first()
val primaryConstructorParameters = primaryConstructor.parameters
val classInstance: AnnotationTest? = try {
primaryConstructorParameters.forEach { parameter ->
parameter.annotations.forEach { annotation ->
if (annotation is MyIntRange) {
if (!isValidateMyIntRange(p, annotation.from, annotation.to)) {
throw Exception()
}
}
}
}
primaryConstructor.call(p)
} catch(e: Exception) { null }
println(classInstance)
}
Annotation Compiletime 제어
위의 코드를 읽으면 클래스 constructor 내부에서 처리하면 될 일을 억지로 만든 느낌이 물씬 풍긴다.
잘 생각해보면 Runtime에서 Annotation을 읽고 어떤 기능을 수행하는 것은 굉장히 번거롭고 효율적이지 못하다.
Annotation을 사용하지 않고 그냥 구현하면 Reflection이라는 overhead를 날릴 수 있으니까 말이다.
그렇다면 Annotation은 어떻게 사용하는 것이 좋을까
우리가 소스코드를 작성하면 Compiler를 거쳐 bytecode가 된다.
그리고 그 Compile 단계에는 annotation을 처리하는 과정이 포함되어 있다.
Room을 사용한 프로젝트를 빌드하면 @Dao을 통해서 MyDao를 만들었을때
MyDao_Impl이 빌드 파일에 포함되어 있는 것을 확인 할 수 있다.
그리고 해당 파일에는 Data Access를 위한 내부 구현이 모두 자동으로 생성된다.
이는 Room을 사용하는데 있어서 상당한 이점을 가져다 준다.
이렇듯 Annotation이 적용된 대상을 통해서 코드를 생성하는 등의 작업을
Compiler에 등록하는 것으로 수행할 수 있다.
먼저 프로젝트 폴더에 Java Library 모듈을 하나 만들어주자.
TestAnnotation이라는 annotation을 만들어 주었다.
@Target(ElementType.TYPE)
public @interface TestAnnotation{}
이후 이 annotation의 연산을 책임질 AbstractProcessor를 만들어주자.
public class TestAnnotationProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
/** 생략 */
}
@Override
public Set<String> getSupportedAnnotationTypes() {
/** 생략 */
}
}
먼저 getSupportedAnnotationTypes 즉, 해당 annotation processor를 통해서 연산할
annotation 대상을 알려주자.
@Override
public Set<String> getSupportedAnnotationTypes() {
return new HashSet<String>(){
{
add(TestAnnotation.class.getCanonicalName());
}
};
}
그리고 예시로 간단한 함수를 포함한 클래스 파일을 생성할 예정이므로
코드 생성 라이브러리 Javapoet을 가져오자.
implementation("com.squareup:javapoet:1.13.0")
내가 만들 간단한 함수는 정수 2개를 받아서 합연산의 결과를 리턴하는 함수이다.
Javapoet의 MethodSpec 함수를 통해서 정의해주자.
private MethodSpec generateMethodJun(TypeElement element) {
return MethodSpec
.methodBuilder("addTwoNumber")
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.addParameter(int.class, "number1")
.addParameter(int.class, "number2")
.returns(int.class)
.addStatement("return number1 + number2")
.build();
}
위의 코드를 읽어보면 해당 라이브러리의 사용법이 상당히 직관적이다.
함수 빌더 -> Modifier설정 -> 함수 파라미터 설정 -> 리턴 타입 설정 -> 내부 구현 코드 입력 -> 빌드
이렇듯 Javapoet에는 파일, 클래스, 메서드 등의 코드를 작성할 수 있는 메서드가 존재한다.
해당 메서드들의 사용법을 이 글에서 다루지는 않겠다.
private void generateJavaFile(List<MethodSpec> methodSpecList) throws IOException {
final TypeSpec.Builder builder = TypeSpec.classBuilder("MyFirstAutoBuildClass");
builder.addModifiers(Modifier.PUBLIC, Modifier.FINAL);
for (MethodSpec methodSpec : methodSpecList) {
builder.addMethod(methodSpec);
}
final TypeSpec typeSpec = builder.build();
JavaFile.builder(packageName, typeSpec) // packageName: Processor에 전역변수로 지정한 값
.build()
.writeTo(processingEnv.getFiler());
}
자바 클래스를 구현하고 내부에 메서드들을 구현하고 자바 파일로 만드는 코드이다.
이제 위의 작업을 AbstractProcessor process에 작성해주면 되겠다.
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
final Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(TestAnnotation.class);
for (Element element : elements) {
if(packageName==null){
Element e = element;
while (!(e instanceof PackageElement)) {
e = e.getEnclosingElement();
}
packageName = ((PackageElement)e).getQualifiedName().toString();
}
if (element.getKind() != ElementKind.CLASS) {
return false;
}
newIntentMethodSpecs.add(generateMethodJun((TypeElement) element));
}
if (roundEnvironment.processingOver()) {
try {
generateJavaFile(newIntentMethodSpecs);
return true;
} catch (IOException ex) {
}
}
return true;
}
이제 다 왔다.
이제 위 프로세서를 컴파일러에게 annotation 처리할때 쓰라고 알려주어야 한다.
annotation_processor 모듈의 main에
resources/META-INF.services/javax.annotation.processing.Processor 파일을 만들어
annotation processor의 경로를 입력해주자.
com.example.annotation_processor.TestAnnotationProcessor
이제 MyTestAnnotation을 적용하고 빌드를 해보면 빌드 파일에서 다음과 같은 자바파일을 확인할 수 있다.