컴파일 시점에 Enum 이름 규칙 강제하기: Annotation Processor 적용기
컴파일 시점에 Enum 이름 규칙 강제하기: Annotation Processor 적용기
moseoh
spring-boot

컴파일 시점에 Enum 이름 규칙 강제하기: Annotation Processor 적용기

moseoh · 2024년 12월 22일

업무 중에 이런 고민이 생겼습니다. “특정 Enum 클래스는 이름에 규칙이 있는데, 이걸 다른 개발자가 실수로 놓치지 않게 강제할 방법이 없을까?” 단순히 코드 리뷰나 문서에 의존하기보다는, 시스템적으로 막아주는 장치가 필요하다고 생각했습니다. Lombok이 어노테이션 하나로 getter, setter를 만들어주는 것처럼, 저희도 비슷한 방식으로 컴파일 시점에 코드 규칙을 검증하고 싶었죠.

이 문제를 해결하기 위해 Annotation Processor를 직접 만들어보기로 했습니다. 요구사항은 명확했습니다.

  1. 규칙에 맞지 않는 코드는 컴파일 시점에 오류를 발생시켜야 한다.
  2. 이 기능은 개발 편의성을 위한 것이므로, 프로덕션 코드에는 포함되지 않아야 한다.

Annotation Processor 직접 만들어보기

먼저 Enum의 이름 패턴을 검사할 간단한 어노테이션과 프로세서를 만들어 보겠습니다. 자세한 내부 로직보다는 전체적인 설정과 흐름에 초점을 맞춰 설명하겠습니다.

1. 어노테이션 정의 (@EnumNamePattern)

가장 먼저, 규칙을 적용할 대상에 붙일 어노테이션을 만듭니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS) // 컴파일 시에만 필요하고, 런타임에는 필요 없도록 설정
public @interface EnumNamePattern {
String value(); // 정규식 패턴을 받을 value
}

@Retention(RetentionPolicy.CLASS)로 설정한 이유는, 이 어노테이션이 컴파일 시점에만 사용되고 런타임에는 바이트코드에 남을 필요가 없기 때문입니다.

2. 프로세서 구현 (EnumNamePatternProcessor)

다음으로, 위 어노테이션을 감지하고 실제 검증 로직을 수행할 프로세서를 구현합니다. AbstractProcessor를 상속받아 필요한 메소드들을 오버라이딩합니다.

public class EnumNamePatternProcessor extends AbstractProcessor {
// 지원하는 소스 버전을 명시합니다.
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
// 이 프로세서가 어떤 어노테이션을 처리할지 지정합니다.
@Override
public Set<String> getSupportedAnnotationTypes() {
return Set.of(EnumNamePattern.class.getCanonicalName());
}
// 실제 검증 로직이 들어가는 핵심 메소드입니다.
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
// @EnumNamePattern 어노테이션이 붙은 모든 요소를 순회합니다.
for (Element element : roundEnv.getElementsAnnotatedWith(EnumNamePattern.class)) {
// 여기에 검증 로직을 구현합니다.
// (예: Enum이 맞는지, 상수의 이름이 대문자인지, 정규식에 맞는지 등)
}
return true;
}
}
EnumNamePatternProcessor 전체 코드 보기
package com.moseoh.annotationprocessor;
import java.util.Set;
import java.util.regex.Pattern;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
public class EnumNamePatternProcessor extends AbstractProcessor {
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
@Override
public Set<String> getSupportedAnnotationTypes() {
return Set.of(EnumNamePattern.class.getCanonicalName());
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (Element element : roundEnv.getElementsAnnotatedWith(EnumNamePattern.class)) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing: " + element.getSimpleName());
if (element.getKind() != ElementKind.ENUM) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@EnumNameRegex 는 enum 타입에만 적용할 수 있습니다.", element);
continue;
}
EnumNamePattern annotation = element.getAnnotation(EnumNamePattern.class);
String regex = annotation.value();
for (Element enclosed : element.getEnclosedElements()) {
if (enclosed.getKind() == ElementKind.ENUM_CONSTANT) {
String enumName = enclosed.getSimpleName().toString();
if (!enumName.equals(enumName.toUpperCase())) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Enum 값 '" + enumName + "'은 대문자여야 합니다.", enclosed);
}
if (!Pattern.matches(regex, enumName)) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Enum 값 '" + enumName + "'이 지정된 패턴과 일치하지 않습니다: " + regex, enclosed);
}
}
}
}
return true;
}
}

3. 프로세서 등록 (SPI)

Java 컴파일러(javac)가 우리 프로세서를 인식하게 하려면, 특정 경로에 설정 파일을 만들어 등록해줘야 합니다. 이걸 SPI(Service Provider Interface)라고 합니다.

src/main/resources/META-INF/services/javax.annotation.processing.Processor 파일을 만들고, 그 안에 우리 프로세서의 전체 클래스 경로를 적어주면 됩니다.

com.moseoh.annotationprocessor.EnumNamePatternProcessor

하지만 매번 이 파일을 수동으로 만들고 관리하는 건 꽤 번거로운 일이죠. 다행히 구글의 auto-service 라이브러리를 쓰면 이 과정을 자동화할 수 있습니다.

**build.gradle.kts**에 의존성 추가:

