안동민 개발노트

본문 시작

메타 애노테이션

메타 애노테이션으로 생존 기간·적용 위치·상속·반복 선언을 제어하고 리플렉션 탐색 차이를 확인합니다.

사용자 정의 애노테이션의 요소가 “무엇을 기록하는가”를 정한다면 메타 애노테이션은 “어디에 붙고 언제까지 남으며 어떻게 조회되는가”를 정합니다.

프레임워크가 실행 시점 리플렉션으로 읽을 설정에는 @Retention(RUNTIME)이 필요합니다.

적용 위치를 @Target으로 좁히면 잘못된 필드나 매개변수 사용을 컴파일러가 막습니다.

@Documented는 Javadoc에 애노테이션이 포함될지를 정하고 실행 의미에는 영향을 주지 않습니다.

@Inherited는 클래스에 붙은 애노테이션을 하위 클래스의 Class.getAnnotation에서 상속처럼 보이게 하지만 메서드, 필드, 인터페이스 구현에는 적용되지 않습니다.

같은 종류를 여러 번 선언하려면 @Repeatable과 컨테이너 애노테이션의 정확한 배열 요소가 필요합니다.


애노테이션 상속 오해

아래 코드는 @Inherited가 있으니 AdminService에서도 @Role을 찾을 수 있다고 기대합니다.

그러나 Java의 상속된 애노테이션 조회는 상위 클래스 체인에만 적용되고 인터페이스 구현 관계를 따라가지 않습니다.

getAnnotation의 결과는 null입니다. role()을 호출하면 이어지는 role.value()에서 NullPointerException이 발생하지만, 원문 main은 이 함수를 호출하지 않고 안내 문자열만 출력합니다.

bad/AssumedInterfaceAnnotationInheritance.java
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

public final class AssumedInterfaceAnnotationInheritance {
    @Inherited
    @Retention(RetentionPolicy.RUNTIME)
    @interface Role {
        String value();
    }

    @Role("ADMIN")
    interface SecuredService {
    }

    static final class AdminService implements SecuredService {
    }

    static String role() {
        Role role = AdminService.class.getAnnotation(Role.class);
        return role.value();
    }

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

인터페이스 메타데이터를 의미 있게 사용하려면 스캐너가 getInterfaces()와 부모 인터페이스를 명시적으로 순회하고 충돌 규칙을 정의합니다.

여러 인터페이스가 서로 다른 Role을 제공할 때 어떤 값이 우선하는지 정해야 합니다.

권한처럼 보안에 민감한 설정은 구체 클래스나 메서드에 직접 선언을 요구하는 편이 더 명확할 수 있습니다.

getAnnotation과 getDeclaredAnnotation도 구분합니다.

전자는 @Inherited 클래스 애노테이션을 상위 클래스에서 찾을 수 있고, 후자는 현재 원소에 직접 선언된 것만 반환합니다.

스캐너가 상속 의미를 원하는지에 맞춰 API를 고릅니다.


유지 정책과 가시성

유지 정책을 생략하면 기본은 CLASS입니다.

클래스 파일에는 남을 수 있지만 JVM 리플렉션에서는 보이지 않습니다.

컴파일러 보조용 애노테이션은 SOURCE, 바이트코드 도구가 읽는 메타데이터는 CLASS, 실행 시점 프레임워크가 읽는 설정은 RUNTIME을 선택합니다.

src/RetentionVisibilityProbe.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.Method;
import java.util.Arrays;

public final class RetentionVisibilityProbe {
    @Retention(RetentionPolicy.SOURCE)
    @Target(ElementType.METHOD)
    @interface SourceHint {
    }

    @Retention(RetentionPolicy.CLASS)
    @Target(ElementType.METHOD)
    @interface ClassHint {
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface RuntimeHint {
        String value() default "visible";
    }

    static final class TargetService {
        @SourceHint
        @ClassHint
        @RuntimeHint("runtime")
        public void execute() {
        }
    }

