
TL;DR: "서블릿 컨테이너"라는 한 단어를 부품 다섯 개로 쪼개서 보면, 톰캣이 무엇을 책임지는지가 명확해진다.
지난 글에서 이런 위계를 그렸다.
| 계층 | 누가 처리하는가 |
|---|---|
| 비즈니스 로직 | 개발자가 작성한 코드 |
| HTTP 메시지 처리 | 서블릿 컨테이너 (톰캣) |
| TCP/IP | OS 커널 |
| 물리 신호 | NIC + 드라이버 |
OS 커널과 NIC는 §2에서 부품 단위로 들여다봤는데, "서블릿 컨테이너"는 한 줄로만 정리하고 넘어왔다. 이번 글에서 그 한 줄을 쪼갠다. 도구는 "톰캣을 직접 만든다면 무엇을 만들어야 하는가?" 라는 질문이다. 직접 만든다고 가정하고 부품 단위로 발라보면, 마법 상자처럼 보이던 톰캣이 의외로 작은 부품들의 조합이라는 게 보인다.
먼저 용어부터 정리한다. 서블릿(Servlet) 과 서블릿 컨테이너 와 톰캣 은 자주 섞여 쓰이는데 정확히 다른 것이다.
| 용어 | 정체 | 비유 |
|---|---|---|
| Servlet 스펙 | Jakarta EE(구 Java EE)가 정의한 표준 인터페이스 | 콘센트 규격 (220V 둥근 핀 두 개) |
| Servlet 컨테이너 | 그 스펙을 구현한 런타임 | 실제 콘센트가 박힌 벽 |
| Tomcat | 가장 널리 쓰이는 컨테이너 구현체 | Apache가 만든 그 벽 |
핵심은 이거다. Servlet은 인터페이스고, 컨테이너는 그 인터페이스를 호출해주는 런타임이다. 개발자는 인터페이스를 구현(HttpServlet 상속)하기만 하면, 컨테이너가 알아서 적절한 시점에 그 메서드를 불러준다. 이게 서블릿 모델의 출발점이다.
다른 컨테이너 구현체로는 Jetty, Undertow 등이 있다. Spring Boot 기본값이 톰캣일 뿐, 같은 서블릿 코드를 다른 컨테이너로 갈아끼울 수 있는 이유가 여기 있다 — 표준 인터페이스에 의존하기 때문에.
서블릿을 컨테이너에 등록하는 방식은 두 가지다.
@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 경로의 요청을 처리한다"고 선언한다. 컨테이너가 시작 시점에 클래스패스를 스캔해서 이 애너테이션을 찾아 자동 등록한다.
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 라는 컴포넌트가 담당한다.
서블릿은 다음 상태를 거친다.
| 단계 | 호출 시점 | 호출 횟수 |
|---|---|---|
| 인스턴스화 | 첫 요청 도착 시 (lazy) 또는 <load-on-startup> 지정 시 시작 시점 | 1회 |
init(ServletConfig) | 인스턴스화 직후 | 1회 |
service(req, res) | 매 요청마다 | N회 |
destroy() | 컨테이너 종료 시 | 1회 |
왜 init과 service가 분리됐는가? 무거운 초기화(DB 커넥션 풀 가져오기, 외부 설정 읽기 등)를 매 요청마다 반복할 수는 없다. init은 한 번만 하는 비싼 준비, service는 매 요청마다 가벼운 처리. 책임이 다르므로 메서드도 분리되어 있다.
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나 메서드 지역 변수에 둬야 한다.
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() 를 오버라이드하면 이 디스패치 로직이 사라져서 직접 분기를 짜야 한다.
여기까지 정리한 개념을 한 그림으로 모으면, 요청이 컨테이너를 통과하는 동안 거치는 부품이 다섯 개다.

보라색으로 강조된 ④번 박스만 사용자가 작성하는 코드다. 나머지 넷은 컨테이너가 떠받쳐주는 부품이다. 다섯 개 중 하나만 비즈니스 로직, 나머지 넷은 보일러플레이트 — 이게 컨테이너가 만드는 압축률이다.
8080 포트의 TCP 연결을 받고, 한 연결당 한 스레드(또는 NIO 이벤트)에 할당한다. 지난 글에서 직접 짠 ServerSocket 코드의 책임이지만, 핵심 차이는 스레드 풀이다.
지난 글의 단순 서버는 while(true) 안에서 한 연결씩 순차 처리했다 — 한 요청이 느리면 모든 요청이 막힌다. Connector는 받은 연결을 워커 스레드 풀에 던져서 동시에 N개 요청을 처리한다. 톰캣에서는 이 부품을 Coyote 라고 부른다.
Connector가 흘려준 raw 바이트를 HttpServletRequest 객체로 변환한다. 지난 글에서 직접 짠 95% 보일러플레이트가 통째로 여기 들어간다.
GET /home HTTP/1.1)Host, Content-Length, Cookie 등)request.getMethod(), request.getParameter("page") 가 깔끔한 이유는, 이 부품이 그 모든 보일러플레이트를 한 번에 처리해서 객체로 만들어뒀기 때문이다.
§2에서 본 Map<URL, Servlet> 에서 매칭되는 서블릿을 찾는 일. 톰캣에서는 Mapper 가 이 역할을 한다.
URL 매칭 규칙(정확/경로/확장자/기본)에 따라 우선순위를 적용하고, 매칭된 서블릿을 §3의 라이프사이클대로 호출한다 — 첫 호출이면 init() 부터, 아니면 바로 service().
다섯 부품 중 유일하게 사용자가 작성하는 부품이다. 깔끔한 API(request.getParameter(), response.setStatus(), response.getWriter().write())로 정보를 꺼내고 응답을 작성한다.
여기서 신경 쓸 일은 "강의를 어떻게 저장할 것인가" 같은 비즈니스 로직뿐이다. 이게 가능한 건 ①②③⑤가 양쪽에서 보일러플레이트를 다 막아주고 있기 때문이다.
Servlet이 작성한 HttpServletResponse 를 응답 바이트로 직렬화한다. 상태 라인(HTTP/1.1 200 OK), 헤더(Content-Type, Content-Length, Set-Cookie), 빈 줄, 본문. ②의 거울짝이다.
| 부품 | 책임 | 톰캣 용어 |
|---|---|---|
| ① Connector | TCP 연결 + 스레드 풀 | Coyote |
| ② HTTP Parser | 바이트 → 요청 객체 | Coyote (HTTP/1.1 protocol) |
| ③ Dispatcher | URL → Servlet 매칭 | Mapper |
| ④ Servlet.service() | 비즈니스 로직 | (사용자 코드) |
| ⑤ HTTP Builder | 응답 객체 → 바이트 | Coyote |
전체를 감싸고 라이프사이클을 관리하는 상위 컴포넌트가 톰캣에서는 Catalina 다. 즉 톰캣 = Catalina(라이프사이클) + Coyote(HTTP I/O) + Mapper(URL 매칭) + 사용자 Servlet.
이번 글에서 정리한 개념을 한 줄씩 모으면 이렇다.
Map<URL, 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개의 서블릿을 대신할 수 있는지, 그리고 그 단순한 발상이 어떤 문을 열어주는지 들여다보자.