서블릿으로 라우팅과 오류 처리 나누기
경로마다 늘어나는 조건문을 서블릿 인터페이스와 라우트 표로 바꾸고, 예외를 404·500 응답으로 바꾸는 방법과 서블릿 컨테이너가 하는 일을 배웁니다.
앞의 글들에서 소켓으로 연결을 받는 서버를 만들고, 요청 대상에서 경로와 쿼리를 나누고, 요청 바이트를 읽고 응답 바이트를 쓰는 방법까지 살펴봤습니다. 이제 서버에 실제 기능을 붙일 차례입니다. 가장 쉬운 방법은 연결을 처리하는 코드 안에 if (path.equals("/search")) 같은 조건을 하나씩 늘려 가는 것입니다. 하지만 기능이 몇 개만 늘어도 소켓과 HTTP 형식을 다루는 코드와 업무 기능이 한데 엉켜, 어느 한쪽을 고칠 때마다 다른 쪽까지 건드리게 됩니다.
이 글에서는 둘을 떼어 놓습니다. 기능마다 '요청을 받아 응답을 채우는' 작은 인터페이스를 구현하게 하고, 라우터가 요청의 메서드와 경로를 보고 실행할 기능을 고릅니다. 기능 안에서 던져진 예외는 한곳에서 404나 500 응답으로 바꿉니다. 마지막으로 이 조각들을 소켓 서버에 붙여 curl로 실제 응답을 확인합니다.
Java 웹 서버가 HTTP 요청을 애플리케이션 코드에 넘길 때 쓰는 표준 인터페이스인 서블릿(Servlet)이 바로 이 생각에서 출발했습니다. 그래서 이 글의 인터페이스도 서블릿이라고 부르고, 글 뒷부분에서는 이 구조가 Tomcat 같은 서블릿 컨테이너로 이어진 과정을 살펴봅니다. 다만 실제 Jakarta Servlet API 전체를 재현하지는 않고, 역할이 어떻게 나뉘는지 보이는 데 필요한 만큼만 만듭니다.
경로가 늘 때마다 커지는 조건문
다음 코드는 경로를 보고 돌려줄 HTML을 고르는 가장 단순한 방식입니다. 연결 처리 코드가 요청 줄에서 경로와 쿼리를 꺼내 handle을 호출하고, 돌려받은 문자열을 응답 본문으로 보낸다고 생각하면 됩니다. main에서는 없는 경로와, 쿼리에 HTML 태그가 섞인 검색 요청을 넣어 봅니다.
public final class PathConditionalHttpService {
static String handle(String path, String query) {
if (path.equals("/")) {
return "<h1>home</h1>";
} else if (path.equals("/site1")) {
return "<h1>site1</h1>";
} else if (path.equals("/search")) {
return "<h1>" + query + "</h1>";
} else {
return "<h1>not found</h1>";
}
}
public static void main(String[] args) {
System.out.println(handle("/missing", ""));
System.out.println(handle("/search", "<script>alert(1)</script>"));
}
}handle은 상태 코드 없이 본문 문자열만 돌려주고, 검색 경로에서는 쿼리를 아무 처리 없이 HTML 사이에 이어 붙입니다. 실행하면 다음 두 줄이 출력됩니다.
<h1>not found</h1>
<h1><script>alert(1)</script></h1>첫 줄은 없는 경로에 대한 결과입니다. 본문에는 not found라고 적혀 있지만, handle이 돌려주는 것은 문자열 하나뿐이라 '이 응답의 상태는 404'라는 정보를 담을 자리가 없습니다. 결국 연결 처리 코드는 이 본문을 200 OK와 함께 보내게 되고, 브라우저나 검색 엔진은 오류 안내를 정상 페이지로 받아들입니다.
둘째 줄은 쿼리 값이 HTML 안에 그대로 들어간 결과입니다. 브라우저가 이 응답을 HTML로 해석하면 사용자가 보낸 <script> 태그가 실행됩니다. 공격자가 만든 링크를 누르기만 해도 공격자의 스크립트가 내 브라우저에서 실행될 수 있는 이 문제를 크로스 사이트 스크립팅(XSS, cross-site scripting)이라고 합니다.
구조에도 문제가 있습니다. 기능을 하나 추가할 때마다 이 메서드를 고쳐야 하는데, 이 메서드는 소켓을 다루는 코드 바로 옆에서 호출되므로 업무 기능을 바꾸다가 전송 코드를 건드리기 쉽습니다. 또 handle은 HTTP 메서드를 받지 않아서 GET /search와 POST /search를 구분하지 못합니다. 검색 도중 데이터베이스 연결이 끊겨 예외가 나면 어떤 상태 코드로 답할지도 정해져 있지 않습니다. 이 글의 나머지는 이 문제들을 하나씩 푸는 과정입니다.
메서드와 경로를 묶어 라우트 키로 쓰기
첫 단계는 '어떤 요청을 어떤 기능이 처리하는가'를 조건문 대신 표로 적는 것입니다. 이 표를 라우트 표(route table)라고 하고, 요청에 맞는 기능을 찾아 실행하는 일을 라우팅(routing)이라고 합니다. 표의 키를 경로 하나로만 잡으면 앞의 코드와 같은 한계가 남습니다. GET /members는 회원 목록을 읽고 POST /members는 새 회원을 만드는 것처럼, 같은 경로라도 메서드가 다르면 다른 동작이기 때문입니다. 그래서 (메서드, 경로) 쌍을 키로 씁니다.
키를 이렇게 잡으면 '처리할 기능이 없다'에도 두 종류가 있다는 것이 드러납니다. 경로 자체가 등록되어 있지 않으면 404 Not Found입니다. 경로는 있지만 그 메서드를 허용하지 않으면 405 Method Not Allowed가 맞습니다. RFC 9110은 405 응답에 그 자원이 지원하는 메서드 목록을 Allow 헤더로 반드시 넣도록 정하고 있으므로, 405를 만들려면 라우터가 '이 경로에 등록된 메서드'를 따로 알아낼 수 있어야 합니다. 아래 예제는 정확히 일치하는 키만 찾기 때문에 두 경우를 모두 404로 처리하고, 405는 연습 문제에서 직접 구현합니다.
/members/42처럼 경로 안에 값이 들어가는 경로 매개변수(path parameter)를 지원하려면 정확히 일치하는 키만 찾는 Map으로는 부족합니다. /members/{id} 같은 패턴과 비교하는 매처가 필요하고, /members/new가 /members/{id}에도 맞는 것처럼 한 요청이 여러 패턴에 맞을 때 어느 쪽을 고를지 우선순위도 정해야 합니다. 이런 모호함은 요청이 들어온 뒤가 아니라 라우트를 등록할 때 검사해 두는 편이 안전합니다. 이 글은 정확히 일치하는 경로만 다룹니다.
서블릿 인터페이스와 라우터 만들기
이제 기능 하나를 나타내는 인터페이스를 정합니다. 기능은 요청을 받아 응답을 채우기만 하고, 소켓이나 바이트 형식은 전혀 모릅니다. 아래 코드는 이 역할을 다섯 가지 타입으로 나눕니다.
Route는 라우트 표의 키입니다. 메서드와 경로를 담고, 경로가/로 시작하지 않으면 등록할 때 거절합니다.Request는 서블릿에 넘길 요청입니다. 연결 처리 코드가 이미 파싱한 메서드, 경로, 쿼리를 담습니다. 쿼리Map은 복사해 두지만, HTTP 입력이 올바른지 검사하는 일은 하지 않습니다.Response는 서블릿이 채우는 응답입니다. 상태 코드(기본값 200)와Content-Type, 본문을 메모리에 모아 둡니다.Servlet은service(Request, Response)메서드 하나만 있는 함수형 인터페이스입니다. 그래서 기능을 람다로 등록할 수 있습니다.ServletRouter는 라우트 표를 들고 있다가, 요청에서 만든 키로 서블릿을 찾아 실행합니다.
라우터는 생성자에서 Map.copyOf로 표를 복사해 수정할 수 없는 Map으로 만듭니다. 서버가 시작된 뒤에는 여러 요청 스레드가 이 표를 동시에 읽게 되는데, 표가 바뀌지 않으면 따로 동기화하지 않아도 안전하기 때문입니다. 또 키를 찾지 못했을 때 null을 돌려주는 대신 404 응답을 쓰는 기본 서블릿 notFound를 실행합니다. 호출하는 쪽에서 null 검사를 빠뜨릴 일이 없도록 하려는 것입니다.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public final class ServletRouter {
record Route(String method, String path) {
Route {
method = method.toUpperCase(Locale.ROOT);
if (!path.startsWith("/")) {
throw new IllegalArgumentException("path must start with slash");
}
}
}
record Request(String method, String path, Map<String, String> query) {
Request {
query = Map.copyOf(query);
}
}
static final class Response {
private int status = 200;
private String contentType = "text/plain; charset=UTF-8";
private final StringBuilder body = new StringBuilder();
void status(int value) {
status = value;
}
void contentType(String value) {
contentType = Objects.requireNonNull(value);
}
void write(String value) {
body.append(value);
}
String summary() {
return status + " " + contentType + " " + body;
}
}
@FunctionalInterface
interface Servlet {
void service(Request request, Response response) throws Exception;
}
private final Map<Route, Servlet> routes;
private final Servlet notFound;
ServletRouter(Map<Route, Servlet> routes) {
this.routes = Map.copyOf(routes);
this.notFound = (request, response) -> {
response.status(404);
response.write("route not found: " + request.path());
};
}
void execute(Request request, Response response) throws Exception {
Route route = new Route(request.method(), request.path());
routes.getOrDefault(route, notFound).service(request, response);
}
static ServletRouter standard() {
Map<Route, Servlet> routes = new LinkedHashMap<>();
routes.put(new Route("GET", "/"), (request, response) -> {
response.contentType("text/html; charset=UTF-8");
response.write("<h1>home</h1>");
});
routes.put(new Route("GET", "/search"), (request, response) -> {
String value = request.query().getOrDefault("q", "");
response.write("query=" + value);
});
routes.put(new Route("POST", "/notes"), (request, response) -> {
response.status(201);
response.write("created");
});
return new ServletRouter(routes);
}
public static void main(String[] args) throws Exception {
var router = standard();
for (Request request : List.of(
new Request("GET", "/", Map.of()),
new Request("GET", "/search", Map.of("q", "java")),
new Request("DELETE", "/missing", Map.of()))) {
var response = new Response();
router.execute(request, response);
System.out.println(response.summary());
}
}
}standard()는 GET /, GET /search, POST /notes 세 키를 등록합니다. main이 넣는 요청 가운데 앞의 두 개는 표에 있는 키이고, DELETE /missing은 없는 키입니다.
200 text/html; charset=UTF-8 <h1>home</h1>
200 text/plain; charset=UTF-8 query=java
404 text/plain; charset=UTF-8 route not found: /missingmain은 요청 세 개를 차례로 라우터에 넘기고, 응답마다 summary()로 상태, Content-Type, 본문을 한 줄에 출력합니다. 첫 줄은 GET / 키로 등록한 서블릿이 Content-Type을 HTML로 바꾸고 본문을 쓴 결과입니다. 둘째 줄의 검색 서블릿은 상태와 Content-Type을 건드리지 않았으므로 Response의 기본값인 200과 text/plain이 그대로 나옵니다. 셋째 줄의 DELETE /missing은 표에 없는 키라서 getOrDefault가 notFound를 돌려주었고, 이 서블릿이 상태를 404로 바꿨습니다.
ServletRouter의 등록 정보와 조회 분기를 비교합니다.
서블릿 라우터 · 메서드와 경로의 정확 일치
라우트 키가 없으면 이 예제는 모두 404로 보낸다
(method, path) 키가 등록되어 있으면 해당 service를 실행합니다. 같은 경로의 다른 메서드도 이 예제에서는 기본 404로 처리합니다.
등록된 메서드·경로
- 원문의 선택
- 해당 Servlet의 service 호출
- 구현 범위
- GET /, GET /search, POST /notes 등록
같은 경로·다른 메서드
- 원문의 선택
- 정확한 키가 없으므로 기본 404
- 구현 범위
- 405·Allow를 만드는 별도 경로 조회 없음
등록되지 않은 경로
- 원문의 선택
- 기본 404 Servlet
- 구현 범위
- null 대신 처리기를 실행
Route는 메서드를 대문자로 바꾸는 학습용 정책을 씁니다. HTTP 메서드 토큰 자체는 대소문자를 구분합니다.
그림은 이 라우터가 요청을 나누는 세 경우를 정리한 것입니다. 등록된 키는 해당 서블릿을 실행하고, 등록되지 않은 경로는 기본 404 서블릿으로 갑니다. 가운데 경우를 눈여겨보세요. POST /search처럼 경로는 등록되어 있고 메서드만 다른 요청도, 이 예제에서는 키가 정확히 일치하지 않으므로 404가 됩니다. 원래는 405와 Allow 헤더가 맞는 상황입니다.
Route의 생성자는 메서드를 대문자로 바꿉니다. 그래서 new Route("get", "/")도 GET /와 같은 키가 됩니다. 이것은 예제를 다루기 쉽게 하려는 학습용 정책일 뿐 HTTP의 규칙은 아닙니다. RFC 9110에서 메서드 이름은 대소문자를 구분하므로 get은 GET과 다른, 서버가 모르는 메서드입니다. RFC는 서버가 이런 요청에 501 Not Implemented로 답하도록 권장합니다.
응답을 바로 보내지 않고 모아 두는 이유
Response의 write는 소켓에 아무것도 쓰지 않고 StringBuilder에 모아 두기만 합니다. 바로 보내지 않는 데는 두 가지 이유가 있습니다. 첫째, HTTP/1.1 응답은 상태 줄과 헤더가 본문보다 먼저 나가야 하는데, Content-Length에 적을 본문의 바이트 수는 본문이 다 만들어져야 알 수 있습니다. 둘째, 서블릿이 본문을 쓰다가 예외를 던지면 응답 전체를 500으로 바꿔야 하는데, 이미 소켓으로 200 OK를 보냈다면 되돌릴 방법이 없습니다.
메모리에 모아 두면 서블릿이 끝난 뒤 최종 상태를 확인하고, 본문을 UTF-8로 인코딩해 바이트 수를 센 다음, 상태 줄과 헤더와 본문을 한 번에 보낼 수 있습니다. 예제의 summary()는 출력용 요약일 뿐 HTTP 형식으로 바꾸지는 않습니다. 실제로 바이트를 쓰는 코드는 뒤에서 소켓 서버에 붙일 때 만듭니다.
상태 줄과 헤더가 한번 소켓으로 나간 상태를 응답이 커밋(commit)되었다고 합니다. 커밋된 뒤에는 상태 코드와 헤더를 바꿀 수 없습니다. 모든 응답을 메모리에 모으는 방식은 작은 응답에는 잘 맞지만, 큰 파일이나 오래 이어지는 스트리밍 응답이라면 메모리를 많이 쓰고 첫 바이트도 늦게 나갑니다. 그런 응답은 본문을 쓰는 도중에 커밋되므로, 커밋 뒤에 생긴 오류를 어떻게 처리할지까지 따로 설계해야 합니다.
검색 서블릿은 쿼리 값을 HTML이 아닌 text/plain 본문에 넣었습니다. 사용자 값을 HTML 안에 넣는 서블릿이라면 <를 <로, &를 &로 바꾸는 식의 HTML 이스케이프를 반드시 적용해야 합니다. 퍼센트 디코딩은 %3C 같은 표기를 원래 문자 <로 되돌릴 뿐이므로 XSS를 막아 주지 않습니다. 서블릿 인터페이스는 전송 방식을 감춰 줄 뿐, 값을 출력할 곳에 맞게 처리하는 일은 여전히 서블릿이 해야 합니다.
서블릿이 던진 예외를 404와 500으로 바꾸기
라우터는 '처리할 기능이 없다'는 경우를 맡았습니다. 그런데 기능을 찾은 뒤에도 일이 잘못될 수 있습니다. /members/42를 처리하는 서블릿은 있어도 42번 회원이 없을 수 있고, 데이터베이스가 응답하지 않아 예외가 날 수도 있습니다. 앞의 경우는 클라이언트가 없는 자원을 요청한 것이므로 404가 맞고, 뒤의 경우는 서버 쪽 문제이므로 500 Internal Server Error가 맞습니다.
두 경우는 운영에서도 다르게 다룹니다. 404는 자주 일어나는 클라이언트 쪽 결과라서 몇 번 일어났는지 세어 두면 충분합니다. 반면 예상하지 못한 예외는 고쳐야 할 결함일 수 있으므로 자세한 내용을 서버 로그에 남겨야 합니다. 이때 예외 메시지나 스택 트레이스를 응답 본문에 넣어서는 안 됩니다. 클래스 이름, 파일 경로, SQL 문장 같은 내부 정보가 그대로 외부에 드러나기 때문입니다.
대신 요청마다 오류 ID를 만들어, 응답에는 ID만 보여 주고 서버 로그에는 같은 ID와 함께 예외의 자세한 내용을 남깁니다. 사용자가 화면의 오류 ID를 알려 주면 운영자는 그 ID로 로그를 찾을 수 있습니다.
아래 HttpErrorBoundary의 execute가 이 일을 합니다. 라우터와 서블릿을 포함한 애플리케이션 전체를 Application 인터페이스 하나로 단순화하고, 이것을 실행하다 던져진 예외의 종류에 따라 응답을 바꿉니다. PageNotFoundException이면 404로, 그 밖의 Exception이면 500으로 바꾸고, 500 응답에는 무작위 UUID로 만든 오류 ID와 Cache-Control: no-store 헤더를 넣습니다. 이 헤더는 요청마다 다른 오류 ID가 담긴 응답을 브라우저나 중간 캐시가 저장해 두지 않도록 명시하는 것입니다. 결과는 응답, 오류 ID, 기록할 예외 종류를 묶은 Execution 레코드로 돌려줍니다.
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public final class HttpErrorBoundary {
record Request(String path) {
}
static final class Response {
int status = 200;
final Map<String, String> headers = new HashMap<>();
String body = "";
boolean successful() {
return status >= 200 && status < 400;
}
}
static final class PageNotFoundException extends RuntimeException {
PageNotFoundException(String path) {
super(path);
}
}
@FunctionalInterface
interface Application {
void handle(Request request, Response response) throws Exception;
}
record Execution(Response response, String errorId,
String loggedType) {
}
static Execution execute(Application application, Request request) {
var response = new Response();
try {
application.handle(request, response);
return new Execution(response, null, null);
} catch (PageNotFoundException error) {
response.status = 404;
response.body = "page not found";
return new Execution(response, null, error.getClass().getSimpleName());
} catch (Exception error) {
String errorId = UUID.randomUUID().toString();
response.status = 500;
response.body = "internal error id=" + errorId;
response.headers.put("Cache-Control", "no-store");
return new Execution(
response,
errorId,
error.getClass().getSimpleName());
}
}
public static void main(String[] args) {
Application application = (request, response) -> {
switch (request.path()) {
case "/ok" -> response.body = "ok";
case "/missing" -> throw new PageNotFoundException(request.path());
default -> throw new IllegalStateException("database unavailable");
}
};
for (String path : List.of("/ok", "/missing", "/boom")) {
Execution execution = execute(application, new Request(path));
System.out.println(path
+ " status=" + execution.response().status
+ " success=" + execution.response().successful()
+ " logged=" + execution.loggedType());
}
}
}출력은 경로마다 한 줄입니다. execute가 정한 상태 코드, 그 상태를 성공으로 보는지(successful()), 기록하려고 남긴 예외 종류를 차례로 찍습니다.
/ok status=200 success=true logged=null
/missing status=404 success=false logged=PageNotFoundException
/boom status=500 success=false logged=IllegalStateExceptionmain의 서비스는 경로에 따라 다르게 움직입니다. /ok는 본문을 쓰고 정상적으로 끝나므로 응답은 200 그대로이고, 기록할 예외가 없어 logged=null입니다. /missing은 PageNotFoundException을 던지므로 첫 번째 catch가 잡아 404로 바꿉니다. 이때도 logged에 예외 종류가 찍히는 것은 횟수를 세는 용도로 남기기 때문이며, 오류 ID는 붙이지 않습니다. /boom은 switch의 default로 가서 IllegalStateException을 던지고, 이 예외는 PageNotFoundException이 아니므로 두 번째 catch가 잡아 500으로 바꿉니다.
successful()은 이 예제가 정한 분류로, 상태가 200 이상 400 미만이면 true입니다. HTTP에서 2xx는 성공, 3xx는 리다이렉션이므로 HTTP 용어의 '성공'과는 범위가 다르고, 여기서는 '오류로 끝나지 않았다'는 정도의 뜻입니다.
catch 블록의 순서에도 이유가 있습니다. PageNotFoundException은 RuntimeException을 상속하므로 Exception이기도 합니다. 만약 catch (Exception error)를 먼저 쓰면 모든 예외가 거기서 잡혀 404 분기에 닿을 수 없으므로, 컴파일러가 exception PageNotFoundException has already been caught 오류를 내며 코드를 거절합니다. 구체적인 예외를 먼저, 일반적인 예외를 나중에 잡아야 합니다.
HttpErrorBoundary.execute의 catch 순서와 처리 범위입니다.
HTTP 오류 경계 · 응답 교체와 전파
응답 상태를 바꾸는 경계와 경계 밖 오류를 구분한다
찾을 수 없음은 404, 그 밖의 Exception은 500으로 바꿉니다. Error와 실제 소켓 전송 실패는 이 메모리 응답 경계의 처리 범위 밖입니다.
정상 반환
- 원문의 응답 처리
- 서비스가 만든 Response 유지
- 경계의 한계
- successful은 200 이상 400 미만인 자체 분류
찾을 수 없음 예외
- 원문의 응답 처리
- 404 · page not found로 본문 대체
- 경계의 한계
- 기존 응답 헤더는 지우지 않음
그 밖의 Exception
- 원문의 응답 처리
- 500 · 오류 ID 본문 · no-store 추가
- 경계의 한계
- 예외 종류를 Execution에 담지만 서버 로그 출력은 하지 않음
Error 또는 실제 전송 실패
- 원문의 응답 처리
- 이 메서드의 처리 범위 밖
- 경계의 한계
- Error는 잡지 않으며 소켓 전송 자체가 없음
main은 /ok·/missing·/boom을 처리하도록 구성합니다. 서비스가 먼저 응답 헤더를 바꾼 뒤 실패하는 입력은 포함하지 않습니다.
그림은 execute가 어디까지 처리하는지 정리한 것입니다. 정상 반환과 두 catch 분기 말고도 눈여겨볼 한계가 두 가지 있습니다. 첫째, 404와 500 분기는 서비스가 쓰던 response 객체를 그대로 고쳐 씁니다. 서비스가 헤더를 넣은 뒤에 실패했다면 그 헤더가 오류 응답에 남는데, main에는 그런 입력이 없어 드러나지 않을 뿐입니다. 오류 응답은 새 Response로 만드는 편이 안전하고, 뒤의 소켓 서버 예제는 그렇게 합니다.
둘째, catch (Exception error)는 OutOfMemoryError 같은 Error를 잡지 않습니다. 또 이 메서드는 메모리 안의 응답만 다루므로, 소켓에 응답을 쓰다가 연결이 끊기는 실패는 이 메서드 바깥에서 처리해야 합니다. 그림에 적힌 대로 Execution에 예외 종류를 담기만 하고 실제 로그를 출력하지 않는다는 점도 실제 서버와 다른 부분입니다.
서블릿까지 오지 못한 요청의 오류
모든 오류가 서블릿 안에서 생기는 것은 아닙니다. 요청 줄의 형식이 틀렸거나, 헤더가 너무 크거나, 본문이 서버가 정한 한도를 넘으면 라우팅을 시작하기도 전에 파싱이 실패합니다. 이런 오류에는 연결 처리 코드가 서블릿을 부르지 않고 직접 답해야 하며, 상태 코드도 원인에 맞게 골라야 합니다. 요청 줄이 잘못되었으면 400 Bad Request, 본문이 너무 크면 413 Content Too Large, 헤더가 너무 크면 431 Request Header Fields Too Large입니다.
이 구분이 흐려지면 양쪽으로 문제가 생깁니다. 형식이 잘못된 요청을 500으로 기록하면 클라이언트의 실수가 서버 결함처럼 집계되어 진짜 결함이 묻힙니다. 반대로 서블릿 안에서 난 NullPointerException을 400으로 바꾸면 서버의 결함을 사용자 탓으로 돌리게 됩니다. 원인이 클라이언트의 요청에 있으면 4xx, 원인이 서버에 있으면 5xx라는 기준으로 나누면 됩니다.
더 깊이: 응답을 보내던 중에 실패하면
응답을 메모리에 모으는 방식에서는 커밋하기 전에 모든 오류가 드러나므로, 언제든 오류 응답으로 바꿀 수 있습니다. 스트리밍 응답은 다릅니다. 상태 줄과 헤더, 본문 일부가 이미 나간 뒤에 예외가 나면, 그 뒤에 HTTP/1.1 500으로 시작하는 바이트를 써 봐야 앞서 보낸 본문에 이어 붙을 뿐입니다. 클라이언트는 이것을 새 응답이 아니라 깨진 본문으로 받습니다. 이때 서버가 할 수 있는 일은 연결을 닫고 오류 ID와 함께 로그를 남기는 것뿐입니다.
연결을 끊어도 클라이언트는 응답이 잘렸다는 것을 알아챌 수 있습니다. RFC 9112는 Content-Length로 길이를 밝힌 응답에서 받은 본문이 그 길이보다 짧은 경우와, chunked 응답에서 마지막 0 크기 청크를 받지 못한 경우를 불완전한 메시지로 봅니다. 반대로 길이를 밝히지 않고 연결을 닫는 것으로 본문의 끝을 알리는 응답이라면, 중간에 끊긴 것과 정상적으로 끝난 것을 구별할 방법이 없습니다. 응답의 길이를 밝히거나 chunked를 써야 하는 이유가 하나 더 있는 셈입니다.
소켓 서버에 연결해 실제 응답 보기
지금까지 만든 라우터와 오류 처리는 메모리 안에서만 움직였습니다. 이제 둘을 합쳐 소켓 서버에 붙이고, 서버가 실제로 보내는 응답을 확인해 보겠습니다. 아래 TinyWas는 연결마다 요청 줄과 헤더를 읽고, 요청을 서블릿에 넘긴 뒤, 완성된 응답을 한 번에 씁니다.
코드의 흐름을 먼저 따라가 보겠습니다. handle은 CRLF로 끝나는 줄 단위로 요청 줄과 헤더를 읽습니다. 요청 줄이 '메서드 대상 버전'의 세 부분으로 나뉘지 않으면 서블릿을 부르지 않고 바로 400으로 답하고, 제대로 나뉘면 요청 대상에서 ? 앞부분을 경로로 떼어 dispatch에 넘깁니다.
dispatch는 라우트 표에서 서블릿을 찾아 실행합니다. 키가 없으면 기본 서블릿 NOT_FOUND가 PageNotFoundException을 던지므로, 등록되지 않은 경로와 서블릿이 직접 던진 '찾을 수 없음'이 같은 404 처리로 모입니다. 그 밖의 예외는 오류 ID를 붙인 500이 되고, 서버 로그 역할을 하는 표준 오류에는 같은 ID와 예외가 찍힙니다. 오류 응답은 앞에서 말한 대로 실패한 서블릿이 쓰던 응답을 버리고 새 Response로 만듭니다.
마지막으로 write가 완성된 Response를 HTTP 형식으로 바꿉니다. 본문을 UTF-8로 인코딩한 바이트 수를 Content-Length에 적고, 500 응답에는 Cache-Control: no-store를 붙이고, Connection: close를 보낸 뒤 연결을 닫습니다. 연결마다 가상 스레드 하나가 일하고 소켓 읽기에는 5초 시간 제한을 두었습니다. 그래서 요청을 보내다 멈춘 클라이언트가 있어도 다른 클라이언트의 요청은 기다리지 않고 처리되며, 멈춘 연결은 5초 동안 아무 바이트도 오지 않으면 끊깁니다. 요청 본문 읽기, 쿼리 파싱, keep-alive는 코드를 짧게 하려고 뺐습니다.
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Executors;
public final class TinyWas {
record Route(String method, String path) {
}
record Request(String method, String path) {
}
static final class Response {
int status = 200;
String contentType = "text/plain; charset=UTF-8";
final StringBuilder body = new StringBuilder();
}
@FunctionalInterface
interface Servlet {
void service(Request request, Response response) throws Exception;
}
static final class PageNotFoundException extends RuntimeException {
PageNotFoundException(String path) {
super(path);
}
}
private static final Map<Integer, String> REASONS = Map.of(
200, "OK", 201, "Created", 400, "Bad Request",
404, "Not Found", 500, "Internal Server Error");
private static final Servlet NOT_FOUND = (request, response) -> {
throw new PageNotFoundException(request.path());
};
private final Map<Route, Servlet> routes;
TinyWas(Map<Route, Servlet> routes) {
this.routes = Map.copyOf(routes);
}
// 라우팅과 오류 처리: 어떤 경우에도 완성된 Response 하나를 돌려준다.
Response dispatch(Request request) {
Servlet servlet = routes.getOrDefault(
new Route(request.method(), request.path()), NOT_FOUND);
try {
var response = new Response();
servlet.service(request, response);
return response;
} catch (PageNotFoundException error) {
return error(404, "page not found: " + error.getMessage());
} catch (Exception error) {
String errorId = UUID.randomUUID().toString();
System.err.println("[" + errorId + "] " + error);
return error(500, "internal error id=" + errorId);
}
}
static Response error(int status, String message) {
var response = new Response(); // 실패한 서블릿이 쓰던 응답은 버린다.
response.status = status;
response.body.append(message);
return response;
}
// 연결 하나: 요청 줄과 헤더를 읽고, 응답을 한 번에 쓴 뒤 닫는다.
void handle(Socket socket) throws IOException {
socket.setSoTimeout(5_000);
InputStream in = socket.getInputStream();
String[] parts = readLine(in).split(" ", -1);
while (!readLine(in).isEmpty()) {
// 이 예제는 헤더를 읽기만 하고 쓰지 않는다.
}
Response response;
if (parts.length == 3) {
String target = parts[1];
int mark = target.indexOf('?');
String path = mark < 0 ? target : target.substring(0, mark);
response = dispatch(new Request(parts[0], path));
} else {
response = error(400, "bad request line");
}
write(socket.getOutputStream(), response);
}
static String readLine(InputStream in) throws IOException {
var line = new ByteArrayOutputStream();
while (line.size() < 8_192) {
int value = in.read();
if (value == -1) {
throw new IOException("connection closed mid-line");
}
if (value == '\r') {
if (in.read() != '\n') {
throw new IOException("CR without LF");
}
return line.toString(StandardCharsets.US_ASCII);
}
line.write(value);
}
throw new IOException("line too long");
}
static void write(OutputStream out, Response response) throws IOException {
byte[] body = response.body.toString().getBytes(StandardCharsets.UTF_8);
String head = "HTTP/1.1 " + response.status + " "
+ REASONS.getOrDefault(response.status, "") + "\r\n"
+ "Content-Type: " + response.contentType + "\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ (response.status >= 500 ? "Cache-Control: no-store\r\n" : "")
+ "Connection: close\r\n"
+ "\r\n";
out.write(head.getBytes(StandardCharsets.US_ASCII));
out.write(body);
out.flush();
}
public static void main(String[] args) throws IOException {
Map<Route, Servlet> routes = new HashMap<>();
routes.put(new Route("GET", "/"), (request, response) -> {
response.contentType = "text/html; charset=UTF-8";
response.body.append("<h1>home</h1>");
});
routes.put(new Route("POST", "/notes"), (request, response) -> {
response.status = 201;
response.body.append("created");
});
routes.put(new Route("GET", "/boom"), (request, response) -> {
throw new IllegalStateException("database unavailable");
});
var was = new TinyWas(routes);
int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
try (var listener = new ServerSocket(
port, 50, InetAddress.getLoopbackAddress());
var workers = Executors.newVirtualThreadPerTaskExecutor()) {
System.out.println("listening on http://127.0.0.1:" + port);
while (true) {
Socket socket = listener.accept();
workers.submit(() -> {
try (socket) {
was.handle(socket);
} catch (IOException error) {
System.err.println("connection dropped: " + error.getMessage());
}
});
}
}
}
}코드 몇 군데를 짚어 보겠습니다. try (socket)은 이미 만든 변수를 try-with-resources에 넣는 문법으로, 작업이 끝나거나 예외가 나면 소켓을 닫아 줍니다. REASONS.getOrDefault(response.status, "")는 표에 없는 상태 코드라면 이유 문구를 비워 둡니다. HTTP/1.1 상태 줄에서 이유 문구는 생략할 수 있기 때문입니다. 서버 소켓은 InetAddress.getLoopbackAddress()가 돌려주는 루프백 주소(보통 127.0.0.1)에만 열어서 같은 컴퓨터에서만 접속할 수 있게 했습니다.
다음처럼 컴파일하고 실행하면 listening on http://127.0.0.1:8080이 출력되고 서버가 요청을 기다립니다. 8080번 포트를 이미 다른 프로그램이 쓰고 있으면 java.net.BindException: Address already in use가 나는데, 이때는 java TinyWas 9090처럼 다른 포트 번호를 주고 아래 curl 명령의 포트도 같이 바꾸세요. 서버는 Ctrl+C로 멈춥니다.
javac TinyWas.java
java TinyWas다른 터미널에서 curl로 요청을 보냅니다. curl은 명령줄에서 HTTP 요청을 보내는 도구로, macOS와 대부분의 Linux, Windows 10 이후 버전에 기본으로 들어 있습니다. Windows PowerShell에서는 curl이 다른 명령의 별칭이므로 curl.exe라고 입력하세요. -i 옵션을 주면 본문뿐 아니라 상태 줄과 헤더도 받은 그대로 출력합니다. 먼저 등록된 두 라우트에 요청해 봅니다.
$ curl -i http://127.0.0.1:8080/
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 13
Connection: close
<h1>home</h1>$ curl -i -X POST http://127.0.0.1:8080/notes
HTTP/1.1 201 Created
Content-Type: text/plain; charset=UTF-8
Content-Length: 7
Connection: close
created응답은 write에서 만든 바이트 그대로입니다. <h1>home</h1>은 ASCII 문자 13개, 즉 13바이트라서 Content-Length: 13이 됩니다. -X POST로 메서드를 바꾼 둘째 요청은 POST /notes 키의 서블릿이 상태를 201로 바꿨으므로 201 Created가 나옵니다. 본문 끝에 줄바꿈 문자가 없어서, 터미널에 따라 다음 프롬프트가 본문 바로 뒤에 붙어 나올 수 있습니다.
다음은 오류 응답입니다. 없는 경로를 요청하면 기본 서블릿이 던진 PageNotFoundException이 404로 바뀝니다.
$ curl -i http://127.0.0.1:8080/missing
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=UTF-8
Content-Length: 24
Connection: close
page not found: /missingcurl -i -X POST http://127.0.0.1:8080/처럼 경로는 있고 메서드만 다른 요청도 본문이 page not found: /인 404를 받습니다. 라우트 표가 (메서드, 경로) 키를 정확히 일치시키기 때문입니다. 서블릿이 예외를 던지는 /boom은 500이 됩니다.
$ curl -i http://127.0.0.1:8080/boom
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=UTF-8
Content-Length: 54
Cache-Control: no-store
Connection: close
internal error id=f40cee8f-d667-4525-98ed-6a4d0ae48ab7서버는 500 응답을 만들 때 오류 ID를 한 번만 만들어, 그 ID를 응답 본문에 넣고 예외와 함께 System.err에도 찍습니다. 같은 순간 서버를 실행한 터미널에는 다음 줄이 찍힙니다.
[f40cee8f-d667-4525-98ed-6a4d0ae48ab7] java.lang.IllegalStateException: database unavailable클라이언트는 오류 ID만 받았고, 예외 종류와 메시지는 서버 쪽에만 남았습니다. 두 ID가 같으므로 사용자가 알려 준 ID로 서버 로그를 찾을 수 있습니다. ID는 실행할 때마다 다르지만, internal error id= 18바이트에 UUID 36자를 더한 본문 길이는 늘 54바이트입니다. 실제 서버라면 System.err 대신 로깅 라이브러리를 쓰고 스택 트레이스까지 기록합니다.
마지막으로 요청 줄 자체가 잘못된 요청을 보내 봅니다.
$ curl -i -X 'NOT VALID' http://127.0.0.1:8080/
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=UTF-8
Content-Length: 16
Connection: close
bad request line-X는 메서드 자리에 준 문자열을 그대로 보내므로 요청 줄이 NOT VALID / HTTP/1.1이 되고, 공백으로 나누면 네 부분이 나옵니다. handle은 이 요청을 dispatch에 넘기지 않고 직접 400으로 답하므로 서버 로그에는 아무것도 남지 않습니다. 클라이언트의 실수를 서버 결함으로 기록하지 않는다는 앞의 기준이 그대로 지켜진 것입니다.
이 서버에 더 필요한 것
TinyWas는 이 장에서 다룬 조각들이 어떻게 맞물리는지 보여 주는 최소한의 서버입니다. 실제로 쓰려면 적어도 다음이 더 필요합니다.
- 같은
(메서드, 경로)를 두 번 등록하면 지금은Map.put이 앞의 서블릿을 조용히 덮어씁니다. 등록할 때 중복을 거절해야 합니다. - 경로는 있지만 메서드가 다른 요청에는
Allow헤더를 붙인 405로 답해야 합니다. - 요청 본문을 읽지 않으므로 서블릿에 본문을 전달할 수 없습니다. 앞 글의 판독기처럼
Content-Length와chunked본문을 읽어야 합니다. - 줄 하나의 길이만 제한할 뿐 헤더 전체의 크기와 개수에는 한도가 없습니다. 한도를 두고, 넘으면 431로 답해야 합니다.
- 읽기 시간 제한은
read한 번마다 적용되므로, 몇 초에 한 바이트씩 보내는 클라이언트는 연결을 계속 붙잡을 수 있습니다. 요청 전체를 받는 데 걸리는 시간에도 한도가 필요합니다. - 종료할 때는 새 연결 수락을 멈추고, 처리 중인 연결을 마무리하거나 닫고, 실행기가 끝나기를 기다리는 순서를 지켜야 합니다. 지금은 Ctrl+C로 프로세스를 끝낼 뿐입니다.
반대로 이미 갖춘 성질도 있습니다. /boom 요청이 실패한 뒤에도 다음 요청은 정상으로 처리됩니다. 서블릿이 던진 예외가 dispatch 안에서 응답으로 바뀌므로 작업자 스레드나 리스너까지 번지지 않기 때문입니다.
서블릿 표준과 서블릿 컨테이너
완성된 구조를 다시 보면, 소켓과 HTTP 형식을 다루는 쪽(handle, readLine, write)과 업무 기능을 담은 쪽(서블릿들)이 Servlet 인터페이스 하나로만 이어져 있습니다. 이 인터페이스를 서버 제품마다 따로 정한다면 어떻게 될까요? 서버 A의 인터페이스에 맞춰 만든 기능을 서버 B로 옮기려면 모든 기능 코드를 B의 인터페이스에 맞게 고쳐야 합니다. 그래서 Java 진영은 이 인터페이스를 표준으로 정했고, 그것이 서블릿 명세입니다. 지금은 Jakarta EE의 일부로 jakarta.servlet 패키지에 들어 있습니다.
명세는 역할을 이렇게 나눕니다. 서블릿 컨테이너(servlet container)는 소켓을 열고, HTTP 요청을 파싱해 요청·응답 객체를 만들고, URL에 맞는 서블릿을 골라 호출하며, 서블릿 객체의 생성부터 종료까지의 생명 주기를 관리합니다. 애플리케이션은 표준 서블릿 인터페이스만 구현합니다. Apache Tomcat, Eclipse Jetty, Undertow가 대표적인 서블릿 컨테이너이고, 이들이 내부 구현과 성능으로 경쟁해도 애플리케이션 코드는 그대로 옮겨 갈 수 있습니다. 이 글의 TinyWas는 아주 작은 서블릿 컨테이너인 셈입니다.
이 장에서 만든 것들을 계층으로 정리하면 다음과 같습니다. 표의 위쪽일수록 네트워크에 가깝고 아래쪽일수록 업무에 가깝습니다.
| 계층 | 하는 일 | 이 장에서 다룬 내용 |
|---|---|---|
| TCP 리스너 | 연결 수락, 시간 제한, 종료 | 서버 소켓과 연결 수명 |
| HTTP 엔진 | 요청 파싱, 메시지 프레이밍, keep-alive | 요청 바이트 읽기와 응답 쓰기 |
| 서블릿 컨테이너 | 라우팅, 오류 응답, 서블릿 생명 주기 | 이 글의 라우터와 예외 처리 |
| 서블릿 | 업무 요청 처리 | 각 기능의 service 구현 |
요청은 표의 위에서 아래로 흘러 서블릿에 도착하고, 응답은 반대 방향으로 나갑니다. 서블릿은 자기 위의 계층이 어떻게 구현되었는지 몰라도 됩니다.
다만 표준이 모든 차이를 없애 주지는 않습니다. 스레드 모델, 비동기 처리 지원, 클래스 로딩, 배포 설정, 성능 특성은 컨테이너마다 다를 수 있습니다. 그래서 애플리케이션은 표준 API에 기대고 특정 제품에만 있는 기능은 어댑터 뒤에 모아 두면, 다른 컨테이너로 옮기기 쉬워집니다. 연습 문제 3에서 이런 어댑터를 직접 만들어 봅니다.
CGI에서 서블릿 컨테이너로
초기 웹 서버는 미리 만들어 둔 HTML 파일을 그대로 보내 주는 정적 서버였습니다. 요청마다 다른 결과를 만들어야 하는 동적 페이지가 필요해지자 CGI(Common Gateway Interface)가 등장했습니다. CGI를 지원하는 웹 서버는 요청이 올 때마다 외부 프로그램을 새 프로세스로 실행하고, 요청 정보는 환경 변수와 표준 입력으로 넘기며, 프로그램이 표준 출력에 쓴 내용을 응답으로 보냅니다. 표준 입출력만 다루면 되니 어떤 언어로든 프로그램을 만들 수 있다는 장점이 있었습니다.
하지만 요청마다 프로세스를 새로 띄우는 비용이 컸고, 프로세스가 요청 하나를 처리하고 끝나 버리니 데이터베이스 연결이나 캐시 같은 자원을 요청 사이에 나눠 쓰기도 어려웠습니다. 서블릿 컨테이너는 이 문제를 하나의 JVM 안에서 풉니다. 서블릿 객체를 한 번 만들어 계속 재사용하고 요청은 프로세스 대신 스레드로 처리하므로, 객체가 들고 있는 연결 풀이나 캐시를 모든 요청이 함께 쓸 수 있습니다.
이렇게 HTTP 요청을 받아 애플리케이션 코드를 실행해 주는 서버를 웹 애플리케이션 서버(WAS, Web Application Server)라고 부릅니다. 실무에서는 Nginx 같은 웹 서버나 프록시를 앞에 두어 TLS와 정적 파일을 맡기고, 뒤의 WAS는 애플리케이션 실행에 집중하게 하는 배치도 흔합니다. 어떻게 배치하든 핵심은 같습니다. 전송을 다루는 코드와 업무를 다루는 코드가 표준 인터페이스를 사이에 두고 나뉘어 있다는 것입니다.
서블릿 위에는 다시 프레임워크가 올라갑니다. 웹 애플리케이션을 Model(데이터와 업무 규칙), View(HTML이나 JSON 같은 표현), Controller(요청을 받아 Model의 작업과 View를 연결하는 부분)로 나누는 설계를 MVC라고 합니다. Spring MVC는 DispatcherServlet이라는 서블릿 하나가 요청을 받아 알맞은 컨트롤러로 넘기는 방식으로 이 설계를 구현하는데, 이 글의 라우터가 하던 일을 훨씬 크게 키운 것이라고 볼 수 있습니다.
서블릿 하나를 여러 스레드가 함께 쓴다
서블릿 객체를 재사용한다는 것은 한 객체의 service가 여러 요청 스레드에서 동시에 호출될 수 있다는 뜻입니다. Jakarta Servlet의 Javadoc도 서블릿은 보통 여러 요청을 동시에 처리하는 다중 스레드 컨테이너 안에서 실행되므로, 공유 자원과 서블릿의 인스턴스 변수에 대한 접근을 동기화해야 한다고 적고 있습니다. 가장 흔한 실수는 요청마다 달라지는 값을 인스턴스 필드에 담는 것입니다. 앞의 ServletRouter 타입으로 쓰면 다음과 같습니다.
Servlet greeting = new Servlet() {
private String user; // 모든 요청 스레드가 이 필드 하나를 함께 쓴다
@Override
public void service(Request request, Response response) {
user = request.query().getOrDefault("user", "guest");
// ... 다른 작업을 하는 사이에 다른 스레드가 user를 바꿀 수 있다
response.write("hello " + user);
}
};요청 A가 user에 kim을 넣고 다른 작업을 하는 사이 요청 B가 lee를 넣으면, A의 응답에 hello lee가 나갈 수 있습니다. 요청마다 달라지는 값은 service 안의 지역 변수에 두면 됩니다. 지역 변수는 호출마다 그 스레드의 스택에 따로 생기므로 다른 요청과 섞이지 않습니다. 인스턴스 필드에는 시작할 때 정해지고 바뀌지 않는 설정값이나, 여러 스레드가 함께 써도 안전한 객체만 둡니다.
더 깊이: 실제 Jakarta Servlet API와 비교하기
이 글의 Servlet에는 service 하나뿐이지만, 실제 jakarta.servlet.Servlet 인터페이스(Servlet 6.1 기준)에는 생명 주기 메서드가 함께 있습니다. 컨테이너는 서블릿 객체를 만든 뒤 init(ServletConfig)를 정확히 한 번 호출하고, init이 성공한 뒤에만 요청마다 service(ServletRequest, ServletResponse)를 호출합니다. 서블릿을 내릴 때는 service 안의 모든 스레드가 끝나거나 제한 시간이 지난 뒤 destroy()를 한 번 호출합니다. service는 ServletException과 IOException을 던질 수 있게 선언되어 있습니다. Java EE 시절에는 같은 API가 javax.servlet 패키지에 있었고, Jakarta EE 9부터 지금의 패키지 이름으로 바뀌었습니다.
실무에서는 Servlet을 직접 구현하기보다 HttpServlet을 상속해 doGet, doPost 같은 메서드를 재정의하는 경우가 많습니다. HttpServlet의 service가 요청 메서드를 보고 알맞은 doXxx 메서드를 호출해 주므로, 이 글의 라우트 키에서 메서드 부분을 맡는 셈입니다. 경로 부분은 @WebServlet("/search") 같은 애노테이션이나 배포 설정으로 컨테이너에 알려 줍니다. 애노테이션을 읽어 라우트를 등록하는 원리는 뒤의 장들에서 리플렉션과 함께 직접 만들어 봅니다.
커밋 개념도 그대로 있습니다. 실제 응답 객체는 본문을 버퍼에 모으다가, 버퍼가 가득 차거나 flushBuffer()가 호출되면 상태 코드와 헤더를 보내면서 커밋됩니다. isCommitted()로 커밋되었는지 확인할 수 있고, 커밋된 뒤에 reset()으로 응답을 지우려 하면 IllegalStateException이 납니다.
정리
- 연결을 다루는 코드 안에 경로 조건문을 쌓으면 기능을 바꿀 때마다 전송 코드를 건드리게 되고, 상태 코드와 예외를 처리할 자리도 없습니다.
- 기능은 '요청을 받아 응답을 채우는'
Servlet인터페이스로 구현하고, 라우터가(메서드, 경로)키로 서블릿을 고릅니다. 경로가 없으면 404, 경로는 있지만 메서드가 다르면Allow헤더를 갖춘 405가 맞습니다. - 응답을 메모리에 모아 두면 서블릿이 끝난 뒤 최종 상태를 정하고
Content-Length를 계산해 한 번에 보낼 수 있습니다. 상태와 헤더가 나간 뒤, 즉 커밋된 뒤에는 응답을 바꿀 수 없습니다. - 서블릿이 던진 예외는 한곳에서 응답으로 바꿉니다. 찾을 수 없음은 404, 예상하지 못한 예외는 오류 ID를 붙인 500으로 보내고, 자세한 내용은 같은 ID와 함께 서버 로그에만 남깁니다. 파싱 오류는 서블릿에 닿기 전에 400, 413, 431로 답합니다.
- 서블릿 컨테이너는 소켓, HTTP 파싱, 라우팅, 생명 주기를 맡고 애플리케이션은 표준 서블릿만 구현합니다. 서블릿 객체 하나를 여러 스레드가 함께 쓰므로 요청마다 달라지는 값은 지역 변수에 둡니다.
연습 문제
- 앞의
ServletRouter.standard()에GET /search(쿼리q=java)와POST /search를 보내면 각각 어떤 상태와 본문이 나올까요? 405를 지원하는 라우터라면POST /search에 어떻게 답해야 하고, 어떤 헤더가 반드시 있어야 할까요? - 경로가 없으면 404, 경로는 있지만 메서드가 다르면
Allow헤더를 붙인 405를 돌려주는 라우터를 만드세요. 같은(메서드, 경로)를 두 번 등록하면 예외를 던지게 하세요. - 어떤 서버 제품이 자체 인터페이스
VendorHandler(메서드는handle(VendorExchange)하나)만 제공한다고 합시다. 이 글의Servlet으로 만든 기능을 이 서버에서 실행할 수 있도록,Servlet을VendorHandler로 감싸는 어댑터를 작성하세요. 서블릿이 던진 예외는 어댑터 바깥에서 처리할 수 있게 그대로 전달해야 합니다. 실제 Jakarta Servlet API를 구현하는 문제는 아닙니다.
1번 해설
GET /search는 등록된 키이므로 검색 서블릿이 실행되어 200 text/plain; charset=UTF-8 query=java가 나옵니다. POST /search는 경로가 같아도 (POST, /search)라는 키가 표에 없으므로 기본 서블릿이 실행되어 404 text/plain; charset=UTF-8 route not found: /search가 나옵니다. 이 라우터는 정확히 일치하는 키만 찾기 때문에 '경로는 등록되어 있다'는 사실을 알아낼 방법이 없습니다.
405를 지원하는 라우터라면 POST /search에 405 Method Not Allowed로 답하고, 이 경로가 허용하는 메서드를 Allow: GET 헤더로 알려야 합니다. RFC 9110이 405 응답에 Allow 헤더를 반드시 넣도록 정하고 있기 때문입니다. 지금의 standard()가 내는 404와, 확장했을 때 나와야 할 405를 구분해 두세요.
2번 해설
경로를 먼저 찾고 그다음에 메서드를 찾도록 표를 두 단계로 만들면 404와 405가 자연스럽게 나뉩니다. 바깥 Map은 경로에서 '메서드별 서블릿 표'로, 안쪽 Map은 메서드에서 서블릿으로 이어집니다. 안쪽 표를 TreeMap으로 만들면 메서드 이름이 정렬되어 Allow 헤더의 순서가 늘 같습니다. 중복 등록은 putIfAbsent가 기존 값을 돌려주는지로 알아냅니다.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public final class MethodAwareRouter {
static final class Response {
int status = 200;
final Map<String, String> headers = new LinkedHashMap<>();
final StringBuilder body = new StringBuilder();
}
@FunctionalInterface
interface Servlet {
void service(String path, Response response) throws Exception;
}
// 경로 → (메서드 → 서블릿)
private final Map<String, Map<String, Servlet>> routes = new LinkedHashMap<>();
void register(String method, String path, Servlet servlet) {
Servlet previous = routes
.computeIfAbsent(path, ignored -> new TreeMap<>())
.putIfAbsent(method, servlet);
if (previous != null) {
throw new IllegalStateException(
"duplicate route: " + method + " " + path);
}
}
void execute(String method, String path, Response response) throws Exception {
Map<String, Servlet> byMethod = routes.get(path);
if (byMethod == null) {
response.status = 404;
response.body.append("route not found: ").append(path);
return;
}
Servlet servlet = byMethod.get(method);
if (servlet == null) {
response.status = 405;
response.headers.put("Allow", String.join(", ", byMethod.keySet()));
response.body.append("method not allowed: ").append(method);
return;
}
servlet.service(path, response);
}
public static void main(String[] args) throws Exception {
var router = new MethodAwareRouter();
router.register("GET", "/search",
(path, response) -> response.body.append("search page"));
router.register("GET", "/notes",
(path, response) -> response.body.append("note list"));
router.register("POST", "/notes", (path, response) -> {
response.status = 201;
response.body.append("created");
});
for (String[] request : List.of(
new String[] {"GET", "/search"},
new String[] {"POST", "/search"},
new String[] {"DELETE", "/notes"},
new String[] {"GET", "/missing"})) {
var response = new Response();
router.execute(request[0], request[1], response);
System.out.println(request[0] + " " + request[1] + " -> "
+ response.status + " " + response.headers + " " + response.body);
}
try {
router.register("GET", "/notes", (path, response) -> { });
} catch (IllegalStateException error) {
System.out.println(error.getMessage());
}
}
}실행하면 다음과 같이 출력됩니다.
GET /search -> 200 {} search page
POST /search -> 405 {Allow=GET} method not allowed: POST
DELETE /notes -> 405 {Allow=GET, POST} method not allowed: DELETE
GET /missing -> 404 {} route not found: /missing
duplicate route: GET /notesPOST /search는 바깥 표에서 /search를 찾는 데는 성공하지만 안쪽 표에 POST가 없어 405가 되고, Allow에는 안쪽 표의 키인 GET이 들어갑니다. /notes에는 GET과 POST가 등록되어 있으므로 DELETE /notes의 Allow는 GET, POST입니다. /missing은 바깥 표에서부터 없으므로 404이고, 마지막 줄은 이미 있는 GET /notes를 다시 등록하려다 거절된 결과입니다. 등록은 서버가 요청을 받기 전에 모두 끝낸다고 가정했기 때문에 일반 LinkedHashMap을 썼습니다.
3번 해설
어댑터는 업무 판단을 더하지 않고 두 모델 사이를 옮겨 적기만 합니다. 서버 제품의 메서드와 경로로 이 글의 Request를 만들고, 임시 Response에 서블릿의 결과를 받은 뒤 상태와 본문을 VendorExchange에 복사합니다.
import java.util.Objects;
public final class ServletVendorAdapterSolution {
record Request(String method, String path) {
}
static final class Response {
private int status = 200;
private String body = "";
void status(int value) {
status = value;
}
void body(String value) {
body = Objects.requireNonNull(value);
}
}
@FunctionalInterface
interface Servlet {
void service(Request request, Response response) throws Exception;
}
static final class VendorExchange {
final String verb;
final String rawPath;
int responseCode;
String responseText;
VendorExchange(String verb, String rawPath) {
this.verb = verb;
this.rawPath = rawPath;
}
}
@FunctionalInterface
interface VendorHandler {
void handle(VendorExchange exchange) throws Exception;
}
static VendorHandler adapt(Servlet servlet) {
Objects.requireNonNull(servlet, "servlet");
return exchange -> {
var request = new Request(exchange.verb, exchange.rawPath);
var response = new Response();
servlet.service(request, response);
exchange.responseCode = response.status;
exchange.responseText = response.body;
};
}
public static void main(String[] args) throws Exception {
Servlet health = (request, response) -> {
if (!request.method().equals("GET")) {
response.status(405);
response.body("method not allowed");
return;
}
response.body("healthy at " + request.path());
};
VendorHandler handler = adapt(health);
var exchange = new VendorExchange("GET", "/health");
handler.handle(exchange);
System.out.println(exchange.responseCode + " " + exchange.responseText);
}
}실행하면 200 healthy at /health가 출력됩니다. main은 서블릿을 직접 부르지 않고, 서버 제품이 하듯 VendorExchange를 만들어 VendorHandler에 넘깁니다. 어댑터가 이것을 Request로 옮겨 서블릿을 실행하고, 서블릿이 정한 상태 200과 본문을 다시 VendorExchange에 적어 둔 것입니다.
service가 예외를 던지면 그 뒤의 상태·본문 복사는 실행되지 않고, 예외가 어댑터를 호출한 쪽으로 그대로 전달됩니다. 덕분에 예외를 404나 500으로 바꾸는 일은 앞에서처럼 바깥의 한곳에서 할 수 있습니다. 예제의 405 분기는 Allow 헤더를 담을 곳이 없으므로, 완전한 HTTP 405 응답을 만들려면 Response와 VendorExchange 양쪽에 헤더를 추가해야 합니다.
서버 제품이 스트리밍 본문이나 비동기 완료 신호를 쓴다면 어댑터도 그 수명을 표현할 수 있어야 합니다. 이 예제처럼 동기 문자열 모델에 억지로 맞추면 큰 응답을 모두 메모리에 모으게 됩니다. 어댑터는 두 모델에 공통된 의미를 옮기는 것이지, 중요한 차이까지 감추는 것이 아닙니다.
다음 글
지금은 라우트 표에 서블릿을 손으로 하나씩 등록했습니다. 다음 장에서 배울 리플렉션과 그 뒤의 애노테이션을 쓰면, 클래스의 메서드를 찾아 라우트 표를 자동으로 만들 수 있습니다. 등록 방식이 바뀌어도 404와 405의 구분, 예외를 응답으로 바꾸는 위치, 응답을 한 번에 직렬화하는 방식은 이 글에서 만든 그대로 쓰입니다.