본문 바로가기

인수 해석기로 컨트롤러 매개변수 채우기

컨트롤러가 필요한 값만 매개변수로 선언하면, 서버가 시작할 때 매개변수마다 값을 만들 해석기를 정해 두고 요청이 오면 그 계획대로 인수를 채워 호출하는 방법을 배웁니다.

앞 글의 실행기는 모든 컨트롤러 메서드가 Request와 Response를 이 순서로 받도록 정했습니다. 덕분에 실행기는 단순해졌지만, 검색어 하나만 필요한 메서드도 쓰지 않는 객체까지 받아야 하고, 쿼리 문자열에서 값을 꺼내는 코드를 메서드마다 되풀이해야 합니다. Spring MVC 같은 프레임워크에서는 search(@RequestParam("q") String keyword)처럼 필요한 값만 매개변수로 선언하면 프레임워크가 알아서 채워 주는데, 이 동작은 요청 파라미터 바인딩에서 다룹니다. 이 글에서는 그런 동작을 직접 만들어 보면서, 그것이 마법이 아니라 매개변수마다 정해 둔 규칙의 결과라는 것을 확인합니다.

메서드를 호출할 때 실제로 넘기는 값을 인수(argument)라고 하고, 요청에 담긴 값을 매개변수에 맞는 인수로 만들어 넣는 일을 바인딩(binding)이라고 합니다. 바인딩을 잘 만드는 핵심은 일을 두 시점으로 나누는 것입니다. 서버가 시작할 때는 매개변수마다 타입과 애노테이션을 읽어 그 자리를 무엇으로 채울지 정해 둡니다. 요청이 오면 미리 정해 둔 방법대로 현재 요청에서 값을 꺼내기만 합니다. 이렇게 나누면 채울 방법이 없는 매개변수는 서버를 시작할 때 오류가 되고, 사용자가 잘못된 값을 보낸 경우는 요청을 처리하면서 400 Bad Request로 돌려줄 수 있습니다.

매개변수 개수로 인수를 추측하면 생기는 일

가장 단순한 방법은 매개변수 개수를 보고 무엇을 넣을지 정하는 것입니다. 아래 invoke는 매개변수가 없으면 아무것도 넣지 않고, 하나면 요청을, 둘이면 요청과 응답을 차례로 넣습니다. MemberController의 list는 이 규칙대로 Request, Response 순서로 선언했지만, show는 같은 두 타입을 Response, Request 순서로 선언했습니다.

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);
    }

    static final class MemberController {
        public String list(Request request, Response response) {
            return "list " + request.path();
        }

        public String show(Response response, Request request) {
            return "show " + request.path();
        }
    }

    public static void main(String[] args) throws Exception {
        MemberController controller = new MemberController();
        Request request = new Request("/members");
        Response response = new Response();

        Method list = MemberController.class.getMethod("list", Request.class, Response.class);
        System.out.println(invoke(controller, list, request, response));

        Method show = MemberController.class.getMethod("show", Response.class, Request.class);
        try {
            invoke(controller, show, request, response);
        } catch (IllegalArgumentException exception) {
            System.out.println("show failed: " + exception.getMessage());
        }
    }
}
실행 결과
list /members
show failed: argument type mismatch

show도 매개변수가 둘이므로 invoke는 요청과 응답을 차례로 넣습니다. 그런데 show의 첫 매개변수 타입은 Response라서 Request 객체를 받을 수 없고, Method.invoke는 이렇게 인수의 타입이 맞지 않으면 IllegalArgumentException을 던집니다. 이 오류는 show가 실제로 요청을 받은 순간에야 나타나고, 메시지도 몇 번째 매개변수가 왜 맞지 않는지 알려 주지 않습니다. 메시지 문구는 JDK 버전에 따라 조금 다를 수 있습니다.

매개변수 순서를 바꾸거나 String 매개변수를 하나 추가하는 평범한 수정이 이렇게 실행 중 오류로 이어지는 것은, 어떤 매개변수를 지원하는지에 대한 규칙이 코드 어디에도 드러나 있지 않기 때문입니다. 실행기는 매개변수 개수만 보고 나머지는 운에 맡기고 있는 셈입니다.

매개변수마다 해석기를 고르는 방식

이 문제를 풀려면 매개변수를 하나씩 보고 그 자리에 무엇을 넣을지 판단하는 규칙이 따로 있어야 합니다. 이 규칙을 담은 객체를 인수 해석기(argument resolver)라고 부르겠습니다. 해석기는 두 가지 질문에 답합니다. supports(Parameter)는 이 매개변수를 자기가 채울 수 있는지 답하고, resolve(Parameter, RequestContext)는 현재 요청에서 실제 값을 만들어 돌려줍니다. 여기서 Parameter는 리플렉션이 매개변수 하나를 나타낼 때 쓰는 타입으로, 매개변수의 타입과 그 매개변수에 붙은 애노테이션을 알려 줍니다.