dependencies {
implementation("com.google.auto.service:auto-service:1.1.1")
annotationProcessor("com.google.auto.service:auto-service:1.1.1")
}

프로세서 클래스에 어노테이션 추가:

import com.google.auto.service.AutoService;
import javax.annotation.processing.Processor;
@AutoService(Processor.class) // 이 한 줄이면 끝!
public class EnumNamePatternProcessor extends AbstractProcessor {
// ...
}

이제 빌드 시 auto-service가 알아서 META-INF/services 파일을 생성해 줍니다.

첫 번째 시도와 예상치 못한 문제

이제 모든 준비가 끝났다고 생각하고, 규칙에 맞지 않는 Enum을 만들어 빌드를 실행해 봤습니다.

@EnumNamePattern(value = "^.*_(ADMIN|MANAGER)$")
public enum UserType {
TEST_ADMIN,
TEST_MANAGER,
TEST_FAILED, // 정규식 패턴에 맞지 않음
TEST_failed, // 대문자가 아님
}

그리고 빌드 명령어를 실행했습니다.

./gradlew clean build
./gradlew build # 실수가 아닙니다. 두 번 실행했습니다.

그런데 이상한 점을 발견했습니다. clean 이후 처음 build를 실행하면 아무 오류 없이 성공하고, **두 번째 ****build**를 실행해야만 비로소 컴파일 오류가 발생하는 것이었죠.

Image

이유는 빌드 순서 때문이었습니다. 같은 프로젝트 안에서는, EnumNamePatternProcessor** 클래스 자체가 컴파일되어야 그 프로세서를 사용해서 다른 코드를 검증할 수 있습니다.** 첫 번째 빌드에서는 프로세서가 아직 컴파일되지 않았기 때문에 로드되지 못하고, 두 번째 빌드부터 비로소 동작하는 ‘닭과 달걀’ 같은 문제였죠.

해결 방법은?

이 문제를 해결하는 정석적인 방법은 두 가지입니다.

  1. 멀티 모듈 구성: Annotation Processor 코드를 별도의 모듈로 분리하고, 메인 애플리케이션 모듈이 이 프로세서 모듈에 의존하도록 구성합니다.
  2. 라이브러리(JAR) 제공: 프로세서 코드를 미리 컴파일하여 JAR 파일로 만들어두고, 의존성으로 추가합니다. 저희 프로젝트에서는 이 문제를 당장 해결하지 않고 넘어가기로 했습니다. 멀티 모듈 구성은 아직 프로젝트 구조가 복잡하지 않은 상황에서 과하다고 판단했고, 라이브러리로 제공하기에는 사내 Maven Repository 같은 인프라가 아직 없었기 때문입니다. 그래서 임시방편으로 CI 스크립트에서 빌드를 두 번 실행하는 식으로 우회하기로 했습니다.

하지만 이 글에서는 정석적인 해결책인 멀티 모듈 구성 방법을 자세히 다뤄보겠습니다.

멀티 모듈로 문제 해결하기

1. processor 모듈 생성

먼저 processor라는 이름의 새 모듈을 만듭니다. IntelliJ를 사용하면 File > New > Module을 통해 쉽게 생성할 수 있습니다. 모듈이 생성되면 settings.gradle.kts에 자동으로 추가됩니다.

settings.gradle.kts
rootProject.name = "annotationprocessor"
include("processor") // <-- 모듈 추가

2. 관련 코드와 의존성 이동

기존에 만들었던 EnumNamePattern 어노테이션과 EnumNamePatternProcessor 클래스를 processor 모듈로 옮깁니다. auto-service 의존성 또한 processor 모듈의 build.gradle.kts로 이동시킵니다.

3. 메인 모듈에서 processor 모듈 의존성 추가

이제 메인 애플리케이션 모듈의 build.gradle.kts에서 processor 모듈을 의존성으로 추가합니다. 여기서 중요한 점은 implementation이 아닌 compileOnlyannotationProcessor를 사용하는 것입니다.

dependencies {
// ...
compileOnly(project(":processor"))
annotationProcessor(project(":processor"))
// ...
}
  • annotationProcessor(project(":processor")): 컴파일 시점에 processor 모듈에 있는 어노테이션 프로세서를 실행하라고 지시합니다.
  • compileOnly(project(":processor")): 메인 모듈의 코드(UserType Enum)에서 processor 모듈에 있는 @EnumNamePattern 어노테이션을 사용할 수 있도록 해줍니다. compileOnly를 사용하면 컴파일 시에만 필요하고, 최종 빌드 결과물(JAR)에는 포함되지 않기 때문에 요구사항을 만족시킬 수 있습니다. 만약 implementation으로 의존성을 추가하면, 최종 JAR 파일에 processor 모듈까지 불필요하게 포함되게 됩니다.

이제 다시 빌드를 실행하면, 단 한 번의 빌드만으로도 컴파일 시점에 오류를 정확히 잡아내는 것을 확인할 수 있습니다.

Image

Annotation Processor는 잘만 활용하면 코드의 일관성을 유지하고, 반복적인 작업을 자동화하며, 컴파일 시점에 버그를 잡는 등 개발 생산성을 크게 향상시킬 수 있는 강력한 도구라는 것을 다시 한번 느꼈습니다.