본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
28장 : 리플렉션과 애노테이션

애노테이션 요소와 기본값

애노테이션 정의에 허용되는 상수형 요소와 배열·열거형·클래스·중첩 애노테이션을 사용하고 기본값의 호환성 위험을 실행 시점 스키마 검증으로 보완합니다.

애노테이션은 일반 인터페이스와 닮았지만 요소 선언에 엄격한 제약이 있습니다. 요소는 매개변수가 없는 추상 메서드 모양이며 반환 타입은 기본형, String, Class, enum, 애노테이션 또는 이들의 1차원 배열이어야 합니다. 임의 객체, List<String>, Optional, void는 사용할 수 없습니다. 값은 애노테이션 사용 지점에서 컴파일 시 상수로 결정되어 클래스 파일 메타데이터에 기록됩니다.

기본값이 없는 요소는 필수입니다. 기본값이 있으면 모든 사용 지점에서 생략할 수 있습니다. 요소 이름이 value 하나뿐이거나 나머지가 모두 기본값을 가지면 @Label("text")처럼 이름을 생략할 수 있습니다. 배열 요소에 값 하나만 줄 때는 중괄호를 생략할 수 있지만 두 개 이상이면 배열 표기를 사용합니다.

모호한 기본값

필수로 받아야 할 값을 임의 센티널 기본값으로 두면 컴파일러가 누락을 잡지 못합니다. 아래 minmax는 사용자가 생략할 수 있지만 실제 실행기는 Integer.MIN_VALUE가 의도인지 미설정인지 다시 추측해야 합니다. 문자열 빈 값도 유효 업무 값이라면 같은 문제가 생깁니다.

bad/SentinelAnnotationDefaults.java
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

public final class SentinelAnnotationDefaults {
    @Retention(RetentionPolicy.RUNTIME)
    @interface RangeRule {
        int min() default Integer.MIN_VALUE;

        int max() default Integer.MIN_VALUE;

        String message() default "";
    }

    @RangeRule
    static final class UnconfiguredTarget {
    }

    public static void main(String[] args) {
        System.out.println("compile-only ambiguous default counterexample");
    }
}

값이 반드시 필요하다면 기본값을 제거해 사용 지점이 컴파일되지 않게 합니다. 흔하고 안전한 동작에는 기본값이 적합합니다. 예를 들어 재시도 횟수 0, 로그 수준 BASIC처럼 생략 의미가 장기간 안정적인 값입니다. 보안 옵션을 enabled=true로 추가하면 기존 클래스 파일에도 새 기본값이 적용될 수 있으므로 애노테이션 API 진화 시 영향 범위를 검토합니다.

애노테이션 요소는 null을 반환할 수 없습니다. 선택값이 필요하면 enumUNSPECIFIED를 명시하거나 별도의 마커 애노테이션으로 존재 여부를 표현합니다. 빈 문자열 센티널을 사용한다면 그 값이 실제 입력으로 허용되지 않는다는 스키마 규칙을 스캐너에서 검증합니다.

애노테이션 요소 타입

다음 정의는 String, int, boolean, Class, enum, 중첩 애노테이션, String 배열을 사용합니다. 애노테이션 인스턴스의 각 메서드를 호출하면 선언값 또는 기본값이 반환됩니다. Class<? extends Runnable>처럼 제네릭 결합을 두면 컴파일러가 허용 타입을 좁혀 줍니다.

src/AnnotationElementCatalog.java
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Arrays;

public final class AnnotationElementCatalog {
    enum Mode {
        SYNC,
        ASYNC
    }

    @Retention(RetentionPolicy.RUNTIME)
    @interface Retry {
        int attempts();

        long delayMillis() default 0;
    }

    @Retention(RetentionPolicy.RUNTIME)
    @interface Job {
        String value();

        Mode mode() default Mode.SYNC;

        boolean enabled() default true;

        Class<? extends Runnable> worker();

        Retry retry() default @Retry(attempts = 1);

        String[] tags() default {};
    }

    static final class CleanupWorker implements Runnable {
        @Override
        public void run() {
            System.out.println("cleanup");
        }
    }

    @Job(
            value = "cleanup",
            mode = Mode.ASYNC,
            worker = CleanupWorker.class,
            retry = @Retry(attempts = 3, delayMillis = 100),
            tags = {"nightly", "storage"})
    static final class CleanupJob {
    }

