Java 애플리케이션에서 개발자가 저수준의 소켓 제어 및 프로토콜 명세에 맞도록 데이터 규격화를 직접 하지 않도록 하기 위해 Java EE에서 Servlet API라는 표준화된 인터페이스를 제공합니다. 또한 Servlet API의 구현체를 정의하고, 이를 기반으로 실제 통신을 처리할 수 있도록 하는 런타임을 서블릿 컨테이너라고 정의합니다. Tomcat, Jetty와 같은 다양한 상용 구현체가 존재합니다.
서블릿 컨테이너의 역할 및 기능을 직접 눈으로 보고 코드로 녹여내보기 위해서, Servlet API를 직접 구현하고, 이를 실행하는 런타임까지 만든 뒤, 직접 만든 서블릿 컨테이너 위에서 WAS 실행까지 해보겠습니다.
컴포넌트 설계는 톰캣의 구조(catalina, coyote)를 참고하였습니다.
아래에서 소스코드를 확인하실 수 있습니다.
컴포넌트 설계
요구사항 정의
- HTTP 프로토콜 지원: 서블릿 HTTP 인터페이스를 구현하며 버전에 따른 HTTP 프로토콜 명세를 준수해야 하며, 클라이언트로부터 GET, POST 등의 HTTP 요청을 수신하고 적절한 응답(예: 200 OK, 404 Not Found)을 반환할 수 있어야 함. 헤더 처리, 바디 데이터 읽기/쓰기, 멀티파트 요청 지원 등을 포함.
- 서블릿 라이프사이클 관리: 서블릿 인스턴스의 생성(init), 서비스(service), 소멸(destroy)을 자동으로 관리. 싱글톤으로 효율적으로 서블릿을 재사용하며, 초기화 시 ServletConfig를 제공.
- 요청-응답 매핑: URL 패턴에 따라 적절한 서블릿을 매핑하고 호출.
- 멀티스레딩 지원: 다중 클라이언트 요청을 동시에 처리하기 위해 스레드 풀을 사용. 각 요청이 별도의 스레드에서 실행되며, thread-safety를 보장.
- 세션 관리: HTTP 세션을 지원하여 사용자 상태를 유지. HttpSession 인터페이스를 구현하고, 쿠키나 URL 재작성을 통해 세션 ID를 관리.
- 필터 체인 지원: 요청/응답을 가로채는 필터를 체인 형태로 적용. 인증, 로깅, 인코딩 등의 전처리/후처리를 가능하게 함.
- 리스너 지원: 애플리케이션 이벤트(예: 컨텍스트 초기화, 세션 생성/소멸)를 감지하는 리스너(ServletContextListener 등)를 등록하고 호출.
- 웹 애플리케이션 로딩: 내장 웹서버 방식으로 애플리케이션을 로드. 서블릿 클래스 등록 및 인스턴스화, 별도의 WAR 파일 대신 코드 내에서 직접 등록하거나 설정.
- 표준 준수: Java Servlet API 사양(예: Jakarta Servlet 6.1)을 준수하여 호환성 보장.
- 네트워크 모듈 지원: 별도의 네트워크 모듈을 통해 소켓 기반 연결을 처리. 서버 소켓 생성, 클라이언트 연결 수락, 데이터 송수신을 담당하며, 컨테이너와 분리.
- 프로토콜 계층 지원: 응용 레이어 프로토콜 파싱과 생성을 위한 전용 계층을 구현. 요청 라인, 헤더, 바디를 독립적으로 처리하며, 다른 프로토콜(예: HTTPS) 확장을 고려.
- 계층간 독립성: 네트워크 모듈과 프로토콜 계층이 서블릿 컨테이너로부터 독립되어야 함. 서블릿은 네트워크 세부 사항이나 프로토콜 구현을 알지 않고, 추상화된 인터페이스만 사용
상위 설계

