안동민 개발노트

본문 시작

Servlet 라우터와 오류 경계

HTTP 전송과 서비스를 서블릿 계약으로 분리하고 라우터·오류 매핑·어댑터로 서버를 구성합니다.

MVC는 웹 애플리케이션을 Model, View, Controller 역할로 나누는 설계 방식입니다.

Model은 회원 같은 데이터와 업무 규칙을 담고, View는 HTML이나 JSON처럼 사용자에게 보낼 표현을 만들며, Controller는 HTTP 요청을 받아 적절한 Model 작업과 View를 연결합니다.

세 역할을 한 클래스에 모두 넣지 않으면 화면과 저장 방식이 바뀌어도 요청 처리 전체를 다시 고치지 않아도 됩니다.

서블릿(Servlet)은 Java 웹 서버가 HTTP 요청을 애플리케이션 코드에 전달하기 위해 정한 표준 인터페이스입니다.

서블릿 컨테이너는 서버 시작, 소켓 처리, 요청·응답 객체 생성, 서블릿 호출과 생명 주기를 담당합니다.

Tomcat이 대표적인 서블릿 컨테이너이며, 웹 애플리케이션 서버(WAS)는 이런 웹 실행 기능을 제공하는 서버를 뜻합니다.

요청 파서와 응답 기록기가 구조화되면 연결 처리기의 남은 코드는 경로에 따라 서비스 메서드를 호출하는 일입니다.

이 단계에서 /, /site1, /search 조건을 네트워크 코드 안에 계속 추가하면 HTTP 프레이밍과 업무 기능이 함께 변경됩니다.

새 회원 서비스를 만들 때 저수준 소켓 클래스를 다시 수정해야 하고 전송 계층 회귀 테스트 범위도 커집니다.

서비스가 Request를 받아 Response를 구성하는 작은 인터페이스를 구현하게 하면 연결 처리는 모든 기능에 공통인 파싱, 오류 경계, 플러시만 담당합니다.

Java 웹 생태계의 Servlet 표준도 이 분리에서 출발합니다.

여기서는 실제 Jakarta Servlet 전체를 재현하지 않고 책임 이동을 이해할 수 있는 최소 계약을 만듭니다.


소켓 핸들러의 책임 과부하

다음 구현은 경로가 늘 때마다 중앙 조건문과 HTML 문자열이 함께 커집니다.

404 상태를 설정하지 않고 본문만 “찾을 수 없음”으로 보내는 실수도 생기기 쉽습니다.

검색 기능에서 예외가 나면 연결 처리기가 어느 상태를 보낼지 정해져 있지 않습니다.

bad/PathConditionalHttpService.java
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("compile-only path chain counterexample");
    }
}

라우팅과 서비스 실행을 분리할 때 경로만 키로 삼을지 메서드와 경로의 조합을 사용할지 결정합니다.

GET /members와 POST /members는 다른 동작이므로 (method, path)를 라우트 키로 쓰는 편이 정확합니다.

경로 매개변수까지 지원하려면 정확 일치 Map에서 패턴 일치기로 확장하되 우선순위와 모호한 패턴을 등록 시 검사합니다.

404는 라우트가 없다는 결과이고, 405는 경로는 있지만 해당 메서드가 허용되지 않는 결과입니다.

두 경우를 모두 기본 서블릿 하나로 처리하면 클라이언트에게 가능한 메서드를 알리는 Allow 헤더를 만들기 어렵습니다.

학습 단계에서도 상태 의미를 구분하면 라우터 모델이 더 분명해집니다. 아래 최소 레지스트리는 정확한 키 조회만 구현하고 405 분기는 확장 과제로 남깁니다.


Servlet 레지스트리

Servlet은 전송 계층 구현을 몰라도 됩니다.

서비스 계층은 전송 계층이 검증한 메서드·경로·쿼리를 받는 계약으로 설계합니다. 아래 Request 자체는 쿼리 Map만 복사하므로 HTTP 입력 검증을 대신하지 않으며, Response는 상태와 본문을 메모리에 모읍니다.

라우터는 시작 시 등록을 끝내고 불변 Map으로 게시합니다.

찾지 못한 경우에도 null 대신 기본 Servlet을 실행합니다.

src/ServletRouter.java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;

public final class ServletRouter {
    record Route(String method, String path) {
        Route {
            method = method.toUpperCase(java.util.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 : java.util.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());
        }
    }
}
라우트 키가 없으면 이 예제는 모두 404로 보낸다

ServletRouter의 등록 정보와 조회 분기를 비교합니다.

라우트 키가 없으면 이 예제는 모두 404로 보낸다
요청 조건원문의 선택구현 범위
등록된 메서드·경로해당 Servlet의 service 호출GET /, GET /search, POST /notes 등록
같은 경로·다른 메서드정확한 키가 없으므로 기본 404405·Allow를 만드는 별도 경로 조회 없음
등록되지 않은 경로기본 404 Servletnull 대신 처리기를 실행
등록된 메서드·경로
원문의 선택: 해당 Servlet의 service 호출
구현 범위: GET /, GET /search, POST /notes 등록
같은 경로·다른 메서드
원문의 선택: 정확한 키가 없으므로 기본 404
구현 범위: 405·Allow를 만드는 별도 경로 조회 없음
등록되지 않은 경로
원문의 선택: 기본 404 Servlet
구현 범위: null 대신 처리기를 실행

