시작하며

안녕하세요. 오픈소스컨설팅 Custom Dev팀 Solution Architect 최치원입니다.

Jira, Confluence, Bitbucket등 Atlassian Product에서 기본적으로 제공하지 않는 기능을 사용할 수 있게 플러그인을 개발하는 업무를 맡고 있습니다.

플러그인을 개발할 때는 아예 Zero Base(제로베이스)에서 시작하는 것이 아니라 Atlassian에서 제공하는 Plugin SDK(Software Development Kit)를 사용하여 개발 생산성을 높일 수가 있는데 이 SDK에는 무수히 많은 애노테이션들이 있습니다.


애노테이션들은 도대체 어떻게 동작하는 걸까요?

롬복

위 코드와 같이 @Getter, @ToString, @NoArgsConstructor 자주 사용하지 않으셨나요? 네 맞습니다. 롬복입니다.

그런데 롬복은 도대체 어떻게 동작하길래 @Getter만 사용하면 getXxx(...) 메서드를 다 만들어줄까요? 저는 이런 잘 만들어진 자바의 라이브러리나 모듈의 내부 동작 흐름이 너무 궁금했습니다.

그래서 파헤쳐봤습니다. 🧐

결론부터 말할게요. Lombok의 핵심 키워드는 ‘애노테이션 프로세싱’과 ‘AST조작’

애노테이션 프로세싱이란(Annotation Processing)?

컴파일 시점에 애노테이션을 분석하고, 이를 기반으로 코드를 생성하거나 검증하는 기능을 말합니다. Java 컴파일러가 .java 파일을 .class 파일로 바꾸는 동안, 지정된 프로세서가 애노테이션을 탐색하고 필요한 로직을 수행하게 됩니다. 대표적으로 Lombok, MapStruct 같은 라이브러리들이 애노테이션 프로세서를 활용해 자동으로 코드를 만들어주는 방식으로 동작합니다.

즉, 사람이 반복적으로 작성해야 할 코드를 자동으로 생성해주는 마법 같은 도구입니다. 😙

애노테이션 프로세서(Annotation Processor)?

  • Java의 Annotation Processor는 컴파일 단계에서 애노테이션을 분석하고, 이를 기반으로 추가적인 코드를 생성하거나 컴파일러에 특정 작업을 지시
  • Java Compiler API의 일부로, 애노테이션 기반의 코드 생성 및 검증을 지원하는 도구
  • 애노테이션 프로세서는 javax.annotation.processing 패키지에 정의된 인터페이스와 클래스들을 통해 구현
  • 일반적으로 컴파일 타임에 실행되고, 런타임에 영향을 미치지 않습니다.

주요 역할

  • 애노테이션 처리: 소스 코드에 선언된 애노테이션을 감지하고, 이를 기반으로 처리 작업 수행
  • 소스코드 생성: @Getter와 같은 애노테이션을 통해 Getter 메서드 자동 생성
  • 애노테이션 검증: 애노테이션 사용이 올바른지 확인하고, 잘못되었으면 컴파일러 오류를 출력. 예를 들어, 특정 필드에만 적용 가능한 애노테이션을 다른 위치에 사용했는지 검증할 수 있습니다.

동작 흐름

  • 1. 애노테이션 프로세서는 컴파일 시점에 동작: Java 컴파일러가 .java 파일을 컴파일할 때, 소스 코드에 붙은 애노테이션을 찾아서 관련된 애노테이션 프로세서를 실행합니다.
  • 2. 컴파일러는 애노테이션 프로세서를 자동으로 탐지: 애노테이션 프로세서는 다음 조건을 만족하면 컴파일 시 자동으로 인식됩니다.
META-INF/services/javax.annotation.processing.Processor

이 파일 안에 아래처럼 Annotation Processor 구현체의 FQCN(Full Qualified Class Name)이 들어있어야 합니다.

com.example.MyAnnotationProcessor