두 질문은 쓰이는 시점이 다릅니다. supports는 서버가 시작할 때 라우트를 준비하면서 매개변수마다 부르고, resolve는 요청이 올 때마다 부릅니다. 요청 때는 어느 해석기를 쓸지 이미 정해져 있으므로 후보를 다시 찾지 않습니다. 한 매개변수를 지원하는 해석기가 둘 이상이라면 어느 쪽을 쓸지가 목록 순서 같은 우연한 조건으로 정해지는데, 이런 숨은 우선순위는 코드만 봐서는 알기 어렵습니다. 그래서 이 글의 실행기는 지원하는 해석기가 정확히 하나일 때만 계획을 만들고, 하나도 없거나 둘 이상이면 시작을 실패시킵니다. 해석기끼리 겹치는 것을 허용하려면 우선순위를 명시하고 충돌을 검사하는 장치가 따로 필요합니다.

타입만으로 구분할 수 없을 때는 매개변수에 애노테이션을 붙입니다. 매개변수 타입이 String이라는 사실만으로는 값을 쿼리 문자열에서 꺼낼지, 헤더에서 꺼낼지, 경로의 일부에서 꺼낼지 알 수 없습니다. @Query("q") String keyword처럼 값의 출처와 이름을 적어 두면, 해석기는 이 애노테이션을 보고 쿼리 문자열의 q 값을 넣으면 된다는 것을 압니다. 값이 없을 때 거절할지 같은 규칙도 애노테이션 요소로 함께 적을 수 있습니다.

해석기로 search 메서드 호출하기

아래 예제는 Request, Response, 그리고 @Query가 붙은 String, 이렇게 세 종류의 매개변수를 지원합니다. compile()은 메서드의 매개변수를 선언 순서대로 보면서 각각을 지원하는 해석기를 찾아 ArgumentPlan 목록을 만들고, 이 목록을 메서드와 함께 HandlerPlan에 담습니다. 요청이 오면 HandlerPlan.invoke가 계획을 순서대로 실행해 인수 배열을 만들고 메서드를 호출합니다. main의 마지막 부분은 지원하는 해석기가 없는 매개변수를 가진 컨트롤러로 계획을 만들어 봅니다.

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);
        }
    }

    static final class PageController {
        public void page(int number, Response response) {
            response.write("page=" + number);
        }
    }

    public static void main(String[] args) throws Exception {
        HandlerPlan plan = compile(new SearchController(), "search");
        System.out.println(plan.arguments().stream()
                .map(argument -> argument.resolver().getClass().getSimpleName())
                .toList());

        Response response = new Response();
        plan.invoke(new RequestContext(
                new Request("/search", Map.of("q", "reflection")),
                response));
        System.out.println(response.body());

        try {
            compile(new PageController(), "page");
        } catch (IllegalArgumentException exception) {
            System.out.println("startup failed: " + exception.getMessage());
        }
    }
}
실행 결과
[QueryResolver, ResponseResolver, RequestResolver]
/search result=reflection
startup failed: expected one resolver for int arg0, got 0

첫 줄은 search의 매개변수 세 개에 각각 고른 해석기입니다. search를 @Query("q") String keyword, Response response, Request request 순서로 선언했으므로 계획도 같은 순서로 QueryResolver, ResponseResolver, RequestResolver가 됩니다. 해석기를 고르는 기준은 위치가 아니라 타입과 애노테이션이라서, 앞 절의 show처럼 매개변수 순서를 바꿔도 계획이 그 순서에 맞춰 만들어집니다.

search의 매개변수 위치별 인수 계획

search의 매개변수 위치별 인수 계획

인수 바인딩

search의 매개변수 위치별 인수 계획

선언된 매개변수의 순서대로 해석기를 선택하고, 요청 문맥에서 같은 순서의 인수 배열을 만듭니다.

  1. @Query("q") String

    선택된 해석기
    QueryResolver
    이 문맥의 값
    reflection
  2. Response

    선택된 해석기
    ResponseResolver
    이 문맥의 값
    현재 응답 객체
  3. Request

    선택된 해석기
    RequestResolver
    이 문맥의 값
    현재 요청 · /search

위치 표시는 선언 순서입니다. 각각의 매개변수를 지원하는 해석기가 정확히 하나여야 계획을 만들 수 있습니다.

둘째 줄은 요청 문맥으로 계획을 실행한 결과입니다. 그림처럼 첫 자리에는 쿼리 q의 값 "reflection"이, 둘째 자리에는 현재 응답 객체가, 셋째 자리에는 경로가 /search인 현재 요청이 들어갑니다. search는 요청 경로와 검색어를 응답 본문에 씁니다. 이 과정에서 실행기는 메서드 목록을 훑지도, 해석기 후보를 다시 고르지도 않습니다.