    @Job(value = "health", worker = CleanupWorker.class)
    static final class HealthJob {
    }

    public static void main(String[] args) {
        Job job = CleanupJob.class.getAnnotation(Job.class);
        System.out.println("name=" + job.value());
        System.out.println("mode=" + job.mode());
        System.out.println("worker=" + job.worker().getSimpleName());
        System.out.println("attempts=" + job.retry().attempts());
        System.out.println("tags=" + Arrays.toString(job.tags()));
    }
}

HealthJob도 필수 작업 스레드를 명시합니다. 이 요소를 빼면 컴파일러가 애노테이션 사용 지점을 거절하므로 실행 시점까지 잘못된 설정이 도달하지 않습니다. @Job("health") 형태의 값 축약을 사용하려면 작업 스레드에도 기본값이 필요하지만, 실제 작업 누락을 숨길 수 있어 여기서는 명시성을 택했습니다.

기본값 메타데이터

애노테이션 타입의 선언된 메서드는 요소 스키마입니다. Method.getDefaultValue()null이면 필수 요소이고, 값이 있으면 생략 가능합니다. 프레임워크는 시작 시점 문서나 구성 보고서를 만들 때 이 정보를 사용할 수 있습니다. 애노테이션 인스턴스만 보면 반환값이 명시값인지 기본값인지 직접 구분할 수 없다는 한계도 있습니다.

src/AnnotationDefaultInspector.java
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;

public final class AnnotationDefaultInspector {
    enum Format {
        JSON,
        TEXT
    }

    @Retention(RetentionPolicy.RUNTIME)
    @interface Export {
        String value();

        Format format() default Format.JSON;

        int batchSize() default 100;

        String[] columns() default {};
    }

    @Export(value = "members", columns = {"id", "name"})
    static final class MemberExport {
    }

    record ElementSchema(String name, String returnType,
                         boolean required, String defaultText) {
    }

    static ElementSchema schema(Method method) {
        Object defaultValue = method.getDefaultValue();
        String text;
        if (defaultValue instanceof Object[] array) {
            text = Arrays.deepToString(array);
        } else {
            text = String.valueOf(defaultValue);
        }
        return new ElementSchema(
                method.getName(),
                method.getReturnType().getTypeName(),
                defaultValue == null,
                text);
    }

    public static void main(String[] args) {
        Arrays.stream(Export.class.getDeclaredMethods())
                .map(AnnotationDefaultInspector::schema)
                .sorted(Comparator.comparing(ElementSchema::name))
                .forEach(System.out::println);

        Export export = MemberExport.class.getAnnotation(Export.class);
        System.out.println("resolved format=" + export.format());
        System.out.println("resolved batch=" + export.batchSize());
        System.out.println("resolved columns="
                + Arrays.toString(export.columns()));
    }
}

기본형 배열은 Object[]로 형변환할 수 없으므로 범용 기본값 렌더러는 java.lang.reflect.Array를 사용해야 합니다. 위 스키마의 열는 String[]이라 해당 분기로 처리됩니다. 지원 요소 전체를 문서화하려면 배열 구성 요소를 확인하고 Array.getLength, Array.get으로 공통 렌더링합니다.

기본값 값은 애노테이션 정의를 다시 컴파일하면 기존 사용 코드에도 리플렉션 시 새로운 값으로 보일 수 있습니다. 바이너리 호환성과 동작 호환성이 항상 같지 않습니다. 공개 라이브러리 애노테이션에 기본값 의미를 바꿀 때는 마이그레이션 기간과 명시값 사용을 고려합니다.

애노테이션 스키마 검증

컴파일러는 minmaxint인지 확인하지만 min <= max는 알지 못합니다. 경로가 슬래시로 시작하는지, 재시도 횟수가 양수인지, ASYNC 모드에 작업 스레드가 필요한지 같은 관계 규칙은 스캐너가 검증합니다. 애노테이션을 단순한 데이터 묶음으로 읽고 곧바로 실행하면 잘못된 구성은 첫 요청에서야 드러납니다.

규칙컴파일러가 확인시작 시점 검증기가 확인요청 시 확인
요소 타입불필요불필요
필수 요소 존재불필요불필요
범위 관계아니오min <= max불필요
Class 역할제네릭 결합 일부생성 가능성·계약인스턴스 실패
외부 자원 존재아니오선택적 검증 프로그램실제 접근