    public static void main(String[] args) throws Exception {
        Method method = TargetService.class.getMethod("execute");
        System.out.println("source="
                + method.isAnnotationPresent(SourceHint.class));
        System.out.println("class="
                + method.isAnnotationPresent(ClassHint.class));
        System.out.println("runtime="
                + method.getAnnotation(RuntimeHint.class).value());
        System.out.println("visible="
                + Arrays.toString(method.getDeclaredAnnotations()));
    }
}

이 선언을 리플렉션으로 조회하면 유지 정책에 따라 RuntimeHint만 보입니다.

CLASS 메타데이터가 실제 클래스 파일에 있는지 확인하려면 javap -v의 RuntimeInvisibleAnnotations 영역을 볼 수 있습니다.

리플렉션 API의 visible이라는 표현은 Java 접근 제어가 아니라 실행 시점 유지 정책 가시성과 관련됩니다.

@Target 배열에는 TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE, TYPE_USE, RECORD_COMPONENT 등이 있습니다.

TYPE_USE는 선언이 아닌 타입 사용 위치까지 넓은 의미라 null 안전성 도구 같은 정적 분석에 쓰입니다.

프레임워크 라우트 애노테이션이 TYPE_USE까지 허용할 이유는 없으므로 METHOD로 좁혀 오용을 컴파일러가 잡게 합니다.


상속·반복 애노테이션

@Inherited는 애노테이션이 붙은 상위 클래스를 따라 조회할 때만 효과가 있습니다.

반복 애노테이션은 컴파일러가 컨테이너 애노테이션으로 묶어 저장할 수 있으므로 getAnnotation(Tag.class)보다 getAnnotationsByType(Tag.class)을 사용해야 직접 선언과 컨테이너 표현을 모두 펼쳐 얻습니다.

src/InheritedRepeatableProbe.java
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Arrays;

public final class InheritedRepeatableProbe {
    @Documented
    @Inherited
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface Tier {
        String value();
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @Repeatable(Tags.class)
    @interface Tag {
        String value();
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface Tags {
        Tag[] value();
    }

    @Tier("parent")
    static class Parent {
    }

    static final class Child extends Parent {
    }

    @Tier("interface")
    interface Contract {
    }

    static final class Implementation implements Contract {
    }

    @Tag("http")
    @Tag("reflection")
    static final class TaggedType {
    }

    public static void main(String[] args) {
        System.out.println("child inherited="
                + Child.class.getAnnotation(Tier.class).value());
        System.out.println("child declared="
                + Child.class.getDeclaredAnnotation(Tier.class));
        System.out.println("implementation="
                + Implementation.class.getAnnotation(Tier.class));
        System.out.println("tags=" + Arrays.toString(
                TaggedType.class.getAnnotationsByType(Tag.class)));
        System.out.println("container="
                + TaggedType.class.getAnnotation(Tags.class));
    }
}

반복 가능 애노테이션과 컨테이너는 유지 정책과 대상이 서로 호환되어야 합니다.

컨테이너의 value()는 정확히 반복 애노테이션 배열 타입이어야 합니다.

컨테이너에 다른 기본 요소를 추가할 수 있지만 API 독자가 직접 컨테이너를 사용할 가능성까지 고려합니다.

상속된 반복 가능 애노테이션은 더 미묘합니다.

반복 애노테이션과 컨테이너에 @Inherited를 일관되게 적용하고 getAnnotationsByType 동작을 테스트해야 합니다.

라우트처럼 하위 클래스가 부모 엔드포인트를 자동 노출하는 것이 위험한 경우에는 @Inherited를 사용하지 않고 선언된 메타데이터만 읽습니다.

같은 선언도 조회 API에 따라 달라지는 결과

같은 선언도 조회 API에 따라 달라지는 결과

같은 선언도 조회 API에 따라 달라지는 결과
대상과 조회 의도조회 API코드와 계약으로 정해지는 값
하위 클래스의 상속 정책Child: getAnnotation(Tier.class)value=parent
하위 클래스의 직접 선언Child: getDeclaredAnnotation(Tier.class)null
인터페이스 구현체의 정책Implementation: getAnnotation(Tier.class)null
반복 레이블 펼치기TaggedType: getAnnotationsByType(Tag.class)http, reflection 순서의 Tag 2개
반복 레이블의 컨테이너TaggedType: getAnnotation(Tags.class)두 Tag를 담은 Tags
하위 클래스의 상속 정책
조회 API: Child: getAnnotation(Tier.class)
코드와 계약으로 정해지는 값: value=parent
하위 클래스의 직접 선언
조회 API: Child: getDeclaredAnnotation(Tier.class)
코드와 계약으로 정해지는 값: null
인터페이스 구현체의 정책
조회 API: Implementation: getAnnotation(Tier.class)
코드와 계약으로 정해지는 값: null
반복 레이블 펼치기
조회 API: TaggedType: getAnnotationsByType(Tag.class)
코드와 계약으로 정해지는 값: http, reflection 순서의 Tag 2개
반복 레이블의 컨테이너
조회 API: TaggedType: getAnnotation(Tags.class)
코드와 계약으로 정해지는 값: 두 Tag를 담은 Tags

InheritedRepeatableProbe의 조회를 정리했으며 애노테이션 객체의 문자열 출력 형식까지 고정하지 않습니다. TaggedType에서 Tag 하나를 getAnnotation으로 직접 찾으면 null입니다.


메타 애노테이션과 탐색

애노테이션 정의에 RUNTIME을 붙였다고 프레임워크가 자동으로 찾는 것은 아닙니다.

스캐너가 클래스 경로 범위를 정하고 원소를 탐색해야 합니다.

반대로 SOURCE 애노테이션을 실행 시점 스캐너가 찾으려 하면 항상 누락됩니다.

정의와 소비 시점이 일치하는지 시작 시점 감사로 검사할 수 있습니다.

@Documented는 사용자 문서에 보이는 API 계약을 돕지만 스캐너 결과에는 차이가 없습니다.

@Target을 생략하면 여러 선언 문맥에 사용할 수 있으나 “모든 곳에 의도적으로 허용”한 것이 아니라면 명시적으로 좁히는 편이 유지보수에 유리합니다.


연습 문제

애노테이션 타입 목록과 필요한 ElementType을 받아 RUNTIME 유지 정책, 대상 포함, 애노테이션 타입 여부를 검사합니다.

반복 가능 애노테이션이면 컨테이너의 유지 정책과 대상, value() 반환 타입도 검증합니다.

모든 오류를 정렬한 목록으로 반환합니다.

메타 애노테이션 자체를 리플렉션으로 읽는 풀이

Class.isAnnotation()과 애노테이션 타입에 붙은 @Retention, @Target, @Repeatable을 조사합니다.

@Retention이 없으면 CLASS가 기본이지만 실행 시점 프레임워크 요구에는 실패입니다. 이 풀이의 checkTarget은 @Target에 요구 값을 직접 명시하도록 요구합니다. Java가 @Target 없는 애노테이션을 여러 선언 문맥에 허용하는 규칙보다 엄격한 정책이며, 대상 간 의미 관계까지 판정하는 일반 언어 검증기는 아닙니다.

solution/RuntimeAnnotationAuditSolution.java
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public final class RuntimeAnnotationAuditSolution {
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @Repeatable(Hooks.class)
    @interface Hook {
        String value();
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface Hooks {
        Hook[] value();
    }

    @Retention(RetentionPolicy.CLASS)
    @Target(ElementType.FIELD)
    @interface BrokenRuntimeRule {
    }

    record Issue(String annotation, String detail) {
    }

    static List<Issue> audit(
            List<Class<?>> annotationTypes,
            ElementType requiredTarget) {
        List<Issue> issues = new ArrayList<>();
        for (Class<?> type : annotationTypes) {
            if (!type.isAnnotation()) {
                issues.add(new Issue(type.getName(), "not an annotation type"));
                continue;
            }
            checkRuntime(type, issues);
            checkTarget(type, requiredTarget, issues);
            Repeatable repeatable = type.getAnnotation(Repeatable.class);
            if (repeatable != null) {
                checkContainer(type, repeatable.value(), requiredTarget, issues);
            }
        }
        issues.sort(java.util.Comparator
                .comparing(Issue::annotation)
                .thenComparing(Issue::detail));
        return List.copyOf(issues);
    }

    private static void checkRuntime(Class<?> type, List<Issue> issues) {
        Retention retention = type.getAnnotation(Retention.class);
        if (retention == null || retention.value() != RetentionPolicy.RUNTIME) {
            issues.add(new Issue(type.getName(), "retention is not RUNTIME"));
        }
    }

    private static void checkTarget(
            Class<?> type,
            ElementType required,
            List<Issue> issues) {
        Target target = type.getAnnotation(Target.class);
        boolean present = target != null
                && Arrays.asList(target.value()).contains(required);
        if (!present) {
            issues.add(new Issue(type.getName(), "required target missing"));
        }
    }

    private static void checkContainer(
            Class<?> repeated,
            Class<?> container,
            ElementType required,
            List<Issue> issues) {
        checkRuntime(container, issues);
        checkTarget(container, required, issues);
        try {
            Method value = container.getDeclaredMethod("value");
            Class<?> expected = java.lang.reflect.Array
                    .newInstance(repeated, 0).getClass();
            if (value.getReturnType() != expected) {
                issues.add(new Issue(
                        container.getName(),
                        "container value type mismatch"));
            }
        } catch (NoSuchMethodException error) {
            issues.add(new Issue(container.getName(), "container value missing"));
        }
    }

    public static void main(String[] args) {
        List<Issue> issues = audit(
                List.of(Hook.class, BrokenRuntimeRule.class),
                ElementType.METHOD);
        issues.forEach(System.out::println);
    }
}

원문에서 정상 Hook은 검사 문제를 만들지 않고 BrokenRuntimeRule은 유지 정책과 대상 두 오류에 해당합니다.

컨테이너는 애노테이션 정의 시 컴파일러도 많은 제약을 확인하지만, 감사는 프레임워크가 요구하는 실행 시점 정책을 별도로 보장합니다.


계약 점검

SOURCE, CLASS, RUNTIME 세 정의를 실행 시점 리플렉션으로 조회해 가시성 차이를 확인합니다.

하위 클래스와 인터페이스 구현체에서 @Inherited 결과가 다르고, getDeclaredAnnotation이 부모 메타데이터를 반환하지 않아야 합니다.

반복 가능은 단일 선언과 복수 선언 모두 getAnnotationsByType으로 같은 배열 형태가 되어야 합니다.

적용할 수 없는 원소에는 컴파일러 오류가 발생하므로 대상을 넓혀 테스트를 통과시키지 않습니다.

프레임워크 시작 시점은 필요한 유지 정책과 대상을 감사하고 실행 표를 만들 때 애노테이션 탐색 정책을 문서화합니다.

다음 문서에서는 필드 애노테이션과 리플렉션을 결합해 직접 검증기를 구현하되 모든 오류 수집, 타입 불일치, 접근 실패를 안전한 결과로 처리합니다.