셋째 줄은 PageController로 계획을 만들려다 난 오류입니다. page(int number, Response response)의 첫 매개변수는 int인데, 세 해석기 가운데 int를 지원하는 것이 없어 matches.size()가 0이 됩니다. 이 예외는 컨트롤러가 요청을 하나도 받기 전에 나므로, 실제 서버라면 시작 단계에서 멈췄을 것입니다. 메시지에 매개변수 이름 number 대신 arg0이 찍힌 이유는 아래 더 깊이 절에서 설명합니다.

이 compile()에는 아직 빈 곳이 있습니다. 이름이 일치하는 첫 메서드를 고르므로 같은 이름의 오버로드가 있으면 어느 것이 선택될지 getDeclaredMethods()의 순서에 달려 있고, 메서드의 접근 가능 여부와 반환 타입도 검사하지 않습니다. 실제 실행기라면 앞 글의 validateSignature()처럼 접근 제한자와 반환 타입을 검사하는 단계를 함께 두어야 합니다. 또 QueryResolver.resolve()는 요청마다 Parameter에서 @Query를 다시 읽습니다. 해석기 선택은 시작할 때 끝났지만 애노테이션 값까지 미리 꺼내 둔 것은 아니므로, 쿼리 이름과 required 값을 시작할 때 계획에 저장해 두면 요청을 처리할 때 하는 리플렉션 작업을 더 줄일 수 있습니다.

해석기 선택 말고도 시작할 때 점검할 수 있는 규칙이 있습니다. Request나 Response를 두 번 받는 매개변수 목록은 대개 실수이므로 거절할 수 있습니다. 쿼리 매개변수는 애노테이션의 이름으로 구분되므로 여러 개를 받아도 괜찮습니다. 나중에 int 같은 기본형 매개변수를 지원하게 되면, 값이 없어도 된다는 뜻의 required = false를 붙인 조합도 시작할 때 막는 편이 좋습니다. 값이 없을 때 해석기가 돌려줄 null을 기본형 매개변수는 받을 수 없어서, 그대로 두면 요청을 처리하다가 invoke가 IllegalArgumentException을 던지기 때문입니다.

더 깊이: 매개변수 이름이 arg0으로 나오는 이유

Parameter의 toString()은 매개변수의 타입과 이름을 보여 줍니다. 그런데 javac는 기본 설정에서 리플렉션이 읽을 수 있는 매개변수 이름을 클래스 파일에 남기지 않습니다. 이름이 남아 있지 않으면 Parameter.getName()은 argN 형태의 이름을 만들어 돌려주는데, N은 0부터 세는 매개변수의 위치입니다. 앞의 출력에 int arg0이 나온 것은 이 때문입니다. 같은 프로그램을 javac -parameters로 컴파일하면 매개변수 이름이 클래스 파일의 MethodParameters 속성에 기록되어, 셋째 줄이 startup failed: expected one resolver for int number, got 0으로 바뀝니다.

이 글의 @Query가 @Query("q")처럼 이름을 직접 받는 이유도 여기에 있습니다. 매개변수 이름을 그대로 쿼리 이름으로 쓰면 편리하지만, 빌드할 때 -parameters를 빠뜨리면 모든 이름이 arg0, arg1로 바뀌어 바인딩이 깨집니다. 이름을 생략할 수 있게 해 주는 프레임워크도 결국 컴파일러가 클래스 파일에 남긴 정보에 기대므로, 그런 프레임워크에서 매개변수 이름을 찾지 못한다는 오류를 만나면 먼저 빌드 설정을 확인해 봐야 합니다.

변환에 실패한 값은 400으로 돌려주기

@Query("page") int page처럼 문자열이 아닌 매개변수를 지원하려면 해석기가 문자열을 원하는 타입으로 변환해야 합니다. 이때 Integer.parseInt가 던지는 NumberFormatException을 그대로 밖으로 내보내면, 실행기는 그것이 사용자의 잘못된 입력인지 서버 코드의 버그인지 구분할 수 없어 500 Internal Server Error로 처리하기 쉽습니다. 그래서 해석기는 변환 실패를 잡아 매개변수 이름과 기대한 형식을 담은 BindingException으로 바꿔 던지고, 실행기는 이 예외를 400으로 바꿉니다.

오류 메시지에 거절한 값을 넣으면 원인을 찾기는 쉽지만, 비밀번호나 토큰처럼 민감한 값은 응답과 로그에서 가려야 합니다. 이 변환 과정은 글 끝의 연습 문제에서 직접 구현합니다.

시작할 때 계획을 세우면 요청 처리에서 사라지는 일

