본문 바로가기

런타임 애노테이션 만들고 리플렉션으로 읽기

메서드 이름이나 설정 문자열 대신 실행 중에도 남는 애노테이션으로 라우트를 표시하고, 시작할 때 리플렉션으로 읽어 시그니처와 중복을 검사한 뒤 라우트 표를 만드는 방법을 배웁니다.

앞 글에서는 리플렉션으로 클래스의 메서드를 찾아 호출했습니다. 이제 남은 질문은 어떤 메서드가 어떤 요청을 처리하는지 프레임워크에 어떻게 알려 줄지입니다.

가장 쉬운 방법은 이름을 맞추는 것입니다. /site1 요청이 오면 site1() 메서드를 찾아 호출하는 식입니다. 하지만 이 방법은 이름이 우연히 일치하는 데 기대고 있어서, 메서드 이름을 리팩터링하면 URL도 함께 바뀌어 버립니다. /members/{id}처럼 메서드 이름으로는 쓸 수 없는 경로도 표현할 수 없습니다.

두 번째 방법은 경로와 메서드 이름을 짝지은 표를 따로 두는 것입니다. 이름에 묶이지는 않지만, 이번에는 선언과 설정이 서로 다른 곳에 있어서 한쪽만 고치는 실수가 생기기 쉽습니다.

이 글에서 다룰 애노테이션(annotation)은 세 번째 방법입니다. 메서드 바로 위에 @Route(method = "GET", path = "/members")처럼 HTTP 정보를 적어 두면, 컴파일러가 요소의 이름과 타입, 빠진 값이 없는지를 검사해 줍니다. 그리고 프로그램이 시작할 때 리플렉션으로 이 정보를 읽어 라우트 표를 만들 수 있습니다. 단, 애노테이션은 정보를 붙여 둘 뿐 스스로 아무 일도 하지 않습니다. 그 정보를 읽고 처리하는 코드, 즉 스캐너와 실행기는 따로 만들어야 합니다.

문자열로 등록한 라우트에서 생기는 일

표 방식의 문제부터 확인해 보겠습니다. 다음 코드에는 두 가지 실수가 있습니다. 첫 번째 등록은 메서드 이름을 siet1로 잘못 적었고, 두 번째 등록은 같은 키 GET /site1을 실수로 다시 썼습니다.

bad/StringRouteConfiguration.java
import java.util.HashMap;
import java.util.Map;

public final class StringRouteConfiguration {
    record Target(String className, String methodName) {
    }

    static Map<String, Target> routes() {
        Map<String, Target> routes = new HashMap<>();
        routes.put("GET /site1", new Target(
                "example.SiteController",
                "siet1"));
        routes.put("GET /site1", new Target(
                "example.OtherController",
                "site1"));
        return routes;
    }

    public static void main(String[] args) {
        Map<String, Target> routes = routes();
        System.out.println("route count = " + routes.size());
        System.out.println(routes);
    }
}

이 코드는 경고 하나 없이 컴파일되고 실행됩니다. 출력은 다음과 같습니다.

route count = 1
{GET /site1=Target[className=example.OtherController, methodName=site1]}

두 실수 모두 컴파일러는 알아채지 못했습니다. 클래스 이름과 메서드 이름은 그냥 문자열이라서, example.SiteController라는 클래스가 실제로 있는지조차 검사되지 않습니다.

실행해 보면 라우트가 하나뿐입니다. HashMap.put이 같은 키의 값을 조용히 덮어써서 첫 번째 등록이 사라졌기 때문입니다. 이번에는 덮어쓰기 때문에 오타가 가려졌지만, 그렇지 않았다면 오타는 그 경로로 첫 요청이 들어올 때에야 드러났을 것입니다. 메서드가 매개변수를 받는지, 무엇을 반환하는지도 실행해 보기 전까지는 알 수 없습니다.

애노테이션으로 바꾼다고 이 문제가 모두 사라지지는 않습니다. 선언을 메서드 옆으로 옮기면 선언과 설정이 따로 노는 문제는 해결됩니다. 하지만 애노테이션을 읽는 스캐너가 같은 경로를 두 번 만났을 때 뒤의 것으로 덮어쓴다면 중복 문제는 그대로 남습니다. 그래서 스캐너는 라우트를 등록하면서 키 중복, 메서드의 매개변수와 반환 타입, 접근 수준을 모두 검사하고, 문제가 하나라도 있으면 서버가 시작하지 못하게 해야 합니다. 잘못된 라우트를 운영 중의 첫 요청에서 발견하는 것보다 배포 직후 시작 단계에서 분명한 오류 메시지를 받는 편이 훨씬 낫습니다.

무엇을 라우트로 볼지도 정해야 합니다. 애노테이션이 없는 메서드는 라우트가 아니라 일반 도우미 메서드로 봅니다. public 메서드를 모두 자동으로 노출하는 방식을 택하면 toString이나 관리용 메서드, 나중에 누군가 추가한 유틸리티 메서드까지 외부에서 호출할 수 있는 주소가 되어 버립니다. 노출할 메서드에만 명시적으로 표시를 붙이게 하면, 외부에서 건드릴 수 있는 범위가 개발자가 의도한 메서드로 줄어듭니다.

@Route 애노테이션 정의하기

애노테이션은 @interface 키워드로 정의합니다. 안에 선언하는 요소(element)는 매개변수가 없는 추상 메서드처럼 생겼고, 사용하는 쪽에서는 @Route(method = "GET", path = "/site1")처럼 요소 이름에 값을 넣습니다. 정의 위에 붙은 두 애노테이션은 Route라는 애노테이션 자체의 성질을 정합니다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Route {
    String method();

    String path();
}

