본문 바로가기

애노테이션 요소의 타입과 기본값

애노테이션 요소에 쓸 수 있는 타입과 값, 필수 요소와 기본값을 나누는 기준, 기본값을 바꿨을 때 이미 컴파일된 코드에 생기는 변화를 배웁니다.

앞 글에서는 메서드에 @Route(method = "GET", path = "/site1")을 붙이고, 프로그램이 시작할 때 리플렉션으로 읽어 라우트 표를 만들었습니다. 이번에는 그런 애노테이션을 설계하는 쪽에 서 보겠습니다. 애노테이션을 만들 때는 요소마다 세 가지를 정해야 합니다. 어떤 타입의 값을 받을지, 사용하는 쪽이 값을 반드시 적어야 하는지, 생략하면 어떤 값으로 볼지입니다.

이 결정은 나중에 바꾸기 어렵습니다. 공개된 애노테이션은 여러 클래스에 붙고, 그 클래스들은 저마다 따로 컴파일되어 배포됩니다. 게다가 기본값은 애노테이션을 붙인 쪽이 아니라 애노테이션을 정의한 쪽에 저장되기 때문에, 정의만 고쳐도 이미 컴파일된 코드의 동작이 바뀝니다. 이 글에서는 요소에 쓸 수 있는 타입과 값의 규칙을 먼저 보고, 필수 요소와 기본값을 나누는 기준, 기본값이 실제로 정해지는 시점, 컴파일러가 확인하지 못하는 규칙을 시작할 때 검사하는 방법을 차례로 다룹니다.

요소는 매개변수 없는 메서드처럼 선언합니다

@interface로 선언한 애노테이션 타입의 본문에는 요소(element)를 적습니다. 요소는 매개변수가 없는 추상 메서드 모양이며, 메서드 이름이 요소의 이름이고 반환 타입이 요소의 타입입니다. 선언 뒤에 default와 값을 붙이면 그 값이 기본값이 됩니다. 사용하는 쪽에서 @Retry(attempts = 3)처럼 적은 값은, 리플렉션으로 얻은 애노테이션 객체에서 같은 이름의 메서드 attempts()를 호출하면 돌려받을 수 있습니다.

다음 예제는 요소에 쓸 수 있는 타입을 한곳에 모은 것입니다. @Job에는 String, enum, boolean, Class, 다른 애노테이션, String 배열 요소가 있고, @Job 안에서 쓰는 @Retry에는 int와 long 요소가 있습니다. main은 CleanupJob에 붙은 @Job을 읽어 값 몇 개를 출력합니다.

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은 이 예제에서 읽지 않고, 뒤에서 축약형의 규칙을 설명할 때 다시 씁니다. 실행 결과는 다음과 같습니다.

name=cleanup
mode=ASYNC
worker=CleanupWorker
attempts=3
tags=[nightly, storage]

출력은 모두 CleanupJob 위에 적은 값입니다. job.worker()는 Class 객체를 돌려주므로 getSimpleName()으로 클래스 이름을 꺼낼 수 있고, job.retry()는 중첩된 @Retry 애노테이션 객체를 돌려주므로 거기에 다시 attempts()를 호출했습니다. tags()가 돌려주는 값은 배열이어서 Arrays.toString으로 출력했습니다.

HealthJob은 value와 worker만 적고 나머지 요소를 생략했습니다. main의 첫 줄에서 CleanupJob을 HealthJob으로 바꿔 실행하면 mode=SYNC, attempts=1, tags=[]가 출력됩니다. 생략한 요소에서는 정의에 적힌 기본값이 나오기 때문입니다. 반대로 기본값이 없는 value와 worker는 생략할 수 없는 필수 요소입니다. worker를 빼면 어떻게 되는지는 뒤에서 보겠습니다.

worker의 타입이 Class<? extends Runnable>인 것도 눈여겨볼 만합니다. 그냥 Class<?>로 두면 어떤 클래스든 받지만, 이렇게 상한을 두면 worker = String.class처럼 Runnable을 구현하지 않은 클래스를 적었을 때 컴파일러가 incompatible types 오류로 거절합니다. 요소의 타입을 좁힐수록 잘못된 설정이 실행 전에 걸러집니다.

요소에 쓸 수 있는 타입

요소의 타입은 아무것이나 될 수 없습니다. 허용되는 것은 다음 여섯 가지뿐입니다.

  • 기본형(int, long, boolean, double 등)
  • String
  • Class, 또는 Class<? extends Runnable>처럼 타입 인수를 붙인 Class
  • enum 타입
  • 애노테이션 타입
  • 위 다섯 가지를 원소로 하는 1차원 배열

