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

안동민 개발노트

본문 시작
29장 : 애노테이션 HTTP 실행기

매개변수 해석기

컨트롤러가 필요한 값만 선언하도록 매개변수별 인수 해석기를 구성하고, 라우트 조회와 바인딩 계획을 시작 시 확정해 요청 경로의 리플렉션 탐색을 제거합니다.

모든 컨트롤러 메서드에 (Request, Response)를 강제하면 실행기는 단순하지만 작은 처리기도 사용하지 않는 객체를 받아야 합니다. 반대로 리플렉션이 알아서 무엇이든 넣어 준다고 생각하면 지원 타입, 누락 값, 변환 실패를 언제 발견하는지 불명확해집니다. 동적 바인딩은 마법이 아니라 각 매개변수를 어떤 규칙으로 해석할지 정한 작은 컴파일러 문제입니다.

좋은 실행기는 두 시점을 분리합니다. 서버 시작 때 Parameter 메타데이터를 읽고 각 위치를 담당할 해석기를 고릅니다. 요청이 들어오면 이미 선택된 해석기가 현재 요청 문맥에서 실제 값을 만듭니다. 지원되지 않는 매개변수는 부팅을 실패시키고, 잘못된 사용자 값은 400 계열 결과로 분류합니다. 이 구분이 있어야 편리한 컨트롤러 시그니처와 예측 가능한 오류가 함께 존재합니다.

Object 배열의 타입 손실

다음 방식은 매개변수가 두 개면 무조건 요청과 응답을 넣습니다. 컨트롤러가 순서를 바꾸거나 String을 추가하면 리플렉션 호출에서야 실패합니다. 분기는 짧지만 시그니처 지원 규칙이 코드에 표현되지 않았고, 오류 메시지도 어떤 매개변수를 해결하지 못했는지 알려 주지 못합니다.

bad/PositionalArgumentBinder.java
import java.lang.reflect.Method;

public final class PositionalArgumentBinder {
    record Request(String path) {
    }

    static final class Response {
    }

    static Object invoke(Object controller, Method method, Request request, Response response)
            throws Exception {
        if (method.getParameterCount() == 0) {
            return method.invoke(controller);
        }
        if (method.getParameterCount() == 1) {
            return method.invoke(controller, request);
        }
        return method.invoke(controller, request, response);
    }

    public static void main(String[] args) {
        System.out.println("compile-only positional binding counterexample");
    }
}

해석기는 supports(Parameter)resolve(Parameter, RequestContext) 두 질문에 답합니다. 첫 메서드는 시작 시점 컴파일러가 사용하고 두 번째 메서드는 요청 실행기가 사용합니다. 두 해석기가 같은 매개변수를 지원하면 선택 순서가 암묵적 우선순위가 됩니다. 따라서 정확히 하나만 일치하도록 하거나 명시적인 우선순위와 충돌 검사를 둬야 합니다.

애노테이션을 매개변수에 적용하면 같은 Java 타입도 다른 입력 원천으로 구별할 수 있습니다. 평범한 String만 보고 쿼리인지 헤더인지 경로 변수인지 판단할 수 없습니다. @Query("q") String keyword처럼 출처와 이름을 적으면 바인더가 계약을 읽고 누락·빈 값·변환 규칙을 적용할 수 있습니다.

ArgumentResolver 선택

다음 예제는 Request, Response, @Query String 세 종류를 지원합니다. compile()Method의 매개변수 순서대로 해석기를 찾아 ArgumentPlan 목록을 만듭니다. 일치 항목이 없거나 둘 이상이면 시작 오류입니다. 실행 시에는 리플렉션을 다시 탐색하지 않고 각 계획에 현재 문맥만 전달합니다.