라우트 표와 인수 계획을 모두 시작할 때 만들어 두면, 요청 하나를 처리하는 과정은 세 단계로 고정됩니다. 라우트 키로 표에서 계획을 찾고, 미리 고른 해석기로 인수를 만들고, Method.invoke로 호출합니다. 메서드 목록을 훑거나 해석기 후보를 고르는 일은 라우트 수와 매개변수 수에 비례하는 만큼 시작할 때 한 번만 일어납니다.

아래 예제는 이 차이를 숫자로 확인합니다. 요청마다 메서드를 훑는 방식과 시작할 때 표를 만드는 방식에 같은 요청을 1,000번씩 보내고, 각 방식이 메서드를 몇 번 조사했는지 셉니다.

src/PrecompiledRouteRuntime.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.util.HashMap;
import java.util.Map;

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

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

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

    static int inspectedMethods = 0;

    static String call(Object target, Method method, String argument) {
        try {
            return (String) method.invoke(target, argument);
        } catch (IllegalAccessException exception) {
            throw new IllegalStateException(exception);
        } catch (InvocationTargetException exception) {
            throw new IllegalStateException(exception.getCause());
        }
    }

    // 방식 1: 요청이 올 때마다 메서드를 끝까지 훑는다
    static String scanAndCall(Object controller, String path, String argument) {
        Method found = null;
        for (Method method : controller.getClass().getDeclaredMethods()) {
            inspectedMethods++;
            Get get = method.getAnnotation(Get.class);
            if (get != null && get.value().equals(path)) {
                if (found != null) {
                    throw new IllegalStateException("duplicate route: " + path);
                }
                found = method;
            }
        }
        if (found == null) {
            throw new IllegalArgumentException("not found: " + path);
        }
        return call(controller, found, argument);
    }

    // 방식 2: 시작할 때 한 번만 훑어 경로별 표를 만든다
    static Map<String, Method> compile(Object controller) {
        Map<String, Method> routes = new HashMap<>();
        for (Method method : controller.getClass().getDeclaredMethods()) {
            inspectedMethods++;
            Get get = method.getAnnotation(Get.class);
            if (get != null) {
                routes.put(get.value(), method);
            }
        }
        return Map.copyOf(routes);
    }

    public static void main(String[] args) {
        Controller controller = new Controller();
        int requests = 1_000;
        String result = null;

        for (int i = 0; i < requests; i++) {
            result = scanAndCall(controller, "/search", "java");
        }
        System.out.println("scan per request: " + result + ", inspected=" + inspectedMethods);

        inspectedMethods = 0;
        Map<String, Method> routes = compile(controller);
        for (int i = 0; i < requests; i++) {
            result = call(controller, routes.get("/search"), "java");
        }
        System.out.println("compile once:     " + result + ", inspected=" + inspectedMethods);
    }
}
실행 결과
scan per request: found:java, inspected=2000
compile once:     found:java, inspected=2

두 방식의 응답은 found:java로 같지만 조사한 메서드 수는 천 배 차이가 납니다. Controller에는 search와 helper 두 메서드가 있으므로, 요청마다 훑는 방식은 요청 한 번에 2개씩 1,000번, 모두 2,000번을 조사합니다. scanAndCall이 일치하는 메서드를 찾은 뒤에도 멈추지 않는 것은 같은 경로를 선언한 메서드가 또 있는지 확인하려면 끝까지 봐야 하기 때문입니다. 반면 표를 만드는 방식은 시작할 때 2번 조사하고, 그 뒤의 요청 1,000번은 맵을 조회하기만 합니다. 라우트와 요청이 늘어날수록 이 차이는 더 벌어집니다.

맵 조회가 평균적으로 일정한 시간에 끝난다는 것은 해시 값이 고르게 퍼진다는 전제에서 하는 이야기입니다. 또 라우트가 수십 개뿐이라면 매번 훑는 비용도 실제로는 크지 않을 수 있습니다. 그래도 시작할 때 계획을 세우는 데에는 속도보다 중요한 이점이 있습니다. 앞 글에서 본 중복 라우트와 잘못된 시그니처, 이 글에서 본 지원하지 않는 매개변수를 모두 요청이 오기 전에 찾을 수 있다는 점입니다. 이 계측 예제는 비교를 위해 단순하게 만든 것이라 그런 검사는 하지 않습니다.

더 깊이: 여러 요청 스레드가 계획을 함께 쓸 때

