본문 바로가기

서블릿 컨테이너 수동 조립

Tomcat 커넥터·컨텍스트·서블릿 매핑을 직접 구성해 내장 서버의 생명 주기와 Spring MVC 연결 책임을 확인합니다.

내장 서버도 내부에는 커넥터와 서블릿 컨테이너가 있습니다.

Tomcat을 직접 띄워 보면 HTTP 소켓, 컨텍스트 경로, 서블릿 등록, 시작·중지를 누가 책임지는지 드러납니다.

Boot의 ServletWebServerApplicationContext와 서버 팩토리 커스터마이저는 이 반복을 일관되게 조립하는 추상화입니다.

먼저 Java 프로젝트의 Tomcat 내장 라이브러리와 jakarta.servlet API가 같은 지원 조합으로 준비돼 있어야 합니다. 원 HelloServlet과 ManualTomcat은 이 클래스 경로에서 실행하는 별도 수동 예제이며 BoardApplication과 함께 자동 시작되는 설정이 아닙니다. 본문의 8080은 예제 조건이고 테스트는 아래처럼 포트 0의 실제 선택값을 사용합니다.

MANUAL TOMCAT · SETUP AND REQUEST

수동 서버는 /healthz를 HelloServlet에 직접 연결한다

본문 ManualTomcat은 DispatcherServlet을 사용하지 않습니다. 먼저 컨텍스트와 서블릿 매핑을 조립한 뒤 서버를 시작하고 실제 HTTP 요청으로 연결을 확인합니다.

실제 소켓의 healthz는 HelloServlet에 도달한다HTTP GET은 시작 후 실제 port에서 connector와 root context의 healthz 매핑을 거쳐 HelloServlet.doGet에 도달한다. 본문 8080과 테스트 port0 뒤의 localPort를 구별한다. DispatcherServlet과 MVC 컨트롤러는 이 수동 코드에 없다. 원 세 노드 두 호출을 모바일에서 같은 의미로 재배치했다.
HTTP 요청실제 port · GET/healthz
연결 관계HTTP 요청 · 실제 port · GET · /healthz → Tomcat · connector · root · /healthz → health
Tomcatconnector · root/healthz → health
연결 관계Tomcat · connector · root · /healthz → health → HelloServlet · doGet · 200 · board-ok
HelloServletdoGet200 · board-ok
HTTP 요청이 수동 Tomcat 매핑을 통해 HelloServlet에 도달한다클라이언트가 실제 서버 포트에 GET healthz를 보내고 커넥터와 루트 컨텍스트의 healthz 매핑을 거쳐 HelloServlet이 응답한다. 수동 예제에는 Spring MVC DispatcherServlet이 없다.HTTP 요청GET /healthzTomcat 매핑connector · root contextHelloServlet200 · board-ok본문 포트 8080 · 테스트에서는 포트 0 뒤 실제 localPort로 요청
  1. 실제 포트 → /healthz 매핑 → HelloServlet

    본문은 8080으로 시작합니다. 테스트에서 포트0을 쓰면 시작 뒤 실제 localPort를 읽어 GET 요청하고 200·UTF-8 board-ok를 확인합니다.

조립과 종료 · 리소스 소유

  1. 시작 전에 매핑 등록

    base dir·connector·root context·HelloServlet과 /healthz 매핑을 준비합니다.

  2. start → 요청 → stop/destroy

    실제 포트로 요청하고 종료 후 새 연결 거절과 서버 스레드 종료를 확인합니다. 부분 초기화 실패도 정리합니다.

실패 위치와 MVC 확장

포트 BindException과 매핑 누락 404를 구분합니다. MVC로 확장할 때는 웹 Spring context, DispatcherServlet 등록·매핑과 handler/adapter/converter가 추가됩니다. 모의 MVC 테스트는 실제 소켓을 증명하지 않습니다.

설정 순서와 요청 경로는 서로 다른 흐름입니다. 서블릿은 여러 요청에서 공유될 수 있으므로 요청 상태를 필드에 저장하지 않습니다.


커넥터·컨테이너 역할

서버는 주소·포트·프로토콜·연결 대기량을 관리하고 서블릿 컨테이너는 URL 매핑, 필터 체인, 세션, 서블릿 생명 주기를 관리합니다.

둘을 “Tomcat” 하나로 뭉개면 바인딩 실패와 매핑 404를 구분하기 어렵습니다.