@Retention(RetentionPolicy.RUNTIME)은 이 애노테이션을 실행 중에도 리플렉션으로 읽을 수 있게 남겨 두라는 뜻입니다. @Retention을 생략하면 기본값인 CLASS가 적용되는데, 이때 애노테이션은 클래스 파일에는 기록되지만 실행 중에는 리플렉션에 보이지 않습니다. @Target(ElementType.METHOD)는 이 애노테이션을 메서드에만 붙일 수 있게 제한합니다. 이렇게 애노테이션에 붙이는 애노테이션을 메타 애노테이션이라고 하며, 종류와 효과는 메타 애노테이션 @Retention·@Target·@Inherited·@Repeatable에서 자세히 다룹니다. 여기서는 RUNTIME이 빠지면 스캐너가 애노테이션을 찾지 못한다는 것만 먼저 확인하겠습니다.

src/RetentionProbe.java
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Method;

public final class RetentionProbe {
    @Retention(RetentionPolicy.RUNTIME)
    @interface Visible {
    }

    @interface Invisible {
    }

    static final class Controller {
        @Visible
        @Invisible
        public void handle() {
        }
    }

    public static void main(String[] args) throws Exception {
        Method handle = Controller.class.getMethod("handle");
        System.out.println("Visible   = " + handle.getAnnotation(Visible.class));
        System.out.println("Invisible = " + handle.getAnnotation(Invisible.class));
    }
}

두 애노테이션은 @Retention이 있느냐 없느냐만 다릅니다. 실행 결과는 다음과 같습니다.

Visible   = @RetentionProbe.Visible()
Invisible = null

handle()에는 두 애노테이션이 모두 붙어 있지만, getAnnotation은 RUNTIME으로 정의한 Visible만 찾아냅니다. 첫 줄의 @RetentionProbe.Visible()은 찾아낸 애노테이션 객체를 출력한 모습이고, @Retention 없이 정의한 Invisible은 null이 나옵니다. 컴파일도 실행도 오류 없이 끝나기 때문에, 라우트 스캐너가 이런 애노테이션을 읽으려 하면 라우트가 하나도 없는 것처럼 보일 뿐입니다. 직접 정의한 애노테이션을 리플렉션으로 읽을 계획이라면 @Retention(RetentionPolicy.RUNTIME)부터 확인해야 합니다.

반대로 컴파일러가 미리 잡아 주는 실수도 있습니다. 다음 코드처럼 @Route를 필드에 붙이거나, path 요소를 빠뜨리거나, String 요소에 숫자를 넣으면 컴파일되지 않습니다.

bad/RouteMisuse.java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

public final class RouteMisuse {
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface Route {
        String method();

        String path();
    }

    @Route(method = "GET", path = "/name")
    private String name;

    @Route(method = "GET")
    public String search() {
        return "search";
    }

    @Route(method = "GET", path = 404)
    public String missing() {
        return "missing";
    }
}

이 파일을 javac RouteMisuse.java로 컴파일하면 세 곳에서 오류가 납니다. 출력은 다음과 같습니다.

RouteMisuse.java:23: error: incompatible types: int cannot be converted to String
    @Route(method = "GET", path = 404)
                                  ^
RouteMisuse.java:15: error: annotation interface not applicable to this kind of declaration
    @Route(method = "GET", path = "/name")
    ^
RouteMisuse.java:18: error: annotation @Route is missing a default value for the element 'path'
    @Route(method = "GET")
    ^
3 errors

javac가 출력한 순서대로 보면, 23번째 줄은 path에 int를 넣어 타입이 맞지 않는다는 오류이고, 15번째 줄은 메서드 전용 애노테이션을 필드에 붙였다는 오류입니다. 18번째 줄은 path를 빠뜨렸다는 오류인데, 메시지가 말하듯 기본값(default)이 없는 요소는 사용할 때마다 반드시 값을 적어야 하는 필수 요소이기 때문입니다. 문자열 표에서는 실행해 봐야 알 수 있던 실수 가운데 일부를 이제 컴파일러가 잡아 주는 셈입니다. 다만 path가 /로 시작해야 한다거나 같은 경로가 두 번 나오면 안 된다는 규칙은 애노테이션 문법으로 표현할 수 없으므로, 여전히 스캐너가 검사해야 합니다.

애노테이션을 읽어 라우트 표 만들기

이제 스캐너를 만들 차례입니다. 다음 예제의 discover는 컨트롤러 객체를 받아 그 클래스에 선언된 메서드를 하나씩 살펴봅니다. @Route가 없는 메서드는 건너뛰고, 있는 메서드는 public이고 매개변수가 없으며 String을 반환하는지 검사합니다. 검사를 통과하면 HTTP 메서드와 경로로 만든 키에 처리기를 등록하되, 같은 키가 이미 있으면 그 자리에서 예외를 던집니다.

src/AnnotatedRouteDiscovery.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.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

public final class AnnotatedRouteDiscovery {
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface Route {
        String method();

        String path();
    }

    static final class SiteController {
        @Route(method = "GET", path = "/site1")
        public String siteOne() {
            return "site1";
        }

        @Route(method = "GET", path = "/search")
        public String search() {
            return "search";
        }

        public String helper() {
            return "not exposed";
        }
    }

    record RouteKey(String method, String path) {
        RouteKey {
            method = method.toUpperCase(Locale.ROOT);
            if (!method.matches("[A-Z]+")) {
                throw new IllegalArgumentException("invalid HTTP method");
            }
            if (!path.startsWith("/")) {
                throw new IllegalArgumentException("invalid route path");
            }
        }
    }

    record Handler(Object target, Method method) {
        String invoke() {
            try {
                return (String) method.invoke(target);
            } catch (InvocationTargetException wrapper) {
                Throwable cause = wrapper.getCause();
                throw new IllegalStateException(
                        "route failed: " + cause.getClass().getSimpleName(),
                        cause);
            } catch (ReflectiveOperationException error) {
                throw new IllegalStateException("route inaccessible", error);
            }
        }
    }