소켓으로 HTTP 서버 만들기: 동시 처리와 keep-alive에서 본 것처럼 서버는 여러 요청을 여러 스레드에서 동시에 처리합니다. Map.copyOf로 만든 표는 수정할 수 없고 HandlerPlan 같은 레코드의 필드는 final이므로, 표를 다 만든 뒤에는 요청 스레드들이 읽기만 하면서 같은 표를 함께 쓸 수 있습니다. 표를 다른 스레드에 넘기는 방법도 따로 신경 쓸 일이 거의 없습니다. Java는 스레드의 start()를 호출하기 전에 한 쓰기가 새 스레드에서 보이고, 실행기(executor)에 작업을 제출하기 전에 한 쓰기도 그 작업에서 보이도록 정해 두었습니다. 그러니 서버가 표를 다 만든 다음 요청 처리를 시작하기만 하면 됩니다.

하지만 불변인 것은 표와 계획이 들고 있는 참조까지입니다. 계획이 가리키는 컨트롤러 객체에 값이 바뀌는 필드가 있다면 그 객체는 여러 스레드에서 동시에 호출되므로, 상태를 두지 않게 만들거나 가시성과 동기화에서 다룬 방법으로 보호해야 합니다. 애노테이션으로 표를 만들었다고 해서 컨트롤러가 저절로 스레드 안전해지지는 않습니다.

단계마다 다른 오류 응답

요청을 처리하다 나는 예외도 어느 단계에서 났는지에 따라 의미가 다릅니다. 해석기가 필수 쿼리 값이 없다는 것을 발견했다면 사용자가 요청을 잘못 보낸 것이므로 400입니다. 컨트롤러가 '이 페이지는 지금 다시 만드는 중'처럼 업무 규칙상 요청을 받아들일 수 없다고 알리는 경우는 예상할 수 있는 결과이고, 예를 들어 409 Conflict로 응답할 수 있습니다. 반면 NullPointerException이나 저장소 연결 장애는 서버 내부의 실패이므로 500입니다. 앞 글에서 본 것처럼 리플렉션이 감싼 InvocationTargetException만 보고 모두 500으로 처리하면 이 구분이 사라집니다.

그래서 실행기는 요청 하나를 네 단계로 처리합니다. 표에서 계획을 찾고(없으면 404나 405), 해석기로 인수를 만들고(실패하면 400), 컨트롤러를 호출하고(업무 예외는 409 같은 상태로), 마지막으로 반환값이나 예외를 HTTP 응답으로 바꿉니다. 단계마다 다른 예외 타입을 쓰면 응답의 상태 코드가 어느 단계에서 정해졌는지 추적하기 쉽습니다.

응답을 쓰는 시점도 생각해야 합니다. HTTP 응답은 상태 줄과 헤더가 본문보다 먼저 나가므로, 컨트롤러가 본문을 쓰기 시작해 응답이 이미 전송된 뒤에 예외가 나면 상태 코드를 바꿀 수 없습니다. 컨트롤러는 결과를 값으로 돌려주고 실행기가 마지막에 응답 전체를 쓰는 방식이 안전한 이유입니다.

정리

  • 매개변수 개수나 위치로 인수를 추측하면, 지원하지 않는 시그니처가 요청을 처리할 때에야 IllegalArgumentException으로 드러납니다.
  • 인수 해석기는 시작할 때 supports로 매개변수마다 정확히 하나씩 선택하고, 요청 때는 선택된 해석기의 resolve만 실행합니다.
  • 타입만으로 출처를 알 수 없는 값은 @Query("q")처럼 매개변수 애노테이션으로 출처와 이름을 적습니다. 매개변수 이름은 -parameters 없이 컴파일하면 리플렉션으로 읽을 수 없습니다.
  • 변환에 실패한 입력은 400, 업무 규칙 위반은 409 같은 상태, 예상하지 못한 실패는 500으로 나누려면 단계마다 다른 예외를 쓰고 InvocationTargetException의 원인을 꺼내야 합니다.

연습 문제

  1. @Query("page") int page를 지원하도록 확장한다고 생각해 봅시다. 필수 쿼리 page를 정수로 바꾸는 requiredInt와, 처리기를 호출하고 결과를 상태 코드와 본문으로 바꾸는 execute를 작성하세요. page가 없거나 정수가 아니면 400을, 컨트롤러가 ConflictException을 던지면 409를 돌려주고, InvocationTargetException은 바깥으로 드러나지 않게 합니다.
  2. 1번 해답의 PageController를 그대로 두고 page에 빈 문자열, "2147483648", "-1"을 각각 넣으면 상태 코드는 무엇이고 컨트롤러는 호출될까요? 또 ArgumentResolverDispatcher의 search에 q를 빈 문자열로 보내면 필수 값이 없다고 거절될까요?
  3. ArgumentResolverDispatcher의 해석기 목록에 타입이 String이기만 하면 지원하는 PlainStringResolver를 추가하면, search의 계획을 만들 때 무슨 일이 생길까요?
1번 해답 보기
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"))));
    }
}
실행 결과
HttpResult[status=200, body=page=2]
HttpResult[status=400, body=parameter page must be an integer]
HttpResult[status=409, body=page is being rebuilt]

