톰캣을 직접 만든다면...?

KwonMoYang·2026년 4월 27일
post-thumbnail

핵심 부품 다섯 개로 보는 서블릿 컨테이너

TL;DR: "서블릿 컨테이너"라는 한 단어를 부품 다섯 개로 쪼개서 보면, 톰캣이 무엇을 책임지는지가 명확해진다.


0. 들어가며

지난 글에서 이런 위계를 그렸다.

계층누가 처리하는가
비즈니스 로직개발자가 작성한 코드
HTTP 메시지 처리서블릿 컨테이너 (톰캣)
TCP/IPOS 커널
물리 신호NIC + 드라이버

OS 커널과 NIC는 §2에서 부품 단위로 들여다봤는데, "서블릿 컨테이너"는 한 줄로만 정리하고 넘어왔다. 이번 글에서 그 한 줄을 쪼갠다. 도구는 "톰캣을 직접 만든다면 무엇을 만들어야 하는가?" 라는 질문이다. 직접 만든다고 가정하고 부품 단위로 발라보면, 마법 상자처럼 보이던 톰캣이 의외로 작은 부품들의 조합이라는 게 보인다.


1. 서블릿 = 표준, 톰캣 = 구현체

먼저 용어부터 정리한다. 서블릿(Servlet)서블릿 컨테이너톰캣 은 자주 섞여 쓰이는데 정확히 다른 것이다.

용어정체비유
Servlet 스펙Jakarta EE(구 Java EE)가 정의한 표준 인터페이스콘센트 규격 (220V 둥근 핀 두 개)
Servlet 컨테이너그 스펙을 구현한 런타임실제 콘센트가 박힌 벽
Tomcat가장 널리 쓰이는 컨테이너 구현체Apache가 만든 그 벽

핵심은 이거다. Servlet은 인터페이스고, 컨테이너는 그 인터페이스를 호출해주는 런타임이다. 개발자는 인터페이스를 구현(HttpServlet 상속)하기만 하면, 컨테이너가 알아서 적절한 시점에 그 메서드를 불러준다. 이게 서블릿 모델의 출발점이다.

다른 컨테이너 구현체로는 Jetty, Undertow 등이 있다. Spring Boot 기본값이 톰캣일 뿐, 같은 서블릿 코드를 다른 컨테이너로 갈아끼울 수 있는 이유가 여기 있다 — 표준 인터페이스에 의존하기 때문에.


2. 서블릿 등록 — "URL에 핸들러를 꽂는다"

서블릿을 컨테이너에 등록하는 방식은 두 가지다.

방식 1: @WebServlet 애너테이션

@WebServlet("/home")
public class HomeServlet extends HttpServlet {

    @Override
    public void init(final ServletConfig config) throws ServletException {
        super.init(config);
    }

    @Override
    protected void service(final HttpServletRequest req, final HttpServletResponse resp)
            throws ServletException, IOException {
        resp.getWriter().write("Home");
    }
}

@WebServlet("/home") 한 줄이 컨테이너에게 "이 클래스는 /home 경로의 요청을 처리한다"고 선언한다. 컨테이너가 시작 시점에 클래스패스를 스캔해서 이 애너테이션을 찾아 자동 등록한다.

방식 2: 명시적 등록

public class Main {
    public static void main(String[] args) {
        final Tomcat tomcat = new Tomcat();
        tomcat.setPort(8080);

        final Context context = tomcat.addContext("", new File(".").getAbsolutePath());
        Tomcat.addServlet(context, "homeServlet", new HomeServlet());
        context.addServletMappingDecoded("/home", "homeServlet");

        tomcat.start();
        tomcat.getServer().await();
    }
}

핵심은 addServletMappingDecoded("/home", "homeServlet") 한 줄. "URL /home 요청은 homeServlet 이름의 서블릿이 처리한다"는 매핑이다.

두 방식의 본질

표면적으로 다르게 보이지만, 둘 다 결국 같은 일을 한다.

"이 URL 패턴 → 이 Servlet 인스턴스" 라는 매핑 정보를 컨테이너 내부 자료구조에 저장한다.

애너테이션 방식은 컨테이너가 자동 스캔해서 넣어주고, 명시적 방식은 직접 코드로 넣을 뿐이다. 그 자료구조의 본질은 Map<String, Servlet> — 키는 URL 패턴, 값은 Servlet 인스턴스다.

