본문 바로가기
Java/Java

애노테이션 프로세서

by oneny 2023. 8. 18.

애노테이션 프로세서

일반적으로 애노테이션에 대한 코드를 검사, 수정 또는 생성하는데 사용된다. 본질적으로 애노테이션 프로세서는 java 컴파일러의 플러그인의 일종이다. 애노테이션 프로세서를 적재적소에 잘 사용한다면 개발자의 코드를 단순화할 수 있다.

애노테이션 프로세스의 대표적인 예로 Lombok이 있다. Lombok은 @Getter, @Setter, @Builder 등의 애노테이션과 애노테이션 프로세서를 제공하여 표준적으로 작성해야할 코드를 개발자 대신 생성해주는 라이브러리이다.

 

Lombok(롬복)은 어떻게 동작하는 걸까?

@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
public class Member {

  private final String name;
  private final int age;

}

 

class MemberTest {

  @Test
  void getterSetter() {
    Member oneny = new Member("oneny", 28);
    assertThat(oneny).isEqualTo(new Member("oneny", 28));
  }
}

생성자, getter, equals, hasCode 메서드 코드를 작성하지 않았음에도 불구하고 어떻게 테스트 코드를 작성할 때 위처럼 사용할 수 있는 것일까?

 

컴파일된 .class 파일을 디컴파일 상태로 봤을때 위처럼 애노테이션만 사용했을 뿐인데 내가 원했던 코드들이 모두 들어가 있는 것을 확인할 수 있다. 이렇게 할 수 있는 이유는 자바가 제공하는 애노테이션 프로세서를 사용하여 소스코드의 AST(Abstract Syntax Tree)를 조작할 수 있기 때문이다. 이 애노테이션 프로세서는 컴파일 할 때 끼어들어서 특정 애노테이션이 붙어있는 소스코드를 참조해서 또 다른 소스코드를 생성해낼 수 있다.

 

애노테이션 프로세서는 애노테이션이 붙어있는 클래스의 정보를 트리 정보로 참조할 수 있는데 이를 AST라고 한다. 이 Interface Processor가 제공하는 API를 통해서는 AST를 참조만 가능하고, 수정하지 못하는데 바이트 코드에는 수정이 되어 컴파일된다. 반면, 공개된 API가 아닌 컴파일러 내부 클래스에는 AST를 참조만이 아닌 수정할 수 있는 클래스가 있어 그 타입으로 타입캐스팅하여 처리를 할 수 있다.

 

애노테이션 특징

 

애노테이션 프로세서 장점

  • 런타임 비용이 제로이다.
    애노테이션 프로세서는 실제로 javac 컴파일러의 일부이므로 모든 처리가 런타임이 아닌 컴파일에 발생하기 때문에 매우 빠르다.
  • 리플렉션을 사용하지 않는다.
    리플렉션을 사용하는 것은 런타임에 많은 예외를 발생시키고, 비용이 큰 작업이다. 애노테이션 프로세서는 리플렉션 없이 프로그램의 의미 구조를 알 수 있게 해준다.
  • Boilderplate code를 생성해준다.
    애노테이션은 개발자가 반복적으로 작성해야 하는 코드를 대신 생성해줌으로써 생산성을 높여줄 수 있다.

 

애노테이션 프로세서 단점

  • 기존 클래스 코드를 변경할 때는 약간의 hack이 필요하다(ex: Lombok)

 

애노테이션 동작 순서

애노테이션 처리는 여러 라운드에 걸쳐 수행된다.

  1. 자바 컴파일러가 컴파일을 수행한다. 이 때, 등록된 애노테이션 프로세서들과 함께 컴파일러가 시작된다.
    (자바 컴파일러는 애노테이션 프로세서에 대해 미리 알고 있어야 한다.)
  2. 아직 실행되지 않은 애노테이션 프로세서들은 애노테이션을 기반으로 코드 검사 및 생성을 수행한다.
    (각각의 프로세서는 모두 각자에 역할에 맞는 구현이 되어 있어야 한다.)
  3. 프로세서 내부에서 애노테이션이 달린 Element(변수, 메서드, 클래스 등)들에 대한 처리를 한다.
    (보통 이곳에서 자바 클래스를 생성한다.)
  4. 컴파일러가 모든 애노테이션 프로세서가 실행되었는지 확인하고, 그렇지 않으면 반복해서 위 작업(다음 프로세싱 라운드)를 수행한다.
    (라운드가 수행되면서 만들어진 소스코드들이 또 다른 어노테이션을 포함하고 있을 수 있다. 그러면 애노테이션 프로세서는 다시 이 새로운 어노테이션을 스캔하고 그에 따른 다음 프로세싱 라운드에서 작업을 수행한다.)
  5. 모든 처리가 끝난다면(더 이상 처리할 애노테이션이 없어졌다면) 전체 코드에 대한 컴파일을 시작한다.

 