src/main/java/board/manual/HelloServlet.java
package board.manual;

import java.io.IOException;

import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public final class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(
            HttpServletRequest request,
            HttpServletResponse response
    ) throws IOException {
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().write("board-ok");
    }
}

서블릿 객체는 컨테이너가 여러 스레드에서 호출할 수 있으므로 요청별 값을 필드에 저장하지 않습니다.

응답 커밋 전에 상태와 콘텐츠 타입을 정합니다.


Tomcat 수동 조립

다음 메인은 임시 기반 디렉터리와 루트 컨텍스트를 만들고 서블릿을 /healthz에 등록합니다.

실행 뒤 GET 요청은 board-ok를 반환합니다.

src/main/java/board/manual/ManualTomcat.java
package board.manual;

import java.nio.file.Files;

import org.apache.catalina.Context;
import org.apache.catalina.startup.Tomcat;

public final class ManualTomcat {
    public static void main(String[] args) throws Exception {
        var base = Files.createTempDirectory("board-tomcat");
        var tomcat = new Tomcat();
        tomcat.setBaseDir(base.toString());
        tomcat.setPort(8080);
        tomcat.getConnector();

        Context context = tomcat.addContext("", base.toString());
        Tomcat.addServlet(context, "health", new HelloServlet());
        context.addServletMappingDecoded("/healthz", "health");

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                tomcat.stop();
                tomcat.destroy();
            } catch (Exception failure) {
                failure.printStackTrace(System.err);
            }
        }));
        tomcat.start();
        System.out.println("server=http://127.0.0.1:8080/healthz");
        tomcat.getServer().await();
    }
}
ManualTomcat 시작과 요청
server=http://127.0.0.1:8080/healthz
GET /healthz -> 200
body=board-ok

getConnector() 호출로 기본값 커넥터가 생성되게 합니다.

포트 0을 사용하면 OS가 고른 실제 로컬 포트를 커넥터에서 읽어 테스트 격리를 만들 수 있습니다.

예시 원 addContext의 빈 문자열은 루트 컨텍스트이고 매핑은 /healthz입니다. 커넥터가 시작된 뒤 GET /healthz는 등록한 HelloServlet.doGet으로 전달되어 200·text/plain;charset=UTF-8·board-ok를 기대합니다. /missing은 등록하지 않았으므로 같은 서버의 404 검사입니다. 여기에는 Spring의 DispatcherServlet이나 컨트롤러 빈이 없어 MVC 핸들러 매핑이 아니라 Tomcat 서블릿 매핑을 확인합니다.

반대로 포트 바인딩 단계에서 시작이 실패하면 정상 GET /missing→404를 관측할 서버 자체가 없습니다. 연결 실패와 HTTP 404를 같은 매핑 오류로 합치지 않습니다.


Spring MVC 연결 요소

애노테이션 구성 컨텍스트에 컨트롤러, 핸들러 매핑, 어댑터, 컨버터를 등록하고 DispatcherServlet 생성자에 웹 애플리케이션 컨텍스트를 전달합니다.

서블릿 컨텍스트 생명 주기와 Spring 컨텍스트 종료를 같은 서버 종료에 묶어야 합니다.

Boot는 MVC 스타터와 자동 구성으로 이 빈 그래프를 제공하고 서블릿 등록 빈을 구성합니다.

사용자가 DispatcherServlet 빈을 교체하거나 경로를 바꾸면 매핑과 오류 디스패치도 함께 검증합니다.

루트 컨텍스트와 자식 서블릿 컨텍스트를 둘 수 있지만 작은 내장 애플리케이션은 하나의 웹 컨텍스트로 단순화할 수 있습니다.

여러 디스패처를 사용할 때 빈 가시성과 경로 소유권을 명시합니다.


시작 실패 진단

BindException이면 포트를 누가 점유했는지 확인하고 임의로 다른 운영 환경 포트로 우회하지 않습니다.

기반 디렉터리 생성 실패는 파일 시스템 권한과 디스크 상태를 봅니다.

서버는 시작했지만 404라면 커넥터보다 컨텍스트 경로와 서블릿 매핑을 확인합니다.

Spring 컨텍스트 새로 고침 실패가 난 뒤 Tomcat이 열린 채 남지 않게 부분 초기화 정리를 수행합니다.

