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

안동민 개발노트

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

런타임 애노테이션

메서드 이름과 외부 맵에 흩어진 설정을 실행 시점 애노테이션으로 결합하고 라우트 탐색 시 시그니처·중복·가시성을 검증해 실행 메타데이터를 만듭니다.

리플렉션만으로 /site1site1() 메서드에 연결할 수 있지만 이름 우연에 의존합니다. Java 이름을 리팩터링하면 URL도 뜻하지 않게 바뀌고, /members/{id}처럼 메서드 이름으로 표현하기 어려운 경로가 생깁니다. 외부 Map에 경로와 메서드 이름을 등록하면 선언과 설정이 떨어져 있어 둘 중 하나만 수정하기 쉽습니다.

애노테이션은 프로그램 요소에 구조화된 메타데이터를 붙입니다. @Route(method="GET", path="/members")처럼 메서드 바로 위에 HTTP 의미를 선언하면 컴파일러가 요소 타입과 필수 값을 검사하고, 시작 시점 탐색이 리플렉션으로 읽어 실행 테이블을 만들 수 있습니다. 애노테이션 자체가 동작을 수행하는 것은 아니며 이를 해석하는 스캐너와 실행기가 있어야 합니다.

문자열 기반 등록

다음 코드는 오타가 있어도 컴파일됩니다. 클래스나 메서드를 이름 변경해도 설정 문자열은 자동으로 바뀌지 않으며, 같은 라우트가 두 번 등록되면 마지막 값이 앞의 설정을 덮어쓸 수 있습니다. 매개변수 시그니처도 실행할 때까지 확인되지 않습니다.

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) {
        System.out.println("compile-only string metadata counterexample");
    }
}

애노테이션을 도입해도 스캐너가 마지막 항목 덮어쓰기를 허용하면 같은 문제가 남습니다. 선언 편의와 실행 안전은 별개입니다. 탐색 단계에서 라우트 키 중복, 메서드 시그니처, 반환 타입, 접근 수준을 모두 검증하고 오류가 하나라도 있으면 서버 시작을 실패시킵니다. 잘못된 라우트를 첫 요청 때 발견하는 것보다 배포 직후 명확한 보고를 얻는 편이 낫습니다.

어떤 메서드에 애노테이션이 없으면 라우트가 아니라 일반 도우미로 간주합니다. 모든 public 메서드를 자동 노출하는 관례은 toString, 관리 메서드, 나중에 추가된 유틸리티가 원격 엔드포인트가 되는 위험을 만듭니다. 명시적 사용 메타데이터가 공격 표면을 줄입니다.

Route 실행 테이블

아래 예제는 RUNTIME 유지 정책과 METHOD 대상을 가진 @Route를 정의합니다. 스캐너는 현재 클래스의 선언된 메서드만 보고, 애노테이션이 있는 public 무인수 String 반환 메서드만 허용합니다. 라우트 키는 정규화한 메서드와 경로로 만들고 중복을 즉시 거절합니다.

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.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.forEach((key, handler) ->
                System.out.println(key + " -> " + handler.invoke()));
        System.out.println("routeCount=" + routes.size());
    }
}

애노테이션 인스턴스는 컴파일러가 생성한 구현으로 method(), path(), annotationType() 값을 제공합니다. 스캐너는 이 값을 일반 설정처럼 검증해야 합니다. 경로가 슬래시로 시작한다는 규칙이나 지원 HTTP 메서드 목록은 애노테이션 문법만으로 완전히 표현되지 않기 때문입니다.

현재 예제는 컨트롤러 인스턴스 하나를 각 Handler가 참조합니다. 컨트롤러가 가변 필드를 가진다면 여러 요청 스레드에서 공유되는 수명을 고려해야 합니다. 무상태 싱글턴, 요청별 인스턴스, 동기화된 상태 중 하나를 컨테이너 계약으로 정합니다. 메타데이터 탐색이 스레드 안전성을 자동 제공하지 않습니다.

