매개변수 해석기
컨트롤러가 필요한 값만 선언하도록 매개변수별 인수 해석기를 구성하고, 라우트 조회와 바인딩 계획을 시작 시 확정해 요청 경로의 리플렉션 탐색을 제거합니다.
모든 컨트롤러 메서드에 (Request, Response)를 강제하면 실행기는 단순하지만 작은 처리기도 사용하지 않는 객체를 받아야 합니다.
반대로 리플렉션이 알아서 무엇이든 넣어 준다고 생각하면 지원 타입, 누락 값, 변환 실패를 언제 발견하는지 불명확해집니다.
동적 바인딩은 마법이 아니라 각 매개변수를 어떤 규칙으로 해석할지 정한 작은 컴파일러 문제입니다.
좋은 실행기는 두 시점을 분리합니다.
서버 시작 때 Parameter 메타데이터를 읽고 각 위치를 담당할 해석기를 고릅니다.
요청이 들어오면 이미 선택된 해석기가 현재 요청 문맥에서 실제 값을 만듭니다.
지원되지 않는 매개변수는 부팅을 실패시키고, 잘못된 사용자 값은 400 계열 결과로 분류합니다.
이 구분이 있어야 편리한 컨트롤러 시그니처와 예측 가능한 오류가 함께 존재합니다.
Object 배열의 타입 손실
다음 방식은 매개변수가 두 개면 무조건 요청과 응답을 넣습니다.
컨트롤러가 순서를 바꾸거나 String을 추가하면 리플렉션 호출에서야 실패합니다.
분기는 짧지만 시그니처 지원 규칙이 코드에 표현되지 않았고, 오류 메시지도 어떤 매개변수를 해결하지 못했는지 알려 주지 못합니다.
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 목록을 만듭니다.
일치 항목이 없거나 둘 이상이면 시작 오류입니다.
실행 시에는 메서드 목록과 해석기 후보를 다시 탐색하지 않고 각 계획에 현재 문맥을 전달합니다. QueryResolver.resolve()는 저장된 Parameter에서 애노테이션을 다시 읽으므로 모든 메타데이터 접근을 없앤 구현은 아닙니다.
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());
}
}search의 선언 순서와 원문 main의 문맥을 함께 보면 각 위치에 들어갈 값이 분명해집니다.
search의 매개변수 위치별 인수 계획
| 위치와 선언 | 선택된 해석기 | 이 문맥의 값 |
|---|---|---|
| 1 · @Query("q") String | QueryResolver | reflection |
| 2 · Response | ResponseResolver | 현재 응답 객체 |
| 3 · Request | RequestResolver | 현재 요청 · /search |
- 1 · @Query("q") String
- 선택된 해석기: QueryResolver이 문맥의 값: reflection
- 2 · Response
- 선택된 해석기: ResponseResolver이 문맥의 값: 현재 응답 객체
- 3 · Request
- 선택된 해석기: RequestResolver이 문맥의 값: 현재 요청 · /search
이 compile()은 메서드 이름만으로 첫 후보를 고릅니다. 같은 이름의 오버로드가 있으면 반환 순서에 의존하며 접근 가능성과 반환 타입도 검증하지 않습니다.
컨트롤러 매개변수 순서는 자유롭지만 같은 타입을 두 번 선언하는 것이 언제나 의미 있는 것은 아닙니다.
Request와 Response는 하나씩만 허용할 수 있고, 여러 쿼리 매개변수는 애노테이션의 이름으로 구분할 수 있습니다.
컴파일러는 해석기 선택뿐 아니라 중복 매개변수, 기본형에 선택적 값을 넣는 문제, 애노테이션과 타입의 부조화까지 점검할 수 있습니다.
값 변환은 입력 경계의 책임입니다.
@Query("page") int page를 지원한다면 NumberFormatException을 그대로 노출하지 말고 매개변수 이름, 거절한 값, 기대 형식을 담은 BindingException으로 바꿉니다.
비밀번호나 토큰처럼 민감한 입력은 오류 메시지와 로그에서 값을 가려야 합니다.
사전 실행 계획
성능 개선은 HashMap 하나로 끝나지 않습니다.
요청마다 컨트롤러 메서드를 훑지 않는 것과 매개변수마다 해석기 목록을 재탐색하지 않는 것이 함께 필요합니다.
시작 단계의 비용은 라우트 수와 매개변수 수에 비례해 한 번 발생합니다.
이후 요청은 라우트 키 조회, 미리 정한 해석기 실행, Method.invoke 순서로 고정됩니다.
다음 프로그램은 시작 시점 컴파일러가 검사 횟수를 기록하고, 여러 요청이 들어와도 그 값이 증가하지 않음을 보여 줍니다.
라우트 표는 compile()에서 완성한 뒤 Map.copyOf()로 반환합니다.
Map 구조와 HandlerPlan의 참조 필드는 변경되지 않지만, record가 가리키는 컨트롤러까지 불변이 되는 것은 아닙니다. 여러 요청에서 공유하려면 계획의 안전한 게시와 대상 객체의 가변 상태에 대한 동시성 정책이 따로 필요합니다.
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 명령이라는 뜻은 아닙니다.
라우트 표가 수십 개라면 리플렉션 검사의 절대 비용이 작을 수도 있습니다.
그래도 시작 시점 계획 생성은 성능보다 더 중요한 이점을 줍니다.
완전한 시작 시점 컴파일러는 중복과 시그니처 오류를 요청 전에 찾아 실행 경로를 단순하게 만들 수 있습니다. 위 계측 예제는 이름이 search인 메서드를 저장할 뿐 중복·시그니처를 검증하지 않으며, 여러 스레드의 동시 실행을 시험하지도 않습니다.
바인딩·컨트롤러 오류
인수 해석기가 필수 쿼리 누락을 발견한 사건은 사용자 요청 문제입니다.
컨트롤러가 도메인 규칙을 위반했다고 알리는 사건도 예측 가능한 업무 결과일 수 있습니다.
반면 NullPointerException이나 저장소 연결 장애는 서버 내부 실패입니다.
리플렉션 래퍼만 보고 모두 500으로 만들면 이 구분을 잃습니다.
실행 경계는 네 단계를 따를 수 있습니다.
먼저 계획을 조회하고, 해석기가 값을 구성하며, 컨트롤러를 호출하고, 반환이나 예외를 HTTP 응답으로 번역합니다.
각 단계가 자체 예외를 갖게 하면 404, 400, 409, 500이 어디에서 결정됐는지 추적할 수 있습니다.
이미 응답 본문을 일부 쓴 뒤 실패하면 상태를 바꾸기 어려우므로 컨트롤러 결과를 값으로 반환한 뒤 마지막에 응답을 커밋하는 방식도 유용합니다.
연습 문제
@Query("page") int page를 지원하도록 확장하는 상황을 생각해 봅니다. 이 연습에서는 필수 쿼리 page를 정수로 바꾸는 함수와 처리기 실행 경계를 작성합니다.
page가 없거나 정수가 아니면 400 결과를 반환하고, 컨트롤러가 ConflictException을 던지면 409로 바꿉니다.
리플렉션이 만든 InvocationTargetException은 외부 분류에 노출하지 않습니다.
해답 보기
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"))));
}
}해답 코드의 분기를 입력별로 비교하면 변환 실패가 호출보다 먼저 처리되는 것을 확인할 수 있습니다. 누락 행은 main에 없는 추가 입력의 코드 경로입니다.
필수 page 변환과 컨트롤러 실패 분류
| page 입력 | 처리 경계 | 반환 결과 |
|---|---|---|
| "2" | 컨트롤러 정상 반환 | 200 · page=2 |
| "two" | 정수 변환 거절 · 호출 안 함 | 400 · 정수 형식 안내 |
| "9" | 원인을 꺼내 업무 충돌 분류 | 409 · page is being rebuilt |
| 누락 | 필수 값 거절 · 호출 안 함 | 400 · missing parameter: page |
- "2"
- 처리 경계: 컨트롤러 정상 반환반환 결과: 200 · page=2
- "two"
- 처리 경계: 정수 변환 거절 · 호출 안 함반환 결과: 400 · 정수 형식 안내
- "9"
- 처리 경계: 원인을 꺼내 업무 충돌 분류반환 결과: 409 · page is being rebuilt
- 누락
- 처리 경계: 필수 값 거절 · 호출 안 함반환 결과: 400 · missing parameter: page
해답의 requiredInt()는 이름 page를 직접 사용하며 애노테이션 해석기 등록은 구현하지 않습니다.
실제 프레임워크라면 예외-응답 매퍼도 레지스트리로 분리해 새로운 도메인 예외를 실행기의 조건문 수정 없이 추가할 수 있습니다.