참고: Element
소스코드의 구성요소(변수, 메서드, 클래스 등)를 Element라 부르고, 지금 컴파일러가 처리하고 있는 소스들의 각각의 Element들을 process() 실행 시 참조할 수 있다. 아래에서 살펴볼 예정이지만 RoundElement의 메서드를 통해서 해당 애노테이션이 붙어있는 엘리먼트들을 참조할 수 있다.

 

애노테이션 프로세서 실습

 

애노테이션 프로세서를 사용할 프로젝트

@Magic
public interface Moja {

  String pullOut();
}

애노테이션 프로세서로 처리할 애노테이션은 @Magic이다.

 

public class App {

  public static void main(String[] args) {
    // MojaFactory가 annotation processing을 통해서 생성해낸 클래스
    Moja moja = new MagicMoja();
    System.out.println(moja.pullOut());
  }
}

Moja 구현체를 작성한 적이 없지만 Lombok처럼 컴파일 시 소스파일을 읽어 애노테이션 프로세스가 특정한 소스파일을 만들면 MagicMoja 클래스를 사용할 수 있게 된다. 이를 위해 애노테이션 프로세서용 프로젝트를 생성하자.

 

애노테이션 프로세서용 프로젝트 생성

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Magic {
}

@Magic 애노테이션은 생성하고, 해당 애노테이션은 컴파일 타임까지만 사용하기 때문에, 즉 소스레벨까지만 필요하고 바이트코드에서는 필요없기 때문에 @Rentention(RetentionPolicy.SOURCE)로 설정한다. @Target 애노테이션은 어떤 엘리먼트에 붙일지를 설정할 수 있는데 ElementType.TYPE으로 설정하면 Interface, Class, Enum에 모두 붙일 수 있다. 이제 @Magic 애노테이션이 붙어있는 클래스들을 컴파일할 때 특별한 처리(새로운 클래스 생성)를 할 수 있는 프로세서를 만들어야 한다.

 

MagicMojaProcessor.java

public class MagicMojaProcessor extends AbstractProcessor {

  /**
   * 프로세서가 어떤 애노테이션을 처리할지 설정
   */
  @Override
  public Set<String> getSupportedAnnotationTypes() {
    return Set.of(Magic.class.getName());
  }

  /**
   * 어떤 소스코드 버전을 지원할지 설정
   */
  @Override
  public SourceVersion getSupportedSourceVersion() {
    return SourceVersion.latestSupported();
  }

  @Override
  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    // Magic 애노테이션을 가지고 있는 엘리먼트들을 참조
    Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(Magic.class);

    // 인터페이스에만 애노테이션을 붙어 있는지 확인
    for (Element element : elements) {
      Name elementName = element.getSimpleName();
      if (element.getKind() != ElementKind.INTERFACE) { // 컴파일 안되게
        processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Magic annotation can not be used on " + elementName); // 컴파일 에러
      } else {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing " + elementName); // 처리된 엘리먼트 로깅
      }
    }



    return true;
  }
}

 Processor 인터페이스를 구현할 수 있지만 자바에서 제공하는 기본적인 추상 클래스인 AbstractProcessor를 상속하면 기본적인 메서드들이 구현되어 있다. process() 메서드의 반환 타입은 boolean 이고, 만약 다른 프로세서에서도 처리할 필요가 잇으면 false를 반환하고, 다음 프로세서에서 해당 애노테이션을 처리할 필요가 없는 경우에는 true로 반환한다. 그러면 다른 프로세서한테 더 이상 해당 애노테이션을 처리하라고 부탁하지 않는다.

@Magic 애노테이션은 현재 Inteface, Class, Enum 모두 붙일 수 있는데 우리는 인터페이스에만 붙이고 싶다. 따라서 process() 메서드에 인터페이스가 아닌 경우에는 해당 엘리먼트에 애노테이션을 붙일 수 없다는 메시지를 남기고 인터페이스인 경우에는 가볍게 로그 정도 남기는 로직을 작성했다. 그러면 컴파일 시 인터페이스가 아닌 엘리먼트에 해당 애노테이션이 붙어있으면 경고와 함께 컴파일이 실행할 것이다.