URL 매칭 규칙(정확 매칭, 경로 매칭 /lectures/*, 확장자 매칭 *.jsp)이 더 붙으면 단순 Map 조회로는 부족해서 우선순위 매칭 로직이 필요해진다. 톰캣은 이 역할을 Mapper 라는 컴포넌트가 담당한다.


3. 서블릿 라이프사이클 — "한 번 init, 매번 service"

서블릿은 다음 상태를 거친다.

단계호출 시점호출 횟수
인스턴스화첫 요청 도착 시 (lazy) 또는 <load-on-startup> 지정 시 시작 시점1회
init(ServletConfig)인스턴스화 직후1회
service(req, res)매 요청마다N회
destroy()컨테이너 종료 시1회

왜 init과 service가 분리됐는가? 무거운 초기화(DB 커넥션 풀 가져오기, 외부 설정 읽기 등)를 매 요청마다 반복할 수는 없다. init은 한 번만 하는 비싼 준비, service는 매 요청마다 가벼운 처리. 책임이 다르므로 메서드도 분리되어 있다.

ServletConfig와 ServletContext

init(ServletConfig config) 의 인자는 이 서블릿 한 개에 대한 설정이다. 이름, 초기화 파라미터, 그리고 ServletContext 참조를 담고 있다.

객체범위비유
ServletConfig서블릿 1개한 사람의 명함
ServletContext웹 애플리케이션 1개 (모든 서블릿이 공유)회사 공용 게시판

여러 서블릿이 공유해야 할 정보(전역 설정, 공유 자원)는 ServletContext 에, 특정 서블릿만의 설정은 ServletConfig 에 들어간다.

서블릿은 싱글톤이다

라이프사이클에서 인스턴스화가 1회뿐이라는 점에서 중요한 결론이 따라 나온다. 서블릿 인스턴스는 컨테이너당 한 개(URL당 한 개) 이고, 그 한 인스턴스의 service()여러 스레드에서 동시에 호출된다.

public class CountServlet extends HttpServlet {
    private int count = 0;  // ⚠️ 위험: 멀티스레드에서 race condition

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) {
        count++;  // 동시 호출되면 값 깨짐
        resp.getWriter().write("count: " + count);
    }
}

서블릿 인스턴스 필드는 stateless이거나 thread-safe해야 한다는 제약이, 라이프사이클로부터 자연스럽게 따라 나온다. 요청별 상태는 인스턴스 필드가 아니라 HttpServletRequest 의 attribute나 메서드 지역 변수에 둬야 한다.

service()와 doGet/doPost

HttpServlet.service() 의 기본 구현은 HTTP 메서드를 보고 doGet, doPost, doPut, doDelete 등으로 디스패치한다. 단순화하면 이런 코드다.

// HttpServlet.service() 내부 (단순화)
String method = req.getMethod();
if ("GET".equals(method))         doGet(req, resp);
else if ("POST".equals(method))   doPost(req, resp);
else if ("PUT".equals(method))    doPut(req, resp);
else if ("DELETE".equals(method)) doDelete(req, resp);
// ...

따라서 메서드별로 처리하고 싶으면 service() 는 건드리지 말고 doGet, doPost 만 오버라이드해야 한다. service() 를 오버라이드하면 이 디스패치 로직이 사라져서 직접 분기를 짜야 한다.


4. 부품 다섯 개 — 한 요청이 거치는 길

여기까지 정리한 개념을 한 그림으로 모으면, 요청이 컨테이너를 통과하는 동안 거치는 부품이 다섯 개다.

보라색으로 강조된 ④번 박스만 사용자가 작성하는 코드다. 나머지 넷은 컨테이너가 떠받쳐주는 부품이다. 다섯 개 중 하나만 비즈니스 로직, 나머지 넷은 보일러플레이트 — 이게 컨테이너가 만드는 압축률이다.

① Connector — 연결 받기

8080 포트의 TCP 연결을 받고, 한 연결당 한 스레드(또는 NIO 이벤트)에 할당한다. 지난 글에서 직접 짠 ServerSocket 코드의 책임이지만, 핵심 차이는 스레드 풀이다.

지난 글의 단순 서버는 while(true) 안에서 한 연결씩 순차 처리했다 — 한 요청이 느리면 모든 요청이 막힌다. Connector는 받은 연결을 워커 스레드 풀에 던져서 동시에 N개 요청을 처리한다. 톰캣에서는 이 부품을 Coyote 라고 부른다.

② HTTP Parser — 바이트를 객체로

Connector가 흘려준 raw 바이트를 HttpServletRequest 객체로 변환한다. 지난 글에서 직접 짠 95% 보일러플레이트가 통째로 여기 들어간다.

  • 요청 라인 파싱 (GET /home HTTP/1.1)
  • 헤더 파싱 (Host, Content-Length, Cookie 등)
  • 바디 읽기 (Content-Length만큼, 또는 chunked encoding)
  • URL 디코딩, 쿼리 스트링 파싱, multipart 처리

request.getMethod(), request.getParameter("page") 가 깔끔한 이유는, 이 부품이 그 모든 보일러플레이트를 한 번에 처리해서 객체로 만들어뒀기 때문이다.

③ Dispatcher — URL 보고 서블릿 고르기

§2에서 본 Map<URL, Servlet> 에서 매칭되는 서블릿을 찾는 일. 톰캣에서는 Mapper 가 이 역할을 한다.

URL 매칭 규칙(정확/경로/확장자/기본)에 따라 우선순위를 적용하고, 매칭된 서블릿을 §3의 라이프사이클대로 호출한다 — 첫 호출이면 init() 부터, 아니면 바로 service().

④ Servlet.service(req, res) — 비즈니스 로직

다섯 부품 중 유일하게 사용자가 작성하는 부품이다. 깔끔한 API(request.getParameter(), response.setStatus(), response.getWriter().write())로 정보를 꺼내고 응답을 작성한다.

여기서 신경 쓸 일은 "강의를 어떻게 저장할 것인가" 같은 비즈니스 로직뿐이다. 이게 가능한 건 ①②③⑤가 양쪽에서 보일러플레이트를 다 막아주고 있기 때문이다.

⑤ HTTP Builder — 객체를 바이트로

Servlet이 작성한 HttpServletResponse 를 응답 바이트로 직렬화한다. 상태 라인(HTTP/1.1 200 OK), 헤더(Content-Type, Content-Length, Set-Cookie), 빈 줄, 본문. ②의 거울짝이다.

책임 분포

부품책임톰캣 용어
① ConnectorTCP 연결 + 스레드 풀Coyote
② HTTP Parser바이트 → 요청 객체Coyote (HTTP/1.1 protocol)
③ DispatcherURL → Servlet 매칭Mapper
④ Servlet.service()비즈니스 로직(사용자 코드)
⑤ HTTP Builder응답 객체 → 바이트Coyote

전체를 감싸고 라이프사이클을 관리하는 상위 컴포넌트가 톰캣에서는 Catalina 다. 즉 톰캣 = Catalina(라이프사이클) + Coyote(HTTP I/O) + Mapper(URL 매칭) + 사용자 Servlet.


마무리

이번 글에서 정리한 개념을 한 줄씩 모으면 이렇다.

  • 서블릿은 표준 인터페이스, 컨테이너는 그 표준의 구현 런타임, 톰캣은 가장 널리 쓰이는 구현체.
  • 서블릿 등록의 본질은 Map<URL, Servlet> 에 매핑 정보를 넣는 일.
  • 서블릿은 싱글톤이고 init 1회 + service N회 라이프사이클을 가진다. 이로부터 인스턴스 필드의 thread-safety 제약이 따라 나온다.
  • 서블릿 컨테이너의 책임은 다섯 부품으로 발라진다 — Connector, HTTP Parser, Dispatcher, Servlet, HTTP Builder.
  • 다섯 부품 중 사용자가 짜는 건 ④ Servlet 하나, 나머지 넷은 컨테이너의 책임.

지난 글의 결론이 "OS 커널이 TCP/IP 보일러플레이트를 처리해준다" 였다면, 이번 글의 결론은 "서블릿 컨테이너가 HTTP 보일러플레이트를 부품 다섯 개로 처리해준다" 다. 추상화는 임의로 쌓이는 게 아니라 표준 스펙을 부품으로 쪼개서 위임받는 계층으로 쌓인다.

다음 글 — 서블릿이 늘어나면 어떻게 되는가

이번 글에서 자연스럽게 받아들였지만 한 번 더 생각해볼 지점이 있다. 서블릿 등록의 본질이 Map<URL, Servlet> 이라면, URL이 100개가 되면 Servlet 클래스도 100개가 되어야 하는가?

HomeServlet, LectureListServlet, LectureCreateServlet, LectureUpdateServlet, LectureDeleteServlet, MemberSignupServlet, MemberLoginServlet... 각 서블릿마다 init 받고, 같은 형태의 응답 직렬화 코드를 또 쓰고. 서블릿 자체가 또 하나의 보일러플레이트가 되는 셈이다.

다음 글에서는 이 문제를 푸는 패턴 — 프론트 컨트롤러(Front Controller) 패턴 을 다룬다. 모든 요청을 받는 단 하나의 서블릿을 두고, 그 안에서 URL에 따라 적절한 핸들러로 분배하는 구조다. Spring MVC의 DispatcherServlet 이 정확히 이 패턴이다. 어떻게 한 개의 서블릿이 N개의 서블릿을 대신할 수 있는지, 그리고 그 단순한 발상이 어떤 문을 열어주는지 들여다보자.

profile
Dot Your moment.

0개의 댓글