애노테이션으로 요청 경로 연결하기
컨트롤러 메서드에 HTTP 메서드와 경로를 애노테이션으로 적고, 서버가 시작할 때 이를 검사해 라우트 표를 만드는 방법과 404·405 구분, 컨트롤러 예외 처리를 배웁니다.
27장에서 만든 서블릿 라우터는 경로마다 처리할 객체를 코드로 직접 등록했고, 28장에서는 메서드에 붙인 애노테이션을 리플렉션으로 읽어 실행 표를 만들었습니다. 이 글에서는 두 가지를 합쳐 작은 웹 프레임워크의 뼈대를 만듭니다. 컨트롤러(controller)는 HTTP 요청을 처리하는 메서드를 모아 둔 클래스입니다. 메서드마다 어떤 요청을 맡을지 애노테이션으로 적어 두면, 서버가 그 선언을 읽어 요청을 알맞은 메서드로 보내 줍니다. 요청을 처리할 메서드를 찾아 연결하는 이 일을 라우팅(routing)이라고 합니다.
메서드 이름을 그대로 URL로 쓰는 방법도 생각해 볼 수 있습니다. home()은 /home을, members()는 /members를 맡는 식입니다. 하지만 이 규칙으로는 루트 경로 /, 하이픈이 들어간 /site-one, 버전이 붙은 /v2/members 같은 경로를 메서드 이름으로 나타낼 수 없습니다. 더 큰 문제는 메서드 이름을 바꾸는 리팩터링이 곧 외부에 공개된 URL을 바꾸는 일이 된다는 점입니다. @Mapping(method = "GET", path = "/members")처럼 경로를 애노테이션 값으로 적으면 Java 이름과 URL이 분리되어, 메서드 이름은 코드를 읽기 좋게 짓고 URL은 URL대로 정할 수 있습니다.
그런데 애노테이션을 붙이는 것만으로 안전해지지는 않습니다. Java 컴파일러는 @Mapping을 붙일 수 있는 자리인지, 필요한 값이 모두 있는지, 값의 타입이 맞는지 정도만 검사합니다. 두 메서드가 같은 GET /members를 선언하지 않았는지, 서버가 넘겨줄 수 없는 매개변수를 받는 메서드는 없는지는 애노테이션을 읽는 쪽, 즉 프레임워크가 확인해야 합니다. 이 글에서는 그 확인을 서버가 시작할 때 한 번만 해서 라우트 표를 만들고, 요청이 오면 표만 찾아보게 합니다. 이어서 표에 없는 요청을 404와 405로 나누는 방법과, 컨트롤러가 던진 예외를 리플렉션 밖으로 꺼내는 방법을 다룹니다.
요청마다 애노테이션을 찾으면 생기는 문제
가장 먼저 떠오르는 구현은 요청이 올 때마다 컨트롤러의 메서드를 모두 훑어 경로가 일치하는 애노테이션을 찾는 것입니다. 아래 dispatch가 그렇게 동작합니다. SiteController에는 라우트가 둘 있는데, 둘째 메서드 greet는 String 매개변수를 하나 받습니다. 그런데 dispatch는 인수 없이 invoke를 호출하므로 greet는 제대로 호출될 수 없습니다.
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 final class SiteController {
@Mapping(path = "/hello")
public String hello() {
return "hello";
}
@Mapping(path = "/greet")
public String greet(String name) {
return "hello, " + name;
}
}
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) throws Exception {
List<Object> controllers = List.of(new SiteController());
System.out.println(dispatch("/hello", controllers));
try {
dispatch("/greet", controllers);
} catch (IllegalArgumentException exception) {
System.out.println("/greet failed: " + exception.getMessage());
}
}
}hello
/greet failed: wrong number of arguments: 0 expected: 1/hello 요청은 문제없이 처리되므로, 이 서버는 정상적으로 시작해 한동안 잘 동작하는 것처럼 보입니다. 결함은 누군가 /greet를 요청한 순간에야 드러납니다. Method.invoke는 넘겨받은 인수 개수가 매개변수 개수와 다르면 IllegalArgumentException을 던지는데, 그때는 이미 사용자의 요청을 처리하는 도중입니다. 둘째 줄의 메시지 문구는 JDK 구현과 버전에 따라 조금 다를 수 있습니다.
이 방식에는 문제가 두 가지 더 있습니다. 먼저 요청마다 모든 컨트롤러의 모든 메서드를 다시 조사하므로, 라우트가 늘어날수록 요청 하나를 처리하는 비용도 커집니다. 다음으로 같은 경로를 선언한 메서드가 둘이면 먼저 발견한 쪽이 호출되는데, 무엇이 먼저 발견될지는 getDeclaredMethods()가 돌려주는 순서에 달려 있습니다. 이 메서드의 API 문서는 돌려주는 배열의 원소가 정렬되어 있지 않고 특정한 순서도 없다고 밝힙니다. 그러니 이 순서에 기대는 서버는 중복 라우트 가운데 어느 메서드가 호출될지 장담할 수 없습니다.
서버가 시작할 때 찾을 수 있는 결함이라면 시작 단계에서 실패시키는 편이 낫습니다. 배포 직후 서버가 오류 메시지와 함께 멈추면 개발자가 바로 고칠 수 있고, 잘못된 라우트가 실제 사용자 요청을 받는 일도 없습니다. 반대로 요청을 처리하다 난 예외는 그 경로가 호출되어야만 나타나므로, 재현하고 영향 범위를 파악하는 데 더 오래 걸립니다. 그래서 이 글의 실행기는 검사를 모두 시작 단계로 옮기고, 요청을 처리할 때는 검사가 끝난 표만 사용합니다.
라우트를 구분하는 키: HTTP 메서드와 경로
표를 만들려면 먼저 무엇을 키로 삼을지 정해야 합니다. 경로만 키로 쓰면 될 것 같지만, GET /members(회원 목록 조회)와 POST /members(회원 등록)는 같은 경로를 쓰면서도 하는 일이 전혀 다릅니다. 경로만 Map의 키로 두면 둘 중 나중에 등록한 쪽이 먼저 등록한 쪽을 덮어씁니다. 따라서 라우트 키는 HTTP 메서드와 경로의 조합이어야 합니다.
키를 만들 때는 같은 라우트가 언제나 같은 키가 되도록 값을 정규화(normalization)합니다. 정규화란 표기가 조금씩 다른 값을 정해진 한 가지 형태로 맞추는 일입니다. 이 글에서는 HTTP 메서드의 앞뒤 공백을 지우고 대문자로 바꿉니다. 경로는 /로 시작해야 하고, 루트 경로 /가 아니라면 끝에 붙은 슬래시를 지웁니다. 그래서 다음과 같이 get과 GET이 같은 메서드가 되고, /site-one/과 /site-one이 같은 경로가 됩니다.
| 애노테이션에 적은 메서드와 경로 | 만들어지는 키 |
|---|---|
GET, / | GET / |
GET, /site-one/ | GET /site-one |
get, /site-one | GET /site-one |
GET, site-one | 키를 만들지 못하고 예외 발생 |
끝 슬래시를 지우는 대신 /site-one/과 /site-one을 서로 다른 경로로 취급하는 프레임워크도 있습니다. 어느 규칙을 고르든 중요한 것은 표를 만들 때와 요청을 찾을 때 같은 규칙을 적용하는 것입니다. 한쪽만 정규화하면 올바른 요청도 표에서 찾지 못하게 됩니다.
시작할 때 라우트 표를 만드는 컴파일러
아래 프로그램은 서버가 시작할 때 컨트롤러를 조사해 라우트 표를 만드는 compile()과, 요청이 오면 표에서 찾은 메서드를 호출하는 HandlerPlan을 보여 줍니다. 여기서 '컴파일'은 javac가 하는 컴파일이 아니라, 애노테이션 선언을 검사해 실행하기 좋은 자료구조로 바꾼다는 뜻으로 붙인 이름입니다. 컨트롤러 메서드는 모두 Request와 Response를 받고 아무것도 반환하지 않는다고 정합니다. main은 먼저 정상적인 컨트롤러로 표를 만들어 요청 하나를 처리하고, 이어서 같은 라우트를 다른 표기로 선언한 컨트롤러를 함께 넣어 시작 단계에서 실패하는 모습을 보여 줍니다.
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("/+$", "");
if (!normalizedPath.startsWith("/")) {
throw new IllegalArgumentException("path must start with slash: " + path);
}
return new RouteKey(normalizedMethod, normalizedPath);
}
@Override
public String toString() {
return method + " " + path;
}
}
record HandlerPlan(Object target, Method method) {
void invoke(Request request, Response response) {
try {
method.invoke(target, request, response);
} catch (IllegalAccessException exception) {
throw new IllegalStateException("cannot access " + describe(method), exception);
} catch (InvocationTargetException exception) {
Throwable cause = exception.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
if (cause instanceof Error error) {
throw error;
}
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 + ": "
+ describe(previous.method()) + " and " + describe(method));
}
}
}
return Map.copyOf(plans);
}
private static void validateSignature(Method method) {
if (!Modifier.isPublic(method.getModifiers())) {
throw new IllegalArgumentException("route method must be public: " + describe(method));
}
if (method.getReturnType() != void.class) {
throw new IllegalArgumentException("route method must return void: " + describe(method));
}
if (!List.of(method.getParameterTypes()).equals(List.of(Request.class, Response.class))) {
throw new IllegalArgumentException(
"route parameters must be (Request, Response): " + describe(method));
}
}
private static String describe(Method method) {
return method.getDeclaringClass().getSimpleName() + "." + method.getName();
}
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");
}
}
static final class LegacyController {
@Mapping(method = "get", path = "/site-one")
public void oldSiteOne(Request request, Response response) {
response.write("old site one");
}
}
public static void main(String[] args) {
Map<RouteKey, HandlerPlan> routes = compile(List.of(new SiteController()));
List<String> keys = routes.keySet().stream().map(RouteKey::toString).sorted().toList();
System.out.println("routes: " + keys);
Response response = new Response();
routes.get(RouteKey.of("get", "/site-one"))
.invoke(new Request("GET", "/site-one"), response);
System.out.println(response);
try {
compile(List.of(new SiteController(), new LegacyController()));
} catch (IllegalArgumentException exception) {
System.out.println("startup failed: " + exception.getMessage());
}
}
}routes: [GET /, GET /site-one]
200 site one
startup failed: duplicate route GET /site-one: SiteController.siteOne and LegacyController.oldSiteOne코드가 길지만 하는 일은 네 덩어리로 나뉩니다. 애노테이션 선언, 키 만들기, 검사하며 표 채우기, 요청 때 호출하기입니다. 차례로 살펴보겠습니다.
@Retention과 @Target이 필요한 이유
@Mapping에 붙은 두 애노테이션은 메타 애노테이션 @Retention·@Target·@Inherited·@Repeatable에서 다룬 것입니다. @Target(ElementType.METHOD)는 @Mapping을 메서드에만 붙일 수 있게 하므로, 실수로 클래스나 필드에 붙이면 컴파일 오류가 납니다. @Retention(RetentionPolicy.RUNTIME)은 애노테이션을 실행 중에도 리플렉션으로 읽을 수 있게 남겨 둡니다. 유지 정책(retention policy)을 적지 않으면 기본값인 CLASS가 적용되는데, 이때 애노테이션은 클래스 파일에는 기록되지만 리플렉션으로는 보이지 않습니다. 그러면 method.getAnnotation(Mapping.class)가 언제나 null을 돌려주어 라우트가 하나도 등록되지 않으므로, 리플렉션으로 읽을 애노테이션에는 반드시 RUNTIME을 적어야 합니다.
RouteKey.of로 같은 라우트를 같은 키로 만들기
RouteKey는 HTTP 메서드와 경로를 담는 레코드이고, 정적 메서드 of가 앞에서 정한 정규화 규칙을 적용합니다. path.replaceAll("/+$", "")의 정규 표현식 /+$는 문자열 끝에 이어진 슬래시 한 개 이상을 뜻하므로, /site-one/과 /site-one//이 모두 /site-one이 됩니다. 루트 경로 /는 이 규칙대로 지우면 빈 문자열이 되기 때문에 따로 남겨 둡니다. toString()을 재정의한 것은 출력과 오류 메시지에 RouteKey[method=GET, path=/site-one] 대신 GET /site-one처럼 읽기 쉬운 형태가 나오게 하려는 것입니다.
compile()은 검사를 통과한 메서드만 표에 넣는다
compile()은 컨트롤러마다 getDeclaredMethods()로 메서드를 꺼내고, @Mapping이 없는 메서드는 건너뜁니다. 애노테이션이 없는 메서드는 라우트가 아니라 컨트롤러 내부의 도우미로 본다는 뜻입니다. 애노테이션이 있으면 validateSignature()로 시그니처(signature)를 검사합니다. 시그니처는 원래 메서드 이름과 매개변수 타입 목록을 가리키는 말인데, 여기서는 반환 타입과 접근 제한자까지 함께 확인합니다.
검사는 세 가지입니다. 메서드가 public이어야 하고, 반환 타입이 void여야 하며, 매개변수 타입이 정확히 Request, Response 순서여야 합니다. List.of(method.getParameterTypes())는 매개변수 타입 배열을 리스트로 바꾸고, 리스트의 equals는 원소를 순서대로 비교합니다. 그래서 개수와 순서와 타입이 모두 같을 때만 통과하고, 통과한 메서드라면 요청 때 invoke(target, request, response)가 인수 문제로 실패하지 않습니다.
시그니처가 맞으면 키를 만들어 putIfAbsent로 표에 넣습니다. putIfAbsent는 그 키에 값이 없을 때만 새 값을 넣고 null을 돌려주며, 이미 값이 있으면 아무것도 바꾸지 않고 기존 값을 돌려줍니다. 따라서 반환값이 null이 아니라면 같은 키를 두 메서드가 선언했다는 뜻이고, 이때 두 메서드의 이름을 담아 예외를 던집니다. put을 썼다면 나중 메서드가 앞의 메서드를 조용히 덮어썼을 것입니다.
main의 두 번째 compile 호출을 따라가 보면 다음과 같습니다. SiteController의 두 메서드는 어느 쪽이 먼저 나올지 정해져 있지 않지만, 둘 다 새 키라서 순서와 상관없이 결과가 같습니다. 컨트롤러는 목록 순서대로 조사하므로 LegacyController는 언제나 마지막에 처리됩니다.
| 조사한 메서드 | 애노테이션 값 | 만든 키 | 표의 변화 |
|---|---|---|---|
SiteController.home | GET, / | GET / | 새로 등록 |
SiteController.siteOne | GET, /site-one/ | GET /site-one | 새로 등록 |
LegacyController.oldSiteOne | get, /site-one | GET /site-one | 이미 있음, 예외 발생 |
oldSiteOne은 애노테이션에 적힌 글자만 보면 siteOne과 다르지만, 정규화를 거치면 같은 키가 됩니다. 키를 만들 때 정규화하지 않았다면 두 메서드는 서로 다른 라우트로 등록되었을 것이고, 같은 페이지를 두 메서드가 나눠 맡는 상태를 아무도 알아채지 못했을 것입니다. 출력 셋째 줄은 이 중복을 서버 시작 단계에서 잡아낸 결과입니다.
모든 검사를 통과하면 Map.copyOf(plans)로 수정할 수 없는 복사본을 만들어 돌려줍니다. 검사 도중 예외가 나면 이 줄에 도달하지 않으므로 일부만 채워진 표가 바깥으로 나가는 일은 없습니다. 돌려받은 표에 put을 호출하면 UnsupportedOperationException이 나기 때문에, 요청을 처리하는 도중 누군가 라우트를 바꿀 수도 없습니다. 다만 Map.copyOf가 만든 맵은 순회 순서가 정해져 있지 않습니다. 그래서 main은 키를 문자열로 바꿔 정렬한 뒤 출력하는데, 실행할 때마다 같은 첫 줄이 나오게 하려는 것입니다.
HandlerPlan은 요청 때 할 일만 남긴다
HandlerPlan은 호출할 컨트롤러 객체와 Method를 묶어 둔 레코드입니다. 요청이 오면 실행기는 RouteKey.of로 요청의 키를 만들어 표에서 계획을 꺼내고, 그 계획의 invoke를 부르기만 하면 됩니다. 출력 둘째 줄 200 site one이 이 과정의 결과입니다. 표를 찾을 때 소문자 get을 넣었고 경로에도 끝 슬래시가 없었지만, 정규화를 거친 키가 GET /site-one으로 같아서 siteOne이 호출되었습니다. 앞의 200은 Response에 들어 있는 상태 코드의 기본값입니다. invoke 안의 예외 처리는 뒤에서 따로 설명합니다.
더 깊이: public 검사만으로는 부족한 경우
validateSignature()는 메서드가 public인지만 봅니다. 그런데 리플렉션 호출이 성공하려면 메서드를 선언한 클래스에도 접근할 수 있어야 합니다. 이 예제의 SiteController는 public이 아닌 중첩 클래스지만, 실행기와 같은 패키지에 있어서 문제없이 호출됩니다. 만약 컨트롤러가 다른 패키지에 있는 public이 아닌 클래스라면, 메서드가 public이어도 invoke가 IllegalAccessException을 던집니다. 모듈을 쓰는 프로젝트라면 컨트롤러가 있는 패키지를 실행기 쪽 모듈에 공개했는지도 따져야 합니다. 이런 조건까지 시작 단계에서 확인하려면 method.canAccess(controller)처럼 실제로 접근할 수 있는지 묻는 검사를 추가하면 됩니다.
비공개 메서드도 setAccessible(true)를 호출하면 리플렉션으로 호출할 수 있지만, 이 실행기는 일부러 public 메서드만 받습니다. 비공개 메서드까지 열어 주면 내부용으로 만든 도우미 메서드에 실수로 애노테이션이 붙었을 때 그대로 외부 요청을 받는 통로가 되기 때문입니다. 게다가 setAccessible(true)는 언제나 성공하지도 않습니다. 다른 모듈에 있는 클래스라면 그 모듈이 해당 패키지를 열어(opens) 두었을 때만 접근 검사를 끌 수 있습니다.
이 밖에도 프레임워크가 정해야 할 정책이 남아 있습니다. getDeclaredMethods()는 상속받은 메서드를 돌려주지 않으므로, 부모 클래스에 선언한 라우트를 포함할지는 따로 정해야 합니다. static 메서드에 @Mapping을 붙여도 지금의 검사로는 막히지 않습니다. 이때 invoke는 대상 객체를 무시하고 메서드를 호출하므로 동작은 하지만, 허용할지 거절할지는 정책으로 정해 두는 편이 좋습니다.
404와 405 구분하기
표에 정확히 같은 키가 없을 때 무조건 404 Not Found를 돌려주면, 클라이언트는 경로를 잘못 적었는지 메서드를 잘못 골랐는지 알 수 없습니다. 예를 들어 /members에 GET과 POST만 등록되어 있는데 DELETE /members가 들어왔다면, 경로는 있지만 그 메서드를 지원하지 않는 상황입니다. HTTP는 이런 경우를 위해 405 Method Not Allowed를 두고 있으며, HTTP 명세(RFC 9110)는 405 응답에 그 자원이 지원하는 메서드 목록을 Allow 헤더로 반드시 담도록 정합니다. 그러려면 정확한 키가 없을 때 같은 경로를 가진 다른 키가 있는지 한 번 더 찾아봐야 합니다.
아래 예제는 조회 결과를 세 가지로 나눠 돌려줍니다. 정확한 키가 있으면 Found, 경로는 있지만 메서드가 다르면 MethodNotAllowed, 경로 자체가 없으면 NotFound입니다. 세 결과는 봉인된 인터페이스(sealed interface) Lookup을 구현하는 레코드로 만들었습니다. sealed와 permits는 이 인터페이스를 구현할 수 있는 타입을 permits 뒤에 적은 것으로 제한합니다. respond()는 실행기가 각 결과를 어떤 응답으로 바꿀지 보여 주는 메서드인데, 실제 HTTP 응답을 보내지는 않고 상태 코드와 핵심 내용만 문자열로 만듭니다.
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public final class RouteLookup {
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 HashSet<>();
for (RouteKey key : routes.keySet()) {
if (key.path().equals(path)) {
allowed.add(key.method());
}
}
return allowed.isEmpty()
? new NotFound(path)
: new MethodNotAllowed(allowed);
}
}
static String respond(Lookup lookup) {
return switch (lookup) {
case Found found -> "200 call " + found.handler();
case MethodNotAllowed notAllowed ->
"405 Allow: " + String.join(", ", new TreeSet<>(notAllowed.allowed()));
case NotFound notFound -> "404 no route for " + notFound.path();
};
}
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(respond(table.lookup("get", "/members")));
System.out.println(respond(table.lookup("DELETE", "/members")));
System.out.println(respond(table.lookup("GET", "/missing")));
}
}200 call listMembers
405 Allow: GET, POST
404 no route for /missing조회 키와 라우트 결과
라우트 조회
조회 키와 라우트 결과
메서드와 경로를 조회하면 서로 다른 세 결과를 돌려줍니다. 이 예제는 HTTP 응답을 전송하지 않습니다.
get /members
- 조회 결과
- Found(listMembers)
- HTTP 실행기 책임
- 처리기 호출
DELETE /members
- 조회 결과
- MethodNotAllowed · GET·POST 집합
- HTTP 실행기 책임
- 405와 Allow 생성
GET /missing
- 조회 결과
- NotFound(/missing)
- HTTP 실행기 책임
- 404 생성
조회 키는 메서드를 대문자로 바꾸지만 경로는 그대로 사용합니다. GET·POST의 집합에는 출력 순서가 보장되지 않습니다.
첫 줄은 소문자 get으로 조회한 결과입니다. RouteKey의 컴팩트 생성자(compact constructor)가 메서드를 대문자로 바꾸므로 GET /members와 같은 키가 되어 Found가 나왔습니다. 둘째 줄의 DELETE /members는 정확한 키가 없어서 표 전체를 훑고, 경로가 /members인 키들의 메서드 GET과 POST를 모아 MethodNotAllowed를 만듭니다. 셋째 줄의 /missing은 같은 경로를 가진 키가 하나도 없으므로 NotFound가 됩니다. 이 조회 예제는 메서드만 대문자로 바꾸고 경로는 그대로 비교한다는 점에 주의하세요. 앞의 컴파일러처럼 공백을 지우거나 끝 슬래시를 정리하지는 않습니다.
MethodNotAllowed는 허용 메서드를 Set.copyOf로 만든 집합에 담습니다. 집합에는 순서가 없고, 특히 Set.copyOf가 만든 집합은 프로그램을 실행할 때마다 순회 순서가 달라질 수 있습니다. 실제로 이 집합을 그대로 출력하는 코드를 여러 번 실행해 보면 [GET, POST]가 나올 때도 있고 [POST, GET]이 나올 때도 있습니다. 그래서 respond()는 Allow 값을 만들 때 TreeSet으로 정렬해, 같은 요청에는 언제나 같은 헤더가 나가게 합니다.
respond()의 switch에는 default가 없습니다. Lookup이 봉인되어 있어서 컴파일러는 가능한 구현이 Found, MethodNotAllowed, NotFound 셋뿐이라는 것을 알고, 세 경우를 모두 다뤘는지 검사해 줍니다. 나중에 새로운 조회 결과를 permits에 추가하면 이 switch가 컴파일 오류를 내므로, 새 결과를 처리하지 않고 넘어가는 실수를 막을 수 있습니다. 봉인된 인터페이스를 대상으로 하는 switch 패턴 매칭은 Java 21부터 정식 기능입니다.
허용 메서드를 찾을 때는 표 전체를 훑으므로 라우트 수에 비례하는 시간이 듭니다. 405 응답이 자주 나가고 라우트가 많다면, 시작할 때 경로별 허용 메서드를 모은 Map<String, Set<String>>을 함께 만들어 두면 됩니다. 어느 쪽이든 요청을 처리하는 도중 애노테이션을 다시 조사하지 않는다는 원칙은 같습니다.
컨트롤러가 던진 예외 꺼내기
Method.invoke로 호출한 메서드가 예외를 던지면, invoke는 그 예외를 그대로 던지지 않고 InvocationTargetException으로 감싸서 던집니다. 리플렉션 호출 자체의 문제(접근할 수 없음, 인수가 맞지 않음)와 호출된 메서드 안에서 난 문제를 구분하려는 설계입니다. 감싼 예외가 실제로 어떻게 보이는지 확인해 보겠습니다.
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public final class InvocationUnwrapDemo {
static final class MemberController {
public void find(String id) {
throw new IllegalArgumentException("unknown member: " + id);
}
}
public static void main(String[] args) throws Exception {
Method method = MemberController.class.getMethod("find", String.class);
try {
method.invoke(new MemberController(), "42");
} catch (InvocationTargetException exception) {
System.out.println("caught: " + exception);
System.out.println("message: " + exception.getMessage());
System.out.println("cause: " + exception.getCause());
}
}
}caught: java.lang.reflect.InvocationTargetException
message: null
cause: java.lang.IllegalArgumentException: unknown member: 42컨트롤러가 던진 예외의 메시지 unknown member: 42는 첫 두 줄 어디에도 없습니다. InvocationTargetException은 자기 메시지 없이 원래 예외를 원인(cause)으로만 들고 있어서 getMessage()가 null을 돌려주고, 원래 예외는 셋째 줄처럼 getCause()로 꺼내야 보입니다. 27장의 서블릿 오류 처리처럼 예외 타입을 보고 404나 500을 고르는 오류 처리기가 있다면 문제가 커집니다. 이 감싼 예외를 그대로 넘기면 모든 예외가 똑같이 InvocationTargetException으로 보여서 타입별 처리가 동작하지 않고, 로그에도 원래 원인 대신 리플렉션 예외가 맨 위에 찍힙니다.
그래서 앞의 HandlerPlan.invoke는 원인을 꺼내 다시 던집니다. 원인이 RuntimeException이나 Error이면 그대로 던져서, 바깥의 오류 처리기가 원래 타입을 보고 판단할 수 있게 합니다. 원인이 검사 예외(checked exception)라면 invoke 메서드에 throws 선언이 없어 그대로 던질 수 없으므로, IllegalStateException으로 감싸고 원인으로 연결합니다. validateSignature()는 throws 절을 검사하지 않으므로, 컨트롤러 메서드가 throws IOException처럼 검사 예외를 선언하면 이 경우가 생길 수 있습니다.
IllegalAccessException은 성격이 다릅니다. 컨트롤러가 던진 예외가 아니라 실행기가 메서드에 접근하지 못했다는 뜻이므로, 사용자의 요청이 아니라 서버 구성에 문제가 있다는 신호입니다. 앞의 더 깊이 절에서 본 것처럼 public 검사만으로는 접근 가능성을 모두 확인하지 못하므로, 이 예외는 서버 내부 오류로 다룹니다.
오류를 발견하는 세 시점
지금까지 본 오류를 발견되는 시점에 따라 나누면 다음과 같습니다.
| 발견 시점 | 예 | 누가 찾는가 |
|---|---|---|
| 컴파일할 때 | 허용되지 않은 자리에 애노테이션을 붙임, 요소를 빠뜨림, 요소 값의 타입이 틀림 | javac |
| 서버가 시작할 때 | 중복 라우트, 지원하지 않는 시그니처, 슬래시로 시작하지 않는 경로 | 라우트 컴파일러 |
| 요청을 처리할 때 | 없는 경로(404), 지원하지 않는 메서드(405), 저장소 장애 같은 실행 중 실패 | 실행기와 오류 처리기 |
모든 오류를 앞 시점으로 옮길 수는 없습니다. 요청이 어떤 경로로 올지, 외부 저장소가 응답할지는 요청을 받아 봐야 압니다. 하지만 클래스와 애노테이션처럼 서버가 이미 가진 정보만으로 판단할 수 있는 문제라면 사용자 요청이 올 때까지 미룰 이유가 없습니다. 이 글의 실행기가 검사를 모두 시작 단계에 모은 것도 그 때문입니다.
정리
- 애노테이션에 HTTP 메서드와 경로를 적으면 Java 메서드 이름과 URL이 분리되어, 메서드 이름을 바꿔도 URL이 바뀌지 않습니다.
- 라우트 키는 HTTP 메서드와 경로의 조합이며, 표를 만들 때와 요청을 찾을 때 같은 정규화 규칙을 적용해야 합니다.
- 중복 라우트와 잘못된 시그니처는 서버가 시작할 때 찾아 시작을 실패시키고, 검사를 모두 통과한 표만 수정할 수 없는 맵으로 내보냅니다.
- 정확한 키가 없더라도 같은 경로에 다른 메서드가 있으면 404 대신 405와
Allow헤더로 응답합니다. Method.invoke는 컨트롤러의 예외를InvocationTargetException으로 감싸므로,getCause()로 꺼내야 예외 타입별 처리가 동작합니다.
연습 문제
MappingRouteCompiler의RouteKey.of에 메서드" get "과 경로"/site-one/"을 넣으면 어떤 키가 나올까요? 또RouteLookup의 표(GET /members,POST /members)에서(" get ", "/members")와("GET", "/members/")를 조회하면respond()는 각각 무엇을 돌려줄까요?- 라우트 키를 경로만으로 만들면
GET /members와POST /members를 선언한 컨트롤러에서 어떤 일이 생기는지, 표에put으로 넣을 때와putIfAbsent로 중복을 검사할 때로 나눠 설명하세요. - 두 컨트롤러를 조사해 비공개 메서드, 슬래시로 시작하지 않는 경로, 중복 라우트를 모두 찾아내는 컴파일러를 작성하세요. 첫 문제에서 바로 예외를 던지지 말고 문제를 모두 목록에 모은 뒤, 목록이 비어 있을 때만 라우트 표를 내보냅니다. 출력 순서가 매번 같도록 클래스 이름과 메서드 이름으로 정렬하세요.
1번과 2번 해설
RouteKey.of는 메서드의 공백을 지우고 대문자로 바꾼 뒤 경로 끝의 슬래시를 지우므로 키는GET /site-one입니다. 반면RouteLookup의RouteKey는 메서드를 대문자로만 바꾸므로" get "은 앞뒤 공백이 남은" GET "이 됩니다. 이 키는 표에 없지만 경로/members는 표에 있으므로 첫 조회는MethodNotAllowed가 되고,respond()는405 Allow: GET, POST를 돌려줍니다. 둘째 조회는 경로를 그대로 비교하므로/members/라는 경로가 표에 없어NotFound가 되고, 결과는404 no route for /members/입니다. 두 프로그램 모두RouteKey라는 이름을 쓰지만 정규화 규칙은 다릅니다. 표를 만드는 쪽과 찾는 쪽이 같은 규칙을 쓰지 않으면 이처럼 올바른 요청이 405나 404가 됩니다.- 경로만 키로 쓰면 두 메서드의 키가 똑같이
/members가 됩니다.put을 쓰면 나중에 처리한 메서드가 앞의 메서드를 덮어써서 조회와 등록 가운데 하나가 사라지고, 어느 쪽이 사라질지는getDeclaredMethods()가 돌려주는 순서에 달려 있습니다.putIfAbsent로 중복을 검사하면 이번에는 정상적인 두 라우트를 중복으로 오인해 서버가 시작하지 못합니다. 어느 쪽이든 원인은 키 설계에 있으므로, HTTP 메서드와 경로를 함께 키로 써야 합니다.
3번 해답 보기
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.Locale;
import java.util.Map;
public final class RouteStartupReportSolution {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Mapping {
String method();
String path();
}
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(Mapping.class))
.sorted(Comparator.comparing((Method method) -> method.getDeclaringClass().getName())
.thenComparing(Method::getName))
.toList();
for (Method method : methods) {
String owner = method.getDeclaringClass().getSimpleName();
Mapping mapping = method.getAnnotation(Mapping.class);
if (!Modifier.isPublic(method.getModifiers())) {
issues.add(new Issue(owner, method.getName(), "method is not public"));
}
if (!mapping.path().startsWith("/")) {
issues.add(new Issue(owner, method.getName(), "path must start with slash"));
}
String key = mapping.method().toUpperCase(Locale.ROOT) + " " + mapping.path();
Method previous = routes.putIfAbsent(key, method);
if (previous != null) {
issues.add(new Issue(owner, method.getName(), "duplicate " + key + " with "
+ previous.getDeclaringClass().getSimpleName() + "." + previous.getName()));
}
}
if (!issues.isEmpty()) {
routes.clear();
}
return new CompileResult(routes, issues);
}
static final class FirstController {
@Mapping(method = "GET", path = "/same")
public void first() {
}
@Mapping(method = "GET", path = "broken")
private void hidden() {
}
}
static final class SecondController {
@Mapping(method = "get", path = "/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());
}
}ready=false
Issue[owner=FirstController, method=hidden, detail=method is not public]
Issue[owner=FirstController, method=hidden, detail=path must start with slash]
Issue[owner=SecondController, method=second, detail=duplicate GET /same with FirstController.first]
publishedRoutes=0해답은 애노테이션이 붙은 메서드를 먼저 모두 모아, 선언한 클래스의 이름과 메서드 이름 순서로 정렬합니다. getName()이 돌려주는 클래스 이름은 RouteStartupReportSolution$FirstController처럼 바깥 클래스 이름을 포함하는데, F가 S보다 앞이므로 FirstController의 메서드가 먼저 처리되고, 그 안에서는 first가 hidden보다 앞입니다. 그래서 first가 GET /same을 먼저 차지합니다. hidden은 비공개이면서 경로도 슬래시로 시작하지 않아 문제를 두 개 남기고, 마지막 second는 메서드를 대문자로 바꾸면 같은 GET /same이 되어 중복으로 기록됩니다.
문제가 하나라도 있으면 routes.clear()로 표를 비우므로 마지막 줄에 publishedRoutes=0이 출력됩니다. 문제가 있는 라우트만 빼고 나머지를 내보내면 서버가 일부 기능만 동작하는 상태로 시작하는데, 그런 상태는 문제를 바로 알아차리기 어렵게 만듭니다. 처음부터 끝까지 검사해서 문제를 한 번에 보여 주면, 개발자는 서버를 여러 번 다시 시작하지 않고 모든 문제를 함께 고칠 수 있습니다.
이 해답은 경로 끝의 슬래시를 정규화하지 않고 매개변수도 검사하지 않는 축소판입니다. 또 클래스 이름과 메서드 이름만으로 정렬하므로, 이름이 같은 오버로드 메서드가 있다면 매개변수 타입 같은 기준을 더해야 순서가 완전히 정해집니다. 실무에서는 문제마다 애노테이션의 위치, 기대한 시그니처, 실제 시그니처까지 함께 담아 두면 고치기가 훨씬 쉬워집니다.
다음 글
인수 해석기로 컨트롤러 매개변수 채우기에서는 모든 컨트롤러 메서드에 Request와 Response를 강요하던 제약을 풀고, 매개변수마다 필요한 값을 골라 넣는 방법을 다룹니다.