    static Map<RouteKey, Handler> discover(Object controller) {
        Map<RouteKey, Handler> handlers = new LinkedHashMap<>();
        for (Method method : controller.getClass().getDeclaredMethods()) {
            Route route = method.getAnnotation(Route.class);
            if (route == null) {
                continue;
            }
            validateSignature(method);
            RouteKey key = new RouteKey(route.method(), route.path());
            Handler previous = handlers.putIfAbsent(
                    key,
                    new Handler(controller, method));
            if (previous != null) {
                throw new IllegalStateException("duplicate route: " + key);
            }
        }
        return Map.copyOf(handlers);
    }

    private static void validateSignature(Method method) {
        if (!Modifier.isPublic(method.getModifiers())) {
            throw new IllegalStateException("route method must be public");
        }
        if (method.getParameterCount() != 0
                || method.getReturnType() != String.class) {
            throw new IllegalStateException(
                    "route signature must be () -> String");
        }
    }

    public static void main(String[] args) {
        Map<RouteKey, Handler> routes = discover(new SiteController());
        routes.entrySet().stream()
                .sorted(Map.Entry.comparingByKey(
                        Comparator.comparing(RouteKey::path)))
                .forEach(entry -> System.out.println(
                        entry.getKey() + " -> " + entry.getValue().invoke()));
        System.out.println("routeCount=" + routes.size());
    }
}

main은 등록된 라우트를 경로 순으로 정렬해 키와 처리기의 실행 결과를 한 줄씩 찍고, 마지막에 라우트 수를 찍습니다. 정렬하는 것은 discover가 메서드를 받는 순서가 정해져 있지 않기 때문입니다.

RouteKey[method=GET, path=/search] -> search
RouteKey[method=GET, path=/site1] -> site1
routeCount=2

discover는 앞 글에서 본 getDeclaredMethods()로 SiteController에 선언된 메서드 세 개를 받습니다. method.getAnnotation(Route.class)는 그 메서드에 @Route가 붙어 있으면 애노테이션 객체를, 없으면 null을 돌려주므로 helper()는 여기서 걸러집니다. 나머지 두 메서드는 validateSignature를 통과한 뒤 RouteKey로 등록됩니다. RouteKey의 컴팩트 생성자는 HTTP 메서드를 대문자로 맞춘 다음, 영문 대문자로만 되어 있는지와 경로가 /로 시작하는지 검사합니다. 그래서 method = "get"이라고 적어도 GET과 같은 키가 됩니다.

등록에 put 대신 putIfAbsent를 쓴 것은 기존 값이 있을 때 덮어쓰지 않고 그 값을 돌려받기 위해서입니다. 돌려받은 값이 null이 아니면 같은 키가 이미 있다는 뜻이므로 예외를 던집니다. 앞에서 본 HashMap.put의 조용한 덮어쓰기를 막는 장치입니다. 모든 메서드를 처리한 뒤에는 Map.copyOf로 불변 맵을 만들어 돌려주므로, 시작 단계가 끝난 뒤에는 누구도 라우트 표를 바꿀 수 없습니다.

main이 출력하기 전에 경로 순으로 정렬한 데도 이유가 있습니다. getDeclaredMethods()가 돌려주는 메서드의 순서도, Map.copyOf로 만든 불변 맵을 순회하는 순서도 정해져 있지 않기 때문입니다. 정렬하지 않으면 두 줄의 순서가 실행 환경에 따라 달라질 수 있습니다.

출력의 각 줄은 등록된 처리기를 Handler.invoke()로 실제로 호출한 결과이고, helper()는 라우트가 아니므로 routeCount는 2입니다. Handler.invoke()는 앞 글의 예제와 같은 방식으로 예외를 나눕니다. 라우트 메서드 안에서 난 예외는 원인을 꺼내 route failed라는 메시지와 함께 다시 던지고, 리플렉션 호출 자체가 실패하면 route inaccessible로 구분합니다.

getAnnotation이 돌려주는 객체는 Route 인터페이스를 구현한 객체입니다. route.method()와 route.path()를 호출하면 사용 지점에 적은 값이 나오고, route.annotationType()은 Route.class를 돌려줍니다. 이 객체를 어떤 클래스로 만드는지는 JDK 구현에 맡겨진 부분이므로(OpenJDK에서는 동적 프록시 객체입니다) 그 구현에 기대는 코드는 쓰지 않습니다. 또 애노테이션에서 읽은 값은 설정 파일에서 읽은 값처럼 검증해야 합니다. 예제의 RouteKey가 HTTP 메서드와 경로를 검사하는 것도 그래서입니다.

확인 문제

SiteController에 @Route(method = "get", path = "/search")를 붙인 public String find() 메서드를 추가하면 프로그램은 어떻게 될까요? 그 대신 search()를 private으로 바꾸면 어떻게 될까요?

해설

두 경우 모두 main은 라우트를 하나도 호출하지 못하고, discover에서 IllegalStateException이 나며 프로그램이 멈춥니다. 첫 번째 경우의 출력은 다음과 같습니다.

Exception in thread "main" java.lang.IllegalStateException: duplicate route: RouteKey[method=GET, path=/search]

RouteKey가 get을 GET으로 바꾸므로 find()의 키가 search()의 키와 같아집니다. 두 메서드 중 어느 쪽이 먼저 등록되는지는 getDeclaredMethods()의 순서에 따라 다를 수 있지만, 충돌하는 키는 같으므로 메시지는 같습니다. 두 번째 경우에는 validateSignature가 먼저 걸립니다.

Exception in thread "main" java.lang.IllegalStateException: route method must be public

어느 쪽이든 오류가 첫 요청이 아니라 서버를 시작하는 단계에서 드러난다는 점이 이 스캐너의 핵심입니다. 문자열 표에서는 실행해 보기 전까지 알 수 없던 문제를 이제는 시작하자마자 알게 됩니다.

더 깊이: 컨트롤러 객체 하나를 여러 요청이 함께 쓴다

