어노테이션 프로세싱 — Lombok은 어떻게 동작하는가
@Getter하나 붙이면 getter가 생기고,@Builder를 달면 빌더 패턴이 완성된다. 편하게 쓰고 있지만, "이거 런타임에 리플렉션으로 도는 건가요?"라는 질문에 막힌 적 있다. Lombok이 어떻게 동작하는지 제대로 이해하려면, 어노테이션 프로세싱이라는 자바 컴파일러의 확장 포인트부터 알아야 한다.
어노테이션 프로세싱이란
컴파일 타임에 어노테이션 정보를 읽어서 코드를 생성하거나 검증하는 메커니즘이다.
javac가 소스 코드를 컴파일할 때, 등록된 어노테이션 프로세서들이 순서대로 실행된다. 프로세서는 새로운 소스 파일을 만들 수도 있고, 에러를 발생시킬 수도 있다. 핵심은 런타임이 아니라 컴파일 타임이라는 점이다.
소스코드(.java) → [어노테이션 프로세싱] → 추가 소스 생성 → [컴파일] → 바이트코드(.class)
새로운 소스가 생성되면 다시 프로세싱 라운드가 돌기 때문에, 프로세서가 만든 코드에도 어노테이션이 있으면 또 다른 프로세서가 처리할 수 있다.
어노테이션의 기본 — 메타 어노테이션
어노테이션 프로세싱을 이해하려면, 먼저 어노테이션 자체를 정의하는 메타 어노테이션부터 알아야 한다.
@Retention — 어노테이션의 수명
어노테이션이 어디까지 살아남는지를 결정한다. 면접에서 가장 많이 물어보는 부분이다.
| 정책 | 설명 | 대표 예시 |
|---|---|---|
SOURCE | 컴파일 후 사라짐. .class에 안 남음 | @Override, @SuppressWarnings, Lombok |
CLASS | .class에는 남지만 런타임에 접근 불가 (기본값) | 일부 분석 도구용 어노테이션 |
RUNTIME | 런타임에 리플렉션으로 접근 가능 | @Component, @Autowired, @Test |
// SOURCE: 컴파일러만 보고 버린다
@Retention(RetentionPolicy.SOURCE)
public @interface MySourceAnnotation {}
// RUNTIME: 런타임에 리플렉션으로 읽을 수 있다
@Retention(RetentionPolicy.RUNTIME)
public @interface MyRuntimeAnnotation {}
공부하다 보니 CLASS가 기본값이라는 게 의외였다. 실무에서는 거의 SOURCE 아니면 RUNTIME만 쓴다.
@Target — 어디에 붙일 수 있는가
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface MyAnnotation {}
주요 ElementType:
TYPE— 클래스, 인터페이스, enumMETHOD— 메서드FIELD— 필드PARAMETER— 메서드 파라미터CONSTRUCTOR— 생성자LOCAL_VARIABLE— 지역 변수ANNOTATION_TYPE— 다른 어노테이션 (메타 어노테이션용)TYPE_USE— Java 8+, 타입이 사용되는 모든 곳
기타 메타 어노테이션
@Documented— Javadoc에 어노테이션 정보를 포함@Inherited— 부모 클래스의 어노테이션을 자식이 상속@Repeatable— 같은 어노테이션을 여러 번 사용 가능 (Java 8+)
// @Repeatable 예시
@Repeatable(Schedules.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Schedule {
String cron();
}
// 컨테이너 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Schedules {
Schedule[] value();
}
// 사용
@Schedule(cron = "0 0 * * *")
@Schedule(cron = "0 12 * * *")
public void doWork() {}
AbstractProcessor — 표준 어노테이션 프로세서
javax.annotation.processing.AbstractProcessor를 상속하면 커스텀 어노테이션 프로세서를 만들 수 있다.
@SupportedAnnotationTypes("com.example.MyAnnotation")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class MyProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
// 어노테이션이 붙은 요소들을 순회
for (Element element : roundEnv.getElementsAnnotatedWith(MyAnnotation.class)) {
// element의 정보를 읽어서 새 소스 파일 생성
String className = element.getSimpleName().toString();
processingEnv.getMessager()
.printMessage(Diagnostic.Kind.NOTE,
"처리 중: " + className);
}
return true; // true: 이 어노테이션을 다음 프로세서에 넘기지 않음
}
}
process() 메서드의 핵심 포인트
annotations— 이 프로세서가 처리할 어노테이션 타입 집합roundEnv— 현재 라운드의 환경 정보 (어노테이션이 붙은 요소 조회)- 반환값
true— 해당 어노테이션을 "소비"했다는 의미, 다음 프로세서에 넘기지 않음 - 반환값
false— 다음 프로세서도 이 어노테이션을 처리할 수 있음
새 소스 파일 생성하기
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
for (Element element : roundEnv.getElementsAnnotatedWith(MyAnnotation.class)) {
String packageName = processingEnv.getElementUtils()
.getPackageOf(element).getQualifiedName().toString();
String className = element.getSimpleName() + "Generated";
try {
// Filer를 통해 새 소스 파일 생성
JavaFileObject file = processingEnv.getFiler()
.createSourceFile(packageName + "." + className);
try (Writer writer = file.openWriter()) {
writer.write("package " + packageName + ";\n\n");
writer.write("// 자동 생성된 클래스\n");
writer.write("public class " + className + " {\n");
writer.write(" public String info() {\n");
writer.write(" return \"" + element.getSimpleName()
+ "에서 생성됨\";\n");
writer.write(" }\n");
writer.write("}\n");
}
} catch (IOException e) {
processingEnv.getMessager()
.printMessage(Diagnostic.Kind.ERROR,
"파일 생성 실패: " + e.getMessage());
}
}
return true;
}
이게 표준 어노테이션 프로세싱의 핵심이다. 기존 코드를 수정하는 게 아니라, 새로운 소스 파일을 생성한다.
META-INF/services 등록 — SPI
만든 프로세서를 javac가 인식하려면 SPI(Service Provider Interface) 방식으로 등록해야 한다.
src/main/resources/
└── META-INF/
└── services/
└── javax.annotation.processing.Processor
파일 내용:
com.example.MyProcessor
이 파일에 프로세서의 정규화된 클래스명(FQCN)을 적으면, javac가 클래스패스에서 자동으로 찾아 실행한다. Google의 auto-service 라이브러리를 쓰면 이 과정을 어노테이션으로 자동화할 수 있다.
// auto-service 사용 시
@AutoService(Processor.class)
public class MyProcessor extends AbstractProcessor {
// ...
}
Lombok은 어떻게 동작하는가
여기서부터가 면접에서 진짜로 물어보는 부분이다.
표준 vs Lombok의 차이
표준 어노테이션 프로세싱은 새 파일을 생성만 할 수 있고, 기존 소스를 수정할 수 없다. 그런데 Lombok의 @Getter는 기존 클래스에 메서드를 추가한다. 어떻게?
Lombok은 javac 내부 API를 사용하여 AST(추상 구문 트리)를 직접 조작한다.
[일반 프로세서]
소스코드 → 파싱 → AST → 프로세서(새 파일 생성) → 컴파일
[Lombok]
소스코드 → 파싱 → AST → Lombok(AST 직접 수정!) → 컴파일
AST 조작이란
javac가 소스코드를 파싱하면 AST(Abstract Syntax Tree)가 만들어진다. Lombok은 com.sun.tools.javac.tree 패키지의 내부 API를 통해 이 트리에 직접 노드를 추가한다.
// 우리가 작성하는 코드
@Getter
public class User {
private String name;
private int age;
}
// Lombok이 AST 조작 후 컴파일되는 결과 (실제로 소스 파일이 변하진 않음)
public class User {
private String name;
private int age;
public String getName() { return this.name; }
public int getAge() { return this.age; }
}
중요한 건, 소스 파일 자체가 변경되는 게 아니라 AST만 수정된다는 점이다. .java 파일을 열어봐도 getter는 없지만, 컴파일된 .class 파일에는 getter가 존재한다.
Lombok이 "해킹"이라 불리는 이유
com.sun.tools.javac.*는 공식 API가 아니다 (내부 API)- Java 모듈 시스템(Java 9+) 도입 후 접근이 제한되면서, Lombok은
--add-opens같은 우회가 필요해졌다 - JDK 버전이 올라갈 때마다 내부 API 변경으로 Lombok이 깨질 위험이 있다
이게 Lombok의 가장 큰 트레이드오프다.
MapStruct의 동작 원리
MapStruct는 Lombok과 달리 표준 어노테이션 프로세싱 API만 사용한다. 그래서 "정석적"이라고 할 수 있다.
// 매퍼 인터페이스 정의
@Mapper
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
// User → UserDto 변환
@Mapping(source = "name", target = "userName")
UserDto toDto(User user);
}
MapStruct의 어노테이션 프로세서가 컴파일 시 UserMapperImpl.java라는 새 파일을 생성한다.
// MapStruct가 자동 생성하는 구현체
@Generated
public class UserMapperImpl implements UserMapper {
@Override
public UserDto toDto(User user) {
if (user == null) {
return null;
}
UserDto userDto = new UserDto();
userDto.setUserName(user.getName()); // 매핑 규칙 적용
userDto.setAge(user.getAge());
return userDto;
}
}
Lombok과 MapStruct 비교
| 항목 | Lombok | MapStruct |
|---|---|---|
| 동작 방식 | AST 직접 조작 (비표준) | 새 소스 파일 생성 (표준) |
| 사용 API | com.sun.tools.javac 내부 API | javax.annotation.processing 표준 API |
| 생성 결과 | 기존 클래스에 메서드 추가 | 별도의 구현체 클래스 생성 |
| JDK 호환성 | 새 JDK마다 깨질 위험 | 안정적 |
| IDE 지원 | 별도 플러그인 필요 | 생성된 소스를 직접 확인 가능 |
면접에서 "Lombok과 MapStruct의 동작 방식 차이"를 물어보면, AST 조작 vs 새 파일 생성으로 구분하면 된다.
커스텀 어노테이션 만들기 예제
실무에서 자주 쓰는 패턴 — 메서드 실행 시간을 측정하는 어노테이션을 만들어보자. 이건 런타임에 리플렉션으로 처리하는 방식이다.
1단계: 어노테이션 정의
@Retention(RetentionPolicy.RUNTIME) // 런타임에 리플렉션으로 읽어야 함
@Target(ElementType.METHOD) // 메서드에만 사용
@Documented
public @interface MeasureTime {
String value() default ""; // 선택적으로 설명 추가
}
2단계: AOP로 처리 (Spring 환경)
@Aspect
@Component
public class MeasureTimeAspect {
private static final Logger log = LoggerFactory.getLogger(MeasureTimeAspect.class);
@Around("@annotation(measureTime)")
public Object measure(ProceedingJoinPoint joinPoint,
MeasureTime measureTime) throws Throwable {
long start = System.nanoTime();
try {
return joinPoint.proceed();
} finally {
long elapsed = System.nanoTime() - start;
String label = measureTime.value().isEmpty()
? joinPoint.getSignature().getName()
: measureTime.value();
// 실행 시간 로그 출력
log.info("[{}] 실행 시간: {}ms", label, elapsed / 1_000_000.0);
}
}
}
3단계: 사용
@Service
public class UserService {
@MeasureTime("사용자 조회")
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
}
이 방식은 RUNTIME Retention이 필요하고, 리플렉션 기반이라 약간의 성능 오버헤드가 있다.
리플렉션 vs 어노테이션 프로세싱 — 성능 차이
면접에서 "리플렉션이랑 어노테이션 프로세싱이랑 뭐가 다르냐"는 질문이 꽤 나온다.
| 항목 | 리플렉션 | 어노테이션 프로세싱 |
|---|---|---|
| 실행 시점 | 런타임 | 컴파일 타임 |
| 성능 | 매 호출마다 비용 발생 | 런타임 오버헤드 없음 |
| 타입 안전성 | 컴파일 시 검증 불가 | 컴파일 시 에러 감지 가능 |
| 유연성 | 동적으로 클래스 분석 가능 | 컴파일 시점의 정보만 사용 |
| 대표 사용처 | Spring DI, JPA, JUnit | Lombok, MapStruct, Dagger |
// 리플렉션: 런타임에 매번 메타데이터 조회
Method method = clazz.getMethod("getName");
Object result = method.invoke(instance); // 매 호출마다 비용
// 어노테이션 프로세싱: 컴파일 타임에 코드 생성, 런타임엔 일반 메서드 호출
String result = instance.getName(); // 직접 호출, 오버헤드 없음
실제로 Dagger(의존성 주입)가 어노테이션 프로세싱 방식인데, 리플렉션 기반인 Guice보다 Android 환경에서 눈에 띄게 빠르다. 모바일처럼 리소스가 제한된 환경에서 이 차이가 크게 느껴진다.
Lombok의 트레이드오프
Lombok을 쓸지 말지는 팀마다 의견이 다르다. 면접에서 "Lombok을 어떻게 생각하나요?"라는 질문이 나오면, 장단점을 균형 있게 말할 수 있어야 한다.
장점
- 보일러플레이트 대폭 감소: getter/setter, toString, equals, hashCode, builder 등
- 런타임 성능 오버헤드 없음: 컴파일 타임에 코드가 생성되므로
- 코드 가독성 향상: 핵심 비즈니스 로직에 집중 가능
단점
- 비표준 내부 API 의존: JDK 업그레이드 시 호환성 문제 가능
- 디버깅 어려움: 소스에 없는 메서드가 .class에 존재
- IDE 플러그인 필수: IntelliJ, Eclipse 모두 별도 플러그인 설치 필요
- 과도한 사용 위험:
@Data가 equals/hashCode를 자동 생성해서 JPA 엔티티에 쓰면 순환 참조 문제가 생길 수 있음 - Java Records와 역할 중복: Java 16+ Records가 나오면서 단순 데이터 클래스에는 Lombok이 덜 필요해짐
실무 팁
// JPA 엔티티에서 @Data 대신 필요한 것만 사용
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
@Builder
public User(String name) {
this.name = name;
}
// @Data는 피한다 — equals/hashCode 자동 생성이 JPA와 충돌 가능
}
컴파일 타임 코드 생성의 전체 흐름 정리
1. javac 시작
2. 소스 코드 파싱 → AST 생성
3. 어노테이션 프로세싱 라운드 시작
├─ [표준 프로세서] 새 소스 파일 생성 (Filer API)
└─ [Lombok] AST 직접 수정 (내부 API)
4. 새 소스가 생성됐으면 → 다시 3번으로 (다음 라운드)
5. 더 이상 새 소스 없으면 → 최종 컴파일
6. 바이트코드(.class) 생성 완료
면접 포인트 정리
- "어노테이션 프로세싱이 뭔가요?" → 컴파일 타임에 어노테이션을 읽어 코드를 생성/검증하는 메커니즘
- "Lombok은 리플렉션인가요?" → 아니다. 컴파일 타임에 AST를 조작한다. 런타임 오버헤드 없음
- "Lombok과 MapStruct 차이?" → Lombok은 AST 직접 조작(비표준), MapStruct는 새 파일 생성(표준)
- "@Retention의 세 가지 정책?" → SOURCE(컴파일 후 삭제), CLASS(클래스 파일까지), RUNTIME(런타임 리플렉션)
- "META-INF/services는 뭔가요?" → SPI 방식으로 구현체를 등록하는 파일. 프로세서, 드라이버 등에서 사용
어노테이션 프로세싱은 "자바 컴파일러에 플러그인을 꽂는 것"이라고 이해하면 된다. Lombok이 편한 건 맞지만, 내부적으로 비표준 API를 쓰고 있다는 점은 알고 써야 한다. 면접에서 이 구분을 명확히 할 수 있으면 꽤 좋은 인상을 줄 수 있다.