src/ArgumentResolverDispatcher.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.Parameter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public final class ArgumentResolverDispatcher {
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.PARAMETER)
    @interface Query {
        String value();

        boolean required() default true;
    }

    record Request(String path, Map<String, String> query) {
    }

    static final class Response {
        private final StringBuilder body = new StringBuilder();

        void write(String value) {
            body.append(value);
        }

        String body() {
            return body.toString();
        }
    }

    record RequestContext(Request request, Response response) {
    }

    interface ArgumentResolver {
        boolean supports(Parameter parameter);

        Object resolve(Parameter parameter, RequestContext context);
    }

    static final class RequestResolver implements ArgumentResolver {
        @Override
        public boolean supports(Parameter parameter) {
            return parameter.getType() == Request.class;
        }

        @Override
        public Object resolve(Parameter parameter, RequestContext context) {
            return context.request();
        }
    }

    static final class ResponseResolver implements ArgumentResolver {
        @Override
        public boolean supports(Parameter parameter) {
            return parameter.getType() == Response.class;
        }

        @Override
        public Object resolve(Parameter parameter, RequestContext context) {
            return context.response();
        }
    }

    static final class QueryResolver implements ArgumentResolver {
        @Override
        public boolean supports(Parameter parameter) {
            return parameter.getType() == String.class
                    && parameter.isAnnotationPresent(Query.class);
        }

        @Override
        public Object resolve(Parameter parameter, RequestContext context) {
            Query query = parameter.getAnnotation(Query.class);
            String value = context.request().query().get(query.value());
            if (value == null && query.required()) {
                throw new BindingException("missing query parameter: " + query.value());
            }
            return value;
        }
    }

    static final class BindingException extends RuntimeException {
        BindingException(String message) {
            super(message);
        }
    }

    record ArgumentPlan(Parameter parameter, ArgumentResolver resolver) {
        Object resolve(RequestContext context) {
            return resolver.resolve(parameter, context);
        }
    }

    record HandlerPlan(Object target, Method method, List<ArgumentPlan> arguments) {
        HandlerPlan {
            arguments = List.copyOf(arguments);
        }

        Object invoke(RequestContext context) {
            Object[] values = arguments.stream()
                    .map(argument -> argument.resolve(context))
                    .toArray();
            try {
                return method.invoke(target, values);
            } catch (IllegalAccessException exception) {
                throw new IllegalStateException("handler is inaccessible", exception);
            } catch (InvocationTargetException exception) {
                Throwable cause = exception.getCause();
                if (cause instanceof RuntimeException runtime) {
                    throw runtime;
                }
                throw new IllegalStateException("checked handler failure", cause);
            }
        }
    }

    static HandlerPlan compile(Object target, String methodName) throws NoSuchMethodException {
        Method method = List.of(target.getClass().getDeclaredMethods()).stream()
                .filter(candidate -> candidate.getName().equals(methodName))
                .findFirst()
                .orElseThrow(() -> new NoSuchMethodException(methodName));
        List<ArgumentResolver> resolvers = List.of(
                new RequestResolver(),
                new ResponseResolver(),
                new QueryResolver());
        List<ArgumentPlan> arguments = new ArrayList<>();
        for (Parameter parameter : method.getParameters()) {
            List<ArgumentResolver> matches = resolvers.stream()
                    .filter(resolver -> resolver.supports(parameter))
                    .toList();
            if (matches.size() != 1) {
                throw new IllegalArgumentException(
                        "expected one resolver for " + parameter + ", got " + matches.size());
            }
            arguments.add(new ArgumentPlan(parameter, matches.getFirst()));
        }
        return new HandlerPlan(target, method, arguments);
    }

    static final class SearchController {
        public void search(@Query("q") String keyword, Response response, Request request) {
            response.write(request.path() + " result=" + keyword);
        }
    }

    public static void main(String[] args) throws Exception {
        HandlerPlan plan = compile(new SearchController(), "search");
        Response response = new Response();
        plan.invoke(new RequestContext(
                new Request("/search", Map.of("q", "reflection")),
                response));
        System.out.println(response.body());
        System.out.println(plan.arguments().stream()
                .map(argument -> argument.resolver().getClass().getSimpleName())
                .toList());
    }
}