Route는 메서드를 대문자로 바꾸는 학습용 정책을 씁니다. HTTP 메서드 토큰 자체는 대소문자를 구분합니다.

Response가 전송 형식에 즉시 쓰지 않고 메모리에 값을 모으는 이유는 오류 처리와 프레이밍을 마지막에 완성하기 위해서입니다.

연결 처리기로 확장할 때는 서비스가 정한 본문을 인코딩하고 그 바이트 길이를 계산해 전송합니다. 현재 클래스의 summary()는 문자열 요약일 뿐 HTTP 직렬화나 플러시를 수행하지 않습니다.

매우 큰 스트리밍 응답은 다른 커밋된 모델이 필요하며, 헤더가 전송된 뒤 발생한 예외는 이미 500 상태로 바꿀 수 없다는 제약을 드러내야 합니다.

예제의 HTML에는 쿼리를 그대로 삽입하지 않았습니다.

실제 SearchServlet이 사용자 값을 HTML에 넣는다면 HTML 이스케이프를 적용해야 합니다.

URL 디코딩은 출력 문맥 공격을 막지 않습니다.

Servlet 계약은 전송 계층을 추상화할 뿐 애플리케이션 보안 책임을 없애지 않습니다.


404·500 오류 경계

라우터가 찾지 못한 경우와 서비스 구현이 실패한 경우는 상태와 운영 중요도가 다릅니다.

찾을 수 없음은 클라이언트 결과로 집계하고, 예상하지 않은 예외는 상관관계 ID와 함께 서버 오류 로그에 남기는 정책을 세웁니다.

원래 예외 메시지와 스택 추적을 응답 본문에 노출하지 않습니다.

src/HttpErrorBoundary.java
import java.util.HashMap;
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 : java.util.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());
        }
    }
}
응답 상태를 바꾸는 경계와 경계 밖 오류를 구분한다

HttpErrorBoundary.execute의 catch 순서와 처리 범위입니다.

응답 상태를 바꾸는 경계와 경계 밖 오류를 구분한다
애플리케이션 결과원문의 응답 처리경계의 한계
정상 반환서비스가 만든 Response 유지successful은 200 이상 400 미만인 자체 분류
찾을 수 없음 예외404 · page not found로 본문 대체기존 응답 헤더는 지우지 않음
그 밖의 Exception500 · 오류 ID 본문 · no-store 추가예외 종류를 Execution에 담지만 서버 로그 출력은 하지 않음
Error 또는 실제 전송 실패이 메서드의 처리 범위 밖Error는 잡지 않으며 소켓 전송 자체가 없음
정상 반환
원문의 응답 처리: 서비스가 만든 Response 유지
경계의 한계: successful은 200 이상 400 미만인 자체 분류
찾을 수 없음 예외
원문의 응답 처리: 404 · page not found로 본문 대체
경계의 한계: 기존 응답 헤더는 지우지 않음
그 밖의 Exception
원문의 응답 처리: 500 · 오류 ID 본문 · no-store 추가
경계의 한계: 예외 종류를 Execution에 담지만 서버 로그 출력은 하지 않음
Error 또는 실제 전송 실패
원문의 응답 처리: 이 메서드의 처리 범위 밖
경계의 한계: Error는 잡지 않으며 소켓 전송 자체가 없음

main은 /ok·/missing·/boom을 처리하도록 구성합니다. 서비스가 먼저 응답 헤더를 바꾼 뒤 실패하는 입력은 포함하지 않습니다.

실제 연결 처리기를 붙일 때는 요청 파싱 오류를 애플리케이션 경계 바깥에서 오류 종류에 맞게 처리합니다. 본문 크기 초과의 413, 과도한 헤더의 431처럼 상태 의미도 구분합니다.

서비스가 실행되기 전의 형식 오류 시작 줄을 500으로 기록하면 클라이언트 오류와 서버 버그를 혼동합니다.

반대로 Servlet 안의 NullPointerException을 400으로 바꾸면 결함을 사용자 탓으로 숨깁니다.

응답이 이미 커밋된 스트리밍 상황에서는 내부 오류 처리기가 새 500 본문을 쓰면 두 응답이 섞입니다.

커밋 여부를 확인해 연결만 닫고 오류 ID를 서버에 기록해야 합니다.

메모리 버퍼 방식은 작은 응답에 한해 커밋 전 오류 대체를 쉽게 합니다. 실패한 서비스가 남긴 Content-Length 등 헤더까지 재사용하지 않도록 오류 응답을 새로 구성하는 정책도 필요합니다.


표준 Servlet 계약

각 WAS 회사가 서로 다른 요청, 응답, 처리기 인터페이스를 제공하면 애플리케이션이 서버를 바꿀 때 모든 서비스 코드를 고쳐야 합니다.

공통 Servlet 사양은 컨테이너가 생명 주기와 HTTP 어댑터를 제공하고 애플리케이션은 표준 서비스 계약만 구현하게 합니다.