패키지 수준의 상위 컴포넌트 설계입니다. net 패키지에서 가장 낮은 수준의 원시 네트워크 통신을 담당합니다. bridge 패키지에서는 net 계층으로부터 받은 원시 바이트 스트림을 해석하여 의미 있는 애플리케이션 프로토콜(HTTP/1.1 등) 단위로 변환합니다.
두 패키지는 서블릿 API와 무관하게 설계합니다. 네트워크 및 프로토콜 처리라는 모듈의 고유 책임을 갖고 이를 수행합니다.
사용자 인터페이스
public class WebApplication {
public static void main(String[] args) {
try (ServletContainer servletContainer = new ServletContainer()) {
servletContainer.addListener(ContextListener.class);
servletContainer.addFilter("LoggingFilter", LoggingFilter.class, "/*");
servletContainer.addServlet("HelloWorldServlet", HelloWorldServlet.class, "/hello");
servletContainer.addConnector(8080, Endpoint.Type.BIO, Protocol.HTTP11);
servletContainer.addConnector(8081, Endpoint.Type.NIO, Protocol.HTTP11);
servletContainer.start();
} catch (Exception e) {
e.printStackTrace();
}
}
}
사용자(웹 애플리케이션)에서 내장 웹서버 방식으로 서블릿 컨테이너 인스턴스를 생성하고 필터, 리스너, 서블릿을 등록한 후 웹서버를 실행할 수 있도록 합니다.
net
원시 네트워크 통신을 담당합니다. BIO나 NIO의 구현 차이를 추상화하여 상위 계층에 일관된 인터페이스를 제공합니다.
Endpoint
public interface Endpoint {
enum Type {
BIO, NIO
}
void start() throws IOException;
void stop() throws IOException;
void bind(int port);
void setHandler(Handler protocolHandler);
}
네트워크 연결을 수신하는 엔드포인트에 대한 인터페이스입니다. 구현체는 특정 I/O 모델(BIO/NIO)을 사용하여 서버 소켓을 열고 클라이언트 연결을 수락합니다. I/O 모델에 대한 다형성을 제공합니다.
시리즈의 이전 글에서 구현한 BIO 소켓을 기본으로 담습니다. 향후 java.nio를 활용해 논블로킹 IO에 대한 확장성을 열기 위해 엔드포인트를 추상화하여 사용합니다.
public class BioEndpoint extends AbstractEndpoint {
private ServerSocket serverSocket;
@Override
public void start() throws IOException {
serverSocket = new ServerSocket(port);
executor.submit(() -> {
System.out.println("started Bio Server Socket at port: " + port);
while (true) {
try {
Socket socket = serverSocket.accept();
SocketWrapperBase wrapper = new BioSocketWrapper(socket);
executor.submit(() -> handler.process(wrapper));
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
@Override
public void stop() throws IOException {
super.stop();
if (serverSocket != null && !serverSocket.isClosed()) {
serverSocket.close();
}
}
}
서버 소켓측 구현은 표준적인 BIO 서버소켓 사이드 코드로 만들었습니다. accept된 소켓을 별도의 스레드를 풀에서 가져와서 핸들러 로직을 실행시킵니다.
Handler
public interface Handler {
boolean process(SocketWrapperBase socketWrapper);
}
소켓의 요청을 처리할 Handler에 대한 인터페이스를 정의해둡니다.
bridge
net 계층과 container 계층을 연결하는 다리 역할을 합니다. net 계층으로부터 받은 원시 바이트 스트림을 해석하여 애플리케이션 프로토콜을 핸들링합니다.
ProtocolHandler
public abstract class ProtocolHandler implements Handler, AutoCloseable {
protected Endpoint endpoint;
protected Endpoint getEndpoint() {
return this.endpoint;
}
@Override
public void close() throws Exception {
this.endpoint.stop();
}
}
Endpoint 를 지니며 Endpoint로 부터 맺어진 통신을 특정 프로토콜로 해석 및 처리하며 생명주기를 관리합니다.
import java.io.IOException;
public class Http11Protocol extends ProtocolHandler {
private final Gateway gateway;
private final int port;
public Http11Protocol(Gateway gateway, Endpoint.Type type, int port) {
this.gateway = gateway;
switch (type) {
case NIO -> this.endpoint = new NioEndpoint();
case BIO -> this.endpoint = new BioEndpoint();
}
this.port = port;
init();
}
@Override
public boolean process(SocketWrapperBase socketWrapper) {
Http11Processor processor = new Http11Processor(socketWrapper, gateway);
return processor.process();
}
private void init() {
this.endpoint.setHandler(this);
this.endpoint.bind(port);
try {
this.endpoint.start();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
HTTP/1.1 프로토콜을 핸들링하는 구현체입니다. 전체 생명주기 관장을 하며 요청 파싱 및 처리는 Http11Processor 클래스가 담당합니다.

Processor는 소켓의 스트림을 파싱하고 서빙합니다.
Gateway
public interface Gateway {
void service(Request request, Response response);
}
Bridge 계층에서 파싱한 Request와 Response를 인자로 받으며 상위 컴포넌트에서 로직을 처리할 수 있게 해주는 Bridge 계층의 관문(Gateway) 입니다.
container
가장 상위 계층으로, Jakarta Servlet 명세를 구현합니다. 서블릿의 생명주기 관리, 요청-서블릿 매핑, 필터 체인 적용, 세션 관리 등 웹 애플리케이션의 핵심 로직을 담당합니다.
Connector
bridge 계층과 container 계층을 최종적으로 연결합니다. 프로토콜 및 네트워크에 대한 로직을 응집해서 처리합니다.
public class Connector implements AutoCloseable{
private final Gateway gateway;
private final ProtocolHandler protocolHandler;
public final int port;
public final String protocol;
public final String scheme;
public Connector(Protocol protocol, Context context, int port, Endpoint.Type type) {
this.gateway = new BridgeGateway(context, this);
this.port = port;
switch (protocol) {
case HTTP11 -> {
this.protocolHandler = new Http11Protocol(gateway, type, port);
this.protocol = "HTTP/1.1";
this.scheme = "http";
}
default -> throw new UnsupportedOperationException();
}
}
// 생략
}
connector 개념으로 네트워크 영역을 응집시킨 덕에, 나머지 서블릿 관련 로직들은 네트워크 무관하게 재사용될 수 있습니다.
가령 아래와 같이 등록하고 서버를 키면 localhost:8080과 localhost:8081에서 각기 다른 형식으로 구현된 서버소켓이 돌아가서 두 포트 모두 요청을 받을 수 있으며 실제 처리로직은 공유된 내부 인스턴스 및 서블릿이 담당합니다.
servletContainer.addServlet("HelloWorldServlet", HelloWorldServlet.class, "/hello");
servletContainer.addConnector(8080, Endpoint.Type.BIO, Protocol.HTTP11);
servletContainer.addConnector(8081, Endpoint.Type.NIO, Protocol.HTTP11);
servletContainer.start();



각기 다른 네트워크 설정에서 서버 소켓이 열리며 비즈니스 로직을 재활용하는 모습
BridgeGateway
public class BridgeGateway implements Gateway {
private final Context context;
private final ServletMapper servletMapper;
private final FilterMapper filterMapper;
private final Connector connector;
public BridgeGateway(Context context, Connector connector) {
this.context = context;
this.servletMapper = context.getServletMapper();
this.filterMapper = context.getFilterMapper();
this.connector = connector;
}
@Override
public void service(Request request, Response response) {
HttpRequest httpRequest = connector.createRequest(request, context);
HttpResponse httpResponse = connector.createResponse(response);
httpRequest.setResponse(httpResponse);
fireRequestInitializedEvent(httpRequest);
try {
String requestURI = request.getRequestURI();
Servlet servlet = servletMapper.map(requestURI);
List<Filter> filters = filterMapper.getMatchingFilters(requestURI);
ApplicationFilterChain filterChain = new ApplicationFilterChain(filters, servlet);
filterChain.doFilter(httpRequest, httpResponse);
if (servlet != null) {
httpResponse.flushBuffer();
} else {
httpResponse.sendError(HttpServletResponse.SC_NOT_FOUND, "Not Found");
}
} catch (ServletException | IOException e) {
throw new RuntimeException(e);
} finally {
fireRequestDestroyedEvent(httpRequest);
}
}
// 생략
}
bridge.Gateway 인터페이스의 구현체로, 컨테이너의 실질적인 진입점입니다. bridge 계층에서 파싱된 bridge.Request와 bridge.Response를 받아, 이를 서블릿 API 표준인 HttpServletRequest와 HttpServletResponse로 변환한 후, 적절한 서블릿과 필터 체인을 찾아 호출합니다.
Context
하나의 웹 애플리케이션 컨텍스트를 구성하는 요소들을 포함합니다.
public class Context {
private final CustomServletContext servletContext;
private final SessionManager sessionManager;
private ServletMapper servletMapper;
private FilterMapper filterMapper;
private final Map<String, Servlet> instantiatedServlets = new ConcurrentHashMap<>();
private final Map<String, Filter> instantiatedFilters = new ConcurrentHashMap<>();
// 생략
}
Servlet API의 ServletContext, ServletRegistration, HttpSession, Filter 등의 인스턴스를 갖고 있으며 getter과 setter를 제공합니다.
HttpRequest / Response

흔히 서블릿 컨테이너 위에서 실행되는 웹 애플리케이션을 개발하다보면 HttpServletRequest / Response 인터페이스를 다루어보셨을 겁니다.
헤더, 쿠키, 세션 등등 다양한 HTTP 요청/응답 관련 메서드를 지원해주는데 Servlet Container는 이 인터페이스에 대한 구현의 책임을 지기에 실제 인터페이스의 getRequestURI , parseCookies 등을 서블릿 컨테이너의 런타임 로직에 맞추어 구현해주어야 합니다.
제 구현을 보면 net 계층에서 소켓 데이터 스트림을 받고, 이를 bridge 계층에서 프로토콜 명세에 맞게 헤더, uri, body 등을 파싱합니다.
HttpRequest Class에서 bridge 계층에서 파싱한 Request와 Connector이 지닌 네트워크 커넥션 관련 정보, Context에 담긴 세션, ServletContext 등을 활용해 ServletAPI의 메서드들을 구현했습니다.
FilterMapper
인스턴스화된 필터를 가지고 있고 url에 걸맞는 필터를 찾아주는 클래스입니다.
public class FilterMapper {
private final Map<String, ? extends FilterRegistration> filterRegistrations;
private final Map<String, Filter> instantiatedFilters;
public FilterMapper(ServletContext servletContext, Map<String, Filter> instantiatedFilters) {
this.filterRegistrations = servletContext.getFilterRegistrations();
this.instantiatedFilters = instantiatedFilters;
}
public List<Filter> getMatchingFilters(String requestURI) {
// 생략
}
}
ApplicationFilterChain
필터체인의 구현체입니다.
BridgeGateway 에서 유저의 요청이 들어오면 URI에 필요한 필터들을 매퍼에서 가져오고, 필터체인의 doFilter를 호출했었습니다.
public class BridgeGateway implements Gateway {
public void service(Request request, Response response) {
// 생략
List<Filter> filters = filterMapper.getMatchingFilters(requestURI);
ApplicationFilterChain filterChain = new ApplicationFilterChain(filters, servlet);
filterChain.doFilter(httpRequest, httpResponse);
// 생략
}
}
여기서 필터체인은 필터 리스트를 순서대로 호출하고, 만약 모두 호출했으면 서블릿의 service를 호출하게 합니다.
public class ApplicationFilterChain implements FilterChain {
private final List<Filter> filters;
private final Servlet servlet;
private int currentFilterIndex = 0;
public ApplicationFilterChain(List<Filter> filters, Servlet servlet) {
this.filters = filters;
this.servlet = servlet;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
if (currentFilterIndex < filters.size()) {
Filter currentFilter = filters.get(currentFilterIndex++);
currentFilter.doFilter(request, response, this);
} else {
if (servlet != null) {
servlet.service(request, response);
}
}
}
}
이를 통해 servlet 서비스 이전에 공통으로 처리할 로직(필터)를 유저가 동적으로 등록할 수 있게 됩니다.
활용을 보자면, ServletContainer 위에서 구동되는 환경인 가령 spring-webmvc를 예로 들면, MVC에선 dispatcherServlet 이라는 하나의 서블릿만 내장 톰캣(== 서블릿 컨테이너)에 등록하기에 Filter를 등록하면 dispatcherServlet의 service 로직 이전에 실행될 로직 체인을 만들 수 있죠.
Session
ServletAPI에서 세션을 관리하기 위해 사용되는 HttpSession 인터페이스를, 서블릿 컨테이너 런타임에 맞게 작동하도록 직접 구현해주어야 합니다.
또한 HttpSessionListener 같은 리스너가 세션의 라이프사이클에 개입할 수 있어야 하니 이벤트 관련 로직 및 등록된 리스너의 메서드를 실핸하는 로직도 넣어줍니다.
public class CustomHttpSession implements HttpSession {
private final String id;
private final long creationTime;
private long lastAccessedTime;
private final Context context;
private final SessionManager sessionManager;
private boolean isValid = true;
private boolean isNew = true;
private int maxInactiveInterval = 1800; // 30 minutes
private final Map<String, Object> attributes = new ConcurrentHashMap<>();
// 생략
private void fireAttributeReplacedEvent(String name, Object value) {
HttpSessionBindingEvent event = new HttpSessionBindingEvent(this, name, value);
for (EventListener listener : context.getServletContext().getListeners()) {
if (listener instanceof HttpSessionAttributeListener) {
((HttpSessionAttributeListener) listener).attributeReplaced(event);
}
}
}
private void fireSessionDestroyedEvent() {
HttpSessionEvent event = new HttpSessionEvent(this);
for (EventListener listener : context.getServletContext().getListeners()) {
if (listener instanceof HttpSessionListener) {
((HttpSessionListener) listener).sessionDestroyed(event);
}
}
}
// 생략
}
ServletContainer
이렇게 만든 모든 컴포넌트를 조립해야합니다. 저희는 최초 요구사항에서 독립 실행되는 애플리케이션으로서의 서블릿 컨테이너 대신, 다른 WebApplication에서 내장되어 웹서버 역할을 할 수 있도록 구현하기로 요구사항에서 정의해두었으니 진입점을 만들어야합니다.

다시 다이어그램을 보면 사용자측 WAS에서 connector, filter, listener과 servlet을 등록할 수 있었죠. 이를 등록하고 start(), close() 를 할 수 있는 서블릿 컨테이너 클래스를 만들겠습니다.
public class ServletContainer implements AutoCloseable {
private final Context context;
private final List<Connector> connectors = new ArrayList<>();
private final List<ConnectorConfig> connectorConfigs = new ArrayList<>();
private CountDownLatch shutdownLatch;
public ServletContainer() {
this.context = new Context();
}
public void addConnector(int port, Endpoint.Type endpointType, Protocol protocol) {
connectorConfigs.add(new ConnectorConfig(port, endpointType, protocol));
}
public void addListener(Class<? extends EventListener> listenerClass) {
this.context.addListener(listenerClass);
}
public void addServlet(String servletName, Class<? extends Servlet> servletClass, String... urlPatterns) {
this.context.addServlet(servletName, servletClass, urlPatterns);
}
public void addFilter(String filterName, Class<? extends Filter> filterClass, String... urlPatterns) {
this.context.addFilter(filterName, filterClass, urlPatterns);
}
public void start() {
context.init();
fireContextInitializedEvent();
shutdownLatch = new CountDownLatch(1);
for (ConnectorConfig config : connectorConfigs) {
Connector connector = new Connector(config.protocol, context, config.port, config.endpointType);
connectors.add(connector);
}
try {
shutdownLatch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Server interrupted");
}
}
// 생략
}
실제 springboot의 web쪽에서 사용하는 내장 톰캣도 이와 유사하게 구현됩니다. new Tomcat()으로 서블릿 컨테이너를 만들고 서블릿을 등록하고 시작하는 것을 Java 애플리케이션 안에서 할 수 있게 해두었습니다.
public class WebApplication {
public static void main(String[] args) {
try (ServletContainer servletContainer = new ServletContainer()) {
servletContainer.addListener(ContextListener.class);
servletContainer.addFilter("LoggingFilter", LoggingFilter.class, "/*");
servletContainer.addServlet("HelloWorldServlet", HelloWorldServlet.class, "/hello");
servletContainer.addConnector(8080, Endpoint.Type.BIO, Protocol.HTTP11);
servletContainer.addConnector(8081, Endpoint.Type.NIO, Protocol.HTTP11);
servletContainer.start();
} catch (Exception e) {
e.printStackTrace();
}
}
}
이제 사용자 측인 WebApplication에서 직접 구현한 서블릿 컨테이너 모듈을 활용해서 Web Server를 구동할 수 있습니다. WebApplication은 저수준의 네트워크 프로그래밍도, 프로토콜 처리도, 라이프사이클 관리도 신경쓸 필요 없이 Servlet을 통해 비즈니스 로직을 개발하고, Filter와 Listener를 통해 횡단관심사나 공통 로직도 넣을 수 있습니다.
마무리
Servlet API의 요구 사항에 맞춰 실제 동작을 구현해보았습니다. 요구사항을 하나씩 코드로 녹여내 보면서 서블릿API가 요청 매핑부터 라이프사이클 관리, 세션 처리, 필터 체인까지 어떻게 실제로 동작하는지 추상적 개념이 아니라 구체적인 흐름으로 환원해서 보았습니다.
Servlet API가 무엇인지, 서블릿 컨테이너가 무엇인지 밑바닥부터 개발하며 보았기에 이제 내가 사용하는 웹 프레임워크에서 서블릿 컨테이너가 무엇을 처리하는 지에 대해선 확실히 이해할 수 있을것입니다.
Tomcat이나 Jetty 같은 상용 구현체도 훨씬 기능이 많고 복잡하긴 하지만 이와 비슷한 구조로 되어 있습니다. 가령 tomcat.util.net 이 제 net 컴포넌트와 유사하고 coyote가 제 bridge와 유사하며 catalina가 container와 유사합니다.
다음 글에서는 대표적인 상용 Servlet Container인 Tomcat의 구현을 뜯어보도록 하겠습니다.