즉, 컴파일러가 이 파일을 통해 등록된 프로세서 목록을 읽고, 그중에서 어떤 걸 실행할지 판단하게 됩니다.

  • 3. 그럼 어떤 애노테이션 프로세서를 실행할지는 @SupportedAnnotationTypes로 결정: 예를 들면, 애노테이션 프로세서 클래스는 이런 식으로 생겼습니다.
애노테이션 프로세서 클래스

@SupportedAnnotationTypes: 이 프로세서가 어떤 애노테이션을 처리할 수 있는지 명시

컴파일러는 소스 코드에 있는 애노테이션과 이 정보를 비교해서 해당 애노테이션을 처리할 수 있는 프로세서만 호출하게 됩니다.

  • 4. 컴파일러는 모든 프로세서를 한 번에 다 실행하지 않습니다: 소스에 붙은 애노테이션을 보고, 그 애노테이션을 처리하는 프로세서만 호출합니다. 그리고 이 과정은 여러 라운드 걸쳐 실행됩니다.
  • 5. 애노테이션 프로세싱은 라운드 기반으로 작동: 아래 예시 흐름을 볼까요?
[컴파일 시작]
      ↓
[소스 분석 → 애노테이션 감지]
      ↓
[적절한 애노테이션 프로세서 호출]
      ↓
[코드 생성 (ex: lombok이 Getter 만들기)]
      ↓
[새로 생성된 코드 포함해서 다시 처리 (라운드 1로 돌아감)]
      ↓
[남은 애노테이션 없으면 종료]

애노테이션 프로세서 동작 흐름 정리

흐름설명
컴파일 때 동작하는가?✅ 컴파일 시점에 동작
모든 애노테이션 프로세서를 호출하는가?❌ 등록된 프로세서 중에서 해당 애노테이션을 처리할 수 있는 것만 호출
컴파일러는 어떤 프로세서를 호출해야 할지 어떻게 아는가?@SupportedAnnotationTypes를 보고 결정
여러 번 호출되는가?✅ 코드 생성이 일어나면 여러 라운드에 걸쳐 반복

AST(Abstract Syntax Tree) 조작?

Lombok은 바이트 코드를 변경하는 것이 아닌 컴파일러의 AST(Abstract Syntax Tree)를 조작하여 결과적으로 수정된 바이트 코드를 생성합니다. 이게 무슨 말..? 😶

Java에서 AST는 컴파일러가 소스 코드를 분석할 때 생성하는 구조화된 트리 형태의 데이터 구조를 말합니다.
바로 어떻게 생겼는지 볼까요?

Java 코드

java 코드

AST (이해를 돕기 위해 살짝 변형)

ast

이렇게 생긴 트리 형태의 데이터 구조를 AST라고 합니다. 컴파일러는 이 AST를 사용하여 소스 코드의 문법 오류를 검증하고 바이트 코드를 생성해 냅니다. 기본 흐름을 나열해보자면

  1. 컴파일러가 소스코드를 분석할 때 AST를 생성
  2. 컴파일러는 이 AST를 기반으로 바이트코드(.class)를 생성

그러니까, Lombok이 ‘AST를 조작한다’는 말은 컴파일러가 생성한 이 구문 트리에 접근하여 수정하거나 요소를 추가한다는 의미가 됩니다. 예를 들면, @Getter 애노테이션이 붙은 필드에 대해 Getter 메서드 노드를 트리에 삽입하고 그렇게 조작된 AST를 컴파일러가 바이트코드(.class)로 변환하면서 실제로 동작하는 코드가 만들어집니다. 예시를 든다면 위 흐름이 이렇게 변경된다고 보면 될 것 같습니다.

  1. 컴파일러가 소스코드를 분석할 때 AST를 생성
  2. Lombok의 애노테이션 프로세서가 동작해서 AST를 조작
  3. 컴파일러는 이 AST를 기반으로 바이트코드(.class)를 생성

여기서, 한가지 인사이트가 생깁니다.

