Annotation Processor 를 활용하여 코드 생산성 높이기
19 Nov 2022개요
개발자는 수많은 코드를 작성하면서 반복적인 코드 작성에 많은 시간을 할애합니다.
반복적인 코드를 줄이는 것은 코드 생산성에 큰 영향을 미칠 뿐만 아니라 관리 포인트를 줄여 유지 보수에도 용이합니다.
자바에서는 이러한 지루하고 반복적인 코드를 줄이기 위한 여러 도구들을 제공합니다.
그중 하나인 Annotation Processor 를 사용한다면 컴파일 시점에 코드를 자동으로 생성하는 기능을 활용할 수 있습니다.
수많은 개발자가 사용하는 Lombok 프로젝트 또한 위 방식을 사용하여 유용한 코드를 생성합니다. Lombok 에서 제공하는 기능 중 하나와 유사한 어노테이션을 만들어보고 내부적으로 어떻게 동작하는지 알아보겠습니다.
기대 효과 (예시)
AS-IS
public class MyClass {
private String name;
private int age;
...
private MyClass(String name, int age ... ) {
this.name = name;
this.age = age;
...
}
public static MyClass of(String name, int age ... ) {
return new MyClass(name, age, ... );
}
}
TO-BE
@StaticFactory(methodName = "of")
public class MyClass {
private String name;
private int age;
...
}
Annotation Processor 란
Annotation Processor 는 자바에서 제공하는 OpenApi 로, 컴파일 시점에 특정 애노테이션을 참조하여 구체적인 동작 행위를 기술할 수 있게 합니다. (실제로 javac 의 일부여서 컴파일 시점에 작동되고 리플렉션을 사용하지 않으므로 빠르고 효율적입니다)
Annotation Processor 의 주요 특징으로는 컴파일 중인 코드 수정은 불가능하고 코드 생성만이 가능합니다.

그럼 도대체 Lombok 과 같은 라이브러리는 어떻게 소스코드를 조작했을까요?
Lombok 은 공개된 API 사용이 아닌 컴파일러 내부 클래스를 사용하여 AST 를 조작한 후 결과적으로 수정된 코드를 제공합니다.
이 부분은 Lombok 이 해킹이라고 취급받는 부분으로, 해킹이다 아니다 라는 주제로 개발자들 사이에서 논란이 있습니다. 실제 com.sun.tools API 설명에도 아래와 같이 ‘예고 없이 변경될 수 있다’ 라고 경고하고 있습니다.
This is NOT part of any supported API. If you write code that depends on this, you do so at your own risk. This code and its internal interfaces are subject to change or deletion without notice.
AST (Abstract Syntax Tree) 란
자바 컴파일러는 자바 코드를 AST 라는 문법 트리 구조로 관리하고 있습니다.
아래 그림을 통해 기존 코드가 어떻게 AST 로 파싱 되어 있는지 알 수 있습니다.
public class MyClass {
private String name;
private int age;
}

위의 AST 에 새롭게 추가할 코드를 붙이면 아래와 같은 구조가 됩니다.