이 밖의 타입을 쓰면 컴파일 오류가 납니다. 다음은 애노테이션을 처음 만들 때 흔히 시도하는 선언을 모은 것입니다.

bad/BadElementTypes.java
import java.util.List;

public final class BadElementTypes {
    @interface Bad {
        List<String> names();

        Integer count();

        void run();

        int[][] matrix();

        String label(int index);
    }
}

javac -d out bad/BadElementTypes.java로 컴파일하면 다섯 요소가 모두 거절됩니다. javac는 첫 오류에서 멈추지 않고 요소마다 오류를 하나씩 알려 주며, 각 오류는 파일 이름과 줄 번호로 시작합니다.

bad/BadElementTypes.java:5: error: invalid type for annotation interface element
        List<String> names();
            ^
bad/BadElementTypes.java:7: error: invalid type for annotation interface element
        Integer count();
        ^
bad/BadElementTypes.java:9: error: invalid type for annotation interface element
        void run();
        ^
bad/BadElementTypes.java:11: error: invalid type for annotation interface element
        int[][] matrix();
           ^
bad/BadElementTypes.java:13: error: elements in annotation interface declarations cannot declare formal parameters
        String label(int index);
                         ^
5 errors

앞의 네 개는 같은 메시지를 받습니다. List<String>, Integer, void, 2차원 배열 int[][] 모두 허용 목록에 없는 타입이기 때문이며, Object나 Optional<String>을 써도 같은 오류가 납니다. 마지막 label(int index)는 매개변수를 받으려 해서 거절됐습니다. 요소는 애노테이션에 적힌 값을 그대로 읽어 오는 통로일 뿐이어서, 인수를 받아 무언가를 계산할 수 없습니다. 메시지에 나오는 annotation interface는 Java 언어 명세가 애노테이션 타입을 부르는 이름입니다.

왜 이렇게 좁게 제한할까요? 애노테이션에 적은 값은 컴파일할 때 클래스 파일에 기록되고, 실행 중에 그 기록을 읽어 애노테이션 객체로 다시 만들어집니다. 클래스 파일이 이 용도로 기록할 수 있는 값은 기본형과 문자열 상수, enum 상수, 클래스, 중첩 애노테이션, 그리고 이것들의 배열뿐입니다. List나 일반 객체는 생성자를 실행해야 만들어지므로 컴파일할 때 값을 확정해 파일에 적어 둘 방법이 없습니다. Integer 같은 래퍼도 허용되지 않는데, 뒤에서 보듯 애노테이션 요소에는 null을 넣을 수 없으니 래퍼를 써서 얻을 것도 없습니다.

요소 값은 컴파일할 때 정해져야 합니다

타입이 맞는다고 아무 값이나 적을 수 있는 것도 아닙니다. 기본형과 String 요소에는 상수 표현식, 즉 컴파일러가 컴파일 시점에 값을 계산할 수 있는 식만 쓸 수 있습니다. Class 요소에는 CleanupWorker.class 같은 클래스 리터럴을, enum 요소에는 Mode.ASYNC 같은 enum 상수를 적습니다. 그리고 어떤 요소에도 null은 쓸 수 없습니다. 다음 코드로 확인해 보겠습니다.

bad/BadElementValues.java
public final class BadElementValues {
    @interface Label {
        String value();
    }

    static final String PREFIX = "api";
    static String current = "dynamic";

    @Label(PREFIX + "/v" + 2)
    static final class Versioned {
    }

    @Label(null)
    static final class Missing {
    }

    @Label(current)
    static final class Dynamic {
    }
}
bad/BadElementValues.java:13: error: element value must be a constant expression
    @Label(null)
           ^
bad/BadElementValues.java:17: error: element value must be a constant expression
    @Label(current)
           ^
2 errors

Versioned에는 오류가 없습니다. PREFIX는 상수로 초기화한 static final 변수라서, 컴파일러가 PREFIX + "/v" + 2를 "api/v2"로 미리 계산할 수 있기 때문입니다. 반면 current는 final이 아니어서 실행 중에 값이 바뀔 수 있으므로, 클래스 파일에 고정된 값으로 적어 둘 수 없습니다. null도 같은 메시지로 거절됩니다. '값이 없음'을 표현하고 싶다면 null 대신 요소를 생략할 수 있게 기본값을 주거나, 뒤에서 볼 것처럼 그 뜻을 가진 enum 상수를 따로 둡니다.

이름과 중괄호를 생략하는 규칙

자주 쓰는 애노테이션을 짧게 적을 수 있도록 생략 규칙이 두 가지 있습니다. 첫째, 요소 이름이 value이면 @Label(value = "text") 대신 @Label("text")라고 쓸 수 있습니다. 둘째, 배열 요소에 값을 하나만 줄 때는 중괄호를 빼도 됩니다. tags = "nightly"는 tags = {"nightly"}와 같습니다. 값이 둘 이상이면 tags = {"nightly", "storage"}처럼 중괄호가 필요하고, 빈 배열은 {}로 적습니다.