이 예제에서는 컨트롤러 객체 하나를 모든 Handler가 함께 참조합니다. 실제 서버에서는 여러 요청 스레드가 동시에 처리기를 호출하므로, 컨트롤러에 요청마다 바뀌는 값을 필드로 두면 요청끼리 그 값을 덮어쓰게 됩니다. 앞 장에서 같은 Servlet 인스턴스가 여러 요청 스레드에서 호출될 수 있다고 했던 것과 같은 문제입니다. 그래서 프레임워크는 상태가 없는 컨트롤러 하나를 공유할지, 요청마다 컨트롤러를 새로 만들지, 공유하는 상태를 동기화할지를 정해 두어야 합니다. 리플렉션으로 메서드를 찾아 호출한다고 해서 스레드 안전성이 저절로 생기지는 않습니다.

표시만 하는 애노테이션과 값을 담는 애노테이션

요소가 하나도 없는 애노테이션을 마커 애노테이션(marker annotation)이라고 합니다. @InternalOnly처럼 붙어 있다는 사실 자체가 의미일 때 씁니다. 반면 감사 로그에 남길 이벤트 이름처럼 추가 정보가 필요하면 요소를 둡니다. 요소에는 default로 기본값을 줄 수 있고, 기본값이 있는 요소는 사용할 때 생략할 수 있습니다. 다음 예제는 두 종류를 함께 쓰고, 메서드마다 붙은 정보를 모아 출력합니다.

src/AnnotationPolicyProbe.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.ArrayList;
import java.util.Comparator;
import java.util.List;

public final class AnnotationPolicyProbe {
    enum Level {
        BASIC,
        SENSITIVE
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface Audited {
        Level level() default Level.BASIC;

        String event();
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface InternalOnly {
    }

    static final class AccountService {
        @Audited(event = "account.view")
        public void view() {
        }

        @Audited(event = "account.delete", level = Level.SENSITIVE)
        @InternalOnly
        public void delete() {
        }

        public void health() {
        }
    }

    record MethodPolicy(String method, String event,
                        Level level, boolean internal) {
    }

    static List<MethodPolicy> policies(Class<?> type) {
        List<MethodPolicy> result = new ArrayList<>();
        for (Method method : type.getDeclaredMethods()) {
            Audited audited = method.getAnnotation(Audited.class);
            if (audited == null) {
                continue;
            }
            result.add(new MethodPolicy(
                    method.getName(),
                    audited.event(),
                    audited.level(),
                    method.isAnnotationPresent(InternalOnly.class)));
        }
        return result.stream()
                .sorted(Comparator.comparing(MethodPolicy::method))
                .toList();
    }

    public static void main(String[] args) {
        policies(AccountService.class).forEach(System.out::println);
    }
}

MethodPolicy는 레코드이므로 메서드마다 한 줄씩, 애노테이션에서 읽은 값이 구성 요소 이름과 함께 찍힙니다. AccountService의 메서드 가운데 어떤 것이 줄로 나오지 않는지도 함께 보세요.

MethodPolicy[method=delete, event=account.delete, level=SENSITIVE, internal=true]
MethodPolicy[method=view, event=account.view, level=BASIC, internal=false]

policies는 @Audited가 붙은 메서드만 모으므로 health()는 결과에 없습니다. view()는 level을 적지 않았기 때문에 기본값 BASIC이 나오고, delete()는 SENSITIVE를 명시했습니다. event에는 기본값이 없어서, 이 요소를 빠뜨리면 앞에서 본 것처럼 컴파일 오류가 납니다. 마지막 칸의 internal은 isAnnotationPresent(InternalOnly.class)로 마커가 붙어 있는지만 확인한 결과입니다. 결과를 메서드 이름으로 정렬한 것은 여기서도 getDeclaredMethods()의 순서가 정해져 있지 않기 때문입니다.

설정할 것이 늘어날 때 boolean 요소를 계속 추가하면 조합의 의미가 모호해집니다. 예를 들어 logged와 masked라는 두 요소가 있다면, 둘이 각각 true이거나 false일 때 어떻게 동작하는지 사용하는 사람이 매번 따져야 합니다. 예제의 Level처럼 가능한 정책을 enum으로 정해 두거나, 성격이 다른 정보는 별도의 애노테이션으로 나누는 편이 읽기 쉽습니다.

기본값에는 알아 둘 성질이 하나 있습니다. Java 언어 명세(9.6.2절)에 따르면 기본값은 애노테이션을 사용한 클래스에 복사되지 않고, 애노테이션을 읽는 시점에 애노테이션 정의에서 가져옵니다. 그래서 나중에 level의 기본값을 SENSITIVE로 바꾸면, 다시 컴파일하지 않은 기존 클래스라도 level을 생략한 곳은 모두 새 기본값으로 읽힙니다.

또 실행 중에 애노테이션 객체만 보아서는 BASIC이 직접 적은 값인지 생략해서 나온 기본값인지 구분할 수 없습니다. 이 구분이 꼭 필요하다면 컴파일 단계의 애노테이션 처리기나 클래스 파일 검사 도구를 써야 하므로, 보안처럼 중요한 설정은 기본값을 두지 말고 필수 요소로 만들어 명시하게 하는 편이 낫습니다. 요소의 타입과 기본값을 정하는 규칙은 애노테이션 요소의 타입과 기본값에서 자세히 다룹니다.

마커를 권한 검사의 유일한 장치로 써서도 안 됩니다. @InternalOnly는 붙어 있다는 정보일 뿐이고, 스캐너가 그 표시를 보고 접근 검사를 실제로 연결해야 비로소 보호 장치가 됩니다. 표시를 읽는 코드가 빠지거나 표시를 거치지 않고 메서드를 부르는 경로가 따로 있다면, 애노테이션은 아무것도 막지 못하는 장식에 그칩니다. 그래서 이런 애노테이션을 쓸 때는 표시가 실제 동작으로 이어지는지를 테스트로 확인해 두는 것이 좋습니다.

오류를 모두 모아 한 번에 보고하기

AnnotatedRouteDiscovery는 첫 번째 문제를 만나는 즉시 예외를 던집니다. 단순하지만, 컨트롤러에 문제가 세 개 있으면 개발자는 오류 하나를 고치고 서버를 다시 시작해 다음 오류를 보는 일을 세 번 되풀이해야 합니다. 더 나은 스캐너는 모든 컨트롤러를 끝까지 훑으면서 잘못된 시그니처, 중복 경로, 잘못된 경로 형식을 하나의 목록으로 모으고, 문제가 하나라도 있으면 그 목록 전체를 보고합니다. 요청 처리에는 문제가 하나도 없을 때 만든 불변 처리기 표만 씁니다. 스캐너가 메서드를 하나 만났을 때의 처리를 정리하면 다음과 같습니다.

발견한 것시작할 때의 판정요청이 왔을 때고칠 곳
정상 라우트처리기 표에 등록처리기를 바로 호출없음
같은 HTTP 메서드와 경로의 중복오류, 서버를 시작하지 않음해당 없음라우트 선언
시그니처 불일치오류, 서버를 시작하지 않음해당 없음컨트롤러 메서드
애노테이션 없음라우트가 아니므로 무시라우트 없음노출할 의도였는지 확인
RUNTIME이 아닌 애노테이션리플렉션에 보이지 않아 무시됨라우트 없음애노테이션 정의의 @Retention

마지막 줄은 오류로 잡히지도 않는다는 점에서 가장 찾기 어렵습니다. 그래서 스캐너를 테스트할 때는 정상 라우트와 오류 사례뿐 아니라, RUNTIME이 빠진 정의를 쓰면 라우트가 보이지 않는다는 것까지 확인해 두는 것이 좋습니다.

연습 문제

두 컨트롤러 클래스에서 @Endpoint가 붙은 메서드를 찾아 라우트 표를 만드는 compile을 작성하세요. 각 메서드는 public이어야 하고, 매개변수가 없어야 하며, String을 반환해야 하고, 경로는 /로 시작해야 합니다. 같은 경로가 두 번 나와서도 안 됩니다. 문제를 발견해도 바로 멈추지 말고 모든 문제를 Issue로 모은 뒤, 문제가 있으면 정렬한 Issue 목록을, 없으면 완성된 처리기 표를 돌려줍니다. 문제가 하나라도 있으면 중간에 모은 처리기 표는 밖으로 내보내지 않습니다.

힌트