public class MyClass {
private String name;
private int age;
public static MyClass of(...) {
return ...
}
}
AST 는 com.sun.source
모듈에서 제공되는 TreeScanner
를 통해 순회할 수 있으며 부모 메서드를 오버라이딩 하여 각 리소스를 접근하게 됩니다.
- visitClass
- visitMethod
- visitVariable
- …
public class CustomTreePathScanner extends TreePathScanner<Object, CompilationUnitTree> {
@Override
public Trees visitClass(ClassTree classTree, CompilationUnitTree unitTree) {
...
}
}
위와 같이 scanner 를 통해 순회된 리소스에 코드 조작을 하려면 TreeTranslator
를 accept 시키면 됩니다.
public class CustomTreeTranslator extends TreeTranslator {
@Override
public void visitClassDef(JCClassDecl jcClass) {
super.visitClassDef(jcClass);
...
jcClass.defs = jcClass.defs.prepend(...);
}
}
public class CustomTreePathScanner extends TreePathScanner<Object, CompilationUnitTree> {
private final ProcessingEnvironment processingEnv;
@Override
public Trees visitClass(ClassTree classTree, CompilationUnitTree unitTree) {
final var compilationUnit = (JCCompilationUnit) unitTree;
final var classTranslator = new CustomTreeTranslator();
compilationUnit.accept(classTranslator);
return Trees.instance(processingEnv);
}
}
멀티 모듈에서의 적용
이제 위 내용을 응용하여 정적 팩토리 메서드 코드를 예시로 생성해 보겠습니다.
정적 팩토리 메서드 생성을 지칭하는 어노테이션을 만듭니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface StaticFactory {
String methodName() default "of";
AccessLevel access() default AccessLevel.PUBLIC;
}
public enum AccessLevel {
PUBLIC,
PROTECTED,
PRIVATE
}
@Retention(RetentionPolicy.SOURCE)
: 컴파일 시점까지만 사용됩니다.String methodName()
: 정적 팩토리 메서드 이름을 지정할 수 있습니다.AccessLevel access()
: 정적 팩토리 메서드 접근제어자를 지정할 수 있습니다
이 주제의 핵심인 AbstractProcessor
를 상속받는 Annotation Processor 를 만듭니다.
public class StaticFactoryProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
...
return true;
}
}
- 재정의된 process() 메서드가 어노테이션 로직 처리를 담당하게 됩니다.
- process() 의 반환값이 true 일 경우 다른 라운드에서의 처리를 하지 않아도 됨을 의미합니다.
위와 같이 정의된 Annotation Processor 는 컴파일러에게 알려줘야 동작하게 되는데, 주로 두 가지 방법이 사용됩니다. (중복은 에러가 발생합니다)
1. META-INF 내 Processor 파일 정의
resources 디랙터리 하위에 META-INF/services 디렉터리를 생성합니다.
{module}/src/main/resources/META-INF/services
파일을 하나 생성합니다. 이름은 다음과 같습니다.
javax.annotation.processing.Processor
등록할 Annotation Processor 의 경로를 적습니다. (패키지 경로를 포함하는 CanonicalName)
xxx.yyy.zzz.StaticFactoryProcessor
2. @AutoService 사용하기
META-INF 내 Processor 파일을 정의하는 방식과 달리 google 에서 제공하는 auto-service 라이브러리를 이용하면 어노테이션 하나로 등록이 완료됩니다.
아래와 같이 의존성을 추가합니다. auto-service 또한 컴파일 시점에 어노테이션 프로세서에 의해 동작되므로 아래와 같이 구성합니다.
dependencies {
compileOnly "com.google.auto.service:auto-service:1.0.1"
annotationProcessor "com.google.auto.service:auto-service:1.0.1"
}
등록할 Annotation Processor 클래스 위에 어노테이션을 정의합니다.
@AutoService(Processor.class)
public class StaticFactoryProcessor extends AbstractProcessor {
...
}
다시 본론으로 돌아와,
@SupportedAnnotationTypes
, @SupportedSourceVersion
어노테이션을 통해 지원하는 어노테이션과 자바 버전을 명시합니다.
@SupportedAnnotationTypes("xxx.yyy.zzz.StaticFactory")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
@AutoService(Processor.class)
public class StaticFactoryProcessor extends AbstractProcessor {
...
}
@SupportedAnnotationTypes
: Annotation Processor 를 동작시킬 어노테이션을 명시합니다.@SupportedSourceVersion
: 자바 버전을 명시합니다.- 두 어노테이션 대신 AbstractProcessor 메서드를 재정의 하여도 됩니다.
getSupportedAnnotationTypes()
getSupportedSourceVersion()
이제 process 를 재정의하여 어노테이션 로직을 생성합니다.
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
roundEnv.getElementsAnnotatedWith(StaticFactory.class)
.stream()
.filter(this::isSupported)
.forEach(this::updateElement);
return true;
}
private boolean isSupported(Element element) {
final var simpleName = element.getSimpleName();
if (element.getKind() != ElementKind.CLASS) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, simpleName + " is not Supported (class type only)");
return false;
} else {
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing " + simpleName);
return true;
}
}
private void updateElement(Element element) {
final var trees = Trees.instance(processingEnv);
final var path = trees.getPath(element);
final var annotation = element.getAnnotation(StaticFactory.class);
final var scanner = new StaticFactoryTreePathScanner(annotation, processingEnv);
scanner.scan(path, path.getCompilationUnit());
}
isSupported()
메서드를 통해 CLASS 타입의 Element 만을 거릅니다.updateElement()
메서드에서는 받은 Element 를 scanner 를 통해 순회하게 합니다.processingEnv
는 부모인AbstractProcessor
에서 제공됩니다. 해당 객체를 통해com.sun.*
모듈에서 제공되는 인스턴스들을 생성할 수 있습니다.
만약 com.sun.source.util.* 모듈이 import 되지 않는다면?
해당 모듈 gradle 내 compileJava 를 아래와 같이 추가합니다.
(만약 사용할 모듈이 더 있다면 아래 --add-exports
구문을 사용하여 추가합니다)
compileJava {
options.compilerArgs += [
"--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"
]
}
IDE 를 인텔리제이로 사용한다면 Preferences... > Build, Execution, Deployment > Compiler > Java Compiler
에 위치한 Compiler Parameters 를 수정합니다.