Tomcat, Jetty, Undertow 같은 구현체가 내부 성능을 경쟁해도 사용자 코드 경계는 유지됩니다.


CGI와 Servlet 컨테이너

초기 웹 서버는 미리 준비한 정적 파일을 전달하는 데 집중했습니다.

동적 결과가 필요해지자 CGI(Common Gateway Interface)는 요청마다 외부 프로그램을 실행하고 표준 입출력으로 요청과 응답을 연결했습니다.

언어에 독립적이라는 장점이 있지만 요청마다 프로세스를 시작하는 비용이 크고, 프로그램 사이에서 메모리 상태와 연결 자원을 공유하기 어렵습니다.

Servlet 컨테이너는 하나의 JVM 안에서 애플리케이션 객체의 생성과 수명, 요청 스레드, HTTP 변환을 관리합니다.

애플리케이션은 표준 Servlet 계약만 구현하고 컨테이너가 소켓과 프로토콜 처리를 맡습니다.

같은 Servlet 인스턴스가 여러 요청 스레드에서 호출될 수 있으므로 요청별 값을 인스턴스 필드에 저장해서는 안 됩니다.

WAS는 HTTP 요청을 받아 동적 애플리케이션 코드를 실행하는 서버 환경입니다.

실무에서는 앞단 웹 서버나 프록시가 TLS와 정적 파일을 처리하고 WAS가 애플리케이션 실행을 맡기도 하지만, 배치 방식보다 중요한 것은 전송 책임과 업무 책임의 경계입니다.

계층소유 책임애플리케이션에 감추는 것교체 가능 단위
TCP 리스너accept·시간 제한·종료Socket 수명커넥터
HTTP 엔진파싱·프레이밍·keep-alive전송 형식 규칙프로토콜 구현
Servlet 컨테이너라우트·오류·생명 주기디스패치 세부WAS
Servlet업무 요청 처리서버 구현서비스 코드

표준화가 모든 구현 차이를 없애는 것은 아닙니다.

스레드 모델, 비동기 지원, 클래스 로딩, 배포 설정, 성능 특성은 컨테이너마다 다를 수 있습니다.

표준 API에만 의존하고 공급자 확장은 어댑터 뒤에 격리하면 이동 가능성을 높일 수 있습니다.


연습 문제

가상의 VendorHandler는 handle(VendorExchange)만 제공하고, 애플리케이션은 Servlet.service(Request, Response)를 구현한다고 가정합니다.

어댑터가 교환 값을 이 문서의 공통 모델로 옮기고 서비스 결과를 공급자 응답에 반영하도록 작성합니다. 실제 Jakarta Servlet API를 구현하는 예제는 아닙니다.

서비스 예외는 어댑터 밖 오류 경계가 처리할 수 있도록 보존합니다.

양쪽 모델을 명시적으로 번역하는 풀이

어댑터는 업무 결정을 추가하지 않습니다.

공급자의 경로와 메서드를 공통 Request로 만들고, 임시 Response에 결과를 받은 뒤 상태와 본문을 교환에 복사합니다.

solution/ServletVendorAdapterSolution.java
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);
    }
}

service가 예외를 던지면 뒤의 상태·본문 복사가 실행되지 않고 호출자에게 예외가 전달됩니다. 예제의 405 분기에는 Allow 헤더를 담을 모델이 없으므로 완성된 HTTP 405 응답으로 확장하려면 이 정보도 추가해야 합니다.

공급자가 스트리밍 본문이나 비동기 완료 신호를 사용한다면 어댑터 계약도 그 수명을 표현해야 합니다.

동기 문자열 예제를 그대로 강제하면 큰 응답을 메모리에 모으는 문제가 생깁니다.

추상화는 공통 의미를 보존해야 하며 중요한 차이를 숨기는 것만이 목적이 아닙니다.


작은 WAS 완성 기준

아래 항목은 작은 WAS로 확장할 때 추가할 검증입니다. 현재 standard()의 Map.put은 같은 키를 다시 넣으면 덮어쓰므로 중복 등록 거절을 보장하지 않습니다.

라우터에는 중복 (method, path) 등록 거절, 정상 라우트, 404와 405를 구분하는 검사를 추가합니다.

서비스가 던진 찾을 수 없음 예외와 내부 예외가 각각 404와 500이 되고, 500 본문에 내부 메시지가 노출되지 않아야 합니다.

Response는 플러시 전 한 번만 전송 형식으로 직렬화되고 Content-Length가 최종 본문과 일치해야 합니다.

전송 계층 테스트에서는 형식이 잘못된 HTTP 요청이 Servlet에 도달하지 않는지, 한 서비스의 실패가 작업 스레드와 리스너를 종료하지 않는지 확인합니다.

컨테이너는 새 연결 수락을 중지하고 활성 연결을 닫거나 비운 뒤 실행기를 기다리는 순서로 종료합니다.

이 경계를 이해하면 이후 리플렉션과 애노테이션으로 라우트 등록을 자동화하더라도 무엇이 편의 기능이고 무엇이 HTTP 안전 계약인지 구분할 수 있습니다.