다른 애노테이션 프로세싱과 다르게 Lombok은 Javac의 AST를 조작해서 메서드, 필드 등을 추가합니다. 일반적인 애노테이션 프로세싱은 컴파일하는 시점에 애노테이션 프로세서가 동작하여 코드를 추가하거나 수정합니다. 즉, 그 말은 컴파일이 된 이후에나 수정된 코드를 사용하고 IDE가 인식할 수 있다는 건데요.

그러나, Lombok을 사용할 때는 컴파일 하기 전에도 우리는 @Getter 애노테이션만 붙이면 getXxx(...) 메서드를 가져다가 사용할 수 있었죠? IntelliJ에서 Lombok 플러그인만 설치했다면 컴파일하기도 전에 우리는 롬복의 도움을 받을 수 있었습니다.

Lombok은 AST를 조작하여 애노테이션 프로세싱을 하고, 이 Lombok 플러그인이 IDE 내부 AST에 메서드를 가상으로 추가하여 우리가 컴파일하기도 전에 getXxx(...) 메서드를 사용할 수 있었던 것입니다 😳

실제로 메서드는 컴파일 타임에 Lombok이 .class 파일에 삽입해주지만,
우리가 컴파일 전에 IDE에서 해당 메서드가 존재하는 것처럼 사용할 수 있는 이유는
Lombok이 일반적인 애노테이션 프로세서가 아닌,
Javac의 내부 API를 사용해 AST(Abstract Syntax Tree)을 직접 조작하는 방식으로 동작하기 때문입니다.


애노테이션 프로세서 직접 만들어보기

애노테이션 프로세싱과 롬복의 원리를 이해해 봤으니 애노테이션 프로세서를 직접 만들어볼까요? 라이브러리를 만든다고 생각해보자구요 😆

라이브러리를 적용할 프로젝트 생성

'chyonibok' 이라는 이름의 Maven 기본 프로젝트를 하나 만들고 다음과 같이 코드를 작성했습니다.

애노테이션 프로서

현재 이 코드는 동작하지 않습니다. 컴파일 오류가 나고 있습니다. 이렇게 만든 Member 라는 클래스가 @AutoGetter 애노테이션이 달린 필드를 보고 해당 필드의 Getter 메서드를 자동으로 만들어주는 작업을 한 번 해보겠습니다.

라이브러리용 프로젝트 생성 및 애노테이션 생성

그러기 위해 라이브러리 역할을 하는 프로젝트를 하나 더 만들 생각입니다. 'chyoniboklib' 이라는 이름의 Maven 기본 프로젝트를 하나 만들고, 다음과 같이 애노테이션을 하나 만듭니다.

이 애노테이션은 런타임이나 바이트코드에 필요하지 않습니다. 컴파일 시에 해당 애노테이션을 찾아 후처리를 하면 끝나기 때문에 런타임이나 바이트코드에 필요하지 않습니다. 따라서 RetentionPolicy.SOURCE로 지정합니다.

애노테이션 프로세서 구현

애노테이션 프로세서를 만들 때는, 일반적으로 AbstractProcessor를 구현합니다.

반드시 구현해야 할 메서드는 process 하나입니다. 말 그대로 이 애노테이션 프로세서가 어떤 작업을 해야 하는지를 작성하면 됩니다. 그다음 두 가지 애노테이션을 붙여주겠습니다.

이 애노테이션 프로세서가 지원하는 애노테이션이 어떤 애노테이션인지 알려주는 @SupportedAnnotationTypes 입니다. 위에서 만든 AutoGetter를 처리하도록 합니다.

그 다음은 @SupportedSourceVersion 입니다. 이 애노테이션 프로세서가 지원하는 자바 소스 코드의 버전을 나타냅니다. 어떤 Java 버전의 문법까지 이해할 수 있는지 컴파일러에게 알려주는 정보입니다. 저는 11로 해보겠습니다.