main은 page가 "2", "two", "9"인 세 요청을 차례로 실행합니다. "2"는 정수 변환에 성공해 컨트롤러가 호출되고 200과 page=2가 나옵니다. "two"는 Integer.parseInt에서 NumberFormatException이 나고 requiredInt가 이를 BindingException으로 바꾸므로, 컨트롤러를 호출하지 않은 채 400이 됩니다. "9"는 변환에 성공해 컨트롤러까지 가지만 컨트롤러가 ConflictException을 던집니다. 이 예외는 InvocationTargetException에 감싸져 오므로, getCause()로 꺼내 타입을 확인한 뒤 409로 바꿉니다.

아래 단계별 그림은 이 세 요청에 main에는 없는 '값 누락' 요청을 더해, 네 경우가 바인딩과 호출 단계를 어떻게 지나는지 비교합니다. 값 누락 요청은 requiredInt의 첫 검사에 걸려 missing parameter: page와 함께 400이 되고, 역시 컨트롤러를 호출하지 않습니다.

필수 page 변환과 컨트롤러 실패 분류

page=2는 바인딩 후 호출되어 200, two/누락은 호출 전 400, 9는 호출 후 업무 충돌 409가 됩니다. 바인딩 게이트와 메서드 실행 허가를 실제 사례로 구분할 수 있습니다.

필수 page 변환과 컨트롤러 실패 분류

필수 page 변환과 컨트롤러 실패 분류

호출 실패의 경계

필수 page 변환과 컨트롤러 실패 분류

입력 변환 실패는 컨트롤러를 부르기 전에 반환하고, 호출된 컨트롤러의 업무 충돌은 원인을 꺼내 분류합니다.

바인딩에서 실패한 입력은 컨트롤러에 가지 않는다

바인딩에서 실패한 입력은 컨트롤러에 가지 않는다two·누락은 requiredInt에서 400을 반환한다. 2와 9만 controller.page를 호출해 각각 200 정상값과 원인을 꺼낸 409 충돌 결과로 나뉜다. 누락은 main 밖 추가 조건이다.실패통과필수 page 값존재·정수?two·누락400 · 미호출2·9 통과본문 호출2 정상값200 page=29 업무충돌409
바인딩에서 실패한 입력은 컨트롤러에 가지 않는다two·누락은 requiredInt에서 400을 반환한다. 2와 9만 controller.page를 호출해 각각 200 정상값과 원인을 꺼낸 409 충돌 결과로 나뉜다. 누락은 main 밖 추가 조건이다.실패통과필수 page 값존재·정수?two·누락400 ·미호출2·9 통과본문 호출2 정상값200 page=29 업무충돌409

two·누락은 requiredInt에서 400을 반환한다. 2와 9만 controller.page를 호출해 각각 200 정상값과 원인을 꺼낸 409 충돌 결과로 나뉜다. 누락은 main 밖 추가 조건이다.

상태별 상세 해설

"2"

처리 경계
컨트롤러 정상 반환
반환 결과
200 · page=2

"two"

처리 경계
정수 변환 거절 · 호출 안 함
반환 결과
400 · 정수 형식 안내

"9"

처리 경계
원인을 꺼내 업무 충돌 분류
반환 결과
409 · page is being rebuilt

누락

처리 경계
필수 값 거절 · 호출 안 함
반환 결과
400 · missing parameter: page

main은 2·two·9를 실행합니다. 누락 행은 같은 코드의 필수 값 검사 분기이며, 표의 숫자는 HttpResult 값입니다.

이 예의 조건

  • 예제의 main은 2, two, 9를 각 별도 요청으로 실행합니다. 누락은 같은 코드의 추가 입력 조건이며 main에 없습니다.
  • 네 요청을 바인딩, 호출 경계, HttpResult 반환의 공통 단계마다 비교합니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보필수 page 변환과 컨트롤러 실패 분류 필수 page 변환과 컨트롤러 실패 분류 필수 page 변환과 컨트롤러 실패 분류 호출 실패의 경계 필수 page 변환과 컨트롤러 실패 분류 입력 변환 실패는 컨트롤러를 부르기 전에 반환하고, 호출된 컨트롤러의 업무 충돌은 원인을 꺼내 분류합니다. "2" 처리 경계 컨트롤러 정상 반환 반환 결과 200 · page=2 "two" 처리 경계 정수 변환 거절 · 호출 안 함 반환 결과 400 · 정수 형식 안내 "9" 처리 경계 원인을 꺼내 업무 충돌 분류 반환 결과 409 · page is being rebuilt 누락 처리 경계 필수 값 거절 · 호출 안 함 반환 결과 400 · missing parameter: page main은 2·two·9를 실행합니다. 누락 행은 같은 코드의 필수 값 검사 분기이며, 표의 숫자는 HttpResult 값입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