+
버튼을 누르고 Annotation Processor 가 위치한 모듈을 선택한 뒤 아래와 같이 파라미터를 입력합니다.
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
주의: 위 gradle 에서 적은 옵션과 문법이 살짝 다릅니다.
다시 본론으로 돌아와 Scanner 를 정의합니다.
TreePathScanner
는 AST 를 순회하며 원하는 위치의 코드를 접근할 수 있게 합니다.
public class StaticFactoryTreePathScanner extends TreePathScanner<Object, CompilationUnitTree> {
private final StaticFactory annotation;
private final ProcessingEnvironment processingEnv;
private final TreeTranslator treeTranslator;
public StaticFactoryTreePathScanner(StaticFactory annotation, ProcessingEnvironment processingEnv) {
final var treeTranslator = new StaticFactoryClassTreeTranslator(annotation, processingEnv);
this.annotation = annotation;
this.processingEnv = processingEnv;
this.treeTranslator = treeTranslator;
}
@Override
public Trees visitClass(ClassTree classTree, CompilationUnitTree unitTree) {
Optional.ofNullable(unitTree)
.map(JCCompilationUnit.class::cast)
.filter(this::isSupported)
.ifPresent(u -> u.accept(treeTranslator));
return Trees.instance(processingEnv);
}
private boolean isSupported(CompilationUnitTree unitTree) {
return unitTree.getSourceFile().getKind() == JavaFileObject.Kind.SOURCE;
}
}
isSupported()
메서드를 통해 자바 파일(.java) 임을 확인합니다.treeTranslator
을 accept 하여 코드 조작을 수행합니다.- 반환값으로 현재 trees 인스턴스를 반환하여 트리 구조를 유지합니다.
TreeTranslator
에서는 순회한 위치의 코드를 조작하는 로직이 담기게 됩니다.
public class StaticFactoryClassTreeTranslator extends TreeTranslator {
private final StaticFactory annotation;
private final TreeMaker maker;
private final Names names;
public StaticFactoryClassTreeTranslator(StaticFactory annotation, ProcessingEnvironment processingEnv) {
final var context = ((JavacProcessingEnvironment) processingEnv).getContext();
this.annotation = annotation;
this.maker = TreeMaker.instance(context);
this.names = Names.instance(context);
}
@Override
public void visitClassDef(JCClassDecl jcClass) {
super.visitClassDef(jcClass);
final var constructor = createPrivateAllArgsConstructor(jcClass);
final var staticFactoryMethod = createStaticFactoryMethod(jcClass);
jcClass.defs = jcClass.defs.prependList(
List.of(constructor, staticFactoryMethod)
);
}
}
TreeMaker
를 통해 새롭게 만들어 붙일 AST 를 생성합니다.Names
: 컴파일러 내부 네임 테이블을 접근하는 객체입니다. 이름에 대응하는 리소스를 찾거나 새로운 이름을 등록할 수 있습니다.JCClassDecl
: 순회된 클래스의 AST 정보입니다. 접근제어자, 클래스명, 클래스 변수 등 여러 정보들이 담겨있습니다.jcClass.defs.prependList()
: 순회된 클래스 위치에 새롭게 조작된 코드를 붙입니다.prepend()
: 현재 코드 위치의 앞 부분에 추가append()
: 현재 코드 위치의 뒤 부분에 추가
private 생성자 생성하기
AST 에서 받은 정보 (JCClassDecl
) 는 위에서 살펴 보았듯이 대략 아래와 같은 구조로 되어 있을 것입니다.