첫째 규칙에는 오해하기 쉬운 부분이 있습니다. 이름 생략은 value =를 쓰지 않아도 된다는 뜻일 뿐, 다른 요소를 생략하게 해 주지는 않습니다. 앞의 AnnotationElementCatalog에서 HealthJob의 애노테이션을 @Job("health")로 줄이면 다음 오류가 납니다.

src/AnnotationElementCatalog.java:49: error: annotation @Job is missing a default value for the element 'worker'
    @Job("health")
    ^
1 error

worker는 기본값이 없는 필수 요소인데 축약형에는 worker가 빠져 있기 때문입니다. 정리하면 @Job("health")처럼 쓸 수 있는 것은 value라는 요소가 있고, 나머지 요소가 모두 기본값을 가질 때뿐입니다. 축약형을 허용하려고 worker에 아무 기본값이나 주면 작업 클래스를 깜빡 빠뜨린 설정도 컴파일을 통과합니다. 그래서 이 예제는 짧게 쓰는 편리함보다 작업 클래스를 반드시 적게 하는 쪽을 택했습니다.

필수 요소와 기본값 나누기

어떤 요소에 기본값을 줄지는 '생략하면 무엇을 뜻하는가'를 한 문장으로 말할 수 있는지로 판단하면 됩니다. 말할 수 없다면 그 요소는 필수여야 합니다. 다음은 이 판단을 잘못한 예입니다. 범위 검사를 설정하는 @RangeRule의 min과 max는 꼭 받아야 할 값인데, 생략해도 컴파일되도록 Integer.MIN_VALUE라는 특별한 값을 기본값으로 넣었습니다. 이렇게 '설정되지 않음'을 나타내려고 고른 특별한 값을 센티널(sentinel) 값이라고 합니다.

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 {
    }

    static boolean accepts(RangeRule rule, int value) {
        return rule.min() <= value && value <= rule.max();
    }

    public static void main(String[] args) {
        RangeRule rule = UnconfiguredTarget.class.getAnnotation(RangeRule.class);
        System.out.println("min=" + rule.min() + ", max=" + rule.max());
        System.out.println("message=\"" + rule.message() + "\"");
        System.out.println("accepts 25? " + accepts(rule, 25));
    }
}
min=-2147483648, max=-2147483648
message=""
accepts 25? false

UnconfiguredTarget에는 값 없이 @RangeRule만 붙어 있지만 컴파일러는 아무 말도 하지 않습니다. 실행해 보면 min과 max가 둘 다 -2147483648이라서 허용 범위가 그 값 하나뿐이 되고, 평범한 값 25도 거절됩니다. 거절할 때 보여 줄 message는 빈 문자열입니다. 실행기 입장에서는 더 곤란한 문제도 있습니다. 사용자가 정말로 Integer.MIN_VALUE를 원한 것인지 값을 빠뜨린 것인지를 애노테이션 객체만 보고는 구별할 수 없으니, 둘 중 하나를 추측해야 합니다.

값이 반드시 필요하다면 기본값을 지우면 됩니다. 그러면 값을 빠뜨린 사용 지점은 컴파일되지 않습니다.

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

public final class RequiredRange {
    @Retention(RetentionPolicy.RUNTIME)
    @interface RangeRule {
        int min();

        int max();

        String message() default "out of range";
    }

    @RangeRule
    static final class Unconfigured {
    }

    @RangeRule(max = 120)
    static final class HalfConfigured {
    }
}
bad/RequiredRange.java:14: error: annotation @RangeRule is missing default values for elements min,max
    @RangeRule
    ^
bad/RequiredRange.java:18: error: annotation @RangeRule is missing a default value for the element 'min'
    @RangeRule(max = 120)
    ^
2 errors

값을 둘 다 빠뜨린 Unconfigured와 min만 빠뜨린 HalfConfigured가 모두 컴파일 단계에서 걸렸습니다. 실행해 봐야 알 수 있던 문제가 고칠 위치와 함께 바로 드러난 것입니다. 기본값은 생략해도 뜻이 분명한 message에만 남겼습니다.