자, 그럼 이제 구현해야 하는 메서드인 process를 구현하겠습니다. 이 메서드는 리턴 타입이 boolean 입니다. true를 리턴하면 여기서 이 애노테이션 처리를 다 끝냈다고 판단하고 다음 애노테이션 프로세서에게 넘기지 않습니다. 그러니까 만약 @AutoGetter를 처리하는 애노테이션 프로세서가 딱 하나라면 true를 리턴하면 되겠죠? 반면에, 또 다른 애노테이션 프로세서가 있고 그 프로세서 역시 @AutoGetter를 처리하는 애노테이션 프로세서라면 false를 리턴해서 두 애노테이션 프로세서 모두 통과하도록 해줘야 합니다.

저는 true를 리턴하도록 하겠습니다. 어차피 이거 하나만 만들거거든요. 😆

우선 처음으로 할 일은 이 애노테이션의 검증입니다. 애노테이션 프로세서가 하는 역할 중에는 애노테이션 검증도 있다고 했죠? 적절한 곳에 애노테이션이 위치했는지 파악해야 합니다. 따라서 먼저 ElementKind.FIELD가 아닌 경우 컴파일 오류를 뱉어내도록 합니다.

이 상태에서 애노테이션 프로세서를 한번 등록해볼까요? 하나씩 하나씩 나아가면 좋으니까요.

애노테이션 프로세서 등록

애노테이션 프로세서는 어떻게 등록할까요? 이렇게 해야 합니다.

resources/META-INF/services/javax.annotation.processing.Processor 파일을 만들고, 그 안에 만든 애노테이션의 FQCN(Full Qualified Class Name)을 작성해주면 됩니다. 다음과 같이 말이죠.

cwchoiit.AutoGetterProcessor

그런 다음에 이 상태에서 빌드를 해볼까요? 다음 명령어를 실행해봅니다.

mvn clean install

잘 될까요? 안 됩니다. 다음과 같은 에러를 마주하게 됩니다.

이 에러가 발생하는 이유는, 지금 Maven으로 빌드를 하는 과정 중에도 이 프로세서가 동작하려고 합니다. 왜냐구요? 제가 프로세서로 등록했으니까요. 그런데 지금은 최초 빌드이죠? 그럼 이 AutoGetterProcessor라는 컴파일된 클래스는 없겠죠? 그래서 애노테이션 프로세서가 없다고 하는 겁니다.

그래서 이 경우에는 어떻게 해야 하냐면, 먼저 저 resources에 등록한 애노테이션을 잠깐 주석 처리하고 빌드를 합니다. 그러면 빌드가 정상적으로 끝나고 .class 파일로 AutoGetterProcessor가 잘 만들어지겠죠? 그리고 다시 주석을 해제하고 mvn install을 하는 겁니다.

그래서 우선 애노테이션 프로세서를 등록한 것을 주석처리하고 빌드를 먼저 합니다. 다음과 같이 정상적으로 빌드가 끝날 거예요.

그리고 컴파일된 파일들을 보면 다음과 같이 AutoGetterProcessor가 잘 만들어진 것을 알 수 있습니다.

이 상태에서 mvn install을 하면 됩니다. 그런데 굉장히 불편하죠? 그래서 이런 불편함을 해결해주기 위한 라이브러리가 하나 있습니다. 구글에서 만든 auto-service인데요. 한번 사용해볼게요.

위와 같이 의존성을 추가해줍니다.
그리고 아까 만든 AutoGetterProcessor에 가서 아래와 같이 애노테이션을 추가해줍니다.

이렇게 애노테이션을 추가하면, 아까 직접 만든 애노테이션 등록 파일 있죠?
resources/META-INF/services/javax.annotation.processing.Processor
이 파일을 컴파일 후 자동으로 만들어서 이 애노테이션 프로세서를 등록해줍니다.

그런데! 개발자들은 직접 눈으로 봐야 믿음이 생기죠!? 그래서 한번 직접 확인해 볼게요.
먼저 mvn clean을 해서 깨끗하게 target 파일을 날려볼게요.
아래와 같이 깔끔해야 합니다. resources, target 폴더는 없어야 해요!