  • 결과를 봉인된 인터페이스(sealed interface) Result와 두 레코드 Compiled, Invalid로 나타내면, 호출하는 쪽이 두 경우를 빠짐없이 처리하게 만들 수 있습니다.
  • 메서드 하나에서 여러 문제가 나올 수 있으므로 검사 함수가 문제 목록을 돌려주게 하고, 그 목록이 비어 있을 때만 처리기 후보로 등록합니다.
풀이 코드와 실행 결과

이 풀이는 메서드를 실행하지 않고 라우트 표만 만듭니다. 오류가 없을 때만 Compiled가 만들어지므로, 요청을 처리하는 코드는 시그니처를 매번 다시 확인할 필요가 없습니다.

solution/RouteMetadataCompilerSolution.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.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public final class RouteMetadataCompilerSolution {
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface Endpoint {
        String value();
    }

    static final class GoodController {
        @Endpoint("/good")
        public String good() {
            return "ok";
        }
    }

    static final class BrokenController {
        @Endpoint("bad")
        private void broken(int value) {
        }

        @Endpoint("/good")
        public String duplicate() {
            return "duplicate";
        }
    }

    record Handler(Object target, Method method) {
    }

    record Issue(String owner, String method, String detail) {
    }

    sealed interface Result permits Compiled, Invalid {
    }

    record Compiled(Map<String, Handler> handlers) implements Result {
        Compiled {
            handlers = Map.copyOf(handlers);
        }
    }

    record Invalid(List<Issue> issues) implements Result {
        Invalid {
            issues = List.copyOf(issues);
        }
    }

    static Result compile(List<Object> controllers) {
        Map<String, Handler> handlers = new LinkedHashMap<>();
        List<Issue> issues = new ArrayList<>();

        for (Object controller : controllers) {
            for (Method method : controller.getClass().getDeclaredMethods()) {
                Endpoint endpoint = method.getAnnotation(Endpoint.class);
                if (endpoint == null) {
                    continue;
                }
                List<String> methodIssues = validate(method, endpoint);
                for (String detail : methodIssues) {
                    issues.add(new Issue(
                            controller.getClass().getSimpleName(),
                            method.getName(),
                            detail));
                }
                if (methodIssues.isEmpty()) {
                    Handler previous = handlers.putIfAbsent(
                            endpoint.value(),
                            new Handler(controller, method));
                    if (previous != null) {
                        issues.add(new Issue(
                                controller.getClass().getSimpleName(),
                                method.getName(),
                                "duplicate path " + endpoint.value()));
                    }
                }
            }
        }

        if (!issues.isEmpty()) {
            issues.sort(Comparator
                    .comparing(Issue::owner)
                    .thenComparing(Issue::method)
                    .thenComparing(Issue::detail));
            return new Invalid(issues);
        }
        return new Compiled(handlers);
    }

    private static List<String> validate(Method method, Endpoint endpoint) {
        List<String> issues = new ArrayList<>();
        if (!Modifier.isPublic(method.getModifiers())) {
            issues.add("method is not public");
        }
        if (method.getParameterCount() != 0) {
            issues.add("parameters are not allowed");
        }
        if (method.getReturnType() != String.class) {
            issues.add("return type must be String");
        }
        if (!endpoint.value().startsWith("/")) {
            issues.add("path must start with slash");
        }
        return issues;
    }

    static void print(Result result) {
        switch (result) {
            case Compiled compiled -> System.out.println(
                    "Compiled " + compiled.handlers().size() + " route(s)");
            case Invalid invalid -> {
                System.out.println(
                        "Invalid " + invalid.issues().size() + " issue(s)");
                invalid.issues().forEach(issue -> System.out.println("  " + issue));
            }
        }
    }

    public static void main(String[] args) {
        print(compile(List.of(
                new GoodController(),
                new BrokenController())));
    }
}

실행 결과:

Invalid 5 issue(s)
  Issue[owner=BrokenController, method=broken, detail=method is not public]
  Issue[owner=BrokenController, method=broken, detail=parameters are not allowed]
  Issue[owner=BrokenController, method=broken, detail=path must start with slash]
  Issue[owner=BrokenController, method=broken, detail=return type must be String]
  Issue[owner=BrokenController, method=duplicate, detail=duplicate path /good]

GoodController를 먼저 훑으므로 good()이 문제없이 /good에 등록됩니다. BrokenController의 broken()은 private이고, 매개변수가 있고, void를 반환하며, 경로 bad가 /로 시작하지 않습니다. 네 가지 문제가 모두 Issue로 모이고, 문제가 있으므로 처리기 후보로 등록하지 않습니다. duplicate()는 메서드 자체의 검사는 통과하지만 /good이 이미 등록되어 있어서 중복 문제가 하나 추가됩니다.

모인 다섯 개의 문제를 클래스 이름, 메서드 이름, 내용 순으로 정렬해 Invalid로 돌려주므로, /good 하나가 담긴 중간 처리기 표는 밖으로 나가지 않습니다. 아래 단계 그림이 이 과정을 순서대로 보여 줍니다.

연습 문제의 메서드별 검사 결과

endpoint 메서드마다 검증 문제와 중복 키를 누적한 뒤 문제가 하나라도 있으면 전체 컴파일 결과를 Invalid로 반환합니다. 유효한 일부 route가 먼저 공개되지 않는 최종 게이트를 추적할 수 있습니다.

연습 문제의 메서드별 검사 결과

연습 문제의 메서드별 검사 결과

라우트 스키마 · 오류 집계 뒤 공개 결정

연습 문제의 메서드별 검사 결과

good은 후보로 등록되지만 broken의 네 문제와 duplicate의 한 문제를 모으면 Invalid가 됩니다. 중간 처리기 Map은 공개하지 않습니다.

모든 메서드를 검사한 뒤 공개 여부를 결정한다모든controller의declared메서드를순회한다.Endpoint가없으면다음메서드로가고있으면접근/인수/반환/경로를검사한다.검사문제는모두Issue로모으고정상후보만putIfAbsent한다.기존키가있으면중복Issue한개를모은다.검사와등록뒤다음메서드로돌아간다.전체순회뒤Issue가있으면정렬한Invalid만반환하고없으면CompiledMap을공개한다.화살표는스캐너제어순서이며Handler본문은호출하지않는다.있음없음있음있음없음있음없음다음 메서드?Endpoint 있음?검사 오류 있음?키 등록 시도기존 키 있음?Issue 수집검사 문제 모두또는 중복 1개모은 Issue 있음?Compiled 공개오류 없는 전체 MapInvalid 반환Issue 정렬후보 Map 미공개오류가 있으면 정상 후보도 공개하지 않음
모든 메서드를 검사한 뒤 공개 여부를 결정한다모든controller의declared메서드를순회한다.Endpoint가없으면다음메서드로가고있으면접근/인수/반환/경로를검사한다.검사문제는모두Issue로모으고정상후보만putIfAbsent한다.기존키가있으면중복Issue한개를모은다.검사와등록뒤다음메서드로돌아간다.전체순회뒤Issue가있으면정렬한Invalid만반환하고없으면CompiledMap을공개한다.화살표는스캐너제어순서이며Handler본문은호출하지않는다.있음있음없음없음다음 메서드?Endpoint 있음?검사 오류 있음?키 등록 시도기존 키 있음?Issue 수집검사 문제 모두또는 중복 1개모은 Issue 있음?Compiled 공개오류 없는 전체 MapInvalid 반환Issue 정렬후보 Map 미공개왼쪽 안쪽: 메서드 반복왼쪽 끝: 전체검사 종료있음: 오른쪽 선오류 하나도 공개 차단

정상 메서드 good

수집되는 Issue
없음
처리기 후보
/good 등록

잘못된 메서드 broken

수집되는 Issue
비공개·매개변수 있음·String 반환 아님·슬래시 경로 아님: 4개
처리기 후보
등록 안 함

중복 메서드 duplicate

수집되는 Issue
duplicate path /good: 1개
처리기 후보
기존 good 유지

원문 main의 두 컨트롤러를 코드로 추적한 결과입니다. Issue 5개를 정렬해 Invalid로 반환하므로 중간 Map은 공개되지 않으며 메서드 본문도 호출하지 않습니다.

이 예의 조건