시작 단계별 리소스를 역순으로 닫습니다. 위 종료 훅에서 stop()이 예외를 던지면 destroy()에는 도달하지 않습니다.

종료 훅 하나에만 기대면 테스트 컨텍스트 반복 실행이 느려집니다.

스레드 풀 최댓값을 크게 한다고 처리량이 무조건 늘지는 않습니다.

하위 시스템의 DB 풀과 CPU, 요청 타임아웃을 함께 조정합니다.

단계적 종료는 새 요청 수락을 멈추고 처리 중인 요청이 제한 시간 안에 끝나기를 기다린 뒤 강제로 종료합니다.


Boot 사용자 정의 지점

포트와 압축은 속성으로, 커넥터별 옵션은 웹 서버 팩토리 커스터마이저로 바꿀 수 있습니다.

Tomcat 팩토리 빈을 통째로 대체하면 Boot가 제공하는 생명 주기와 SSL 번들, 단계적 종료 지원을 놓칠 수 있습니다.

통합 테스트는 임의 포트로 실제 소켓을 열고 HTTP 상태·헤더·본문을 확인합니다.

컨트롤러 모의만으로 커넥터 인코딩과 필터 디스패치, 오류 페이지를 검증했다고 말하지 않습니다.

관찰 결과는 커넥터의 로컬 포트가 0보다 크고, /healthz 응답은 200과 UTF-8 board-ok, 등록하지 않은 /missing 응답은 404여야 합니다.

중지한 뒤 같은 포트로 새 연결을 시도하면 거절되어야 하며, 서버 스레드가 테스트 프로세스를 붙잡아 타임아웃을 일으킨다면 생명 주기 관리가 완전하지 않은 것입니다.

수동 컨테이너 종료 검증
selectedPort > 0 = true
GET /healthz = 200 board-ok
GET /missing = 404
connection after close = refused
remaining non-daemon server threads = 0

연습 문제

수동 서버를 포트 0에서 시작하고 실제 선택된 포트로 GET 요청한 뒤 정리하는 테스트 도구를 작성하세요.

매핑 누락과 포트 바인딩 실패를 서로 다른 신호로 보고해야 합니다.

해설 보기

서버 리소스를 소유한 래퍼를 AutoCloseable로 만들면 테스트의 try-with-resources 구문에서 close()가 호출됩니다. 아래 순차 구현은 stop()이 예외를 던지면 destroy()에 도달하지 않으므로, 정리의 성공까지 보장하지는 않습니다.

package board.manual;

import org.apache.catalina.startup.Tomcat;

public final class RunningTomcat implements AutoCloseable {
    private final Tomcat tomcat;

    public RunningTomcat(Tomcat tomcat) {
        this.tomcat = tomcat;
    }

    public int localPort() {
        return tomcat.getConnector().getLocalPort();
    }

    @Override
    public void close() throws Exception {
        tomcat.stop();
        tomcat.destroy();
    }
}

시작 실패 전에는 로컬 포트가 유효하지 않으므로 생성자가 아니라 팩토리 메서드가 성공 뒤 래퍼를 반환하게 개선할 수 있습니다.

독립 확인: 실제 소켓 실행을 가정한 세 테스트의 기대 증거를 쓰세요. A는 start 성공 뒤 선택된 port로 /healthz를 요청, B는 같은 port로 /missing 요청, C는 다른 서버가 이미 점유한 고정 port로 start 실패입니다. 이어 별도 정리 실험에서 RunningTomcat.stop이 예외 E를 던지면 원 close의 destroy와 try-with-resources 밖 정상 진행은 어떻게 되나요?

풀이 확인

A는 HTTP 200·콘텐츠 타입·board-ok를, B는 같은 살아 있는 서버의 HTTP 404를 기대합니다. C는 시작의 바인딩 실패이며 HTTP 응답 상태와 구별합니다. 정리 실험의 원 close는 stop 다음 줄로 가지 못하므로 destroy 호출 0회이고 E를 전달합니다. try-with-resources는 close를 호출한다는 보장이지 그 구현의 두 정리가 모두 성공한다는 보장은 아닙니다. 개선할 때는 stop 실패 뒤에도 destroy를 시도하되 최초 실패 E를 유지하고 두 번째 정리 실패를 suppressed로 남기는 계약을 별도로 시험합니다.