메타 애노테이션 @Retention·@Target·@Inherited·@Repeatable
애노테이션 정의에 붙이는 메타 애노테이션으로 유지 기간, 붙일 수 있는 위치, 하위 클래스로의 상속, 반복 선언을 정하는 방법과 조회 메서드마다 결과가 달라지는 이유를 배웁니다.
앞 글에서는 애노테이션의 요소를 설계했습니다. 요소가 애노테이션이 '무엇을 기록하는가'를 정한다면, 메타 애노테이션(meta-annotation)은 그 애노테이션이 '어디에 붙고, 언제까지 남으며, 어떻게 조회되는가'를 정합니다. 메타 애노테이션은 이름 그대로 애노테이션 정의에 붙이는 애노테이션입니다. 지금까지 예제의 애노테이션 정의에 붙여 온 @Retention(RetentionPolicy.RUNTIME)이나 @Target(ElementType.METHOD)가 바로 메타 애노테이션입니다.
이 글에서는 java.lang.annotation 패키지의 메타 애노테이션 다섯 가지를 다룹니다. 남는 기간을 정하는 @Retention, 붙일 수 있는 위치를 정하는 @Target, Javadoc 표시 여부를 정하는 @Documented, 하위 클래스에서도 보이게 하는 @Inherited, 같은 애노테이션을 여러 번 붙이게 하는 @Repeatable입니다. 마지막으로 같은 선언이라도 어떤 조회 메서드를 쓰느냐에 따라 결과가 달라지는 이유를 확인합니다.
@Retention으로 애노테이션이 남는 기간 정하기
애노테이션 정보를 쓰는 쪽은 크게 셋입니다. @Override처럼 컴파일러가 확인하고 나면 버려도 되는 정보가 있고, 빌드가 끝난 클래스 파일을 읽는 도구가 쓰는 정보가 있으며, 실행 중에 프레임워크가 리플렉션으로 읽어야 하는 정보가 있습니다. @Retention은 애노테이션을 어디까지 남길지를 RetentionPolicy의 세 상수 가운데 하나로 정합니다.
| 유지 정책 | 어디까지 남는가 | 주로 읽는 쪽 |
|---|---|---|
SOURCE | 소스 코드까지 (컴파일 후 버림) | 컴파일러와 애노테이션 처리기 (@Override, @SuppressWarnings) |
CLASS | 클래스 파일까지 (실행 중 리플렉션으로는 읽지 못함) | 클래스 파일을 읽는 바이트코드 도구 |
RUNTIME | 실행 중까지 (리플렉션으로 읽을 수 있음) | 실행 중에 애노테이션을 읽는 프레임워크 |
@Retention을 생략하면 기본값은 CLASS입니다. 실행 중에 읽으려고 만든 애노테이션에서 @Retention을 빠뜨리면, 애노테이션은 클래스 파일에 기록되지만 리플렉션에는 보이지 않습니다. 컴파일 오류도 경고도 없이 스캐너가 아무것도 찾지 못하게 되므로 흔히 저지르는 실수입니다. 다음 예제는 유지 정책만 다른 애노테이션 세 개를 한 메서드에 붙이고 리플렉션으로 조회합니다.
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()));
}
}source=false
class=false
runtime=runtime
visible=[@RetentionVisibilityProbe.RuntimeHint("runtime")]execute()에는 애노테이션이 셋 붙어 있지만 리플렉션에 보이는 것은 RuntimeHint 하나입니다. SourceHint는 컴파일러가 확인한 뒤 버렸으니 클래스 파일에도 없고, ClassHint는 클래스 파일에는 있지만 실행 중에는 읽을 수 없는 곳에 기록되었습니다. 그래서 isAnnotationPresent가 둘 다 false이고, 메서드에 직접 붙은 애노테이션을 모두 돌려주는 getDeclaredAnnotations()에도 RuntimeHint만 들어 있습니다. 세 번째 줄의 runtime은 사용 지점에 적은 값입니다. 생략했다면 기본값 visible이 나왔을 것입니다.
RUNTIME을 붙였다고 해서 프레임워크가 저절로 찾아 주는 것은 아닙니다. RUNTIME은 실행 중에 읽을 수 있다는 뜻일 뿐이고, 어떤 클래스와 메서드를 살펴볼지는 런타임 애노테이션을 다룬 글의 라우트 스캐너처럼 누군가 범위를 정해 직접 조회해야 합니다. 반대로 SOURCE나 CLASS 애노테이션은 아무리 잘 만든 스캐너라도 리플렉션으로는 찾을 수 없습니다.
더 깊이: javap로 CLASS 애노테이션 확인하기
ClassHint가 정말 클래스 파일에 들어 있는지는 JDK에 포함된 javap으로 확인할 수 있습니다. -v 옵션을 주면 클래스 파일의 내용을 자세히 보여 줍니다. 중첩 클래스의 이름에는 $가 들어가므로, 셸이 변수로 해석하지 않도록 작은따옴표로 감쌉니다.
javac -d out src/RetentionVisibilityProbe.java
javap -v -cp out 'RetentionVisibilityProbe$TargetService'javap -v는 상수 풀과 메서드마다의 바이트코드까지 모두 보여 주므로 출력이 깁니다. 그 가운데 execute() 메서드 부분의 끝은 다음과 같습니다.
RuntimeVisibleAnnotations:
0: #13(#14=s#15)
RetentionVisibilityProbe$RuntimeHint(
value="runtime"
)
RuntimeInvisibleAnnotations:
0: #17()
RetentionVisibilityProbe$ClassHintRUNTIME 애노테이션은 RuntimeVisibleAnnotations 속성(attribute)에, CLASS 애노테이션은 RuntimeInvisibleAnnotations 속성에 기록되었고, SourceHint는 어디에도 없습니다. 리플렉션이 읽는 것은 앞의 속성뿐입니다. 이름에 붙은 visible과 invisible은 실행 중 리플렉션에 보이는지를 뜻할 뿐, public이나 private 같은 접근 제어와는 관계가 없습니다. #13 같은 번호는 클래스 파일 안 상수 풀의 위치라서 컴파일러 버전에 따라 다를 수 있습니다.
@Target으로 붙일 수 있는 위치 정하기
@Target은 애노테이션을 붙일 수 있는 위치를 ElementType 상수의 배열로 정합니다. 라우트 애노테이션은 메서드에만 의미가 있으니 @Target(ElementType.METHOD)로 좁혀 두면, 다른 곳에 붙이는 실수를 컴파일러가 막아 줍니다. 다음 코드는 메서드용 @Route를 필드에 붙였습니다.
import java.lang.annotation.ElementType;
import java.lang.annotation.Target;
public final class TargetMisuse {
@Target(ElementType.METHOD)
@interface Route {
String value();
}
@Route("/members")
private String name;
}bad/TargetMisuse.java:10: error: annotation interface not applicable to this kind of declaration
@Route("/members")
^
1 error@Target에 적을 수 있는 ElementType 상수는 다음과 같습니다. 여러 위치를 허용하려면 @Target({ElementType.FIELD, ElementType.METHOD})처럼 배열로 적습니다.
| 상수 | 붙일 수 있는 곳 |
|---|---|
TYPE | 클래스, 인터페이스(애노테이션 타입 포함), enum, 레코드 선언 |
FIELD | 필드 선언(enum 상수 포함) |
METHOD | 메서드 선언 |
PARAMETER | 매개변수 선언 |
CONSTRUCTOR | 생성자 선언 |
LOCAL_VARIABLE | 지역 변수 선언 |
ANNOTATION_TYPE | 애노테이션 타입 선언 (메타 애노테이션을 만들 때) |
PACKAGE | 패키지 선언 (package-info.java) |
TYPE_PARAMETER | 타입 매개변수 선언 (Java 8부터) |
TYPE_USE | 타입을 쓰는 모든 자리 (Java 8부터) |
MODULE | 모듈 선언 (Java 9부터) |
RECORD_COMPONENT | 레코드 구성 요소 (Java 16부터) |
@Target을 아예 생략하면 모든 선언에 붙일 수 있습니다. 다만 아래에서 설명할 타입 사용 위치에는 붙일 수 없습니다. 생략하면 편하지만 의도하지 않은 곳에 붙어도 컴파일러가 막아 주지 못하므로, 쓸 곳을 명시해 좁히는 편이 유지보수에 유리합니다.
TYPE_USE는 선언이 아니라 타입을 쓰는 자리에 애노테이션을 붙일 수 있게 합니다. List<@NonNull String>의 타입 인수나 (@NonNull String) value 같은 형변환이 그런 자리입니다. 이런 애노테이션은 주로 null 안전성을 검사하는 정적 분석 도구가 읽습니다. 메서드에만 의미가 있는 라우트 애노테이션이 TYPE_USE까지 허용할 이유는 없습니다.
@Documented는 Javadoc 표시만 바꿉니다
@Documented가 붙은 애노테이션은 그것이 붙은 클래스나 메서드의 Javadoc 문서에 함께 표시됩니다. 그 애노테이션이 API를 쓰는 사람도 알아야 할 공개 정보라는 표시입니다. 예를 들어 메서드가 사용 중단되었다는 사실은 사용자에게 중요하므로 @Deprecated에는 @Documented가 붙어 있습니다. 내부 구현에만 쓰는 애노테이션이라면 문서에 나올 필요가 없으니 붙이지 않습니다. @Documented는 문서 생성 도구에만 영향을 줄 뿐, 유지 정책이나 리플렉션 조회 결과는 바꾸지 않습니다.
@Inherited로 상위 클래스의 애노테이션 물려받기
@Inherited가 붙은 애노테이션을 어떤 클래스에 붙이면, 그 클래스를 상속한 하위 클래스에서 getAnnotation으로 조회해도 찾을 수 있습니다. 하위 클래스에 그 종류의 애노테이션이 없으면 상위 클래스, 그 위의 상위 클래스 순으로 Object에 닿을 때까지 찾아 올라가기 때문입니다. 그런데 이 규칙이 적용되는 범위는 생각보다 좁습니다. 다음 코드는 인터페이스에 붙인 애노테이션도 구현 클래스로 상속될 것이라고 기대합니다.
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("role=" + role());
}
}Exception in thread "main" java.lang.NullPointerException: Cannot invoke "AssumedInterfaceAnnotationInheritance$Role.value()" because "<local0>" is null
at AssumedInterfaceAnnotationInheritance.role(AssumedInterfaceAnnotationInheritance.java:21)
at AssumedInterfaceAnnotationInheritance.main(AssumedInterfaceAnnotationInheritance.java:25)getAnnotation이 null을 돌려주었고, 21번 줄에서 그 null에 value()를 호출하다가 NullPointerException이 났습니다. 메시지의 <local0>은 role() 메서드의 0번 지역 변수인 role을 가리킵니다. javac는 기본 설정에서 지역 변수 이름을 클래스 파일에 남기지 않기 때문에 번호로 표시된 것입니다. 원인은 @Inherited가 상위 클래스만 따라 올라가고 구현한 인터페이스는 보지 않는다는 데 있습니다. 또 @Inherited는 클래스에 붙은 애노테이션에만 효과가 있어서, 인터페이스끼리의 상속이나 메서드, 필드에 붙은 애노테이션은 물려받지 않습니다.
메서드에서도 비슷한 착각이 자주 생깁니다. 하위 클래스가 메서드를 재정의하면, 재정의한 메서드에는 상위 메서드에 붙어 있던 애노테이션이 없습니다. 재정의하지 않은 메서드는 하위 클래스에서 getMethod로 찾아도 애노테이션이 보이는데, 이것은 물려받은 것이 아니라 상위 클래스에 선언된 바로 그 Method 객체를 돌려받았기 때문입니다. 그 Method에 getDeclaringClass()를 호출해 보면 상위 클래스가 나옵니다.
인터페이스의 애노테이션을 쓰고 싶다면 스캐너가 getInterfaces()로 구현한 인터페이스와 그 상위 인터페이스를 직접 훑어야 합니다. 이때 여러 인터페이스가 서로 다른 @Role을 가지면 어느 것을 따를지도 정해야 합니다. 권한처럼 보안에 민감한 설정이라면 이런 규칙을 만들기보다, 구체 클래스나 메서드에 직접 선언하도록 요구하는 편이 명확합니다.
@Repeatable로 같은 애노테이션 여러 번 붙이기
보통 같은 애노테이션은 한 곳에 한 번만 붙일 수 있습니다. 두 번 붙이면 다음과 같은 오류가 납니다.
public final class NotRepeatable {
@interface Tag {
String value();
}
@Tag("http")
@Tag("reflection")
static final class Tagged {
}
}bad/NotRepeatable.java:7: error: Tag is not a repeatable annotation interface
@Tag("reflection")
^
1 error여러 번 붙이게 하려면 애노테이션에 @Repeatable을 붙이고, 반복된 애노테이션들을 담을 컨테이너 애노테이션(containing annotation)을 따로 정의해야 합니다. 컨테이너는 반복할 애노테이션의 배열을 돌려주는 value() 요소를 가집니다. 컴파일러는 같은 애노테이션이 두 번 이상 붙은 것을 보면 그것들을 컨테이너 하나로 묶어서 기록합니다. 즉 @Tag("http")와 @Tag("reflection")을 붙이면 클래스 파일에 실제로 기록되는 것은 @Tags({@Tag("http"), @Tag("reflection")}) 하나입니다. 한 번만 붙였을 때는 묶을 것이 없으니 @Tag가 그대로 기록됩니다.
컨테이너는 반복 선언을 대신 기록하므로, 한 번 붙였을 때와 여러 번 붙였을 때 결과가 어긋나지 않도록 컴파일러가 몇 가지 규칙을 확인합니다. 반복할 애노테이션을 Tag, 컨테이너를 Tags라고 하면 다음과 같습니다.
| 규칙 | 어겼을 때 javac 메시지 |
|---|---|
Tags의 value()는 Tag[]를 돌려줌 | containing annotation interface (Tags) must declare an element named 'value' of type Tag[] |
value() 외의 요소에는 모두 기본값이 있음 | containing annotation interface (Tags) does not have a default value for element priority() |
Tags의 유지 기간이 Tag보다 짧지 않음 | retention of containing annotation interface (Tags) is shorter than the retention of repeatable annotation interface (Tag) |
Tags는 Tag가 붙을 수 없는 곳에 붙지 못함 | containing annotation interface (Tags) is applicable to more targets than repeatable annotation interface (Tag) |
Tag가 @Inherited이면 Tags도 @Inherited | repeatable annotation interface (Tag) is @Inherited while containing annotation interface (Tags) is not |
Tag가 @Documented이면 Tags도 @Documented | repeatable annotation interface (Tag) is @Documented while containing annotation interface (Tags) is not |
규칙의 이유는 모두 같습니다. 만약 Tag는 RUNTIME인데 Tags는 CLASS인 정의가 허용된다면, 한 번 붙인 @Tag는 실행 중에 보이지만 두 번 붙인 @Tag는 CLASS인 컨테이너에 담겨 보이지 않을 것입니다. @Inherited도 마찬가지입니다. 컨테이너가 상속되지 않는다면 한 번만 붙인 @Tag만 하위 클래스로 물려받는 이상한 결과가 생깁니다. 이런 어긋남을 컴파일 단계에서 막는 것이 위 규칙들입니다. 컨테이너는 보통 직접 쓰지 않지만 공개된 애노테이션 타입이므로, 사용자가 @Tags({@Tag("a")})처럼 직접 적을 수도 있다는 점도 기억해 둡니다.
조회 메서드마다 결과가 다른 이유
@Inherited와 @Repeatable이 끼어들면 같은 선언도 어떤 메서드로 조회하느냐에 따라 결과가 달라집니다. 다음 예제에서 Tier는 @Inherited가 붙은 애노테이션으로, 상위 클래스 Parent와 인터페이스 Contract에 붙어 있습니다. Tag는 반복 가능한 애노테이션으로 TaggedType에 두 번 붙어 있습니다. Tier에 함께 붙은 @Documented는 조회 결과에 아무 영향도 주지 않습니다.
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));
}
}child inherited=parent
child declared=null
implementation=null
tags=[@InheritedRepeatableProbe.Tag("http"), @InheritedRepeatableProbe.Tag("reflection")]
[email protected]({@InheritedRepeatableProbe.Tag("http"), @InheritedRepeatableProbe.Tag("reflection")})처음 세 줄은 @Inherited의 동작입니다. Child에는 Tier가 붙어 있지 않지만, getAnnotation이 상위 클래스 Parent까지 올라가 parent를 찾았습니다. 같은 Child에서 getDeclaredAnnotation은 직접 붙은 것만 보므로 null입니다. Implementation은 Tier가 붙은 인터페이스 Contract를 구현했지만, 인터페이스의 애노테이션은 상속되지 않으므로 getAnnotation도 null을 돌려줍니다.
뒤의 두 줄은 반복 애노테이션의 동작입니다. getAnnotationsByType(Tag.class)는 컨테이너를 풀어서 Tag 두 개를 선언한 순서대로 돌려줍니다. 반면 getAnnotation(Tags.class)가 보여 주듯 클래스에 실제로 붙어 있는 것은 Tags 컨테이너 하나입니다. 그래서 예제에는 없지만 TaggedType.class.getAnnotation(Tag.class)를 호출하면 null이 나옵니다. Tag가 직접 붙어 있지 않고 컨테이너 안에 들어 있기 때문입니다. 반복 가능한 애노테이션을 찾을 때 getAnnotation 대신 getAnnotationsByType을 써야 하는 이유가 여기에 있습니다. getAnnotationsByType은 Tag를 한 번만 붙인 클래스에서도 원소 하나짜리 배열을 돌려주므로, 붙인 횟수와 상관없이 같은 코드로 처리할 수 있습니다.
출력에 보이는 애노테이션의 형식은 애노테이션 객체의 toString() 결과입니다. Javadoc은 이 형식을 구현에 맡기므로 JDK 버전에 따라 다를 수 있고, 프로그램이 이 문자열에 의존해서는 안 됩니다. 아래 그림은 다섯 조회의 결과를 정리한 것입니다.
같은 선언도 조회 API에 따라 달라지는 결과
애노테이션 조회 · 상속과 반복 컨테이너
같은 선언도 조회 API에 따라 달라지는 결과
Child의 getAnnotation은 parent를 찾고 getDeclaredAnnotation은 null입니다. 반복 Tag는 ByType 조회가 컨테이너 안의 두 값을 펼칩니다.
| 대상과 조회 의도 | 조회 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입니다.
조회 메서드는 두 가지 기준으로 나뉩니다. 상위 클래스에서 물려받은 @Inherited 애노테이션까지 보는지, 그리고 컨테이너 안에 묶인 반복 애노테이션을 펼쳐서 보는지입니다. java.lang.reflect.AnnotatedElement의 Javadoc이 정리한 내용을 표로 옮기면 다음과 같습니다.
| 메서드 | 물려받은 애노테이션 | 컨테이너 안의 반복 애노테이션 |
|---|---|---|
getDeclaredAnnotation, getDeclaredAnnotations | 보지 않음 | 펼치지 않음 |
getDeclaredAnnotationsByType | 보지 않음 | 펼침 |
getAnnotation, getAnnotations | 봄 | 펼치지 않음 |
getAnnotationsByType | 봄 | 펼침 |
스캐너를 만들 때는 이 표를 보고 의도에 맞는 메서드를 고릅니다. 상속된 설정을 인정하지 않으려면 getDeclared로 시작하는 메서드를, 반복 가능한 애노테이션을 읽으려면 ByType으로 끝나는 메서드를 씁니다.
더 깊이: 상속과 반복이 겹칠 때
@Inherited와 @Repeatable을 함께 쓰면 결과가 한층 미묘해집니다. 다음 예제에서 Parent에는 Tag가 두 개, Child에는 하나 붙어 있고, Plain은 아무것도 붙이지 않은 채 Parent를 상속합니다. 앞의 규칙대로 Tag와 컨테이너 Tags에 모두 @Inherited를 붙였습니다.
import java.lang.annotation.Inherited;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Arrays;
public final class InheritedRepeatedTags {
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Tags.class)
@interface Tag {
String value();
}
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@interface Tags {
Tag[] value();
}
@Tag("a")
@Tag("b")
static class Parent {
}
@Tag("c")
static class Child extends Parent {
}
static class Plain extends Parent {
}
public static void main(String[] args) {
System.out.println("child byType="
+ Arrays.toString(Child.class.getAnnotationsByType(Tag.class)));
System.out.println("child Tags="
+ Child.class.getAnnotation(Tags.class));
System.out.println("child Tag="
+ Child.class.getAnnotation(Tag.class));
System.out.println("plain byType="
+ Arrays.toString(Plain.class.getAnnotationsByType(Tag.class)));
}
}child byType=[@InheritedRepeatedTags.Tag("c")]
child [email protected]({@InheritedRepeatedTags.Tag("a"), @InheritedRepeatedTags.Tag("b")})
child [email protected]("c")
plain byType=[@InheritedRepeatedTags.Tag("a"), @InheritedRepeatedTags.Tag("b")]Child에서 getAnnotationsByType(Tag.class)는 c 하나만 돌려주는데, getAnnotation(Tags.class)는 Parent의 a, b 묶음을 돌려줍니다. 같은 클래스에서 두 조회가 서로 다른 답을 낸 것입니다. AnnotatedElement Javadoc의 정의를 보면 이유를 알 수 있습니다. getAnnotation은 찾는 종류의 애노테이션이 직접 붙어 있지 않을 때 상위 클래스를 봅니다. Child에는 Tags가 직접 붙어 있지 않으므로 Parent의 Tags가 나왔습니다. 반면 getAnnotationsByType은 직접 붙은 Tag와 직접 붙은 컨테이너 안의 Tag를 모두 확인하고, 둘 중 하나라도 있으면 상위 클래스를 보지 않습니다. Child에는 c가 직접 붙어 있으니 거기서 멈추고, 하위 클래스에 Tag가 하나도 없는 Plain에서만 Parent의 a, b가 나옵니다.
결국 하위 클래스에 Tag를 하나라도 붙이면 상위 클래스의 Tag는 합쳐지지 않고 통째로 가려집니다. 상위 클래스의 태그에 하위 클래스의 태그를 더하고 싶다면, 스캐너가 getSuperclass()로 계층을 따라 올라가며 클래스마다 getDeclaredAnnotationsByType의 결과를 모아야 합니다. 라우트처럼 하위 클래스가 부모의 설정을 자동으로 물려받으면 위험한 애노테이션이라면, 처음부터 @Inherited를 쓰지 말고 직접 선언된 애노테이션만 읽는 편이 안전합니다.
정리
@Retention은 애노테이션이 남는 기간을SOURCE,CLASS,RUNTIME가운데 하나로 정합니다. 생략하면CLASS라서 리플렉션에 보이지 않으므로, 실행 중에 읽을 애노테이션에는RUNTIME을 적습니다.@Target은 붙일 수 있는 위치를 정합니다. 생략하면 모든 선언에 붙일 수 있지만 타입 사용 위치에는 붙일 수 없습니다. 쓸 곳만 허용해 두면 잘못 붙이는 실수를 컴파일러가 막아 줍니다.@Documented는 Javadoc에 표시할지만 정하고, 실행 결과에는 영향이 없습니다.@Inherited는 클래스에 붙은 애노테이션을 상위 클래스 쪽으로만 찾아 올라가게 합니다. 인터페이스, 메서드, 필드의 애노테이션은 물려받지 않습니다.@Repeatable을 쓰려면 컨테이너 애노테이션이 필요하고, 여러 번 붙인 애노테이션은 컨테이너 하나로 기록됩니다. 그래서 반복 가능한 애노테이션은getAnnotationsByType으로 읽습니다.getDeclared로 시작하는 메서드는 물려받은 애노테이션을 보지 않고,ByType으로 끝나는 메서드는 컨테이너를 펼칩니다.
연습 문제
-
다음 애노테이션에 알맞은 유지 정책을 고르세요. (가) 특정 경고를 컴파일러가 내지 않게 하는 애노테이션, (나) 서버가 시작할 때 리플렉션으로 읽어 라우트 표를 만드는 애노테이션, (다) 빌드가 끝난 뒤 클래스 파일을 검사하는 도구만 읽는 표시.
-
@Inherited가 붙은@Tier("gold")를 인터페이스Premium에 붙였습니다. 클래스Base는Premium을 구현하고,Child는Base를 상속합니다.Child.class.getAnnotation(Tier.class)는 무엇을 돌려주고, 그 이유는 무엇인가요? -
프레임워크가 시작할 때 쓸 점검 도구
audit를 만드세요. 애노테이션 타입 목록과 필요한ElementType을 받아, 각 타입이 정말 애노테이션 타입인지, 유지 정책이RUNTIME인지,@Target에 필요한 위치가 명시되어 있는지 검사합니다. 반복 가능한 애노테이션이면 컨테이너도 같은 기준으로 검사하고, 컨테이너의value()가 반복할 애노테이션의 배열을 돌려주는지도 확인합니다. 발견한 문제는 모두 모아 정렬한 목록으로 돌려주세요. -
3번 풀이의
audit에@Retention과@Target을 모두 생략한 애노테이션@interface Plain을 넣고ElementType.METHOD를 요구하면 어떤 문제가 보고되나요? Java 규칙으로는@Plain을 메서드에 붙일 수 있는지도 함께 설명하세요.
해설 보기
1번. (가)는 SOURCE입니다. 컴파일러만 읽고 끝나는 정보이므로 클래스 파일에 남길 필요가 없습니다. 실제로 @SuppressWarnings가 SOURCE입니다. (나)는 RUNTIME입니다. 실행 중에 리플렉션으로 읽어야 하므로 다른 정책으로는 스캐너가 찾지 못합니다. (다)는 CLASS입니다. 클래스 파일에는 남아야 하지만 실행 중에 읽을 일은 없습니다. RUNTIME으로 해도 도구는 읽을 수 있지만, 필요 없는 정보를 리플렉션에 드러낼 이유가 없습니다.
2번. null입니다. @Inherited는 상위 클래스 쪽으로만 찾아 올라가고 구현한 인터페이스는 보지 않으므로, Base부터 Tier를 갖지 못합니다. Child는 상위 클래스 Base를 보지만 거기에도 Tier가 없으니 물려받을 것이 없습니다. 실제로 실행해 보면 Base.class.getAnnotation(Tier.class)와 Child.class.getAnnotation(Tier.class) 모두 null이 나옵니다.
3번. 메타 애노테이션도 애노테이션이므로, 애노테이션 타입의 Class 객체에 getAnnotation(Retention.class)처럼 조회하면 읽을 수 있습니다. @Retention, @Target, @Repeatable 자신은 모두 RUNTIME 유지 정책이라 리플렉션에 보입니다. checkRuntime은 @Retention이 없으면 기본값이 CLASS이므로 문제로 기록하고, checkTarget은 @Target에 필요한 위치가 명시되어 있을 때만 통과시킵니다. 컨테이너의 value() 반환 타입은 repeated.arrayType()과 비교합니다. Java 12에 추가된 arrayType()은 Hook.class에서 Hook[].class를 얻어 줍니다.
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.Comparator;
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(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 = repeated.arrayType();
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);
}
}Issue[annotation=RuntimeAnnotationAuditSolution$BrokenRuntimeRule, detail=required target missing]
Issue[annotation=RuntimeAnnotationAuditSolution$BrokenRuntimeRule, detail=retention is not RUNTIME]Hook과 컨테이너 Hooks는 모든 조건을 만족해서 아무 문제도 나오지 않습니다. BrokenRuntimeRule은 유지 정책이 CLASS이고 대상이 FIELD뿐이라 두 문제가 나왔습니다. 애노테이션 이름이 같으면 설명의 알파벳순으로 정렬하므로 required target missing이 먼저 나옵니다.
컨테이너 규칙의 상당수는 컴파일러가 이미 확인하므로, 컨테이너 검사는 컴파일러가 허용하는 경우를 잡을 때 쓸모가 있습니다. 예를 들어 @Target({ElementType.METHOD, ElementType.FIELD})인 반복 애노테이션에 @Target(ElementType.FIELD)뿐인 컨테이너를 짝지어도 컴파일러는 허용합니다. 컨테이너가 반복 애노테이션보다 좁은 위치에만 붙는 것은 규칙 위반이 아니기 때문입니다. 하지만 이 애노테이션을 메서드에 두 번 붙이면 그제야 container NarrowHooks is not applicable to element run() 같은 컴파일 오류가 납니다. 이런 정의를 audit에 ElementType.METHOD와 함께 넣으면, 컨테이너에 대해 required target missing을 미리 알려 줍니다.
4번. retention is not RUNTIME과 required target missing 두 문제가 보고됩니다. @Retention이 없으니 유지 정책은 기본값 CLASS이고, @Target이 없으니 METHOD가 명시되어 있지 않기 때문입니다. Java 규칙으로는 @Target이 없는 애노테이션을 모든 선언에 붙일 수 있으므로 @Plain을 메서드에 붙여도 컴파일됩니다. 다만 CLASS 유지 정책이라 실행 중에는 보이지 않습니다. 이 점검 도구의 대상 검사는 Java보다 엄격하게 '쓸 곳을 명시할 것'을 요구하는 정책입니다.
다음 글
애노테이션으로 필드 값 검증하기에서는 필드에 붙인 애노테이션을 리플렉션으로 읽어 객체의 값을 검사하는 검증기를 직접 만듭니다. 첫 오류에서 멈추지 않고 모든 위반을 모으는 방법과, 필드 타입이 맞지 않거나 필드에 접근할 수 없는 경우를 사용자 입력 오류와 구별해 처리하는 방법을 다룹니다.