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

안동민 개발노트

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

메타 애노테이션

메타 애노테이션으로 클래스 파일과 실행 시점 생존 기간, 적용 위치, 클래스 상속, 반복 선언을 제어하고 리플렉션 탐색 API의 직접·상속·컨테이너 차이를 검증합니다.

사용자 정의 애노테이션의 요소가 “무엇을 기록하는가”를 정한다면 메타 애노테이션은 “어디에 붙고 언제까지 남으며 어떻게 조회되는가”를 정합니다. 프레임워크가 실행 시점 리플렉션으로 읽을 설정에는 @Retention(RUNTIME)이 필요합니다. 적용 위치를 @Target으로 좁히면 잘못된 필드나 매개변수 사용을 컴파일러가 막습니다.

@Documented는 Javadoc에 애노테이션이 포함될지를 정하고 실행 의미에는 영향을 주지 않습니다. @Inherited는 클래스에 붙은 애노테이션을 하위 클래스의 Class.getAnnotation에서 상속처럼 보이게 하지만 메서드, 필드, 인터페이스 구현에는 적용되지 않습니다. 같은 종류를 여러 번 선언하려면 @Repeatable과 컨테이너 애노테이션의 정확한 배열 요소가 필요합니다.

애노테이션 상속 오해

아래 코드는 @Inherited가 있으니 AdminService에서도 @Role을 찾을 수 있다고 기대합니다. 그러나 Java의 상속된 애노테이션 조회는 상위 클래스 체인에만 적용되고 인터페이스 구현 관계를 따라가지 않습니다. 결과는 null이며 권한 검사가 이 값에 의존하면 보호가 빠집니다.

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을 제공할 때 어떤 값이 우선하는지 정해야 합니다. 권한처럼 보안에 민감한 설정은 구체 클래스나 메서드에 직접 선언을 요구하는 편이 더 명확할 수 있습니다.

getAnnotationgetDeclaredAnnotation도 구분합니다. 전자는 @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 -vRuntimeInvisibleAnnotations 영역을 볼 수 있습니다. 리플렉션 API의 visible이라는 표현은 Java 접근 제어가 아니라 실행 시점 유지 정책 가시성과 관련됩니다.

@Target 배열에는 TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE, TYPE_USE, RECORD_COMPONENT 등이 있습니다. TYPE_USE는 선언이 아닌 타입 사용 위치까지 넓은 의미라 null 안전성 도구 같은 정적 분석에 쓰입니다. 프레임워크 라우트 애노테이션이 TYPE_USE까지 허용할 이유는 없으므로 METHOD로 좁혀 오용을 컴파일러가 잡게 합니다.

상속·반복 애노테이션

@Inherited는 부모 클래스의 TYPE 애노테이션에만 효과가 있습니다. 반복 애노테이션은 컴파일러가 컨테이너 애노테이션으로 묶어 저장할 수 있으므로 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를 사용하지 않고 선언된 메타데이터만 읽습니다.

메타 애노테이션과 탐색

애노테이션 정의에 RUNTIME을 붙였다고 프레임워크가 자동으로 찾는 것은 아닙니다. 스캐너가 클래스 경로 범위를 정하고 원소를 탐색해야 합니다. 반대로 SOURCE 애노테이션을 실행 시점 스캐너가 찾으려 하면 항상 누락됩니다. 정의와 소비 시점이 일치하는지 시작 시점 감사로 검사할 수 있습니다.

소비자적합 유지 정책흔한 대상조회 방식
컴파일러 플러그인SOURCEMETHOD·TYPE_USE애노테이션 처리
바이트코드 변환기CLASSTYPE·METHOD클래스 파일 파서
실행 시점 라우터RUNTIMEMETHOD선언된 메서드 검사
실행 시점 정책 상속RUNTIME+@InheritedTYPEgetAnnotation
다중 레이블RUNTIME+@Repeatable요구 원소getAnnotationsByType

@Documented는 사용자 문서에 보이는 API 계약을 돕지만 스캐너 결과에는 차이가 없습니다. @Target을 생략하면 여러 선언 문맥에 사용할 수 있으나 “모든 곳에 의도적으로 허용”한 것이 아니라면 명시적으로 좁히는 편이 유지보수에 유리합니다.

연습 문제

애노테이션 타입 목록과 필요한 ElementType을 받아 RUNTIME 유지 정책, 대상 포함, 애노테이션 타입 여부를 검사합니다. 반복 가능 애노테이션이면 컨테이너의 유지 정책과 대상, value() 반환 타입도 검증합니다. 모든 오류를 정렬한 목록으로 반환합니다.

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

Class.isAnnotation()과 애노테이션 타입에 붙은 @Retention, @Target, @Repeatable을 조사합니다. @Retention이 없으면 CLASS가 기본이지만 실행 시점 프레임워크 요구에는 실패입니다.

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으로 같은 배열 형태가 되어야 합니다.

적용할 수 없는 원소에는 컴파일러 오류가 발생하므로 대상을 넓혀 테스트를 통과시키지 않습니다. 프레임워크 시작 시점은 필요한 유지 정책과 대상을 감사하고 실행 표를 만들 때 애노테이션 탐색 정책을 문서화합니다. 다음 문서에서는 필드 애노테이션과 리플렉션을 결합해 직접 검증기를 구현하되 모든 오류 수집, 타입 불일치, 접근 실패를 안전한 결과로 처리합니다.