Class 요소는 클래스 리터럴을 보관할 뿐 인스턴스 생성을 보장하지 않습니다. 인터페이스인지, abstract인지, 필요한 생성자가 있는지 스캐너에서 확인합니다. 모듈 접근과 생성자 본문 실패는 리플렉션 팩터리의 예외 경계로 이어집니다.

연습 문제

@ScheduledJob의 이름, periodMillis, 작업 스레드를 검사합니다. 이름은 비어 있지 않고 주기는 10 이상이어야 하며 작업 스레드 클래스는 구체이고 public 무인수 생성자를 가져야 합니다. 여러 클래스의 모든 문제를 Issue 목록으로 반환합니다.

Class 메타데이터까지 확인하는 풀이

애노테이션 원소 제네릭 결합은 Runnable 구현 여부를 컴파일 시간에 제한합니다. 시작 시점 검증기는 abstract 여부와 생성자 접근을 추가로 봅니다.

solution/AnnotationSchemaValidatorSolution.java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;

public final class AnnotationSchemaValidatorSolution {
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface ScheduledJob {
        String name();

        long periodMillis();

        Class<? extends Runnable> worker();
    }

    public static final class GoodWorker implements Runnable {
        public GoodWorker() {
        }

        @Override
        public void run() {
        }
    }

    abstract static class AbstractWorker implements Runnable {
    }

    @ScheduledJob(
            name = "cleanup",
            periodMillis = 100,
            worker = GoodWorker.class)
    static final class GoodConfig {
    }

    @ScheduledJob(
            name = " ",
            periodMillis = 1,
            worker = AbstractWorker.class)
    static final class BadConfig {
    }

    record Issue(String owner, String detail) {
    }

    static List<Issue> validate(List<Class<?>> configs) {
        List<Issue> issues = new ArrayList<>();
        for (Class<?> config : configs) {
            ScheduledJob job = config.getAnnotation(ScheduledJob.class);
            if (job == null) {
                issues.add(new Issue(config.getSimpleName(), "annotation missing"));
                continue;
            }
            if (job.name().isBlank()) {
                issues.add(new Issue(config.getSimpleName(), "name is blank"));
            }
            if (job.periodMillis() < 10) {
                issues.add(new Issue(config.getSimpleName(), "period below 10ms"));
            }
            Class<? extends Runnable> worker = job.worker();
            if (worker.isInterface()
                    || Modifier.isAbstract(worker.getModifiers())) {
                issues.add(new Issue(config.getSimpleName(), "worker is abstract"));
                continue;
            }
            try {
                if (!Modifier.isPublic(
                        worker.getConstructor().getModifiers())) {
                    issues.add(new Issue(
                            config.getSimpleName(),
                            "constructor is not public"));
                }
            } catch (NoSuchMethodException error) {
                issues.add(new Issue(
                        config.getSimpleName(),
                        "public no-arg constructor missing"));
            }
        }
        return List.copyOf(issues);
    }

    public static void main(String[] args) {
        validate(List.of(GoodConfig.class, BadConfig.class))
                .forEach(System.out::println);
    }
}

추상 작업 스레드는 생성자 조회로 넘어가지 않고 별도 문제로 기록합니다. 확장 과제로 동일 작업 이름 중복과 주기 상한을 추가하고, 문제 목록이 비어 있을 때만 실제 Runnable 팩터리를 만듭니다.

공개 API 승인 기준

필수와 기본값 요소가 의도대로 나뉘고, value 축약이 읽기 쉬운 경우에만 쓰였는지 확인합니다. 배열 단일값 축약이 스키마 의미를 숨기지 않는지, Class 결합이 너무 넓지 않은지, 기본값 변화가 기존 사용자 동작에 미칠 영향도 검토합니다.

애노테이션 인스턴스 값만 믿지 않고 관계 규칙을 시작 시점에 검증하며, 구성 오류를 모두 모아 수정 가능한 보고서를 제공해야 합니다. 다음 문서에서는 유지 정책, 대상, 문서화 여부, 상속된과 반복 가능 같은 메타 애노테이션이 메타데이터의 생존 기간과 탐색 범위를 어떻게 바꾸는지 실행 결과로 확인합니다.