컨트롤러 매개변수 순서는 자유롭지만 같은 타입을 두 번 선언하는 것이 언제나 의미 있는 것은 아닙니다. RequestResponse는 하나씩만 허용할 수 있고, 여러 쿼리 매개변수는 애노테이션의 이름으로 구분할 수 있습니다. 컴파일러는 해석기 선택뿐 아니라 중복 매개변수, 기본형에 선택적 값을 넣는 문제, 애노테이션과 타입의 부조화까지 점검할 수 있습니다.

값 변환은 입력 경계의 책임입니다. @Query("page") int page를 지원한다면 NumberFormatException을 그대로 노출하지 말고 매개변수 이름, 거절한 값, 기대 형식을 담은 BindingException으로 바꿉니다. 비밀번호나 토큰처럼 민감한 입력은 오류 메시지와 로그에서 값을 가려야 합니다.

사전 실행 계획

성능 개선은 HashMap 하나로 끝나지 않습니다. 요청마다 컨트롤러 메서드를 훑지 않는 것과 매개변수마다 해석기 목록을 재탐색하지 않는 것이 함께 필요합니다. 시작 단계의 비용은 라우트 수와 매개변수 수에 비례해 한 번 발생합니다. 이후 요청은 라우트 키 조회, 미리 정한 해석기 실행, Method.invoke 순서로 고정됩니다.

다음 프로그램은 시작 시점 컴파일러가 검사 횟수를 기록하고, 여러 요청이 들어와도 그 값이 증가하지 않음을 보여 줍니다. 라우트 표는 생성자에서 완성한 뒤 Map.copyOf()로 게시합니다. Map 안의 HandlerPlan도 불변 record이므로 별도 잠금 없이 여러 요청 스레드가 읽을 수 있습니다.

src/PrecompiledRouteRuntime.java
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

public final class PrecompiledRouteRuntime {
    record Request(String path, Map<String, String> query) {
    }

    record HandlerPlan(Object target, Method method, String queryName) {
        String invoke(Request request) {
            try {
                return (String) method.invoke(target, request.query().get(queryName));
            } catch (IllegalAccessException exception) {
                throw new IllegalStateException(exception);
            } catch (InvocationTargetException exception) {
                throw new IllegalStateException(exception.getCause());
            }
        }
    }

    static final class Compiler {
        private final AtomicInteger inspectedMethods = new AtomicInteger();

        Map<String, HandlerPlan> compile(Object controller) {
            Map<String, HandlerPlan> result = new LinkedHashMap<>();
            for (Method method : controller.getClass().getDeclaredMethods()) {
                inspectedMethods.incrementAndGet();
                if (method.getName().equals("search")) {
                    result.put("/search", new HandlerPlan(controller, method, "q"));
                }
            }
            return Map.copyOf(result);
        }

        int inspectedMethods() {
            return inspectedMethods.get();
        }
    }

    static final class Controller {
        public String search(String keyword) {
            return "found:" + keyword;
        }

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