필수 page 바인딩

requiredInt가 네 요청의 값 존재와 정수 형식을 각각 검사합니다.

  • "2": int 2, 바인딩 통과
  • "two": BindingException: parameter page must be an integer
  • "9": int 9, 바인딩 통과
  • 누락 조건: BindingException: missing parameter: page
  • two와 누락은 바인딩에서 거절되어 controller 호출에 도달하지 않습니다.
본문 호출 또는 조기 종료

바인딩을 통과한 2와 9만 PageController.page를 호출합니다.

  • 2 본문: "page=2" 정상 반환
  • two 본문: 호출 안 함, 400 결과 구성
  • 9 본문: ConflictException: page is being rebuilt
  • 누락 본문: 호출 안 함, 400 결과 구성
  • 9의 대상 예외는 InvocationTargetException의 원인을 꺼내 업무 충돌로 분류합니다.
요청별 HttpResult 반환

정상 값, 바인딩 안내, 업무 충돌을 각 요청의 결과로 반환합니다.

  • 2 결과: 200 · page=2
  • two 결과: 400 · parameter page must be an integer
  • 9 결과: 409 · page is being rebuilt
  • 누락 결과: 400 · missing parameter: page
  • 각 요청은 하나의 결과로 종료하며 다음 입력의 처리가 같은 요청의 후속 단계가 되지 않습니다.

필수 page 변환과 컨트롤러 실패 분류

page=2는 바인딩 후 호출되어 200, two/누락은 호출 전 400, 9는 호출 후 업무 충돌 409가 됩니다. 바인딩 게이트와 메서드 실행 허가를 실제 사례로 구분할 수 있습니다.

필수 page 변환과 컨트롤러 실패 분류

필수 page 변환과 컨트롤러 실패 분류

호출 실패의 경계

필수 page 변환과 컨트롤러 실패 분류

입력 변환 실패는 컨트롤러를 부르기 전에 반환하고, 호출된 컨트롤러의 업무 충돌은 원인을 꺼내 분류합니다.

바인딩에서 실패한 입력은 컨트롤러에 가지 않는다

바인딩에서 실패한 입력은 컨트롤러에 가지 않는다two·누락은 requiredInt에서 400을 반환한다. 2와 9만 controller.page를 호출해 각각 200 정상값과 원인을 꺼낸 409 충돌 결과로 나뉜다. 누락은 main 밖 추가 조건이다.실패통과필수 page 값존재·정수?two·누락400 · 미호출2·9 통과본문 호출2 정상값200 page=29 업무충돌409
바인딩에서 실패한 입력은 컨트롤러에 가지 않는다two·누락은 requiredInt에서 400을 반환한다. 2와 9만 controller.page를 호출해 각각 200 정상값과 원인을 꺼낸 409 충돌 결과로 나뉜다. 누락은 main 밖 추가 조건이다.실패통과필수 page 값존재·정수?two·누락400 ·미호출2·9 통과본문 호출2 정상값200 page=29 업무충돌409

two·누락은 requiredInt에서 400을 반환한다. 2와 9만 controller.page를 호출해 각각 200 정상값과 원인을 꺼낸 409 충돌 결과로 나뉜다. 누락은 main 밖 추가 조건이다.

상태별 상세 해설

"2"

처리 경계
컨트롤러 정상 반환
반환 결과
200 · page=2

"two"

처리 경계
정수 변환 거절 · 호출 안 함
반환 결과
400 · 정수 형식 안내

"9"

처리 경계
원인을 꺼내 업무 충돌 분류
반환 결과
409 · page is being rebuilt

누락

처리 경계
필수 값 거절 · 호출 안 함
반환 결과
400 · missing parameter: page

main은 2·two·9를 실행합니다. 누락 행은 같은 코드의 필수 값 검사 분기이며, 표의 숫자는 HttpResult 값입니다.

이 예의 조건

  • 예제의 main은 2, two, 9를 각 별도 요청으로 실행합니다. 누락은 같은 코드의 추가 입력 조건이며 main에 없습니다.
  • 네 요청을 바인딩, 호출 경계, HttpResult 반환의 공통 단계마다 비교합니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보필수 page 변환과 컨트롤러 실패 분류 필수 page 변환과 컨트롤러 실패 분류 필수 page 변환과 컨트롤러 실패 분류 호출 실패의 경계 필수 page 변환과 컨트롤러 실패 분류 입력 변환 실패는 컨트롤러를 부르기 전에 반환하고, 호출된 컨트롤러의 업무 충돌은 원인을 꺼내 분류합니다. "2" 처리 경계 컨트롤러 정상 반환 반환 결과 200 · page=2 "two" 처리 경계 정수 변환 거절 · 호출 안 함 반환 결과 400 · 정수 형식 안내 "9" 처리 경계 원인을 꺼내 업무 충돌 분류 반환 결과 409 · page is being rebuilt 누락 처리 경계 필수 값 거절 · 호출 안 함 반환 결과 400 · missing parameter: page main은 2·two·9를 실행합니다. 누락 행은 같은 코드의 필수 값 검사 분기이며, 표의 숫자는 HttpResult 값입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