  • 스캐너는 Handler 본문을 호출하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2모든 controller의 declared 메서드를 순회한다. Endpoint가 없으면 다음 메서드로 가고 있으면 접근/인수/반환/경로를 검사한다. 검사 문제는 모두 Issue로 모으고 정상 후보만 putIfAbsent한다. 기존 키가 있으면 중복 Issue 한 개를 모은다. 검사와 등록 뒤 다음 메서드로 돌아간다. 전체 순회 뒤 Issue가 있으면 정렬한 Invalid만 반환하고 없으면 Compiled Map을 공개한다. 화살표는 스캐너의 제어 순서이며 Handler 본문은 호출하지 않는다.
원 도표의 설명과 주석
구분내용
설명모든 controller의 declared 메서드를 순회한다. Endpoint가 없으면 다음 메서드로 가고 있으면 접근/인수/반환/경로를 검사한다. 검사 문제는 모두 Issue로 모으고 정상 후보만 putIfAbsent한다. 기존 키가 있으면 중복 Issue 한 개를 모은다. 검사와 등록 뒤 다음 메서드로 돌아간다. 전체 순회 뒤 Issue가 있으면 정렬한 Invalid만 반환하고 없으면 Compiled Map을 공개한다. 화살표는 스캐너의 제어 순서이며 Handler 본문은 호출하지 않는다.
전체 읽기 정보연습 문제의 메서드별 검사 결과 연습 문제의 메서드별 검사 결과 연습 문제의 메서드별 검사 결과 라우트 스키마 · 오류 집계 뒤 공개 결정 연습 문제의 메서드별 검사 결과 good은 후보로 등록되지만 broken의 네 문제와 duplicate의 한 문제를 모으면 Invalid가 됩니다. 중간 처리기 Map은 공개하지 않습니다. 정상 메서드 good 수집되는 Issue 없음 처리기 후보 /good 등록 잘못된 메서드 broken 수집되는 Issue 비공개·매개변수 있음·String 반환 아님·슬래시 경로 아님: 4개 처리기 후보 등록 안 함 중복 메서드 duplicate 수집되는 Issue duplicate path /good: 1개 처리기 후보 기존 good 유지 원문 main의 두 컨트롤러를 코드로 추적한 결과입니다. Issue 5개를 정렬해 Invalid로 반환하므로 중간 Map은 공개되지 않으며 메서드 본문도 호출하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
메서드 선택과 검사

declared 메서드 중 Endpoint가 있는 메서드만 접근·인수·반환·경로를 검사합니다.