이 상태에서 mvn install을 실행하면 정상적으로 빌드가 되고, Jar 파일 하나가 만들어집니다.
Jar 파일을 Zip 파일로 확장자 변경을 한 뒤 안에 파일을 까보면 다음과 같이 자동으로 애노테이션 프로세서 등록 파일을 만들어줍니다.

그런데 말이죠, 결국 이 구글에서 만든 auto-service 역시 애노테이션 프로세서입니다.
그만큼 애노테이션 프로세서는 곳곳에 널려있고 라이브러리나 모듈을 만들 때 자주 사용됨을 알 수 있는 대목이네요!

애노테이션 프로세서 동작 확인

위에서 mvn install을 했으니 로컬 레포지토리에 해당 Jar 파일이 추가될 겁니다.
아래처럼 mvn install 시 로그로도 남는 모습을 확인할 수 있는데요.

이 말은 다른 프로젝트에서 이 Jar 파일을 다운받아 사용할 수 있다는 말입니다.
아까 최초에 만든 ‘chyonibok‘ 프로젝트로 돌아가 볼까요? 그리고 다음과 같이 의존성을 추가해 볼게요.

추가하고 싱크를 다시 맞춘 후, 아까 컴파일 오류가 났던 Member 클래스로 돌아가 보면 @AutoGetter를 잘 인식하는 모습을 확인할 수 있습니다.

그럼 애노테이션 프로세서가 잘 동작하는지 확인하기 위해 저 애노테이션을 필드 레벨이 아닌 클래스 레벨에 붙이고 빌드를 해 볼까요? 애노테이션 프로세서를 만들 때 저희는 필드 레벨에 달린 애노테이션이 아니라면 컴파일 오류를 뱉도록 구현했습니다.

이렇게 변경한 다음 빌드를 다시 해볼게요. 다음과 같이 예쁘게 에러가 발생하고 있습니다. 애노테이션 프로세서가 잘 동작하고 있네요.

Getter 메서드 자동 생성

이제 메서드를 동적으로 만들어볼게요. @AutoGetter 애노테이션이 달린 필드는 해당 필드의 Getter 메서드를 자동으로 만들도록 애노테이션 프로세서를 구현해보도록 하겠습니다.

메서드나 클래스를 동적으로 만들어낼 때 자주 사용되는 라이브러리인 javapoet을 사용해 볼 거예요.

Javapoet 공식 깃 주소: https://github.com/palantir/javapoet

위 코드를 분석해 볼게요.

MethodSpecjavapoet에서 제공해주는 메서드를 동적으로 만들어주는 녀석입니다.
이렇게 Getter 메서드를 동적으로 만들 수가 있습니다.

Lombok은 AST를 조작할 때 Javac 내부 API를 사용하는데 여기서는 내부 API까지는 가지 않더라도 애노테이션 프로세서를 거치면 자동으로 새로운 클래스를 만들고 그 클래스에 Getter 메서드가 추가되는 모습을 확인해 보겠습니다.

TypeSpec은 클래스를 만들어주는 녀석입니다.
기존 클래스 명에 'AutoGenerated' 라는 이름을 추가해볼게요.
그리고 여기에 addMethod()를 사용해서 MethodSpec으로 만든 Getter 메서드를 추가해주면 됩니다.

여기까지만 하면, 실제로 만든 건 아니고 메모리에 클래스와 메서드를 만들어 낸 것까지만 한 거예요.
우리는 메모리에만 둥둥 떠있는 게 아니라 실제 클래스로 만들어야겠죠?
JavaFile로 해당 클래스가 있던 패키지 위치에 새로 만든 클래스를 만들어 줍니다.

이제, 다 끝났습니다.
다시 mvn install 명령어를 수행해서 로컬 레포지토리에 최신 소스의 Jar 파일을 내려받고 'chyonibok' 프로젝트에서 싱크를 다시 맞춰주면 됩니다.

싱크를 다시 맞췄으면 Member 클래스로 돌아가서 다시 필드 레벨에 애노테이션을 달아줍니다.

이 상태에서 mvn compile 명령어를 수행해 볼까요?

