Java Annotation Processing 내부 동작 — JSR 269, 라운드 모델, 그리고 Lombok이 javac의 AST를 직접 바꾸는 비밀
@Getter한 줄로 게터가 생기고,@Inject한 줄로 의존성 그래프가 짜이고,@ConfigurationProperties가 외부 설정을 빈에 묶어 줍니다. 매크로가 없는 언어인 Java에서 이 마법은 모두 컴파일 시점에 일어납니다. 이 글은 그 마법의 표준 골격인 JSR 269 Pluggable Annotation Processing API의 라운드 모델과 Processor 라이프사이클을 풀어내고, 그 표준 바깥에서 javac의 내부 AST를 직접 변형하는 Lombok이 어떤 트릭으로 동작하는지까지 따라갑니다.
1. 왜 컴파일 시점 코드 생성이 필요한가
매크로 없는 언어의 숙제
C++에는 매크로가 있고 Lisp에는 매크로 시스템이 있습니다. Kotlin에는 컴파일러 플러그인이 있고 Rust에는 procedural macro가 있습니다. Java는 이런 종류의 컴파일 시점 변형 장치를 정식 언어 차원에서 제공하지 않습니다. 대신 별도의 SPI 한 가닥을 통해 컴파일 과정에 끼어들 수 있도록 해 두었는데, 그것이 바로 JSR 269 Pluggable Annotation Processing API입니다.
이 SPI 위에서 다음과 같은 일이 일어납니다.
- Lombok이
@Getter,@Builder,@Data를 보고 메서드와 필드를 추가합니다. - Dagger 2가
@Inject그래프를 분석해DaggerAppComponent같은 의존성 그래프 코드를 생성합니다. - MapStruct가
@Mapper를 보고 빈 매퍼의 구현 클래스를 생성합니다. - Google AutoValue가
@AutoValue를 보고 값 클래스의 구현체를 만듭니다. - Spring Boot Configuration Processor가
@ConfigurationProperties를 스캔해spring-configuration-metadata.json을 생성합니다. - Hibernate Validator Annotation Processor가
@NotNull,@Size같은 제약을 컴파일 시점에 검증합니다.
리플렉션이나 런타임 프록시로도 비슷한 일을 할 수 있지만, 컴파일 시점에 코드를 만들어 두면 다음과 같은 이득이 있습니다.
- 런타임 비용 0 — 생성된 코드는 평범한
.class이므로 호출에 리플렉션이 끼지 않습니다. - IDE 자동완성 가능 — 생성된 타입이 그대로 빌드 클래스패스에 들어가므로 IntelliJ나 Eclipse가 인지합니다.
- 빌드 시점 검증 — 잘못된 사용을 컴파일 오류로 잡아낼 수 있어 NPE 한 줄을 운영 시점이 아닌 빌드 시점에 발견합니다.
apt에서 JSR 269로
Java 5에는 별도의 apt 명령어가 있었습니다. Annotation Processing Tool이라는 도구로 javac와 분리된 별도 도구였습니다. Java 6에서 JSR 269가 등장하면서 javac 자체가 표준 API로 어노테이션 처리를 흡수했고, apt 도구는 결국 Java 8에서 제거되었습니다. 지금 우리가 이야기하는 어노테이션 처리는 모두 JSR 269 기반의 javac 통합 메커니즘입니다.
2. javac 컴파일 흐름과 어노테이션 처리의 위치
어노테이션 처리는 javac 컴파일 파이프라인의 한 단계입니다. 큰 흐름은 다음과 같습니다.
flowchart TD
Parse[Parse<br/>source to AST] --> Enter[Enter<br/>symbol tables]
Enter --> AP[Annotation Processing<br/>JSR 269 rounds]
AP --> Attribute[Attribute<br/>type checking]
Attribute --> Flow[Flow analysis]
Flow --> Desugar[Desugar<br/>generics, lambdas, etc.]
Desugar --> Generate[Generate<br/>bytecode .class]
요점은 어노테이션 처리가 타입 검사 이전에 실행된다는 사실입니다. 정확히는 파싱과 심볼 등록이 끝나고 타입 검사 직전에 끼어듭니다. 이 위치 선택은 의도적입니다.
- 어노테이션 처리기에서 생성한 새 소스가 다시 타입 검사를 받아야 하므로, 타입 검사 이전에 끝나야 모순이 없습니다.
- 어노테이션 처리기는 아직 검증되지 않은 AST를 보게 됩니다. 따라서
getElementsAnnotatedWith같은 API가 돌려주는Element는 타입 정보가 완전히 채워지지 않은 상태일 수 있습니다.
이 점이 표준 API가 AST 자체를 노출하지 않고 javax.lang.model.element.Element 같은 추상 모델만 노출하는 이유입니다. 컴파일러 단계에 종속되는 정보를 직접 다루지 못하게 막아 둔 것입니다.
3. Processor 인터페이스의 계약
어노테이션 처리기는 javax.annotation.processing.Processor 인터페이스를 구현합니다. 직접 구현하기보다 보통 AbstractProcessor를 상속해서 시작합니다.
다섯 가지 핵심 메서드
public interface Processor {
Set<String> getSupportedOptions();
Set<String> getSupportedAnnotationTypes();
SourceVersion getSupportedSourceVersion();
void init(ProcessingEnvironment processingEnv);
boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv);
Iterable<? extends Completion> getCompletions(
Element element, AnnotationMirror annotation,
ExecutableElement member, String userText);
}
각 메서드의 역할은 다음과 같습니다.
| 메서드 | 호출 시점 | 의미 |
|---|---|---|
init |
라이프사이클 시작 시 한 번 | ProcessingEnvironment 보관, 초기화 |
getSupportedAnnotationTypes |
javac가 처리기 선택 시 | 이 처리기가 관심 있는 어노테이션 FQN 목록 |
getSupportedSourceVersion |
javac가 처리기 선택 시 | 지원하는 소스 레벨 (예: RELEASE_17) |
getSupportedOptions |
javac가 옵션 파싱 시 | -A 옵션으로 받을 수 있는 키 목록 |
process |
라운드마다 호출 | 실제 어노테이션 처리 로직 |
처리기를 만들 때 직접 작성하는 코드는 대부분 process 안에 들어갑니다. 나머지는 보통 어노테이션으로 선언합니다.
@SupportedAnnotationTypes("com.example.Builder")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
@SupportedOptions({"debug", "verbose"})
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
// ...
return true;
}
}
와일드카드 지원
getSupportedAnnotationTypes가 돌려주는 문자열은 단순 FQN뿐 아니라 와일드카드를 허용합니다.
"com.example.Builder"— 정확히 이 어노테이션만"com.example.*"—com.example패키지와 하위 패키지의 모든 어노테이션"*"— 모든 어노테이션
Lombok이 "*"를 쓰는 처리기의 대표적인 예입니다. 자신이 다룰 어노테이션 목록을 정적으로 선언하지 않고 라운드마다 동적으로 판단합니다.
process의 반환값 — claim
process가 돌려주는 boolean은 단순한 성공/실패 플래그가 아닙니다. 이 처리기가 자신이 받은 어노테이션 집합을 점유했는지를 가리키는 플래그입니다.
true를 반환하면 이 라운드에서 처리한 어노테이션들은 다른 처리기에게 전달되지 않습니다.false를 반환하면 같은 라운드에서 다른 처리기도 동일한 어노테이션을 본 뒤 처리할 수 있습니다.
일반적인 코드 생성 처리기는 true를 돌려줘서 같은 어노테이션을 두 번 다루지 않도록 합니다. 반면 검증만 하는 처리기나 AST를 직접 건드리는 처리기는 보통 false를 돌려줍니다. 후술하지만 Lombok은 false를 돌려주는 처리기의 대표적인 예입니다.
4. 라운드 모델 — 새 소스가 다시 들어오는 메커니즘
JSR 269의 가장 독특한 개념은 **라운드(round)**입니다. 처리기가 새 소스 파일을 생성하면 그 파일이 다시 어노테이션 처리 대상이 되도록 javac가 처리기를 반복 호출합니다.
라운드의 흐름
flowchart TD
Start[Initial round<br/>sources from user]
Start --> P1[Run all processors]
P1 --> Gen1{New source<br/>files generated?}
Gen1 -->|yes| Next[Next round<br/>new sources only]
Next --> P2[Run all processors again]
P2 --> Gen2{New source<br/>files generated?}
Gen2 -->|yes| Next
Gen2 -->|no| Final[Final round<br/>processingOver = true]
Gen1 -->|no| Final
Final --> P3[Run all processors once more]
P3 --> Compile[Compile all sources]
규칙을 정리하면 다음과 같습니다.
- 첫 라운드에서는 사용자가 작성한 모든 소스가 입력입니다.
- 어떤 처리기가
Filer로 새.java파일을 생성하면 그 파일이 다음 라운드의 입력이 됩니다. - 어떤 라운드에서 새 파일이 생성되지 않으면 한 번 더 라운드가 돌며 이때
RoundEnvironment.processingOver()가true를 돌려줍니다. 처리기에게 마무리할 기회를 주는 라운드입니다. - 모든 라운드가 끝나면 원본과 생성된 모든 소스가 함께 타입 검사 → 바이트코드 생성으로 넘어갑니다.
이 모델 덕분에 처리기는 다른 처리기가 만든 소스도 다시 처리할 수 있습니다. 예를 들어 Lombok이 @Builder로 빌더 클래스를 생성하면, 이후 라운드에서 다른 처리기가 그 빌더 클래스의 메서드를 보고 또 다른 처리를 할 수 있습니다.
RoundEnvironment의 역할
각 라운드마다 처리기는 RoundEnvironment 객체를 받습니다. 이 객체가 그 라운드의 상태를 보여 줍니다.
public interface RoundEnvironment {
boolean processingOver();
boolean errorRaised();
Set<? extends Element> getRootElements();
Set<? extends Element> getElementsAnnotatedWith(TypeElement a);
Set<? extends Element> getElementsAnnotatedWith(Class<? extends Annotation> a);
}
getRootElements는 이 라운드의 입력에 포함된 최상위 타입과 패키지를 돌려줍니다.getElementsAnnotatedWith는 특정 어노테이션이 붙은 모든 요소를 돌려줍니다.processingOver는 이 라운드가 마지막인지 알려 줍니다. 마지막 라운드에서는getRootElements가 보통 비어 있습니다.errorRaised는 직전 라운드에서Messager로 에러가 보고되었는지 알려 줍니다. 에러 상황에서 코드 생성을 건너뛰는 가드로 자주 씁니다.
마지막 라운드의 함정
processingOver()가 true인 라운드에서 새 소스 파일을 생성하려고 하면 FilerException이 발생합니다. 더 이상 라운드를 돌 수 없기 때문입니다. 처리기는 마지막 라운드를 별도로 분기해서 새 파일을 만들지 않도록 작성해야 합니다.
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
if (roundEnv.processingOver()) {
// 마무리 작업만. 새 .java 파일을 만들면 FilerException.
return false;
}
// ...
}
5. ProcessingEnvironment — 처리기에 주어지는 네 가지 도구
init에서 받은 ProcessingEnvironment가 처리기의 모든 도구함입니다. 네 가지 핵심 도구를 제공합니다.
Filer — 새 파일 만들기
Filer로 새 소스 파일, 새 클래스 파일, 또는 리소스 파일을 만듭니다. 가장 자주 쓰는 메서드는 createSourceFile입니다.
JavaFileObject file = processingEnv.getFiler()
.createSourceFile("com.example.OrderBuilder", originatingElement);
try (Writer w = file.openWriter()) {
w.write("package com.example;\n");
w.write("public class OrderBuilder { ... }\n");
}
두 번째 인자인 originating elements는 이 파일이 어떤 입력 요소에서 파생되었는지 javac에게 알려 줍니다. Gradle이나 Bazel 같은 점진 빌드 도구가 이 정보를 활용해 “원본이 바뀌면 생성물도 다시 만든다”는 의존성을 추적합니다.
Filer는 같은 라운드에서 같은 이름의 소스 파일을 두 번 만들 수 없게 막아 줍니다. 처리기가 무한 라운드 루프에 빠지는 흔한 실수를 차단합니다.
Messager — 컴파일 메시지 보고
Messager로 에러, 경고, 노트를 javac의 메시지 스트림에 흘려보냅니다.
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@Builder can only be applied to classes",
element);
Diagnostic.Kind.ERROR로 보고한 메시지는 javac의 컴파일 실패 조건이 됩니다. 처리기에서 검증을 수행하고 잘못된 사용을 컴파일 오류로 만들고 싶을 때 이 채널을 씁니다. 메시지에 Element를 같이 넘기면 IDE가 해당 위치에 빨간 줄을 그어 줍니다.
Elements — 요소 모델 유틸리티
javax.lang.model.util.Elements는 Element를 다루는 유틸리티입니다.
getTypeElement("java.util.List")— FQN으로TypeElement조회getPackageOf(element)— 요소가 속한 패키지getAllMembers(typeElement)— 상속받은 멤버까지 포함한 모든 멤버getDocComment(element)— Javadoc 주석 추출
Types — 타입 모델 유틸리티
Types는 TypeMirror를 다루는 유틸리티입니다.
isAssignable(t1, t2)—t1이t2에 대입 가능한지isSameType(t1, t2)— 두 타입이 동일한지erasure(t)— 제네릭 소거된 타입getDeclaredType(typeElement, typeArgs...)— 파라미터화된 타입 만들기
타입 비교를 equals로 하면 안 됩니다. Types의 메서드를 통해서 비교해야 합니다. TypeMirror는 컴파일러 내부 표현이라 동일성 비교가 단순하지 않습니다.
6. SPI 등록 — 처리기를 javac에게 알리는 방법
JSR 269 처리기는 표준 Java SPI 메커니즘으로 등록합니다. 처리기 JAR 안에 다음 경로의 파일을 두기만 하면 됩니다.
META-INF/services/javax.annotation.processing.Processor
이 파일에 한 줄에 하나씩 처리기 클래스의 FQN을 적습니다.
com.example.builder.BuilderProcessor
com.example.builder.ValidationProcessor
javac는 컴파일 시 클래스패스(정확히는 --processor-path나 -classpath)를 스캔해서 이 파일을 찾고, 거기 적힌 처리기들을 모두 로드해 라운드를 돕니다.
Google AutoService
이 META-INF 파일을 손으로 쓰면 처리기 이름을 바꿀 때마다 누락되기 쉽습니다. Google AutoService는 자기 자신이 어노테이션 처리기인데, @AutoService(Processor.class)가 붙은 클래스를 찾아서 SPI 파일을 자동 생성해 줍니다. 처리기를 작성하면서 다른 처리기가 자신을 등록해 주는 재귀적인 구조입니다.
7. 실전 — 간단한 @Builder 처리기
여기까지의 개념을 작은 처리기에 묶어 봅니다. @Builder가 붙은 클래스에 대해 빌더 클래스를 생성하는 최소 코드입니다.
@SupportedAnnotationTypes("com.example.Builder")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
for (Element element : roundEnv.getElementsAnnotatedWith(Builder.class)) {
if (element.getKind() != ElementKind.CLASS) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@Builder can only be applied to classes",
element);
continue;
}
TypeElement type = (TypeElement) element;
generateBuilder(type);
}
return true;
}
private void generateBuilder(TypeElement type) {
String pkg = processingEnv.getElementUtils()
.getPackageOf(type).getQualifiedName().toString();
String className = type.getSimpleName() + "Builder";
String qualifiedName = pkg + "." + className;
List<VariableElement> fields = ElementFilter.fieldsIn(type.getEnclosedElements());
try {
JavaFileObject file = processingEnv.getFiler()
.createSourceFile(qualifiedName, type);
try (PrintWriter w = new PrintWriter(file.openWriter())) {
w.printf("package %s;%n", pkg);
w.printf("public class %s {%n", className);
for (VariableElement f : fields) {
w.printf(" private %s %s;%n", f.asType(), f.getSimpleName());
}
for (VariableElement f : fields) {
w.printf(" public %s %s(%s v) { this.%s = v; return this; }%n",
className, f.getSimpleName(), f.asType(), f.getSimpleName());
}
w.printf(" public %s build() { return new %s(); }%n",
type.getSimpleName(), type.getSimpleName());
w.printf("}%n");
}
} catch (IOException e) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR, "Failed: " + e.getMessage(), type);
}
}
}
이 처리기는 다음과 같이 동작합니다.
- 첫 라운드에서
@Builder가 붙은 클래스를 찾습니다. - 클래스가 아닌 곳에 붙어 있으면 컴파일 오류를 보고합니다.
Filer로<ClassName>Builder.java를 생성합니다.true를 돌려줘 다른 처리기가 같은@Builder를 다시 처리하지 않도록 합니다.- 다음 라운드에서는 새로 생성된 빌더 클래스가 입력으로 들어오지만, 그 안에는
@Builder가 없으므로 더 이상 처리할 것이 없고 라운드가 종료됩니다.
JavaPoet 같은 라이브러리를 쓰면 위의 문자열 조립을 타입 안전하게 작성할 수 있어 실무에서는 보통 그쪽을 씁니다. 참고로 JavaPoet 원조는 Square 저장소였으나 2024년 archived 처리되며 Palantir fork가 사실상 후속 유지자가 되었습니다.
8. Lombok이 표준에서 벗어난 지점
여기까지가 JSR 269 표준입니다. 표준이 허용하는 것은 명확합니다. 읽기 전용 Element 모델을 보고, Filer로 새 파일을 만들 수 있을 뿐. 기존 소스를 수정하는 것은 표준에 포함되지 않습니다.
그런데 Lombok은 기존 소스를 수정합니다. @Getter를 붙이면 그 클래스에 getXxx() 메서드가 새로 생기고, @AllArgsConstructor를 붙이면 생성자가 추가됩니다. 새 클래스를 생성하는 게 아니라 기존 클래스의 AST 자체를 변경합니다. 이건 표준이 허용하지 않는 동작입니다.
Lombok의 트릭 — javac 내부 클래스 캐스팅
Lombok이 사용하는 비밀은 단순합니다. JSR 269가 처리기에게 넘기는 Element는 인터페이스이지만, 그 구현체는 사실 javac 내부 클래스 com.sun.tools.javac.code.Symbol의 인스턴스입니다. Symbol은 자신의 AST 노드를 참조하고 있습니다.
// javax.lang.model.element.Element 위로 받은 객체를 javac 내부 타입으로 캐스팅
JCTree.JCClassDecl classDecl = (JCTree.JCClassDecl) trees.getTree(element);
// AST에 새 메서드 노드를 추가
classDecl.defs = classDecl.defs.append(generatedGetterMethod);
com.sun.tools.javac.tree.JCTree는 javac가 내부에서 쓰는 AST 노드입니다. Lombok은 이 클래스의 필드를 직접 수정해서 클래스 선언에 메서드 노드를 추가하거나 필드의 modifier를 바꿉니다. JSR 269가 던져 준 객체가 사실 javac 내부 객체 그 자체라는 사실을 이용한 캐스팅 트릭입니다.
이 변형은 어노테이션 처리 단계에서 일어나므로, 그 다음 단계인 타입 검사기는 변형된 AST를 보게 됩니다. 사용자 코드 입장에서는 마치 처음부터 메서드가 있었던 것처럼 보입니다.
claim=false와 "*" 트릭
Lombok은 자기가 다룰 어노테이션을 정적으로 선언하지 않습니다.
@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class LombokProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
// 모든 라운드에서 AST를 훑어 Lombok 어노테이션을 찾음
// ...
return false; // 절대 claim 하지 않음
}
}
두 가지가 핵심입니다.
"*"로 모든 어노테이션을 구독 — 자기가 직접 어노테이션을 등록하지 않아도 라운드마다 호출됩니다.false를 돌려줘 claim 하지 않음 — 다른 처리기가 같은 어노테이션을 보지 못하게 막지 않습니다.
@Builder가 붙은 클래스를 Lombok이 처리해서 빌더 클래스를 추가하고, 같은 라운드에서 다른 어노테이션 처리기가 그 어노테이션을 또 처리할 수 있습니다. 표준에 어긋나지 않는 시민으로 보이게 하면서 내부 AST를 바꾸는 것이 Lombok의 설계입니다.
Lombok의 라운드 흐름
flowchart TD
Round1[Round 1] --> Scan[Scan all elements<br/>for Lombok annotations]
Scan --> Handlers[Dispatch to handlers<br/>Getter, Builder, EqualsAndHashCode]
Handlers --> Mutate[Mutate JCTree nodes<br/>in place]
Mutate --> Return[Return false<br/>do not claim]
Return --> Done{processingOver?}
Done -->|no| Round1
Done -->|yes| End[Compiler continues<br/>to type checking]
Lombok은 한 라운드에서 모든 변형을 끝내려고 합니다. 변형이 끝나면 그 다음 라운드에는 새로 만들 일이 없으니 마지막 라운드까지 빠르게 도달합니다.
9. JDK 17 이후의 강한 캡슐화와 Lombok의 회피
JDK 9의 모듈 시스템이 도입된 뒤로 com.sun.tools.javac.* 같은 내부 패키지는 모듈 외부에서 접근하기가 어려워졌습니다. JDK 17에서는 강한 캡슐화가 기본값이 되면서 (--illegal-access=deny가 사실상 기본 동작) --add-opens 없이는 리플렉션 접근도 차단됩니다.
이로 인해 Lombok은 한동안 JDK 16+에서 다음과 같은 오류를 띄웠습니다.
java.lang.IllegalAccessError: class lombok.javac.apt.LombokProcessor
cannot access class com.sun.tools.javac.processing.JavacProcessingEnvironment
Lombok은 이 문제를 두 방향으로 해결했습니다.
빌드 도구 설정 측면
pom.xml이나 build.gradle에 다음과 같은 --add-opens 옵션을 컴파일러 인자로 추가하면 Lombok이 다시 내부 클래스에 접근할 수 있습니다.
tasks.withType<JavaCompile> {
options.compilerArgs.addAll(listOf(
"--add-exports", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
"--add-exports", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
"--add-exports", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"--add-exports", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
))
}
Lombok 측면
Lombok의 최신 버전들은 자기 자신이 시작될 때 인스트루멘테이션 에이전트를 부착해 런타임에 jdk.compiler 모듈을 자기 자신에게 add-opens 하는 트릭을 씁니다. 사용자는 명시적으로 옵션을 주지 않아도 동작하지만, 내부적으로는 모듈 경계를 우회하고 있는 셈입니다.
이런 우회가 가능한 것은 Java 인스트루멘테이션 API가 합법적으로 제공하는 기능이기 때문이지만, JDK가 더 엄격해질 가능성도 있어서 Lombok 같은 도구는 항상 JDK 새 버전 출시 직후에 추격 릴리스를 내야 합니다. JDK가 com.sun.tools.javac.tree.JCTree 내부 구조를 바꿀 때마다 (예를 들어 JCImport의 필드가 바뀐 경우) Lombok이 그에 맞춰 어댑터를 추가해야 합니다.
10. Gradle 점진 빌드와 어노테이션 처리기
Gradle 4.7부터 어노테이션 처리기에 대한 점진 빌드를 지원합니다. 어떤 처리기가 점진 빌드 친화적인지를 처리기 자신이 선언해야 합니다.
isolating vs aggregating
| 카테고리 | 정의 | 예시 |
|---|---|---|
isolating |
각 입력 요소를 독립적으로 처리. 한 요소가 바뀌면 그 요소만 다시 처리 | Dagger 한 모듈, Lombok |
aggregating |
여러 요소의 정보를 모아 단일 산출물 생성. 입력이 하나만 바뀌어도 전체를 다시 처리 | Configuration Properties Metadata |
dynamic |
런타임에 자기 카테고리를 결정 | 옵션에 따라 동작이 달라지는 처리기 |
선언 위치
처리기 JAR 안에 다음 파일을 두면 Gradle이 인식합니다.
META-INF/gradle/incremental.annotation.processors
파일 내용은 처리기마다 한 줄:
com.example.builder.BuilderProcessor,isolating
com.example.module.MetadataProcessor,aggregating
이 메타파일이 없는 처리기는 “비점진”으로 분류되어 한 클래스만 바뀌어도 전체 어노테이션 처리가 다시 돌게 됩니다. Lombok이나 Dagger 같은 인기 처리기가 모두 이 메타파일을 제공합니다.
isolating 처리기의 제약
isolating으로 선언하려면 다음 두 가지를 모두 만족해야 합니다.
- 한 입력 요소에 대한 결정은 그 요소의 AST에서 도달 가능한 정보만으로 내려야 한다.
- 한 입력 요소당 하나의 산출물(또는 하나의 일관된 산출물 집합)만 만들어야 한다.
getRootElements()를 그대로 받아 전체를 순회하는 처리기는 isolating이 될 수 없습니다. 어떤 요소가 변경되었는지를 Gradle이 좁힐 수 없기 때문입니다. isolating 처리기는 보통 getElementsAnnotatedWith로 받은 요소 각각을 따로 처리하고, 그 요소 자체와 originating elements만 본 채로 끝냅니다.
11. 한계와 함정
어노테이션 처리는 만능이 아닙니다. 몇 가지 알려진 한계가 있습니다.
메서드 본문은 보지 못한다
Element/TypeMirror 모델은 시그니처와 어노테이션, 변수 선언 정도까지만 보여 줍니다. 메서드 본문은 모델에 노출되지 않습니다. 처리기가 “이 메서드 안에서 이 변수가 어떻게 쓰이는지”를 보려면 com.sun.source.util.Trees API로 따로 AST에 접근해야 하는데, 이는 표준에서 살짝 벗어난 영역이고 IDE마다 호환성이 다릅니다.
같은 클래스의 새 멤버 추가는 표준이 아님
Lombok이 하는 “기존 클래스에 메서드 추가”는 JSR 269가 보장하지 않습니다. 같은 효과를 표준 범위에서 내려면 보통 하위 클래스를 새로 생성하는 방향으로 우회합니다. AutoValue가 Foo 옆에 AutoValue_Foo를 만드는 이유가 이것입니다.
디버깅이 어렵다
처리기 자신이 javac 안에서 실행되므로 일반 디버거로 잡기가 까다롭습니다. 보통은 -Xlint:processing이나 처리기 안에서 Messager.NOTE로 로그를 흘리는 식으로 디버깅합니다. IntelliJ는 “Annotation Processor Debug” 옵션을 제공해 별도 JVM에서 처리기만 따로 실행할 수 있게 해 줍니다.
라운드 폭주
새 어노테이션이 붙은 새 소스를 라운드마다 만들어 내는 처리기는 라운드가 끝나지 않고 폭주할 수 있습니다. javac는 Filer가 같은 이름의 파일을 두 번 만들지 못하게 막아서 가장 흔한 무한 라운드는 차단하지만, 매번 다른 이름의 파일을 만들면 막을 수 없습니다. 처리기 작성자는 “이 라운드에서 만들 파일이 정말로 새 정보를 담는가”를 의식해야 합니다.
Kotlin과의 거리
Kotlin은 자체 컴파일러를 쓰므로 JSR 269 처리기를 직접 실행할 수 없습니다. Kapt(Kotlin Annotation Processing Tool)이 Kotlin 코드를 Java stub으로 변환한 뒤 그 stub 위에 처리기를 돌리는 우회를 사용합니다. JetBrains는 이 모델의 성능 문제를 해결하기 위해 KSP(Kotlin Symbol Processing) API를 별도로 만들었습니다. KSP는 JSR 269와 비슷한 모양의 API이지만 Java 모델이 아니라 Kotlin 모델 위에서 직접 동작합니다.
12. 정리
JSR 269는 매크로 없는 언어 Java가 마련한 컴파일 시점 확장 지점입니다. 핵심은 다음과 같습니다.
- 라운드 모델 — 처리기가 만든 새 소스가 다시 입력으로 들어와 라운드가 반복됩니다. 새 파일이 더 생기지 않으면
processingOver=true인 마지막 라운드 한 번이 더 돕니다. - claim 의미의
boolean—process의 반환값은 “이 어노테이션을 다음 처리기에 넘기지 않겠다”는 점유 표시입니다. - 읽기 전용 모델 + Filer — 표준이 허락하는 것은
Element/TypeMirror를 읽고Filer로 새 파일을 만드는 일까지입니다. - Lombok의 비표준 트릭 — JSR 269가 던져 준
Element를 javac 내부JCTree로 캐스팅해 기존 AST를 직접 변형합니다. JDK 17 이후의 강한 캡슐화에는 인스트루멘테이션 에이전트로 모듈을 열어 우회합니다. - Gradle 점진 빌드 —
META-INF/gradle/incremental.annotation.processors파일로 처리기를isolating/aggregating/dynamic카테고리에 등록합니다.
@Getter 한 줄이나 DaggerAppComponent 한 줄 뒤에 이런 라운드와 AST가 굴러가고 있다는 사실을 알고 나면, “왜 컴파일 에러 메시지가 이상한 곳을 가리키지” 같은 상황에서 한 단계 더 깊이 추적할 수 있게 됩니다. 어노테이션 처리는 마법이 아니라 잘 정의된 SPI 한 가닥입니다.
참고자료
- JSR 269 Pluggable Annotation Processing API — https://jcp.org/en/jsr/detail?id=269
- JSR 269 1.8 Maintenance Release 2 Specification — https://download.oracle.com/otndocs/jcp/pluggable_annotation_processing-1_8-mrel2-spec/
- Java Platform SE Processor API (
javax.annotation.processing.Processor) — https://docs.oracle.com/en/java/javase/21/docs/api/java.compiler/javax/annotation/processing/Processor.html - Java Platform SE RoundEnvironment — https://docs.oracle.com/en/java/javase/21/docs/api/java.compiler/javax/annotation/processing/RoundEnvironment.html
- Java Platform SE Filer — https://docs.oracle.com/en/java/javase/21/docs/api/java.compiler/javax/annotation/processing/Filer.html
- javac 명령어 문서 — https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html
- Project Lombok 공식 문서 — https://projectlombok.org/
- Project Lombok 저장소 — https://github.com/projectlombok/lombok
- Project Lombok Annotation Processing System (DeepWiki) — https://deepwiki.com/projectlombok/lombok/2.1-annotation-processing-system
- Gradle Incremental Annotation Processing — https://docs.gradle.org/current/userguide/java_plugin.html#sec:incremental_annotation_processing
- Google AutoService — https://github.com/google/auto/tree/main/service
- JavaPoet (Square 원조, 2024년 archived) — https://github.com/square/javapoet
- JavaPoet (Palantir fork, 사실상 후속 유지자) — https://github.com/palantir/javapoet
- KSP(Kotlin Symbol Processing) — https://kotlinlang.org/docs/ksp-overview.html