    public static void main(String[] args) {
        Compiler compiler = new Compiler();
        Map<String, HandlerPlan> routes = compiler.compile(new Controller());
        List<Request> requests = List.of(
                new Request("/search", Map.of("q", "java")),
                new Request("/search", Map.of("q", "http")),
                new Request("/search", Map.of("q", "lambda")));
        for (Request request : requests) {
            System.out.println(routes.get(request.path()).invoke(request));
        }
        System.out.println("startupInspections=" + compiler.inspectedMethods());
        System.out.println("requestLookups=" + requests.size());
    }
}

평균 O(1) 조회는 충돌이 적절히 관리되는 HashMap의 일반적 특성이지 무조건 한 번의 CPU 명령이라는 뜻은 아닙니다. 라우트 표가 수십 개라면 리플렉션 검사의 절대 비용이 작을 수도 있습니다. 그래도 시작 시점 계획 생성은 성능보다 더 중요한 이점을 줍니다. 중복과 시그니처 오류를 요청을 받기 전에 찾아 실행 경로를 단순하게 만들고, 그 결과를 읽기 전용으로 공유합니다.

바인딩·컨트롤러 오류

인수 해석기가 필수 쿼리 누락을 발견한 사건은 사용자 요청 문제입니다. 컨트롤러가 도메인 규칙을 위반했다고 알리는 사건도 예측 가능한 업무 결과일 수 있습니다. 반면 NullPointerException이나 저장소 연결 장애는 서버 내부 실패입니다. 리플렉션 래퍼만 보고 모두 500으로 만들면 이 구분을 잃습니다.

실행 경계는 네 단계를 따를 수 있습니다. 먼저 계획을 조회하고, 해석기가 값을 구성하며, 컨트롤러를 호출하고, 반환이나 예외를 HTTP 응답으로 번역합니다. 각 단계가 자체 예외를 갖게 하면 404, 400, 409, 500이 어디에서 결정됐는지 추적할 수 있습니다. 이미 응답 본문을 일부 쓴 뒤 실패하면 상태를 바꾸기 어려우므로 컨트롤러 결과를 값으로 반환한 뒤 마지막에 응답을 커밋하는 방식도 유용합니다.

연습 문제

@Query("page") int page를 지원하는 해석기와 처리기 실행기를 작성합니다. page가 없거나 정수가 아니면 400 결과를 반환하고, 컨트롤러가 ConflictException을 던지면 409로 바꿉니다. 리플렉션이 만든 InvocationTargetException은 외부 분류에 노출하지 않습니다.

해답 보기
src/InvocationFailureBoundarySolution.java
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;

public final class InvocationFailureBoundarySolution {
    record Request(Map<String, String> query) {
    }

    record HttpResult(int status, String body) {
    }

    static final class BindingException extends RuntimeException {
        BindingException(String message) {
            super(message);
        }
    }

    static final class ConflictException extends RuntimeException {
        ConflictException(String message) {
            super(message);
        }
    }

    static int requiredInt(Request request, String name) {
        String value = request.query().get(name);
        if (value == null) {
            throw new BindingException("missing parameter: " + name);
        }
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException exception) {
            throw new BindingException("parameter " + name + " must be an integer");
        }
    }

    static HttpResult execute(Object target, Method method, Request request) {
        try {
            int page = requiredInt(request, "page");
            String body = (String) method.invoke(target, page);
            return new HttpResult(200, body);
        } catch (BindingException exception) {
            return new HttpResult(400, exception.getMessage());
        } catch (IllegalAccessException exception) {
            return new HttpResult(500, "inaccessible handler plan");
        } catch (InvocationTargetException exception) {
            Throwable cause = exception.getCause();
            if (cause instanceof ConflictException conflict) {
                return new HttpResult(409, conflict.getMessage());
            }
            return new HttpResult(500, "controller failure");
        }
    }

    static final class PageController {
        public String page(int number) {
            if (number == 9) {
                throw new ConflictException("page is being rebuilt");
            }
            return "page=" + number;
        }
    }

    public static void main(String[] args) throws Exception {
        PageController controller = new PageController();
        Method method = PageController.class.getMethod("page", int.class);
        System.out.println(execute(controller, method, new Request(Map.of("page", "2"))));
        System.out.println(execute(controller, method, new Request(Map.of("page", "two"))));
        System.out.println(execute(controller, method, new Request(Map.of("page", "9"))));
    }
}

해답은 바인딩을 리플렉션 호출보다 앞에서 수행하고, 컨트롤러 예외는 원인을 꺼내 분류합니다. 실제 프레임워크라면 예외-응답 매퍼도 레지스트리로 분리해 새로운 도메인 예외를 실행기의 조건문 수정 없이 추가할 수 있습니다.