전혀 작성하지 않은 코드인 MemberAutoGenerated 라는 클래스가 컴파일됐습니다.
애노테이션 프로세서가 자동으로 만들어 준 클래스입니다. 들여다볼까요?

Getter 메서드가 잘 만들어져 있는 걸 확인할 수 있습니다. 가져다가 사용하는 것도 당연히 잘 되겠죠?


마치며

우선, 긴 글 읽어주셔서 감사합니다.
애노테이션 프로세싱 기술은 라이브러리를 만들때 빼놓을 수 없는 기술입니다.
위에서 봤지만, 애노테이션 프로세서를 만들기 위해서 또 다른 애노테이션 프로세서를 사용했던 것처럼 말이죠.

“그럼 라이브러리를 만들지 않으면 애노테이션 프로세싱을 배우지 않아도 되는 거 아니야?”

라는 의구심이 생길 수도 있죠. 절반만 맞는 얘기라고 생각합니다.
물론 제 생각이 정답이라고 할 순 없지만 제가 생각할 때, 라이브러리에 관심이 없어도 왜 이 기술을 배우면 좋은지 나열해 볼게요.

1. 프레임워크의 마법을 이해하는 열쇠

그저 편리하다 사용하기만 해왔던 Lombok이라는 라이브러리는 그 안에 엄청난 깊이가 있음을 확인할 수 있었습니다. 이 Lombok이 컴파일 시점에 뭘 하고 있는지 이해할 수 있게 됐죠. 즉, “이게 왜 되는 거지?” 라는 질문에 대해 “어떻게 되는 건지”까지 이해할 수 있게 됐습니다.

2. 디버깅 능력이 강력해진다.

예를 들어, 프로젝트에서 이상한 현상이 발생했다고 가정해볼까요?

“Lombok이 코드 생성을 안 한 것 같다?”

이럴 때 애노테이션 프로세싱의 작동 시점, 한계, 라운드 개념을 이해하고 있으면 무엇이 문제인지 빠르게 판단이 가능해집니다. 실제로 Lombok은 문제가 발생할 여지가 있습니다. 왜냐하면 공개 API를 사용하는 것이 아닌 Javac 내부 API를 사용해 AST를 조작하기 때문에 Java 버전이 달라지면서 내부 API가 변경되면 문제가 발생할 수 있거든요.

애노테이션 프로세싱은 “눈에 안 보이는 코드“를 만드는 기술이기 때문에, 모르면 디버깅 지옥이 되고, 알면 그 지옥문을 닫을 수 있습니다.

3. 빌드 타임 최적화 아이디어에 눈이 트임

“런타임 리플렉션은 느려” 이 말이 “컴파일 타임에 미리 처리하면 더 빠르지 않을까?” 라는 고민을 할 수 있도록 시야를 넓혀줄 수 있습니다. RUNTIME → COMPILE TIME SHIFT 전략에 대한 시야가 생깁니다.

4. 커스텀 유효성 검사, 반복되는 코드 자동 생성 가능

팀 내 공통 스타일, 검증 규칙이 있다면? → 커스텀 애노테이션 + 프로세서로 컴파일 타임에 검사가 가능해지겠죠?

이렇듯, 라이브러리를 만들지 않더라도, 애노테이션 프로세싱은 개발자의 사고방식을 바꿔주는 기술이라고 생각합니다.

저도 이 내용을 바탕으로 오픈소스컨설팅이라는 회사 이름에 걸맞게 유의미한 라이브러리를 만들어 오픈 소스에 기여해보도록 하겠습니다.

감사합니다.

👉Atlassian 앱 개발과 관련하여 더 궁금한 내용은 아래 블로그도 참고해 보세요:)

안녕하세요. 오픈소스컨설팅 Custom Dev팀 BE 최치원입니다. 제가 만드는 서비스, 제품, 소프트웨어가 어떤 가치를 창출할 수 있는지 고민하며 개발하고 있습니다.

Leave a Reply

Your email address will not be published. Required fields are marked *