참고로 모든 AbstractProcessor를 상속받은 클래스는 processEnv 변수를 사용할 수 있다.

 

배포

 

Maven 로컬 저장소에 배포

배포 관련해서는 Reflection과 Annotation을 이해하고 DI 컨테이너 만들기 블로그 글을 작성하면서 만든 적이 있다. 자세한 내용은 해당 블로그를 참고하자.

resources/META-INF/java.annotation.processing.Processor 파일을 만들어야 해당 프로세서를 사용하는 프로젝트에서 소스코드를 컴파일하는 시점에 프로세서가 위 설정대로 동작을 할 수 있다. 따라서 위 사진처럼 파일을 생성하고 그 안에 내용으로 위에서 만든 MagicMojaProcessor의 패키지 경로를 작성한다.

 

프로젝트가 성공적으로 빌드되어 Maven 로컬 저장소에 생성된 것을 확인할 수 있다.

 

기존 사용 프로젝트에 배포된 라이브러리 적용

아까 애노테이션 프로세서를 사용할 프로젝트에 maven 로컬 저장소를 사용하도록 설정하면 위에서 만든 애노테이션 프로세서용 라이브러리가 설치된 것을 확인할 수 있다.

 

프로젝트 컴파일 확인

의도한대로 인터페이스가 아닌 곳에서의 @Magic 애노테이션을 선언하는 경우 해당 엘리먼트에는 애노테이션을 사용할 수 없는 메시지를 확인할 수 있다.

 

MaginMojaProcess 로직 완성

implementation group: 'com.squareup', name: 'javapoet', version: '1.13.0'

javapoet 라이브러리를 사용하여 새로운 소스코드를 생성하자.

 

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
  // Magic 애노테이션을 가지고 있는 엘리먼트들을 참조
  Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(Magic.class);

  // 인터페이스에만 애노테이션을 붙어 있는지 확인
  for (Element element : elements) {
    Name elementName = element.getSimpleName();
    if (element.getKind() != ElementKind.INTERFACE) { // 컴파일 안되게
      processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Magic annotation can not be used on " + elementName); // 컴파일 에러
    } else {
      processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing " + elementName); // 처리된 엘리먼트 로깅
    }

    TypeElement typeElement = (TypeElement) element; // element를 TypeElement로 변환
    ClassName className = ClassName.get(typeElement); // className을 통해서 클래스에 대한 여러 정보를 참조할 수 있음

    // 메서드 생성
    MethodSpec pullOut = MethodSpec.methodBuilder("pullOut")
            .addModifiers(Modifier.PUBLIC)
            .returns(String.class)
            .addStatement("return $S", "Rabbit")
            .build();

    // 클래스 생성
    TypeSpec magicMoja = TypeSpec.classBuilder("MagicMoja")
            .addModifiers(Modifier.PUBLIC)
            .addSuperinterface(className)
            .addMethod(pullOut)
            .build();

    // ---------- 지금까지는 메모리 상의 객체로 클래스 정의 ----------

    // 소스코드 생성
    Filer filer = processingEnv.getFiler(); // Filer 인터페이스: 소스 코드, 클래스 코드 및 리소스를 생성할 수 있는 인터페이스
    try {
      // javapoet을 이용하여 해당 패키지에 magicMoja 클래스를 생성하도록 filer가 쓰도록 한다.
      JavaFile.builder(className.packageName(), magicMoja)
              .build()
              .writeTo(filer);
    } catch (IOException e) {
      processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "FATAL ERROR: " + e);
    }
  }

  return true;
}

javapoet 라이브러리에서 제공하는 JavaFile을 통해 filter를 직접적으로 사용하지 않고 좀 더 손쉽게 소스코드를 작성할 수 있다. 참고로 Filter는 createClassFile(), createResource(), createSourceFile() 메서드 등을 통해 각종 코드 및 리소스를 생성할 수 있다. JavaFile.builder() 메서드는 @Magic 애노테이션이 붙은 인터페이스(element)와 동일한 패키지 경로에 magicMoja를 만들고, writeTo(filter) 메서드를 통해 소스파일을 생성한다.

 

결과 확인

컴파일 시에 @MagicMoja 애노테이션이 붙은 인터페이스를 통해 애노테이션 프로세서가 이를 읽어 MagicMoja 클래스를 생성한 것을 확인할 수 있다.

 

 

출처

더 자바, 코드를 조작하는 다양한 방법

[Android] Annotation Processor 만들기

애노테이션 프로세서

Kotlin Symbol Processing Api Part 1 - Annotation과 KART