  • Endpoint 없음: 다음 메서드
  • 검사 오류: 모두 Issue 수집
  • 문제가 있는 메서드는 정상 후보 등록에 들어가지 않습니다.
후보 키 등록

정상 후보만 putIfAbsent하고 기존 키면 중복 Issue를 모읍니다.

  • 후보 Map: 내부에만 보관
  • 다음 동작: 다음 메서드
  • 중복 한 건도 전체 공개 판단에 반영됩니다.
전체 공개 판단

모든 메서드 뒤 Issue 유무를 검사합니다.

  • Issue 있음: 정렬된 Invalid, 후보 미공개
  • Issue 없음: 전체 Compiled Map
  • 정상 후보가 있어도 오류가 하나 남으면 공개하지 않습니다.

연습 문제의 메서드별 검사 결과

endpoint 메서드마다 검증 문제와 중복 키를 누적한 뒤 문제가 하나라도 있으면 전체 컴파일 결과를 Invalid로 반환합니다. 유효한 일부 route가 먼저 공개되지 않는 최종 게이트를 추적할 수 있습니다.

연습 문제의 메서드별 검사 결과

연습 문제의 메서드별 검사 결과

라우트 스키마 · 오류 집계 뒤 공개 결정

연습 문제의 메서드별 검사 결과

good은 후보로 등록되지만 broken의 네 문제와 duplicate의 한 문제를 모으면 Invalid가 됩니다. 중간 처리기 Map은 공개하지 않습니다.

모든 메서드를 검사한 뒤 공개 여부를 결정한다모든controller의declared메서드를순회한다.Endpoint가없으면다음메서드로가고있으면접근/인수/반환/경로를검사한다.검사문제는모두Issue로모으고정상후보만putIfAbsent한다.기존키가있으면중복Issue한개를모은다.검사와등록뒤다음메서드로돌아간다.전체순회뒤Issue가있으면정렬한Invalid만반환하고없으면CompiledMap을공개한다.화살표는스캐너제어순서이며Handler본문은호출하지않는다.있음없음있음있음없음있음없음다음 메서드?Endpoint 있음?검사 오류 있음?키 등록 시도기존 키 있음?Issue 수집검사 문제 모두또는 중복 1개모은 Issue 있음?Compiled 공개오류 없는 전체 MapInvalid 반환Issue 정렬후보 Map 미공개오류가 있으면 정상 후보도 공개하지 않음
모든 메서드를 검사한 뒤 공개 여부를 결정한다모든controller의declared메서드를순회한다.Endpoint가없으면다음메서드로가고있으면접근/인수/반환/경로를검사한다.검사문제는모두Issue로모으고정상후보만putIfAbsent한다.기존키가있으면중복Issue한개를모은다.검사와등록뒤다음메서드로돌아간다.전체순회뒤Issue가있으면정렬한Invalid만반환하고없으면CompiledMap을공개한다.화살표는스캐너제어순서이며Handler본문은호출하지않는다.있음있음없음없음다음 메서드?Endpoint 있음?검사 오류 있음?키 등록 시도기존 키 있음?Issue 수집검사 문제 모두또는 중복 1개모은 Issue 있음?Compiled 공개오류 없는 전체 MapInvalid 반환Issue 정렬후보 Map 미공개왼쪽 안쪽: 메서드 반복왼쪽 끝: 전체검사 종료있음: 오른쪽 선오류 하나도 공개 차단

정상 메서드 good

수집되는 Issue
없음
처리기 후보
/good 등록

잘못된 메서드 broken

수집되는 Issue
비공개·매개변수 있음·String 반환 아님·슬래시 경로 아님: 4개
처리기 후보
등록 안 함

중복 메서드 duplicate

수집되는 Issue
duplicate path /good: 1개
처리기 후보
기존 good 유지

원문 main의 두 컨트롤러를 코드로 추적한 결과입니다. Issue 5개를 정렬해 Invalid로 반환하므로 중간 Map은 공개되지 않으며 메서드 본문도 호출하지 않습니다.

이 예의 조건