기본값이 잘 어울리는 것은 생략했을 때의 의미가 분명하고 오래 바뀌지 않을 값입니다. 앞 예제의 재시도 지연 delayMillis() default 0, 실행 방식 mode() default Mode.SYNC, 빈 태그 목록 tags() default {}가 그런 예입니다. 값이 있을 수도 없을 수도 있는 선택 항목이라면, UNSPECIFIED처럼 '지정하지 않음'을 뜻하는 enum 상수를 따로 두어 실행기가 구별할 수 있게 합니다. 아니면 그 옵션을 별도의 마커 애노테이션으로 만들어, 붙어 있는지 여부로 표현할 수도 있습니다. 빈 문자열을 '지정하지 않음'으로 쓰려면 빈 문자열은 실제 입력으로 허용하지 않는다는 규칙을 정하고, 스캐너가 그 규칙을 검사해야 합니다.

보안과 관련된 옵션은 특히 조심해야 합니다. 기본값은 정의 쪽에 저장되므로, 나중에 enabled() default true 같은 요소를 추가하거나 기존 기본값을 바꾸면 그 요소를 생략한 모든 사용 지점의 동작이 한꺼번에 바뀝니다. 이 동작은 뒤의 실험에서 직접 확인하겠습니다.

기본값을 리플렉션으로 조회하기

애노테이션 타입도 인터페이스의 일종이어서, 요소는 리플렉션에서 Method 객체로 보입니다. Method.getDefaultValue()는 그 요소의 기본값을 돌려주고, 기본값이 없으면 null을 돌려줍니다. 요소의 타입이 기본형이면 int 기본값 100이 Integer로 감싸져 나오는 식으로 래퍼 객체를 돌려줍니다. 따라서 반환값이 null인지만 봐도 필수 요소인지 알 수 있고, 프레임워크는 이 정보로 시작할 때 설정 문서나 점검 보고서를 만들 수 있습니다.

다음 예제는 @Export의 요소를 모두 읽어 이름, 타입, 필수 여부, 기본값을 출력합니다. 이어서 MemberExport에 붙은 애노테이션에서 실제로 읽히는 값을 출력합니다.

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()));
    }
}
ElementSchema[name=batchSize, returnType=int, required=false, defaultText=100]
ElementSchema[name=columns, returnType=java.lang.String[], required=false, defaultText=[]]
ElementSchema[name=format, returnType=AnnotationDefaultInspector$Format, required=false, defaultText=JSON]
ElementSchema[name=value, returnType=java.lang.String, required=true, defaultText=null]
resolved format=JSON
resolved batch=100
resolved columns=[id, name]

앞의 네 줄은 @Export 정의만 보고 만든 것입니다. getDeclaredMethods()는 메서드를 특별한 순서 없이 돌려주므로, 실행할 때마다 같은 순서로 나오도록 이름순으로 정렬했습니다. batchSize의 기본값은 Integer로 감싼 100이고, columns의 기본값은 빈 String 배열이어서 Arrays.deepToString이 []로 출력합니다. format의 타입 이름에 $가 들어간 것은 중첩 enum이 클래스 파일 안에서 바깥클래스$안쪽클래스 형태의 이름을 갖기 때문입니다. 기본값이 없는 value만 required=true이고, String.valueOf(null)이 문자열 "null"을 돌려주므로 defaultText=null이 찍혔습니다.

뒤의 세 줄은 MemberExport에서 읽은 값입니다. MemberExport는 value와 columns만 적었으므로 columns는 적은 그대로 [id, name]이고, 생략한 format과 batchSize는 정의의 기본값인 JSON과 100입니다. 아래 그림은 요소마다 정의의 기본값과 MemberExport에서 읽히는 값을 나란히 보여 줍니다.

애노테이션 요소 기본값과 사용 지점의 값

애노테이션 요소 기본값과 사용 지점의 값

애노테이션 값 · 정의와 사용 지점

애노테이션 요소 기본값과 사용 지점의 값

MemberExport는 value와 columns를 명시합니다. 생략한 format과 batchSize는 Export 정의의 JSON과 100을 조회 시 적용받습니다.

이름: value

정의의 기본값
없음 → 필수
MemberExport에서 읽는 값
members

형식: format

정의의 기본값
JSON
MemberExport에서 읽는 값
JSON

묶음 크기: batchSize

정의의 기본값
100
MemberExport에서 읽는 값
100

열 목록: columns

정의의 기본값
빈 배열
MemberExport에서 읽는 값
[id, name]

AnnotationDefaultInspector의 선언을 대조한 표입니다. 정의의 기본값은 요소 Method에서, 사용 지점의 값은 Export 인스턴스에서 읽습니다.

이 방법에는 한계도 있습니다. export.format()이 JSON을 돌려줄 때, 사용자가 format = Format.JSON을 직접 적었는지 생략했는지는 애노테이션 객체만 보고 알 수 없습니다. 기본값과 비교해 봐도, 사용자가 기본값과 같은 값을 직접 적은 경우와 구별되지 않습니다. 명시했는지가 정말 중요하다면 컴파일할 때 소스를 검사하는 애노테이션 처리기를 쓰거나, 클래스 파일에 기록된 값을 직접 읽어야 합니다.

