애노테이션 라우트 계획
URL과 메서드 이름의 우연한 일치를 버리고 HTTP 메서드·경로를 애노테이션으로 선언한 뒤, 시작 단계에서 시그니처와 중복을 검증해 불변 실행 계획을 만듭니다.
애노테이션 기반 컨트롤러의 장점은 리플렉션 호출 자체가 아닙니다.
외부 요청의 식별자인 HTTP 메서드와 경로를 Java 메서드 옆에 선언하고, 서버가 시작될 때 그 선언을 검증 가능한 자료구조로 바꾸는 데 핵심이 있습니다.
메서드 이름을 URL로 사용하면 home()은 /home만 자연스럽게 표현하고 /, 하이픈이 있는 경로, 버전이 포함된 경로는 별도 규칙이 필요합니다.
이름 변경이 공개 URL 변경으로 이어지는 것도 위험합니다.
@Mapping(method = "GET", path = "/members")는 Java 이름과 HTTP 계약을 분리합니다.
하지만 선언만 붙인다고 안전해지지는 않습니다.
같은 키를 두 메서드가 차지하는지, 컨트롤러 메서드가 호출 가능한지, 매개변수가 실행기가 공급할 수 있는 타입인지 확인하는 컴파일 단계가 필요합니다.
이 장에서는 애노테이션 검사를 요청마다 수행하지 않고 서버 부팅 때 한 번 수행해 RouteKey -> HandlerPlan 테이블을 만듭니다.
요청 시점 라우트 탐색
다음 구현은 동작해 보이지만 요청 경로를 받을 때마다 모든 컨트롤러와 선언된 메서드를 순회합니다.
첫 번째로 일치한 메서드를 호출하므로 중복 라우트가 있어도 어떤 메서드가 선택될지 선언 순서에 기대게 됩니다.
getDeclaredMethods()의 반환 순서는 애플리케이션 계약으로 삼을 수 없습니다.
잘못된 시그니처도 해당 URL을 실제로 호출할 때까지 숨어 있습니다.
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.List;
public final class RequestTimeAnnotationScanner {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Mapping {
String path();
}
static Object dispatch(String path, List<Object> controllers) throws Exception {
for (Object controller : controllers) {
for (Method method : controller.getClass().getDeclaredMethods()) {
Mapping mapping = method.getAnnotation(Mapping.class);
if (mapping != null && mapping.path().equals(path)) {
return method.invoke(controller);
}
}
}
throw new IllegalArgumentException("not found: " + path);
}
public static void main(String[] args) {
System.out.println("compile-only request-time scan counterexample");
}
}서버가 시작할 때 발견할 수 있는 결함은 시작 실패로 바꾸는 편이 좋습니다. 배포 과정이 즉시 실패하면 운영 트래픽이 잘못된 라우트에 도달하지 않습니다. 반대로 요청 시점 예외는 특정 경로가 호출된 뒤에야 관찰되며, 재현과 영향 범위 확인에 더 많은 시간이 듭니다. Java 컴파일러가 애노테이션 값 사이의 중복까지 알지는 못하므로, 프레임워크의 시작 시점 컴파일러가 그 빈틈을 맡습니다.
라우트 식별자는 경로 하나가 아니라 메서드와 경로의 조합이어야 합니다.
GET /members와 POST /members는 같은 자원 경로를 사용해도 의미가 다릅니다.
경로만 Map 키로 두면 조회와 등록 중 하나가 다른 하나를 덮어씁니다.
또한 메서드는 대문자로, 경로는 선행 슬래시와 후행 슬래시 정책을 일관되게 정규화해야 합니다.
Mapping과 HandlerPlan
아래 프로그램은 @Mapping을 선언 정보로만 사용합니다.
compile()은 컨트롤러를 조사해 라우트 키, 대상 객체, 리플렉션 Method를 묶은 HandlerPlan을 만듭니다.
검증이 모두 끝난 뒤에만 Map.copyOf()로 게시하므로 요청 처리 중 라우트 표가 바뀌지 않습니다.
실행기는 키를 한 번 조회하고 계획을 호출합니다.
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.List;
import java.util.Locale;
import java.util.Map;
public final class MappingRouteCompiler {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Mapping {
String method();
String path();
}
record Request(String method, String path) {
}
static final class Response {
private int status = 200;
private final StringBuilder body = new StringBuilder();
void status(int value) {
status = value;
}
void write(String value) {
body.append(value);
}
@Override
public String toString() {
return status + " " + body;
}
}
record RouteKey(String method, String path) {
static RouteKey of(String method, String path) {
String normalizedMethod = method.trim().toUpperCase(Locale.ROOT);
String normalizedPath = path.equals("/")
? "/"
: path.replaceAll("/+quot;, "");
if (!normalizedPath.startsWith("/")) {
throw new IllegalArgumentException("path must start with slash: " + path);
}
return new RouteKey(normalizedMethod, normalizedPath);
}
}
record HandlerPlan(Object target, Method method) {
void invoke(Request request, Response response) {
try {
method.invoke(target, request, response);
} catch (IllegalAccessException exception) {
throw new IllegalStateException("validated method became inaccessible", exception);
} catch (InvocationTargetException exception) {
Throwable cause = exception.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
throw new IllegalStateException("controller failure", cause);
}
}
}
static Map<RouteKey, HandlerPlan> compile(List<Object> controllers) {
Map<RouteKey, HandlerPlan> plans = new LinkedHashMap<>();
for (Object controller : controllers) {
for (Method method : controller.getClass().getDeclaredMethods()) {
Mapping mapping = method.getAnnotation(Mapping.class);
if (mapping == null) {
continue;
}
validateSignature(method);
RouteKey key = RouteKey.of(mapping.method(), mapping.path());
HandlerPlan previous = plans.putIfAbsent(key, new HandlerPlan(controller, method));
if (previous != null) {
throw new IllegalArgumentException(
"duplicate route " + key + ": " + previous.method() + " and " + method);
}
}
}
return Map.copyOf(plans);
}
private static void validateSignature(Method method) {
if (!Modifier.isPublic(method.getModifiers())) {
throw new IllegalArgumentException("route method must be public: " + method);
}
if (method.getReturnType() != void.class) {
throw new IllegalArgumentException("route method must return void: " + method);
}
if (!List.of(method.getParameterTypes()).equals(List.of(Request.class, Response.class))) {
throw new IllegalArgumentException("route signature must be Request, Response: " + method);
}
}
static final class SiteController {
@Mapping(method = "GET", path = "/")
public void home(Request request, Response response) {
response.write("home from " + request.path());
}
@Mapping(method = "GET", path = "/site-one/")
public void siteOne(Request request, Response response) {
response.write("site one");
}
}
public static void main(String[] args) {
Map<RouteKey, HandlerPlan> routes = compile(List.of(new SiteController()));
Response response = new Response();
routes.get(RouteKey.of("get", "/site-one"))
.invoke(new Request("GET", "/site-one"), response);
System.out.println(routes.keySet());
System.out.println(response);
}
}검증 함수가 허용하는 시그니처는 프레임워크 계약입니다.
이 버전은 학습 범위를 좁히기 위해 (Request, Response) -> void만 받습니다.
비공개 메서드에 setAccessible(true)를 적용해 편의를 높일 수도 있지만 모듈 경계를 우회하고 실수로 내부 도우미를 엔드포인트로 노출할 수 있습니다.
명시적인 public 계약은 오류 설명과 보안 검토를 단순하게 합니다.
List.of(method.getParameterTypes())는 배열 요소를 순서대로 비교합니다.
매개변수 개수뿐 아니라 순서와 정확한 타입까지 검증해야 리플렉션 호출에서 IllegalArgumentException이 나지 않습니다.
이후 동적 인수 해석기를 도입하더라도 지원 가능성은 시작 시점에 미리 판정해야 합니다.
404와 405
라우트 표에서 정확한 키가 없다는 사실만으로 404를 반환하면 경로는 존재하지만 메서드만 틀린 경우를 설명하지 못합니다.
HTTP 실행기는 같은 경로를 가진 다른 키가 있는지 확인해 405와 Allow 값을 만들 수 있습니다.
다음 예제는 조회 결과를 봉인 인터페이스로 모델링합니다.
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class RouteLookupBoundary {
record RouteKey(String method, String path) {
RouteKey {
method = method.toUpperCase(Locale.ROOT);
}
}
sealed interface Lookup permits Found, MethodNotAllowed, NotFound {
}
record Found(String handler) implements Lookup {
}
record MethodNotAllowed(Set<String> allowed) implements Lookup {
MethodNotAllowed {
allowed = Set.copyOf(allowed);
}
}
record NotFound(String path) implements Lookup {
}
static final class RouteTable {
private final Map<RouteKey, String> routes;
RouteTable(Map<RouteKey, String> routes) {
this.routes = Map.copyOf(routes);
}
Lookup lookup(String method, String path) {
String handler = routes.get(new RouteKey(method, path));
if (handler != null) {
return new Found(handler);
}
Set<String> allowed = new LinkedHashSet<>();
for (RouteKey key : routes.keySet()) {
if (key.path().equals(path)) {
allowed.add(key.method());
}
}
return allowed.isEmpty()
? new NotFound(path)
: new MethodNotAllowed(allowed);
}
}
public static void main(String[] args) {
Map<RouteKey, String> routes = new LinkedHashMap<>();
routes.put(new RouteKey("GET", "/members"), "listMembers");
routes.put(new RouteKey("POST", "/members"), "createMember");
RouteTable table = new RouteTable(routes);
System.out.println(table.lookup("get", "/members"));
System.out.println(table.lookup("DELETE", "/members"));
System.out.println(table.lookup("GET", "/missing"));
}
}여기서 경로별 허용 메서드 탐색은 선형입니다.
405가 빈번하고 라우트 수가 크다면 시작 시점에 Map<String, Set<String>> 보조 인덱스를 함께 만들 수 있습니다.
중요한 원칙은 요청 처리 도중 라우트 선언을 다시 조사하지 않는 것입니다.
성능 자료구조는 먼저 의미를 올바르게 모델링한 뒤 실제 경로의 비용에 맞춰 선택합니다.
리플렉션 예외 경계
Method.invoke()는 컨트롤러에서 발생한 예외를 InvocationTargetException으로 감쌉니다.
래퍼 자체를 500 처리기에 넘기면 로그의 최상위 원인이 리플렉션으로 보이고, 도메인 예외를 400이나 409로 변환하는 정책도 작동하지 않습니다.
계획은 원인을 꺼내 원래 실행 시점 예외를 다시 던져야 합니다.
반면 IllegalAccessException은 시작 시점 검증과 실제 호출 사이 계약이 깨진 프레임워크 결함에 가깝습니다.
오류 분류를 세 단계로 보면 설계가 명료해집니다. Java 문법과 타입 불일치는 컴파일 오류로 잡습니다. 중복 라우트와 지원하지 않는 시그니처는 시작 시점 오류로 끌어올립니다. 올바른 입력에서도 외부 저장소가 실패하는 상황처럼 실제 요청에서만 알 수 있는 사건은 연산 오류로 처리합니다. 모든 오류를 컴파일 시간으로 옮길 수는 없지만, 이미 가진 메타데이터로 판정 가능한 문제를 고객 요청까지 미룰 이유는 없습니다.
연습 문제
두 컨트롤러를 조사해 비공개 메서드, 잘못된 경로, 중복 키를 모두 모으는 컴파일러를 작성합니다.
첫 오류에서 즉시 던지지 말고 CompileResult에 문제 목록을 담습니다.
문제가 비어 있을 때만 불변 라우트 표를 반환해야 합니다.
출력 순서를 안정화하기 위해 클래스 이름과 메서드 이름으로 정렬합니다.
해답 보기
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 RouteStartupReportSolution {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Route {
String value();
}
record Issue(String owner, String method, String detail) {
}
record CompileResult(Map<String, Method> routes, List<Issue> issues) {
CompileResult {
routes = Map.copyOf(routes);
issues = List.copyOf(issues);
}
boolean ready() {
return issues.isEmpty();
}
}
static CompileResult compile(List<Class<?>> controllerTypes) {
Map<String, Method> routes = new LinkedHashMap<>();
List<Issue> issues = new ArrayList<>();
List<Method> methods = controllerTypes.stream()
.flatMap(type -> List.of(type.getDeclaredMethods()).stream())
.filter(method -> method.isAnnotationPresent(Route.class))
.sorted(Comparator.comparing((Method method) -> method.getDeclaringClass().getName())
.thenComparing(Method::getName))
.toList();
for (Method method : methods) {
String owner = method.getDeclaringClass().getSimpleName();
String path = method.getAnnotation(Route.class).value();
if (!Modifier.isPublic(method.getModifiers())) {
issues.add(new Issue(owner, method.getName(), "method is not public"));
}
if (!path.startsWith("/")) {
issues.add(new Issue(owner, method.getName(), "path must start with slash"));
}
Method previous = routes.putIfAbsent(path, method);
if (previous != null) {
issues.add(new Issue(owner, method.getName(),
"duplicate with " + previous.getDeclaringClass().getSimpleName()
+ "." + previous.getName()));
}
}
if (!issues.isEmpty()) {
routes.clear();
}
return new CompileResult(routes, issues);
}
static final class FirstController {
@Route("/same")
public void first() {
}
@Route("broken")
private void hidden() {
}
}
static final class SecondController {
@Route("/same")
public void second() {
}
}
public static void main(String[] args) {
CompileResult result = compile(List.of(FirstController.class, SecondController.class));
System.out.println("ready=" + result.ready());
result.issues().forEach(System.out::println);
System.out.println("publishedRoutes=" + result.routes().size());
}
}해답은 검사 중 임시 Map을 사용하지만 문제가 하나라도 있으면 결과 라우트를 비웁니다.
불완전한 표를 게시하지 않는 원자적 시작 규칙이 핵심입니다.
실무에서는 문제에 애노테이션 위치, 기대 시그니처, 실제 시그니처도 포함해 수정 비용을 낮춥니다.