마커·값 애노테이션

요소가 없는 마커 애노테이션은 @InternalOnly, @Audited처럼 분류 자체가 의미일 때 적합합니다. 실행 설정이 필요하다면 요소를 둡니다. 단순 boolean 옵션을 여러 개 늘리면 조합 의미가 모호해질 수 있어 enum 정책나 별도 애노테이션으로 표현하는 편이 낫습니다.

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.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(java.util.Comparator.comparing(MethodPolicy::method))
                .toList();
    }

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

event에는 기본값이 없어 사용자가 반드시 지정하고, level은 흔한 BASIC을 기본값으로 둡니다. 기본값은 편리하지만 시간이 지나 의미가 바뀌어도 기존 클래스 파일에는 값이 명시되지 않았다는 점을 기억합니다. 애노테이션 처리기나 시작 시점 감사로 중요한 보안 설정은 명시값을 요구할 수 있습니다.

마커를 권한 보장의 유일한 장치로 쓰지 않습니다. 스캐너가 애노테이션을 발견해 인터셉터를 실제 연결해야 하며, 우회 호출 경로가 없는지 아키텍처 테스트를 둡니다. 선언과 실행 연결이 끊기면 장식처럼 남은 메타데이터는 아무 보호도 하지 못합니다.

탐색 오류 집계

첫 잘못된 메서드에서 즉시 throw하면 개발자는 오류를 하나씩 고치며 서버를 여러 번 재시작해야 합니다. 스캐너가 모든 컨트롤러를 훑어 잘못된 시그니처, 중복 라우트, 빈 경로, 비공개 메서드를 DiscoveryIssue 목록으로 모은 뒤 하나의 시작 시점 보고서를 만들 수 있습니다. 요청 처리는 문제가 0일 때 만든 불변 처리기 표만 사용합니다.

발견 항목시작 시점 판정요청 시 동작수정 위치
정상 라우트Handler 생성직접 invoke없음
중복 메서드·경로오류서버 시작 안 함라우트 선언
시그니처 불일치오류노출 금지컨트롤러 메서드
애노테이션 없음무시라우트 아님의도 확인
RUNTIME 아님오류 또는 미발견라우트 없음애노테이션 정의

연습 문제

두 컨트롤러 클래스를 받아 @Endpoint 메서드를 스캔합니다. public, 매개변수 없음, String 반환, 슬래시 경로, 라우트 중복 없음 규칙을 검사합니다. 오류가 있으면 Handler Map을 만들지 말고 모든 Issue를 정렬해 반환합니다.

성공과 실패를 sealed 결과로 나누는 풀이

메서드 invoke까지 수행하지 않고 탐색 산출물만 만듭니다. 오류가 없는 경우에만 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.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(java.util.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;
    }

    public static void main(String[] args) {
        Result result = compile(List.of(
                new GoodController(),
                new BrokenController()));
        System.out.println(result);
    }
}

중복 경로를 발견했을 때 기존 정상 HandlerMap에 남겨도 최종 결과는 Invalid이므로 사용되지 않습니다. 확장 과제로 메서드까지 키에 포함하고, 경로가 같지만 GETPOST가 다른 경우에는 허용되도록 바꿉니다.

도입 완료 기준

선언과 스캐너를 함께 테스트해야 합니다. 애노테이션이 없는 도우미가 노출되지 않고, 이름 변경과 무관하게 명시 경로가 유지되며, 중복과 잘못된 시그니처가 첫 요청 전에 발견되어야 합니다. RUNTIME 유지 정책이 빠진 동일 정의를 만들어 스캐너에서 보이지 않는다는 사실도 확인합니다.

애노테이션은 문자열 설정을 타입 있는 선언으로 옮기지만 값 검증과 실행 연결까지 자동화하지 않습니다. 다음 문서에서는 애노테이션 원소에 허용되는 타입, 필수값과 기본값, value 축약, 배열 표기 규칙을 살펴보고 오래 유지될 메타데이터 API로 설계합니다.