컴파일 타임에 컴파일러가 어노테이션 정보를 참고해서 새로운 소스 코드, 바이트 코드, 리소스를 만들거나 조작한다. 대표적으로 Lombok이 있다.
예시: @Override
@Override
예시: Lombok
를 통해서 컴파일 타임에 특정한 어노테이션이 붙어있는 소스코드를 참조해서 또 다른 소스코드를 만드는 방식이다.
‘어노테이션이 붙어있는 소스코드를 참조’ 하는 방식은 소스코드의 를 에서 public 하게 제공하는 API 를 통해서 ‘참조’하는 방식이다.
그런데 롬복은 ‘참조’ 를 넘어서 조작이 가능한 타입으로 타입 캐스팅을 해서 코드의 변경까지 불러오기 때문에 ‘해킹’이라는 의견도 있다. 심지어 해킹이 맞긴 한거 같다.
It’s a total hack. Using non-public API. Presumptuous casting (knowing that an annotation processor running in javac will get an instance of JavacAnnotationProcessor, which is the internal implementation of AnnotationProcessor (an interface), which so happens to have a couple of extra methods that are used to get at the live AST).
어노테이션 프로세서 원리
미리 META-INF에서 메타정보로 어노테이션 프로세서가 지정이 된 상태에서 컴파일러가 소스 컴파일을 위해서 코드를 읽는다. 그러던 중 해당 어노테이션을 만나면 지정된 어노테이션 프로세서들 중 처리하도록 지정되었는지 확인을 하게 되고, 만약 그 어노테이션이 특정한 어노테이션 프로세서에 의해서 처리가 되어야 하는 어노테이션인 경우 컴파일 타임에 미리 정해진 어노테이션 프로세서의 처리대로 코드가 처리된다. 이게 어노테이션 프로세서 처리를 한 문단으로 정리해서 표현한 것이다. 실습 코드에서 자세히 알아보자.
실습은 @Starbucks라는 어노테이션을 붙여준 인터페이스가 있는 패키지에 makeCoffee()라는 메소드를 가진 클래스를 자동으로 주입해주는 어노테이션 프로세서를 구현하는 것이다.
어노테이션 프로세서 실습
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Starbucks {
}
사용해줄 Starbucks 어노테이션을 지정해주고 RetentionPolicy를 SOURCE 레벨로 지정해준다. 컴파일을 하는 타이밍에 소스레벨의 탐색에서 어노테이션을 탐지해서 어노테이션 프로세서가 이를 참조하여 원하는 처리를 할 것이기 때문이다.
package me.fistkim101;
import com.google.auto.service.AutoService;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.TypeSpec;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import java.io.IOException;
import java.util.Set;
@AutoService(Processor.class)
public class StarbucksProcessor extends AbstractProcessor {
@Override
public Set<String> getSupportedAnnotationTypes() {
return Set.of(Starbucks.class.getName());
}
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(Starbucks.class);
for (Element element : elements) {
if (element.getKind() != ElementKind.INTERFACE) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "me.fistkim101.Starbucks annotation only can be used for interface.");
}
TypeElement typeElement = (TypeElement) element;
ClassName className = ClassName.get(typeElement);
MethodSpec makeCoffee = MethodSpec.methodBuilder("makeCoffee")
.addModifiers(Modifier.PUBLIC)
.returns(String.class)
.addStatement("return $S", "Starbucks today’s coffee")
.build();
TypeSpec starbucksCafe = TypeSpec.classBuilder("StarbucksCafe")
.addModifiers(Modifier.PUBLIC)
.addSuperinterface(className)
.addMethod(makeCoffee)
.build();
Filer filer = processingEnv.getFiler();
try {
JavaFile.builder(className.packageName(), starbucksCafe)
.build()
.writeTo(filer);
} catch (IOException e) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, e.getMessage());
}
}
// If {@code true} is returned, the annotation types are claimed and subsequent processors will not be asked to process them;
return true;
}
}
AbstractProcessor는 Processor 인터페이스를 implements하고 있고 이를 편하게 쓸 수 있도록 한단계 추상화된 abstract class이다.
getSupportedAnnotationTypes의 경우 이 어노테이션 프로세서가 어떤 어노테이션을 지원할지를 정해주는 것이다. 나는 @Starbucks 라는 어노테이션에 이 어노테이션 프로세서를 적용시킬 것이므로 아까 만들어둔 @Starbucks를 지정했다.
getSupportedSourceVersion는 지원하는 자바의 버전에 대한 것인데 특별히 명시해주지 않을 경우 1.6이 반영된다.
process 에서 실질적으로 이 어노테이션 프로세서가 어떤 처리를 할지를 정해준다. 어노테이션 프로세서의 특징이 round 를 돌면서 결과물을 넘겨가며 처리하는 것인데, 마지막에 return 하는 boolean에서 true를 넘길 경우 이 어노테이션에 대한 처리는 여기서 끝내겠다는 의미가 된다.
그 외에는 인터페이스에서만 컴파일이 되도록 한 것과 원하는 메소드, 그 메소드를 가진 클래스를 만들고 이를 해당 어노테이션이 붙은 동일한 패키지에 클래스를 만들어 주는 처리를 해주었다.
Or can I only create a different class?
That’s correct. The existing API doesn’t let us modify existing classes, just generate new ones.
이렇게 명료하게 요약이 된다.
@Starbucks에 대한 어노테이션 프로세서를 만드는데 사용된 라이브러리는 아래와 같다.
강의에서는 maven으로 진행하고 나는 gradle로 하느라고 좀 막혔었는데 auto-service를 annotationProcessor로 사용할 것이므로 annotationProcessor로 정의를 해주어야 한다. 여기까지 어노테이션 프로세서를 만드는 과정이었고 아래부터는 이를 활용하는 코드이다.
새로운 프로젝트를 만들고 루트 패키지 내에 libs 라는 패키지를 만든다음 아까 만든 어노테이션 프로세서인 jar 파일을 넣어주고 아래와 같이 의존성을 잡아준다.
그리고 아래와 같이 Cafe라는 인터페이스를 만들고 여기에 아까만들어서 주입해준 @Starbucks를 붙여준다.
@Starbucks
public interface Cafe {
}
이렇게 하고 build를 해주면 Cafe 인터페이스가 위치한 패키지에 Starbucks.class 라는 어노테이션 프로세서에서 만들어준 클래스가 생기고 이 클래스 내부에 어노테이션 프로세서에서 지정해준대로 makeCoffee() 메소드가 들어가 있다. 이를 그대로 활용한다.
public class App {
public static void main(String[] args) {
StarbucksCafe starbucksCafe = new StarbucksCafe();
System.out.println(starbucksCafe.makeCoffee());
}
}
이렇게 어노테이션 프로세서를 직접 커스텀 해서 local에서 라이브러리를 넣어주는 형식으로 실습해보았다.
어노테이션 프로세서 장점
런타임 비용이 없다는 것이 가장 큰 장점이다. 런타임시에 동적으로 무엇인가를 하는 것이 아니라 애초에 컴파일 단계에서 바이트 코드를 추가해두고 런타임시에는 그냥 컴파일된 코드를 읽기만 하면 되기 때문이다.
여기서 롬복과의 차이가 있는데 롬복은 기존의 코드를 조작해서 기존의 클래스 내부에 원하는 생성자, getter, setter등을 만들도록 했고 나는 그저 새로운 클래스를 만들어 준 것이다. 내가 공개된 public 한 api만으로 이를 처리한 것이고 롬복이 위에서 설명한대로 hack 을 해서 코드를 조작한 것이라고 보면 된다. 을 찾았는데,