본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
27장 : HTTP와 웹 서버

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 /membersPOST /members는 다른 동작이므로 (method, path)를 라우트 키로 쓰는 편이 정확합니다. 경로 매개변수까지 지원하려면 정확 일치 Map에서 패턴 일치기로 확장하되 우선순위와 모호한 패턴을 등록 시 검사합니다.

404는 라우트가 없다는 결과이고, 405는 경로는 있지만 해당 메서드가 허용되지 않는 결과입니다. 두 경우를 모두 기본 서블릿 하나로 처리하면 클라이언트에게 가능한 메서드를 알리는 Allow 헤더를 만들기 어렵습니다. 학습 단계에서도 상태 의미를 구분하면 라우터 모델이 더 분명해집니다.

Servlet 레지스트리

Servlet은 전송 계층 구현을 몰라도 됩니다. Request는 이미 검증된 메서드, 경로, 쿼리 값을 제공하고 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());
        }
    }
}

Response가 전송 형식에 즉시 쓰지 않고 메모리에 값을 모으는 이유는 오류 처리와 프레이밍을 마지막에 완성하기 위해서입니다. 서비스 도중 상태나 본문이 정해진 뒤 연결 처리기가 Content-Length를 계산해 한 번 플러시합니다. 매우 큰 스트리밍 응답은 다른 커밋된 모델이 필요하며, 헤더가 전송된 뒤 발생한 예외는 이미 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());
        }
    }
}

실제 연결 처리기는 요청 파싱 오류를 이 애플리케이션 경계 바깥에서 400 또는 413으로 처리합니다. 서비스가 실행되기 전의 형식 오류 시작 줄을 500으로 기록하면 클라이언트 오류와 서버 버그를 혼동합니다. 반대로 Servlet 안의 NullPointerException을 400으로 바꾸면 결함을 사용자 탓으로 숨깁니다.

응답이 이미 커밋된 스트리밍 상황에서는 내부 오류 처리기가 새 500 본문을 쓰면 두 응답이 섞입니다. 커밋 여부를 확인해 연결만 닫고 오류 ID를 서버에 기록해야 합니다. 메모리 버퍼 방식은 작은 응답에 한해 커밋 전 오류 대체를 쉽게 합니다.

표준 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에만 의존하고 공급자 확장은 어댑터 뒤에 격리하면 이동 가능성을 높일 수 있습니다.

연습 문제

가상의 VendorHandlerhandle(VendorExchange)만 제공하고, 애플리케이션은 Servlet.service(Request, Response)를 구현한다고 가정합니다. 어댑터가 교환 값을 표준 모델로 옮기고 서비스 결과를 공급자 응답에 반영하도록 작성합니다. 서비스 예외는 어댑터 밖 오류 경계가 처리할 수 있도록 보존합니다.

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

어댑터는 업무 결정을 추가하지 않습니다. 공급자의 경로와 메서드를 표준 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);
    }
}

공급자가 스트리밍 본문이나 비동기 완료 신호를 사용한다면 어댑터 계약도 그 수명을 표현해야 합니다. 동기 문자열 예제를 그대로 강제하면 큰 응답을 메모리에 모으는 문제가 생깁니다. 추상화는 공통 의미를 보존해야 하며 중요한 차이를 숨기는 것만이 목적이 아닙니다.

작은 WAS 완성 기준

라우터에는 중복 (method, path) 등록 실패, 정상 라우트, 404, 같은 경로의 다른 메서드를 검증합니다. 서비스가 던진 예상 찾을 수 없음와 내부 예외가 각각 404와 500이 되고, 500 본문에 내부 메시지가 노출되지 않아야 합니다. Response는 플러시 전 한 번만 전송 형식으로 직렬화되고 Content-Length가 최종 본문과 일치해야 합니다.

전송 계층 테스트에서는 형식이 잘못된 HTTP 요청이 Servlet에 도달하지 않는지, 한 서비스의 실패가 작업 스레드와 리스너를 종료하지 않는지 확인합니다. 컨테이너는 새 연결 수락을 중지하고 활성 연결을 닫거나 비운 뒤 실행기를 기다리는 순서로 종료합니다. 이 경계를 이해하면 이후 리플렉션과 애노테이션으로 라우트 등록을 자동화하더라도 무엇이 편의 기능이고 무엇이 HTTP 안전 계약인지 구분할 수 있습니다.