JCClassDecl
의 getMembers()
를 통해 variables(멤버 변수)를 얻을 수 있습니다.
private List<JCVariableDecl> getMembers(JCClassDecl jcClass) {
return jcClass.getMembers()
.stream()
.filter(JCVariableDecl.class::isInstance)
.map(JCVariableDecl.class::cast)
.collect(List.collector());
}
- 멤버 변수 조작을 위해
JCVariableDecl
객체로 캐스팅하여 사용합니다.
이제 TreeMaker
를 통해 여러 코드들을 생성하겠습니다. TreeMaker
는 코드 제작을 위한 여러 메서드를 제공합니다.
MethodDef()
: 메서드 코드NewClass()
: 클래스 생성 코드 (new Class(...);
)Ident()
: 변수를 참조하는 식별자- …
메서드 코드를 생성하는 MethodDef()
사용법은 다음과 같습니다.
JCMethodDecl MethodDef(
JCModifiers mods,
Name name,
JCExpression restype,
List<JCTypeParameter> typarams,
List<JCVariableDecl> params,
List<JCExpression> thrown,
JCBlock body,
JCExpression defaultValue
)
mods
: 접근제어자를 지정합니다.name
: 메서드 이름을 지정합니다.restype
: 메서드 반환 타입을 지정합니다.typarams
: 파라미터의 타입을 지정합니다. (아래 params 을 통해 타입을 명시할 수 있습니다, 그럴 경우 List.nil() 을 넣어주면 됩니다.)params
: 파라미터의 값을 지정합니다.thrown
: 메서드에서 반환되는 예외를 지정합니다.body
: 메서드 내부 로직을 지정합니다.defaultValue
: 어노테이션에서 사용되는 default 값을 지정합니다. (어노테이션 외에는 null 을 넣어주면 됩니다)
private 생성자 코드는 다음과 같습니다.
private JCMethodDecl createPrivateAllArgsConstructor(JCClassDecl jcClass) {
final var modifier = maker.Modifiers(Flags.PRIVATE);
final var name = names.fromString("<init>");
final var parameters = createParameters(jcClass);
final var body = maker.Block(0, createParameterAssigns(jcClass));
return maker.MethodDef(
modifier,
name,
null,
List.nil(),
parameters,
List.nil(),
body,
null
);
}
TreeMaker.Modifiers()
: 접근제어자 객체를 만듭니다.Flags
는 자바 클래스와 멤버에 사용되는 접근제어 타입들을 명시합니다.Names.fromString()
: 컴파일러 네임 테이블에 등록된 리소스를 불러옵니다."<init>"
은 생성자에 해당하는 네임을 지칭합니다.TreeMaker.Block()
: 코드 블록을 의미합니다. 파라미터에서 값을 받아 멤버 변수에 assign 시켜주는 로직은 아래에 기술하겠습니다. body 에 넣는 코드 블록인 경우 첫 번째 파라미터인 flags 는 0으로 지정합니다. (플래그를 사용하지 않음을 나타냅니다)
JCClassDecl
에서 variables(멤버 변수) 정보를 받아 파라미터를 구성합니다.
private List<JCVariableDecl> createParameters(JCClassDecl jcClass) {
return getMembers(jcClass)
.stream()
.map(m -> maker.VarDef(
maker.Modifiers(Flags.LocalVarFlags),
createAltName(m),
m.vartype,
null
))
.peek(param -> param.pos = jcClass.pos)
.collect(List.collector());
}
private Name createAltName(JCVariableDecl jcVariable) {
return names.fromString("_" + jcVariable.getName());
}
createAltName()
: 기존 변수명을 그대로 사용하여 네임 테이블을 조회할 경우 기존 변수를 가져오므로 변수명 앞에_
기호를 붙입니다.TreeMaker.VarDef()
: 변수 객체를 만듭니다.param.pos
: 파라미터의 소스파일 위치를 지정합니다. 클래스 정보 위치를 넣어줍니다.
파라미터를 멤버 변수에 할당하는 body 로직입니다.
private List<JCStatement> createParameterAssigns(JCClassDecl jcClass) {
return getMembers(jcClass)
.stream()
.map(m -> maker.Assign(
maker.Ident(m),
maker.Ident(maker.Param(createAltName(m), m.vartype.type, null))
))
.map(maker::Exec)
.collect(List.collector());
}
TreeMaker.Assign
: 할당 연산 객체를 만듭니다. 첫 번째 파라미터는 좌변, 두 번째 파라미터는 우변을 의미합니다.TreeMaker.Ident
: 변수를 참조하는 식별자입니다.JCVariableDecl
로 받은 값을 코드로 변환합니다.TreeMaker.Param
: 파라미터에서 받아온 값을 넣어줍니다. 이름은 파라미터와 동일한 메서드로 생성합니다.TreeMaker.Exec
: 표현형 객체를 만듭니다.
static factory 메서드 생성하기
위에서 생성한 생성자는 접근제어를 private 으로 설정하여 내부에서만 객체가 생성되도록 막아두었습니다. 인스턴스 생성을 위해 static 한 factory 메서드를 생성해 줍니다. processor 에서 받아온 어노테이션 정보를 사용하여 메서드 이름과 접근 레벨을 로직에 녹입니다.
private JCMethodDecl createStaticFactoryMethod(JCClassDecl jcClass) {
final var accessLevel = annotation.access().getFlags();
final var modifier = maker.Modifiers(Flags.STATIC | accessLevel);
final var name = names.fromString(annotation.methodName());
final var returnType = maker.Type(jcClass.sym.type);
final var parameters = createParameters(jcClass);
final var body = maker.Block(0, createReturnNewInstance(jcClass));
return maker.MethodDef(
modifier,
name,
returnType,
List.nil(),
parameters,
List.nil(),
body,
null
);
}
@Getter
public enum AccessLevel {
PUBLIC(Flags.PUBLIC),
PROTECTED(Flags.PROTECTED),
PRIVATE(Flags.PRIVATE);
int flags;
AccessLevel(int flags) {
this.flags = flags;
}
}
- 접근제어자
Flags
는 파이프 (|
) 를 사용하여 여러개 지정이 가능합니다.Flags.STATIC | Flags.PRIVATE
의 경우,private static ...
으로 코드가 생성됩니다.
TreeMaker.Type
: 타입 객체를 만듭니다.jcClass.sym.type
: 메서드 반환 객체를 지정하기 위해 기존 클래스 타입을 넣어줍니다.
객체를 생성하는 코드는 다음과 같습니다.
private List<JCStatement> createReturnNewInstance(JCClassDecl jcClass) {
final var arguments = getMembers(jcClass)
.stream()
.map(m -> maker.Ident(createAltName(m)))
.map(JCExpression.class::cast)
.collect(List.collector());
final var newInstance = maker.NewClass(
null,
List.nil(),
maker.Type(jcClass.sym.type),
arguments,
null
);
return List.of(maker.Return(newInstance));
}
TreeMaker.NewClass
: 클래스 생성 코드입니다. (new Class(...);
)
TreeMaker.NewClass
는 다음과 같이 사용됩니다.
JCNewClass NewClass(
JCExpression encl,
List<JCExpression> typeargs,
JCExpression clazz,
List<JCExpression> args,
JCClassDecl def
)
encl
: enclosing 클래스를 지정합니다. 해당 클래스가 innerClass 가 아닌 경우 null 로 지정합니다.typeargs
: 전달할 파라미터의 타입을 지정합니다. (아래 params 을 통해 타입을 명시할 수 있습니다, 그럴 경우 List.nil() 을 넣어주면 됩니다.)clazz
: 생성할 클래스를 지정합니다.args
: 전달할 파라미터의 값을 지정합니다.def
: 클래스 body 를 지정합니다. 보통 null 로 지정합니다.
Annotation Processor 실행
우선 위에서 만든 모듈을 사용할 호출 모듈을 만들어야 합니다. 적당한 모듈을 만든 뒤 gradle 의존성을 추가해 줍니다. 이 역시 컴파일 시점에 어노테이션 프로세서가 작동하도록 아래와 같이 구성합니다.
dependencies {
compileOnly project(":${annotation-processor-모듈}")
annotationProcessor project(":${annotation-processor-모듈}")
}
적당한 클래스를 생성하고 위에서 만든 어노테이션을 붙여줍니다.
@StaticFactory
public class MyClass {
private String name;
private int age;
}
이전에 gradle 에 지정해둔 compileJava
를 실행하면 컴파일러가 해당 옵션을 적용하여 com.sun.*
모듈을 사용하여 Annotation Processor 를 실행합니다.
build/
하위에 생성된 빌드 파일을 보면 추가된 코드를 확인할 수 있습니다.
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package ...;
public class MyClass {
private String name;
private int age;
private MyClass(final String _name, final int _age) {
this.name = _name;
this.age = _age;
}
public static MyClass of(final String _name, final int _age) {
return new MyClass(_name, _age);
}
public MyClass() {
}
}
Compile 디버깅
compile 디버깅을 하려면, command + shift + A
를 눌러 Debug Build Process
옵션을 ON
으로 설정합니다.

마치며
AST 를 수정하여 코드를 조작하는 방법이 탐탁지 않다면 JavaPoet 등의 OpenApi 를 사용하여 파일 생성(수정이 아닙니다)을 하여도 됩니다. 반복적인 코드를 최대한 줄이고 관리 포인트를 하나로 집약하여 생산성과 유지 보수 용이성을 높이는 것에 초점을 두었습니다.
위 기능들을 활용한다면 반복 코드 생성뿐만 아니라 타입 또는 파라미터 등에 대해 유효성 검사를 추가하여 코드 안정성을 높일 수도 있을 거라 기대합니다. 이 모든 걸 컴파일 시점에 성능 누락 하나 없이 말이죠.