필수 page 바인딩

requiredInt가 네 요청의 값 존재와 정수 형식을 각각 검사합니다.

  • "2": int 2, 바인딩 통과
  • "two": BindingException: parameter page must be an integer
  • "9": int 9, 바인딩 통과
  • 누락 조건: BindingException: missing parameter: page
  • two와 누락은 바인딩에서 거절되어 controller 호출에 도달하지 않습니다.
본문 호출 또는 조기 종료

바인딩을 통과한 2와 9만 PageController.page를 호출합니다.

  • 2 본문: "page=2" 정상 반환
  • two 본문: 호출 안 함, 400 결과 구성
  • 9 본문: ConflictException: page is being rebuilt
  • 누락 본문: 호출 안 함, 400 결과 구성
  • 9의 대상 예외는 InvocationTargetException의 원인을 꺼내 업무 충돌로 분류합니다.
요청별 HttpResult 반환

정상 값, 바인딩 안내, 업무 충돌을 각 요청의 결과로 반환합니다.

  • 2 결과: 200 · page=2
  • two 결과: 400 · parameter page must be an integer
  • 9 결과: 409 · page is being rebuilt
  • 누락 결과: 400 · missing parameter: page
  • 각 요청은 하나의 결과로 종료하며 다음 입력의 처리가 같은 요청의 후속 단계가 되지 않습니다.

execute의 try 블록은 변환과 호출을 함께 감싸지만, 변환이 먼저 일어나므로 변환에 실패하면 invoke까지 가지 않습니다. 두 실패가 서로 다른 catch로 나뉘는 이유도 여기에 있습니다. BindingException은 requiredInt가 직접 던지므로 그대로 잡히고, 컨트롤러가 던진 예외는 언제나 InvocationTargetException에 감싸져 오므로 원인을 꺼내 분류합니다.

이 해답의 requiredInt는 이름 page를 직접 받을 뿐, 해석기로 등록하지는 않았습니다. 해석기 구조에 합치려면 @Query가 붙은 int 매개변수를 지원하는 해석기를 만들고, 그 resolve에서 requiredInt와 같은 변환을 하면 됩니다. 실제 프레임워크라면 예외 타입별 상태 코드도 따로 등록해 두어, 새로운 업무 예외가 생겨도 execute의 catch 목록을 고치지 않아도 되게 만듭니다.

2번과 3번 해설
  1. 빈 문자열은 null이 아니므로 누락 검사는 통과하지만, Integer.parseInt("")가 NumberFormatException을 던져 400이 되고 컨트롤러는 호출되지 않습니다. "2147483648"은 int의 최댓값 2147483647보다 1 커서 역시 변환에 실패하고, 결과도 같습니다. "-1"은 올바른 정수라 변환을 통과하고, 컨트롤러는 9일 때만 충돌로 처리하므로 HttpResult[status=200, body=page=-1]이 나옵니다. 이것은 음수 페이지를 허용해야 한다는 뜻이 아니라 지금 코드에 범위 검사가 없다는 뜻입니다. search의 QueryResolver는 값이 null일 때만 거절하므로, 빈 q는 그대로 컨트롤러에 전달되어 응답 본문이 /search result=가 됩니다. 값이 있는지, 비어 있지 않은지, 정수로 바뀌는지, 업무상 허용되는 범위인지는 모두 별개의 조건이라 각각 따로 검사해야 합니다.
  2. keyword 매개변수는 타입이 String이고 @Query도 붙어 있어서 QueryResolver와 PlainStringResolver가 모두 지원합니다. matches.size()가 2가 되므로 compile()은 expected one resolver for java.lang.String arg0, got 2라는 예외를 던지고 서버는 시작하지 않습니다. 겹치는 해석기를 그냥 허용했다면 목록 순서에 따라 keyword에 들어갈 값이 조용히 바뀌었을 것입니다. PlainStringResolver가 꼭 필요하다면 @Query가 없는 String만 지원하도록 조건을 좁히거나, 해석기 사이의 우선순위를 명시해야 합니다.

다음 글

회원 등록을 서비스 계층으로 분리하기에서는 이 실행기 위에 회원 등록과 목록 기능을 올리면서, 컨트롤러는 HTTP 요청과 응답을 바꾸는 일만 맡고 업무 규칙은 서비스 계층으로 옮기는 구조를 만듭니다.