더 깊이: 기본형 배열 기본값까지 출력하려면

schema 메서드는 기본값이 Object[]인지 검사해서 배열을 처리합니다. String[]은 Object[]의 하위 타입이라 이 검사를 통과하지만, int[] 같은 기본형 배열은 Object[]가 아닙니다. 그래서 기본형 배열 기본값은 else 쪽의 String.valueOf로 넘어가고, 원소 대신 [I@로 시작하는 타입 표시와 해시 값이 출력됩니다. 모든 요소 타입을 처리하려면 배열의 실제 타입과 상관없이 길이와 원소를 읽을 수 있는 java.lang.reflect.Array를 씁니다.

src/DefaultValueRenderer.java
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Array;
import java.util.StringJoiner;

public final class DefaultValueRenderer {
    @Retention(RetentionPolicy.RUNTIME)
    @interface Server {
        int[] ports() default {80, 443};

        String[] hosts() default {"localhost"};
    }

    static String render(Object value) {
        if (value == null || !value.getClass().isArray()) {
            return String.valueOf(value);
        }
        StringJoiner joiner = new StringJoiner(", ", "[", "]");
        for (int i = 0; i < Array.getLength(value); i++) {
            joiner.add(String.valueOf(Array.get(value, i)));
        }
        return joiner.toString();
    }

    public static void main(String[] args) throws NoSuchMethodException {
        Object ports = Server.class.getDeclaredMethod("ports").getDefaultValue();
        Object hosts = Server.class.getDeclaredMethod("hosts").getDefaultValue();
        System.out.println("ports is Object[]? " + (ports instanceof Object[]));
        System.out.println("hosts is Object[]? " + (hosts instanceof Object[]));
        System.out.println("ports=" + render(ports));
        System.out.println("hosts=" + render(hosts));
    }
}
ports is Object[]? false
hosts is Object[]? true
ports=[80, 443]
hosts=[localhost]

첫 두 줄이 문제의 원인을 보여 줍니다. int[] 기본값은 Object[]가 아니고 String[] 기본값은 Object[]입니다. render는 getClass().isArray()로 배열인지만 확인한 다음, Array.getLength와 Array.get으로 원소를 꺼냅니다. 두 메서드는 int[]든 String[]든 똑같이 동작하고, Array.get은 기본형 원소를 래퍼로 감싸 돌려줍니다. 애노테이션 요소의 배열은 1차원뿐이므로 원소를 한 단계만 펼치면 충분합니다.

기본값을 바꾸면 이미 컴파일된 코드의 동작도 바뀝니다

기본값이 어디에 저장되는지는 직접 실험해 보면 분명해집니다. 애노테이션 Timeout과 그것을 사용하는 Client를 서로 다른 파일로 만듭니다. Client 안의 FastCall은 seconds를 직접 적었고, NormalCall은 생략했습니다.

src/Timeout.java
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface Timeout {
    int seconds() default 30;
}
src/Client.java
public final class Client {
    @Timeout(seconds = 5)
    static final class FastCall {
    }

    @Timeout
    static final class NormalCall {
    }

    public static void main(String[] args) {
        Timeout fast = FastCall.class.getAnnotation(Timeout.class);
        Timeout normal = NormalCall.class.getAnnotation(Timeout.class);
        System.out.println("FastCall seconds=" + fast.seconds());
        System.out.println("NormalCall seconds=" + normal.seconds());
    }
}

먼저 두 파일을 함께 컴파일하고 실행합니다. 이때 나온 출력이, 뒤에서 Timeout.java만 고쳐 다시 실행한 결과와 비교할 출발점입니다.

javac -d out src/Timeout.java src/Client.java
java -cp out Client
FastCall seconds=5
NormalCall seconds=30

값을 생략한 NormalCall은 정의의 기본값 30을 받았습니다. 이제 Timeout.java의 default 30을 default 10으로 고친 다음, Timeout.java만 다시 컴파일합니다. Client.java는 손대지 않고 다시 컴파일하지도 않습니다.

javac -d out src/Timeout.java
java -cp out Client
FastCall seconds=5
NormalCall seconds=10

Client의 클래스 파일은 그대로인데 NormalCall의 값이 30에서 10으로 바뀌었습니다. 컴파일러는 애노테이션을 붙인 쪽의 클래스 파일에 사용자가 직접 적은 값만 기록합니다. FastCall에는 seconds = 5가 기록되고, NormalCall에는 @Timeout이 붙어 있다는 사실만 기록됩니다. 기본값은 Timeout.class 안의 seconds 요소 정보에 들어 있습니다. 실행 중에 getAnnotation이 애노테이션 객체를 만들 때, 기록되지 않은 요소는 그때 불러온 Timeout.class의 기본값으로 채웁니다. 그래서 정의만 바꿔도 생략한 쪽의 값이 따라 바뀌고, 직접 적은 FastCall의 5는 그대로 남습니다.

이 변경은 아무 오류도 일으키지 않았습니다. 다시 컴파일하지 않은 클래스가 새 정의와 함께 문제없이 실행된다는 점에서 바이너리 호환성은 유지됐지만, 프로그램의 동작은 조용히 바뀌었습니다. 호환성에는 이렇게 '실행이 되는가'와 '같은 일을 하는가'라는 두 층이 있습니다. 공개 라이브러리의 애노테이션이라면 기본값의 의미를 바꾸는 일은 API를 바꾸는 일과 같습니다. 변경을 미리 알리고 옮겨 갈 기간을 두어야 하며, 시간 제한이나 보안 옵션처럼 중요한 설정은 사용자가 값을 직접 적도록 권하는 편이 안전합니다.

더 깊이: 요소를 추가하거나 타입을 바꿀 때

요소를 새로 추가할 때도 같은 원리가 적용됩니다. 기본값이 있는 요소를 추가하면, 그 요소를 모른 채 컴파일된 예전 사용 지점은 모두 새 기본값을 받습니다. 기본값 없이 추가하면 예전 사용 지점에는 그 요소의 값이 어디에도 없습니다. 앞 실험에 이어 Timeout에 기본값 없는 요소 String unit();를 추가하고, 이 요소를 읽는 새 클래스 Reader를 만들어 보겠습니다. Client는 이번에도 다시 컴파일하지 않습니다.

src/Reader.java
public final class Reader {
    public static void main(String[] args) {
        Timeout normal = Client.NormalCall.class.getAnnotation(Timeout.class);
        System.out.println("seconds=" + normal.seconds());
        System.out.println("unit=" + normal.unit());
    }
}
javac -cp out -d out src/Timeout.java src/Reader.java
java -cp out Reader

출력의 처음 두 줄은 다음과 같습니다. 그 아래의 스택 트레이스는 생략했습니다.

seconds=10
Exception in thread "main" java.lang.annotation.IncompleteAnnotationException: Timeout missing element unit

getAnnotation과 seconds()는 정상으로 동작했고, 값이 없는 unit()을 호출하는 순간에야 IncompleteAnnotationException이 났습니다. 이제 Client.java를 다시 컴파일하면 unit 값이 빠졌다는 컴파일 오류가 나므로, 이 예외는 새 버전의 애노테이션과 예전에 컴파일된 클래스가 함께 실행될 때만 생깁니다. 라이브러리를 올렸는데 사용하는 쪽 코드는 다시 빌드하지 않은 경우가 바로 그렇습니다. 요소의 타입을 바꾸는 것도 위험합니다. int seconds()를 String seconds()로 바꾸면, seconds = 5처럼 정수가 기록된 예전 클래스에서 그 요소를 읽을 때 AnnotationTypeMismatchException이 납니다. 두 예외 모두 애노테이션 객체를 얻을 때가 아니라 해당 요소를 호출할 때 드러납니다. 그러니 프레임워크라면 시작할 때 필요한 요소를 모두 한 번씩 읽어 두는 편이 안전합니다.

컴파일러가 확인하지 못하는 규칙은 시작할 때 검사합니다

컴파일러는 min과 max가 int인지, 필수 요소를 모두 적었는지는 확인하지만 min <= max인지는 알지 못합니다. 경로가 슬래시로 시작하는지, 재시도 횟수가 양수인지, 어떤 모드를 고르면 함께 적어야 하는 값이 있는지 같은 값 사이의 관계도 마찬가지입니다. 이런 규칙은 애노테이션을 읽는 스캐너가 검사해야 합니다. 애노테이션을 단순한 데이터 묶음으로 읽어 곧바로 실행에 쓰면, 잘못된 설정은 그 설정을 처음 쓰는 요청이 들어와서야 드러납니다. 앞 글에서 라우트 오류를 모아 시작할 때 한꺼번에 보고한 것처럼, 애노테이션 값도 시작할 때 검사하는 것이 좋습니다.

규칙컴파일러시작할 때 검증기실제로 실행할 때
요소 타입이 맞는가확인함필요 없음필요 없음
필수 요소를 모두 적었는가확인함필요 없음필요 없음
min <= max 같은 값 사이의 관계확인하지 못함확인함검증기가 없으면 여기서 처음 드러남
Class 요소로 객체를 만들 수 있는가상한(extends Runnable)까지만추상 클래스인지, public 무인수 생성자가 있는지접근 거부, 생성자 안에서 난 예외
파일이나 URL 같은 외부 자원이 있는가확인하지 못함필요하면 확인실제로 접근할 때

Class 요소는 클래스 리터럴을 담고 있을 뿐, 그 클래스로 객체를 만들 수 있다는 뜻은 아닙니다. 인터페이스나 추상 클래스는 생성할 수 없고, 필요한 생성자가 없을 수도 있습니다. 그래서 검증기는 시작할 때 이런 구조를 미리 확인합니다. 다만 구조 검사를 통과해도 실제 생성은 실패할 수 있습니다. 예를 들어 다른 패키지에 있는 패키지 전용(package-private) 클래스 jobs.HiddenWorker에 public 생성자가 있다고 합시다. getConstructor()는 이 생성자를 찾아 주지만, app.Factory에서 newInstance()를 호출하면 클래스 자체에 접근할 수 없어서 다음 예외가 납니다.

java.lang.IllegalAccessException: class app.Factory cannot access a member of class jobs.HiddenWorker with modifiers "public"

생성자 본문이 예외를 던지는 경우도 있습니다. 이때는 그 예외가 InvocationTargetException에 감싸여 나옵니다. 작업 클래스가 이름 있는 모듈에 있는데 그 패키지를 프레임워크 쪽에 내보내지(exports) 않았다면, public 생성자라도 리플렉션으로 호출할 수 없습니다. 이런 예외를 다루는 방법과 모듈의 접근 규칙은 리플렉션을 다룬 첫 글에서 설명했습니다.

정리

  • 요소의 타입은 기본형, String, Class, enum, 애노테이션과 이들의 1차원 배열만 됩니다. 값을 클래스 파일에 상수로 기록해야 하기 때문입니다.
  • 요소 값은 컴파일할 때 정해져야 합니다. 기본형과 String에는 상수 표현식, Class에는 클래스 리터럴, enum에는 enum 상수를 쓰고, null은 어디에도 쓸 수 없습니다.
  • value 축약은 value =만 생략하게 해 줍니다. 나머지 요소가 모두 기본값을 가져야 @Job("health")처럼 쓸 수 있습니다.
  • 생략했을 때의 의미를 한 문장으로 말할 수 없는 요소는 필수로 둡니다. 센티널 기본값은 값을 빠뜨린 실수를 컴파일러에게서 숨깁니다.
  • 기본값은 애노테이션 정의의 클래스 파일에 있고 애노테이션을 읽을 때 채워집니다. 그래서 정의를 바꾸면 다시 컴파일하지 않은 코드의 동작도 바뀝니다.
  • 값 사이의 관계와 Class 요소로 객체를 만들 수 있는지는 컴파일러가 확인하지 못하므로, 시작할 때 검사해 문제를 한꺼번에 보고합니다.

연습 문제

  1. 다음 애노테이션 Q의 요소 가운데 컴파일되는 것을 모두 고르고, 나머지가 거절되는 이유를 설명하세요.

    public final class Quiz {
        enum Mode {
            SYNC,
            ASYNC
        }
    
        @interface Q {
            Integer a();
    
            String[] b() default {};
    
            Class<?> c() default null;
    
            long[][] d();
    
            Mode e() default Mode.SYNC;
        }
    }
  2. 본문의 실험에서 Client에 @Timeout(seconds = 30)이 붙은 SlowCall도 있었다고 합시다. Timeout의 기본값을 30에서 10으로 바꾸고 Timeout.java만 다시 컴파일했을 때, SlowCall에서 읽히는 값은 무엇이고 그 이유는 무엇인가요?

  3. @ScheduledJob이 붙은 설정 클래스 목록을 받아 이름, periodMillis, 작업 클래스를 검사하는 validate를 만드세요. 이름은 비어 있거나 공백만으로 이루어지면 안 되고, 주기는 10 이상이어야 합니다. 작업 클래스는 인터페이스나 추상 클래스가 아니어야 하고 public 무인수 생성자를 가져야 합니다. 첫 문제에서 멈추지 말고 모든 클래스의 문제를 Issue 목록으로 모아 돌려주세요. 단, 작업 클래스가 추상 클래스이면 어차피 생성할 수 없으므로 생성자 검사로 넘어가지 않습니다.

  4. 3번 풀이의 validate에 다음 설정을 하나씩 넣으면 Issue가 각각 몇 개 나오나요? 이름은 모두 "check"입니다. (가) GoodWorker와 주기 10, (나) GoodWorker와 주기 9, (다) AbstractWorker와 주기 100. 그리고 결과가 빈 목록이면 작업이 실행되었다는 뜻인지도 생각해 보세요.

해설 보기

1번. b와 e만 컴파일됩니다. 문제의 코드를 bad/Quiz.java로 저장하고 javac -d out bad/Quiz.java로 컴파일하면 나머지 세 요소가 다음과 같이 거절됩니다.

bad/Quiz.java:12: error: annotation value must be a class literal
        Class<?> c() default null;
                             ^
bad/Quiz.java:8: error: invalid type for annotation interface element
        Integer a();
        ^
bad/Quiz.java:14: error: invalid type for annotation interface element
        long[][] d();
            ^
3 errors

a는 래퍼 타입 Integer라서, d는 2차원 배열이라서 허용 목록에 없습니다. c의 타입 Class<?>는 허용되지만 기본값 null이 문제입니다. Class 요소의 값은 클래스 리터럴이어야 하고 null은 어떤 요소에도 쓸 수 없습니다. b의 {}는 빈 배열이고, e의 Mode.SYNC는 enum 상수이므로 둘 다 규칙에 맞습니다. 오류가 줄 번호 순서대로 나오지 않는 것은 javac가 값 검사와 타입 검사를 서로 다른 단계에서 하기 때문입니다.

2번. 30입니다. SlowCall은 seconds = 30을 직접 적었으므로 그 값이 Client$SlowCall.class에 기록되어 있습니다. 정의 쪽 기본값은 요소를 생략한 사용 지점에만 쓰이므로, 기본값이 10으로 바뀌어도 SlowCall에는 영향이 없습니다. 기본값과 같은 값을 직접 적어 두는 것이 기본값 변경에 휩쓸리지 않는 방법이라는 뜻이기도 합니다.

3번. 애노테이션 요소의 타입 Class<? extends Runnable>이 Runnable 구현 여부를 컴파일할 때 이미 보장하므로, 검증기는 나머지 조건만 봅니다. 이름과 주기를 검사한 다음, isInterface()와 Modifier.isAbstract로 생성할 수 없는 클래스를 걸러 내고, 그런 클래스면 continue로 생성자 검사를 건너뜁니다. 무인수 생성자는 getDeclaredConstructor()로 찾습니다. 이 메서드는 접근 제한자와 상관없이 그 클래스에 선언된 생성자를 찾습니다. 그래서 무인수 생성자가 아예 없으면 NoSuchMethodException이 나고, 무인수 생성자는 있지만 public이 아니면 constructor is not public이 기록됩니다. public 생성자만 찾는 getConstructor()를 쓰면 두 경우가 모두 NoSuchMethodException이 되어, 둘째 분기는 실행될 일이 없습니다.

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.getDeclaredConstructor().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);
    }
}
Issue[owner=BadConfig, detail=name is blank]
Issue[owner=BadConfig, detail=period below 10ms]
Issue[owner=BadConfig, detail=worker is abstract]