  • 스캐너는 Handler 본문을 호출하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2모든 controller의 declared 메서드를 순회한다. Endpoint가 없으면 다음 메서드로 가고 있으면 접근/인수/반환/경로를 검사한다. 검사 문제는 모두 Issue로 모으고 정상 후보만 putIfAbsent한다. 기존 키가 있으면 중복 Issue 한 개를 모은다. 검사와 등록 뒤 다음 메서드로 돌아간다. 전체 순회 뒤 Issue가 있으면 정렬한 Invalid만 반환하고 없으면 Compiled Map을 공개한다. 화살표는 스캐너의 제어 순서이며 Handler 본문은 호출하지 않는다.
원 도표의 설명과 주석
구분내용
설명모든 controller의 declared 메서드를 순회한다. Endpoint가 없으면 다음 메서드로 가고 있으면 접근/인수/반환/경로를 검사한다. 검사 문제는 모두 Issue로 모으고 정상 후보만 putIfAbsent한다. 기존 키가 있으면 중복 Issue 한 개를 모은다. 검사와 등록 뒤 다음 메서드로 돌아간다. 전체 순회 뒤 Issue가 있으면 정렬한 Invalid만 반환하고 없으면 Compiled Map을 공개한다. 화살표는 스캐너의 제어 순서이며 Handler 본문은 호출하지 않는다.
전체 읽기 정보연습 문제의 메서드별 검사 결과 연습 문제의 메서드별 검사 결과 연습 문제의 메서드별 검사 결과 라우트 스키마 · 오류 집계 뒤 공개 결정 연습 문제의 메서드별 검사 결과 good은 후보로 등록되지만 broken의 네 문제와 duplicate의 한 문제를 모으면 Invalid가 됩니다. 중간 처리기 Map은 공개하지 않습니다. 정상 메서드 good 수집되는 Issue 없음 처리기 후보 /good 등록 잘못된 메서드 broken 수집되는 Issue 비공개·매개변수 있음·String 반환 아님·슬래시 경로 아님: 4개 처리기 후보 등록 안 함 중복 메서드 duplicate 수집되는 Issue duplicate path /good: 1개 처리기 후보 기존 good 유지 원문 main의 두 컨트롤러를 코드로 추적한 결과입니다. Issue 5개를 정렬해 Invalid로 반환하므로 중간 Map은 공개되지 않으며 메서드 본문도 호출하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
메서드 선택과 검사

declared 메서드 중 Endpoint가 있는 메서드만 접근·인수·반환·경로를 검사합니다.

  • Endpoint 없음: 다음 메서드
  • 검사 오류: 모두 Issue 수집
  • 문제가 있는 메서드는 정상 후보 등록에 들어가지 않습니다.
후보 키 등록

정상 후보만 putIfAbsent하고 기존 키면 중복 Issue를 모읍니다.

  • 후보 Map: 내부에만 보관
  • 다음 동작: 다음 메서드
  • 중복 한 건도 전체 공개 판단에 반영됩니다.
전체 공개 판단

모든 메서드 뒤 Issue 유무를 검사합니다.

  • Issue 있음: 정렬된 Invalid, 후보 미공개
  • Issue 없음: 전체 Compiled Map
  • 정상 후보가 있어도 오류가 하나 남으면 공개하지 않습니다.

확장 과제로, 라우트 키에 HTTP 메서드까지 넣어 보세요. 그러면 경로가 같더라도 GET과 POST처럼 메서드가 다른 두 라우트는 함께 등록되고, 메서드와 경로가 모두 같을 때만 중복이 되어야 합니다.

확인 문제

  1. 풀이의 compile에 같은 GoodController 인스턴스를 두 번 넣어 compile(List.of(good, good))처럼 호출하면 결과는 무엇일까요?
  2. 상위 클래스 Parent에 @Endpoint("/parent")가 붙은 public String parent() 메서드가 있고, Child는 Parent를 상속할 뿐 메서드를 하나도 선언하지 않았다고 합시다. compile(List.of(new Child()))의 결과는 무엇일까요? 둘 다 이 문제를 위해 새로 만든 클래스이고, 풀이의 다른 클래스는 그대로입니다.
해설
  1. 결과는 Invalid이고 문제는 하나입니다.

    Invalid 1 issue(s)
      Issue[owner=GoodController, method=good, detail=duplicate path /good]

    compile은 목록의 항목마다 클래스를 다시 훑으므로, 두 번째 항목에서 good()을 만났을 때 /good이 이미 등록되어 있어 중복이 됩니다. 같은 객체라고 알아서 건너뛰지 않고, 정상 후보가 하나 남아 있다고 해서 일부 라우트만 공개하지도 않습니다.

  2. 결과는 라우트가 하나도 없는 Compiled이고, 출력은 Compiled 0 route(s)입니다. getDeclaredMethods()는 상속받은 메서드를 돌려주지 않으므로(Class.getDeclaredMethods의 공식 문서), Child에서는 @Endpoint가 붙은 메서드를 하나도 찾지 못합니다. 자바 코드에서 child.parent()를 호출할 수 있다는 사실과 이 스캐너가 그 메서드를 라우트로 찾는다는 사실은 별개입니다. 상속받은 라우트까지 찾으려면 상위 클래스를 따라 올라가며 훑도록 조회 범위를 바꾸고, 재정의와 중복을 어떻게 처리할지도 함께 정해야 합니다.

정리

  • 애노테이션은 선언에 구조화된 정보를 붙일 뿐이고, 실제 동작은 그 정보를 읽는 스캐너와 실행기가 만듭니다.
  • 리플렉션으로 읽으려면 @Retention(RetentionPolicy.RUNTIME)이 필요합니다. 빠지면 오류 없이 null만 돌아옵니다. @Target은 애노테이션을 붙일 수 있는 위치를 제한합니다.
  • 요소의 타입, 필수 요소의 누락, 붙이는 위치는 컴파일러가 검사합니다. 경로 형식이나 중복처럼 문법으로 표현할 수 없는 규칙은 스캐너가 시작할 때 검사합니다.
  • 노출할 메서드에만 애노테이션을 붙이게 하고, 문제는 모두 모아 서버 시작 단계에서 보고합니다. 문제가 하나라도 있으면 일부 라우트만 공개하지 않습니다.
  • 기본값은 애노테이션을 읽는 시점에 정의에서 가져오므로, 기본값을 바꾸면 이미 컴파일된 클래스의 동작도 바뀝니다.

다음 글

애노테이션 요소의 타입과 기본값에서는 애노테이션 요소에 쓸 수 있는 타입, 필수 요소와 기본값, value 요소의 축약 표기와 배열 표기 규칙을 살펴봅니다. 이 글의 @Audited처럼 기본값이 있는 요소를 설계할 때 무엇을 조심해야 하는지도 함께 다룹니다.