GoodConfig는 모든 조건을 만족하므로 아무것도 출력되지 않습니다. BadConfig는 이름이 공백 한 칸이고 주기가 1이며 작업 클래스가 추상 클래스라서, 세 문제가 모두 모였습니다. 이름 검사에서 멈추지 않았기 때문에 사용자는 한 번 실행으로 고칠 곳을 전부 알 수 있습니다. 확장 과제로 작업 이름이 중복되는지와 주기의 상한을 검사해 보세요. 그리고 문제 목록이 비어 있을 때만 작업 객체를 실제로 만드는 팩터리를 붙여 보세요. 본문에서 본 것처럼 그 단계에서도 접근 거부나 생성자 예외가 날 수 있습니다.

4번. (가)는 0개입니다. 주기 10은 periodMillis < 10에 걸리지 않습니다. (나)는 period below 10ms 하나입니다. (다)는 worker is abstract 하나입니다. 추상 클래스 문제를 추가한 직후 continue하므로 생성자 검사로 넘어가지 않기 때문입니다. 세 경우 모두 직접 실행해 보면 각각 [], 문제 1개짜리 목록, 문제 1개짜리 목록이 나옵니다. 빈 목록은 작업이 실행되었다는 뜻이 아닙니다. 이 검증기는 Runnable을 실행하지도, 스레드를 시작하지도 않고 애노테이션 값과 Class 정보만 살펴봅니다.

다음 글

메타 애노테이션 @Retention·@Target·@Inherited·@Repeatable에서는 애노테이션 정의에 붙이는 애노테이션을 다룹니다. 이 메타 애노테이션들이 애노테이션이 언제까지 남는지, 어디에 붙일 수 있는지, 하위 클래스에서도 보이는지, 여러 번 붙일 수 있는지를 정합니다. 이 글의 예제마다 붙어 있던 @Retention(RetentionPolicy.RUNTIME)이 왜 필요한지도 거기서 확인합니다.