이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 아홉 번째 글입니다. 8편에서는 요청이 Engine → Host → Context → Wrapper의 파이프라인을 차례로 지나며 밸브(valve)를 거치는 것을 봤습니다. 이번에는 그 사슬의 맨 끝, Wrapper의 기본 밸브인 StandardWrapperValve에서 시작합니다. 여기서 비로소 우리가 쓴 코드 — 필터와 서블릿 — 가 불립니다. Spring MVC를 쓰는 애플리케이션이라면 이 자리에서 불리는 서블릿이 Spring 21편의 DispatcherServlet입니다.
답하려는 질문은 네 가지입니다.
- 서블릿 객체는 누가, 언제 만들고,
init()은 언제 불리는가 (load-on-startup은 무엇을 바꾸는가) - web.xml에 적은 필터들은 요청마다 어떻게 골라지고, 어떤 순서로 묶이며,
chain.doFilter()는 어떻게 "다음"을 아는가 RequestDispatcher의forward()와include()는 소스에서 무엇이 다른가- 서블릿이 던진 예외는 어떤 길을 거쳐
<error-page>로 바뀌는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main, 개발판 12.0.0-M1-dev) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | core/StandardWrapperValve, core/StandardWrapper, core/ApplicationFilterFactory, core/ApplicationFilterChain, core/ApplicationDispatcher, core/StandardHostValve |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 이 글에서 인용한 코드는 흔히 쓰는 안정판 11.0.x 브랜치에서도 내용이 같습니다(파일 비교로 확인했습니다. StandardContext는 인용하지 않은 곳이 달라 11.0.x에서는 줄 번호가 어긋나고, 이 글과 닿는 차이 하나는 "이 글에서 뺀 것"에 적었습니다).
ApplicationDispatcher 하나만 800줄이 넘고, 비동기 처리와 얽힌 분기도 많습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다.
먼저 알아 둘 말
Wrapper는 Tomcat의 컨테이너 넷 가운데 가장 안쪽의 것으로, 서블릿 정의 하나를 맡습니다. web.xml의 <servlet> 하나, 또는 @WebServlet 하나가 StandardWrapper 객체 하나가 됩니다. 컨테이너의 계층은 2편에서, 요청 URL로 어느 Wrapper인지 고르는 일은 7편에서 봤습니다. 이 글은 Wrapper가 이미 정해진 뒤에서 시작합니다.
밸브와 기본 밸브. 컨테이너마다 파이프라인이 하나 있고, 그 끝에는 반드시 기본 밸브(basic valve)가 있습니다. 기본 밸브는 다음 컨테이너로 요청을 넘기는 일을 맡는데, Wrapper는 더 안쪽 컨테이너가 없으므로 기본 밸브인 StandardWrapperValve가 대신 필터와 서블릿을 부릅니다(8편).
필터(filter)는 서블릿 규격의 jakarta.servlet.Filter 인터페이스를 구현한 객체로, 서블릿 앞에서 요청과 응답을 살펴보거나 바꿉니다. 인코딩 설정, 인증, 로깅이 대표적입니다. 필터는 doFilter(request, response, chain)에서 할 일을 하고 chain.doFilter(request, response)를 불러 "다음"으로 넘깁니다. 이 chain이 필터 체인(FilterChain)이고, Tomcat의 구현이 ApplicationFilterChain입니다. Spring이 이 인터페이스 위에 만든 OncePerRequestFilter 같은 기반 클래스는 Spring 20편에서 봤습니다.
디스패치 종류(DispatcherType)는 "이 요청이 어떤 길로 서블릿에 왔나"를 나타내는 값입니다. 클라이언트에서 바로 온 REQUEST, 다른 서블릿이 넘긴 FORWARD와 INCLUDE, 오류 페이지로 온 ERROR, 비동기 처리 뒤에 돌아온 ASYNC의 다섯 가지가 있습니다. 필터 매핑의 <dispatcher>가 이 값과 맞춰 봅니다.
forward와 include. 서블릿 안에서 request.getRequestDispatcher("/다른경로")로 얻은 RequestDispatcher로 요청을 다른 서블릿에 넘길 수 있습니다. forward()는 "응답을 통째로 맡긴다", include()는 "그 서블릿의 출력을 내 응답 가운데에 끼워 넣는다"입니다. 둘 다 서버 안에서 일어나서 브라우저 주소는 바뀌지 않습니다.
1. 큰 그림: 밸브 사슬의 끝에서 서블릿까지
Wrapper의 파이프라인은 StandardWrapper를 만들 때 생성자에서 기본 밸브를 정해 둡니다.
public StandardWrapper() {
super();
swValve = new StandardWrapperValve();
pipeline.setBasic(swValve);
broadcaster = new NotificationBroadcasterSupport();
}
java/org/apache/catalina/core/StandardWrapper.java 83–90행
Context의 기본 밸브 StandardContextValve는 요청에 붙은 Wrapper를 꺼내 wrapper.getPipeline().getFirst().invoke(request, response)를 부르고(StandardContextValve.java 65–77행), Wrapper에 따로 건 밸브가 없으면 곧바로 StandardWrapperValve.invoke()가 불립니다. 이 메서드의 본론은 이렇습니다.
// Allocate a servlet instance to process this request
try {
if (!unavailable) {
servlet = wrapper.allocate();
}
} catch (UnavailableException e) {
// ...
}
MessageBytes requestPathMB = request.getRequestPathMB();
DispatcherType dispatcherType = DispatcherType.REQUEST;
if (request.getDispatcherType() == DispatcherType.ASYNC) {
dispatcherType = DispatcherType.ASYNC;
}
request.setAttribute(Globals.DISPATCHER_TYPE_ATTR, dispatcherType);
request.setAttribute(Globals.DISPATCHER_REQUEST_PATH_ATTR, requestPathMB);
// Create the filter chain for this request
ApplicationFilterChain filterChain = ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
// Call the filter chain for this request
// NOTE: This also calls the servlet's service() method
Container container = this.container;
try {
if ((servlet != null) && (filterChain != null)) {
// Swallow output if needed
if (context.getSwallowOutput()) {
// ...
} else {
if (request.isAsyncDispatching()) {
request.getAsyncContextInternal().doInternalDispatch();
} else {
filterChain.doFilter(request.getRequest(), response.getResponse());
}
}
java/org/apache/catalina/core/StandardWrapperValve.java 110–166행
파란 상자 넷이 이 밸브가 하는 일의 전부입니다. 빨간 상자는 예외가 났을 때만 지나는 길입니다.
순서대로 읽으면 네 단계입니다.
wrapper.allocate()— 서블릿 인스턴스를 받습니다. 아직 없으면 이 자리에서 만들고init()을 부릅니다(2절).- 디스패치 종류와 경로를 요청 속성에 적습니다(131–136행). 클라이언트에서 바로 온 요청이므로
REQUEST입니다(비동기 디스패치로 다시 온 요청만ASYNC). 다음 단계에서 필터를 고를 때 이 두 속성을 읽습니다. ApplicationFilterFactory.createFilterChain()— 이 요청에 맞는 필터들을 골라 체인을 만듭니다(3절).filterChain.doFilter()— 주석 그대로, 이 호출이 필터들을 지나 서블릿의service()까지 부릅니다(4절). 넘기는 객체는request.getRequest(), 곧 7편에서 본RequestFacade입니다. 애플리케이션 코드는 Tomcat 내부의Request가 아니라 이 껍데기만 봅니다.
끝나면 finally에서 체인을 비우고(filterChain.release()), 서블릿 인스턴스를 돌려줍니다(wrapper.deallocate(), 217–225행). "돌려준다"고 했지만 지금의 deallocate()는 사용 중인 횟수를 하나 줄일 뿐입니다(StandardWrapper.java 622–625행). 인스턴스는 하나를 모든 요청이 같이 씁니다.
예제 1: service()에 닿았을 때의 호출 스택
정말 이 길로 가는지 확인하려고, 필터 셋(A, B, C)과 서블릿 하나를 web.xml로 등록하고 서블릿 안에서 호출 스택을 찍었습니다. 필터는 들어갈 때 -> 이름, 나올 때 <- 이름을 찍습니다.
이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. 예제 폴더는 ex/u09-filter-chain이고, run.sh가 클래스를 javac로 컴파일해 $CATALINA_BASE/webapps/u09에 두고 catalina.sh start로 띄운 뒤 curl로 요청하고, catalina.out에서 예제가 찍은 줄만 골라 보여 줍니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
package ex.u09;
// ... import 생략 ...
/** 들어갈 때 "-> 이름", 나올 때 "<- 이름" 을 찍는 필터. 같은 클래스를 이름만 바꿔 여러 번 등록한다. */
public class LogFilter implements Filter {
private String name;
@Override
public void init(FilterConfig config) {
name = config.getFilterName();
System.out.println("[u09] filter init: " + name);
}
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
System.out.println("[u09] -> " + name);
chain.doFilter(req, resp); // 다음 필터, 또는 마지막이면 서블릿
System.out.println("[u09] <- " + name);
}
}
[예제 ex/u09-filter-chain/src/ex/u09/LogFilter.java에서 import만 줄임]
서블릿(TraceServlet)은 ?stack이 붙은 요청이면 new Throwable().getStackTrace()에서 org.apache.catalina, jakarta.servlet, 예제 패키지의 줄만 골라 CoyoteAdapter가 나올 때까지 찍습니다. 결과입니다(맨 앞의 [u09]는 떼었습니다).
===== GET /hello?stack =====
-> A
-> C
-> B
servlet hello doGet, instance @50cab0f2
at ex.u09.TraceServlet.doGet(TraceServlet.java:23)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
at ex.u09.LogFilter.doFilter(LogFilter.java:22)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at ex.u09.LogFilter.doFilter(LogFilter.java:22)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at ex.u09.LogFilter.doFilter(LogFilter.java:22)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:164)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:540)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:82)
at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:777)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:71)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:347)
<- B
<- C
<- A
instance @… 값은 실행마다 다릅니다. 아래에서 위로 읽으면 이렇습니다.
CoyoteAdapter.service()(7편)에서 Engine·Host·Context의 밸브(8편)를 지나StandardWrapperValve.invoke()164행, 곧 위 발췌의filterChain.doFilter(...)줄에 닿습니다.ApplicationFilterChain.doFilter()가 네 번 쌓여 있습니다. 세 번은 111행(필터를 부르는 줄), 마지막 한 번은 132행(서블릿을 부르는 줄)입니다. 그 사이사이에 우리 필터의doFilter()가 끼어 있습니다. 필터 셋이 반복문이 아니라 서로를 부르는 재귀로 엮여 있다는 뜻입니다. 4절에서 소스로 봅니다.HttpServlet.service()가 두 번 있습니다. 762행은ServletRequest를 받는 규격의service()이고, 이것이HttpServletRequest로 바꿔 673행의service()를 부르고, 거기서 메서드가 GET이므로doGet()을 부릅니다.- 필터의 순서가 A → C → B입니다. web.xml에는 B의 매핑을 맨 앞에 적었는데도 그렇습니다. 3절에서 이유를 봅니다.
헷갈리기 쉬운 곳: 필터 안에서 시간이 걸리면 서블릿도 그만큼 늦습니다
<- B, <- C, <- A가 서블릿이 끝난 뒤에 찍혔습니다. 필터의 chain.doFilter()는 서블릿까지 다 돌고 나서야 돌아오고, 이 모든 일이 한 스레드(5편의 http-nio-8080-exec-N)의 한 호출 스택 안에서 일어납니다. 필터에서 응답 시간을 재면 서블릿 시간까지 들어가는 이유이고, 필터 어딘가에서 chain.doFilter()를 부르지 않으면 서블릿은 아예 불리지 않습니다.
2. 서블릿 인스턴스와 init() — allocate()와 load-on-startup
allocate(): 처음 쓰는 순간에 만든다
// Load and initialize our instance if necessary
if (instance == null || !instanceInitialized) {
synchronized (this) {
if (instance == null) {
try {
if (log.isTraceEnabled()) {
log.trace("Allocating instance");
}
instance = loadServlet();
newInstance = true;
// Increment here to prevent a race condition
// with unload. Bug 43683, test case #3
countAllocated.incrementAndGet();
} catch (ServletException e) {
throw e;
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
throw new ServletException(sm.getString("standardWrapper.allocate"), t);
}
}
if (!instanceInitialized) {
initServlet(instance);
}
}
}
java/org/apache/catalina/core/StandardWrapper.java 584–608행
- 인스턴스는 Wrapper마다 하나입니다. 필드
instance에 주석으로 "The (single) possibly uninitialized instance of this servlet"이라고 적혀 있습니다(120–123행). 그래서 서블릿의 필드에 요청마다 다른 값을 넣으면 동시에 들어온 요청끼리 섞입니다. - 바깥
if는 잠금 없이 보고, 안쪽에서synchronized를 잡은 뒤 한 번 더 봅니다. 첫 요청 여러 개가 동시에 와도 인스턴스는 하나만 만들어집니다. 두 번째 요청부터는 바깥if가 거짓이라 잠금도 잡지 않습니다. loadServlet()은InstanceManager.newInstance(servletClass)로 객체를 만듭니다(763–765행).new를 직접 쓰지 않는 것은@Resource같은 주입과@PostConstruct를 처리하기 위해서입니다(DefaultInstanceManager.java195–201행). 그다음initServlet()이servlet.init(facade)를 부릅니다(829–832행). 넘기는ServletConfig는 Wrapper 자신이 아니라StandardWrapperFacade라는 껍데기입니다.RequestFacade와 같은 이유로, 서블릿이 Tomcat 내부 메서드에 손대지 못하게 하려는 것입니다.
load-on-startup: Context가 켜질 때 미리 만든다
web.xml의 <load-on-startup>(또는 @WebServlet(loadOnStartup = …))은 이 "처음 쓰는 순간"을 Context가 시작하는 순간으로 앞당깁니다. 기본값은 -1입니다(StandardWrapper.java 135행). StandardContext.startInternal()의 끝부분입니다.
// Configure and call application filters
if (ok) {
if (!filterStart()) {
log.error(sm.getString("standardContext.filterFail"));
ok = false;
}
}
// Load and initialize all "load on startup" servlets
if (ok) {
if (!loadOnStartup(findChildren())) {
log.error(sm.getString("standardContext.servletFail"));
ok = false;
}
}
java/org/apache/catalina/core/StandardContext.java 4642–4656행
그 바로 앞에서는 ServletContainerInitializer들을 부르고(4605–4614행), ServletContextListener들을 부릅니다(4616–4622행). Spring의 SpringServletContainerInitializer와 ContextLoaderListener가 불리는 자리가 여기입니다(Spring 20편 5절). Context가 켜지는 전체 순서는 10편에서 봅니다.
// Collect "load on startup" servlets that need to be initialized
TreeMap<Integer,ArrayList<Wrapper>> map = new TreeMap<>();
for (Container child : children) {
Wrapper wrapper = (Wrapper) child;
int loadOnStartup = wrapper.getLoadOnStartup();
if (loadOnStartup < 0) {
continue;
}
Integer key = Integer.valueOf(loadOnStartup);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(wrapper);
}
// Load the collected "load on startup" servlets
for (ArrayList<Wrapper> list : map.values()) {
for (Wrapper wrapper : list) {
try {
wrapper.load();
java/org/apache/catalina/core/StandardContext.java 4353–4369행
음수면 건너뛰고, 0 이상이면 숫자를 열쇠로 TreeMap에 모읍니다. TreeMap은 열쇠 순으로 정렬되므로 숫자가 작은 것부터 wrapper.load()가 불립니다. load()는 loadServlet()과 initServlet()을 부르는, allocate()의 앞부분과 같은 일입니다(704–709행). 여기서 예외가 나도 기본으로는 로그만 남기고 Context는 계속 켜집니다(4374–4380행의 주석, failCtxIfServletStartFails).
필터는 모두 시작할 때, 서블릿은 load-on-startup이 있을 때만 시작할 때 만들어집니다. 노란 상자가 "나중에 만드는" 자리입니다.
필터는 전부 시작할 때 만든다
서블릿과 달리 필터에는 "처음 쓸 때 만든다"가 없습니다. filterStart()는 정의된 필터(filterDefs)를 전부 돌며 new ApplicationFilterConfig(this, entry.getValue())를 만들고(StandardContext.java 4037–4046행), 그 생성자가 필터 객체를 만들어 filter.init(this)까지 부릅니다(ApplicationFilterConfig.java 93–100행, 213–243행). 요청이 올 때 체인이 꺼내 쓰는 것은 이렇게 미리 만들어 둔 ApplicationFilterConfig들입니다.
예제 2: init이 불리는 시점
예제 1의 web.xml에는 같은 TraceServlet 클래스를 이름만 바꿔 셋 등록했습니다. hello는 load-on-startup이 없고, late는 5, early는 1입니다. web.xml에는 late를 먼저 적었습니다. 서블릿의 init()은 이름과 스레드 이름을 찍습니다.
===== startup, then the readiness probe GET /early =====
filter init: A
filter init: B
filter init: C
servlet init: early (thread main)
servlet init: late (thread main)
-> A
servlet early doGet, instance @60e7357c
<- A
===== GET /hello =====
hello from hello
servlet init: hello (thread http-nio-8080-exec-2)
-> A
-> C
-> B
servlet hello doGet, instance @50cab0f2
<- B
<- C
<- A
===== GET /hello (second time) =====
hello from hello
-> A
-> C
-> B
servlet hello doGet, instance @50cab0f2
<- B
<- C
<- A
첫 묶음은 기동 로그와, 기동이 끝났는지 확인하려고 run.sh가 보낸 GET /early의 로그입니다. 각 묶음에서 hello from …(응답 본문)이 로그보다 먼저 보이는 것은 run.sh가 응답을 먼저 출력하고 로그를 나중에 꺼내기 때문입니다. instance @…와 exec-2의 번호는 실행마다 다릅니다.
- 필터 init 셋 → 서블릿 init 둘 순서입니다.
filterStart()가loadOnStartup()보다 먼저라는 것과 같습니다. early(1)가late(5)보다 먼저입니다. web.xml에 적은 순서가 아니라 숫자 순서입니다.- 둘 다
main스레드에서 불렸습니다. Host가 Context를 시작할 때 쓰는startStopThreads의 기본값이 1이고, 1이면 별도 스레드 없이 그 자리에서 실행하기 때문입니다(ContainerBase.java244행, 690–694행). hello는 첫 요청에서, 그 요청을 받은 작업 스레드(http-nio-8080-exec-2)에서 init됩니다. 첫 요청을 보낸 사람은 init에 걸리는 시간만큼 더 기다립니다.- 두 번째 요청에는
servlet init이 없고,instance @50cab0f2가 같습니다. 같은 객체를 다시 썼습니다.
헷갈리기 쉬운 곳
- 필터 init 순서는 web.xml 순서를 보장하지 않습니다.
filterDefs와filterConfigs는HashMap입니다(StandardContext.java366행, 372행). 예제에서 A, B, C 순으로 나온 것은 이름이 한 글자라 해시 순서가 우연히 그렇게 된 것입니다. 실행 순서(3절)와 init 순서는 다른 이야기입니다. DispatcherServlet의 초기화도 이 규칙을 따릅니다. Spring 21편 2절에서 본HttpServletBean.init()→initWebApplicationContext()는 결국 Tomcat이 부르는servlet.init()입니다.load-on-startup을 주지 않으면 그 무거운 초기화가 첫 요청에서 일어납니다.
3. 필터 고르기 — ApplicationFilterFactory.createFilterChain()
매핑 배열을 두 번 훑는다
// Add the relevant path-mapped filters to this filter chain
for (FilterMap filterMap : filterMaps) {
if (!matchDispatcher(filterMap, dispatcher)) {
continue;
}
if (!FilterUtil.matchFiltersURL(filterMap, requestPath)) {
continue;
}
ApplicationFilterConfig filterConfig =
(ApplicationFilterConfig) context.findFilterConfig(filterMap.getFilterName());
if (filterConfig == null) {
log.warn(sm.getString("applicationFilterFactory.noFilterConfig", filterMap.getFilterName()));
continue;
}
filterChain.addFilter(filterConfig);
}
// Add filters that match on servlet name second
for (FilterMap filterMap : filterMaps) {
if (!matchDispatcher(filterMap, dispatcher)) {
continue;
}
if (!matchFiltersServlet(filterMap, servletName)) {
continue;
}
// ... 위와 같이 filterConfig 를 찾아 addFilter ...
}
java/org/apache/catalina/core/ApplicationFilterFactory.java 98–130행
filterMaps는 web.xml의 <filter-mapping>을 적은 순서대로 담은 배열입니다(context.findFilterMaps(), 80행). 이 배열을 두 번 훑습니다.
- 첫 번째는 URL 패턴(
<url-pattern>)이 맞는 매핑만 고릅니다. - 두 번째는 서블릿 이름(
<servlet-name>)이 맞는 매핑만 고릅니다.
두 번 모두 먼저 matchDispatcher()로 디스패치 종류를 봅니다. 매핑에 <dispatcher>를 하나도 적지 않으면 REQUEST로 칩니다(FilterMap.java 233–241행). 그래서 평범하게 등록한 필터는 forward나 오류 페이지로 온 요청에서는 불리지 않습니다(5절, 6절의 예제).
URL 패턴은 FilterUtil.matchFiltersURL()이 정확히 일치 → /경로/* → *.확장자 → 빈 문자열(컨텍스트 루트) 순으로 봅니다(FilterUtil.java 76–124행). 서블릿 매핑(7편)과 규칙이 닮았지만, 소스 주석이 짚듯 필터는 "하나라도 맞으면 참"이라 순서가 의미가 없습니다(83–84행). 서블릿 매핑은 하나만 고르고, 필터 매핑은 맞는 것을 전부 고릅니다.
같은 필터는 한 번만
addFilter()는 넣기 전에 이미 있는지 봅니다.
void addFilter(ApplicationFilterConfig filterConfig) {
// Prevent the same filter being added multiple times
for (int i = 0; i < n; i++) {
if (filters[i] == filterConfig) {
return;
}
}
if (n == filters.length) {
filters = Arrays.copyOf(filters, n + INCREMENT);
}
filters[n++] = filterConfig;
}
java/org/apache/catalina/core/ApplicationFilterChain.java 174–188행
체인의 속은 그냥 배열(filters)과 개수(n)입니다. 자리가 모자라면 10칸씩 늘립니다(INCREMENT, 53행).
예제 1 다시 보기: 왜 A → C → B인가
예제의 매핑은 이렇게 적었습니다.
<!-- 일부러 servlet-name 매핑(B)을 맨 앞에 적었다 -->
<filter-mapping><filter-name>B</filter-name><servlet-name>hello</servlet-name></filter-mapping>
<filter-mapping><filter-name>A</filter-name><url-pattern>/*</url-pattern></filter-mapping>
<filter-mapping><filter-name>C</filter-name><url-pattern>/hello</url-pattern></filter-mapping>
<!-- A 를 servlet-name 으로 한 번 더 매핑한다 -->
<filter-mapping><filter-name>A</filter-name><servlet-name>hello</servlet-name></filter-mapping>
[예제 ex/u09-filter-chain/web.xml에서]
/hello 요청에 대해 첫 번째 훑기는 URL 패턴 매핑인 A(/*)와 C(/hello)를 적은 순서대로 넣습니다. 두 번째 훑기는 서블릿 이름 매핑인 B와 A를 차례로 보는데, A는 이미 있으므로 addFilter()가 건너뜁니다. 결과 배열은 [A, C, B]이고, 예제의 출력 그대로입니다. 같은 web.xml에서 /early로 가는 요청에는 A만 걸렸습니다(예제 2의 첫 묶음). C는 /hello에만, B는 서블릿 hello에만 매핑했기 때문입니다.
web.xml에서 B를 맨 앞에 적었는데도 마지막에 오는 까닭이 두 번째 훑기입니다.
헷갈리기 쉬운 곳
- URL 패턴 매핑이 항상 서블릿 이름 매핑보다 앞섭니다. web.xml에서 어디에 적었는지와 상관없습니다. "필터 순서 = web.xml 순서"는 같은 종류끼리만 맞는 말입니다.
- 코드로 등록한 필터는 위치가 다를 수 있습니다.
ServletContext.addFilter()로 얻은 등록 객체의addMappingForUrlPatterns(..., isMatchAfter, ...)에서isMatchAfter가false면 web.xml 매핑보다 앞에 들어갑니다(ApplicationFilterRegistration.java114–117행). 이때 쓰는 것이ContextFilterMaps.addBefore()이고, 앞에 넣은 것끼리는 부른 순서를 지키도록insertPoint를 기억합니다(StandardContext.java3974–3989행). - 체인 객체는 요청마다 새로 만들지 않습니다.
createFilterChain()은 Tomcat 내부Request에 붙어 있는 체인을 꺼내 다시 쓰고, 없을 때만 만듭니다(63–68행).CoyoteAdapter는Request객체를 한 번 만들어 커넥터 쪽 요청 객체에 붙여 두고 다시 쓰므로(CoyoteAdapter.java307–321행) 체인도 함께 재사용됩니다. 그래서StandardWrapperValve가finally에서release()로 배열을 비우는 것이 중요합니다.
4. ApplicationFilterChain.doFilter() — 배열 위의 재귀
이 글의 중심인 메서드입니다. 길지 않으니 통째로 옮깁니다.
@Override
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
// Call the next filter if there is one
if (pos < n) {
ApplicationFilterConfig filterConfig = filters[pos++];
try {
Filter filter = filterConfig.getFilter();
if (request.isAsyncSupported() && !(filterConfig.getFilterDef().getAsyncSupportedBoolean())) {
request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE);
}
filter.doFilter(request, response, this);
} catch (IOException | ServletException | RuntimeException e) {
throw e;
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
throw new ServletException(sm.getString("filterChain.filter"), t);
}
return;
}
// We fell off the end of the chain -- call the servlet instance
try {
if (dispatcherWrapsSameObject) {
lastServicedRequest.set(request);
lastServicedResponse.set(response);
}
if (request.isAsyncSupported() && !servletSupportsAsync) {
request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE);
}
// Use potentially wrapped request from this point
servlet.service(request, response);
} catch (IOException | ServletException | RuntimeException e) {
throw e;
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
throw new ServletException(sm.getString("filterChain.servlet"), t);
} finally {
if (dispatcherWrapsSameObject) {
lastServicedRequest.set(null);
lastServicedResponse.set(null);
}
}
}
java/org/apache/catalina/core/ApplicationFilterChain.java 100–144행
노란 상자가 "다음이 누구인가"를 아는 유일한 곳입니다. 필터는 체인을 다시 부를 뿐입니다.
chain.doFilter()가 "다음"을 아는 방법은 필드 pos 하나입니다.
pos < n이면filters[pos]를 꺼내고pos를 하나 올린 뒤(pos++), 그 필터의doFilter(request, response, this)를 부릅니다. 세 번째 인자가 체인 자기 자신입니다.- 필터가 할 일을 하고
chain.doFilter()를 부르면 같은 메서드로 다시 들어옵니다.pos가 하나 올라 있으므로 다음 필터가 불립니다. - 필터를 다 쓰면(
pos == n) 아래쪽으로 떨어져servlet.service()를 부릅니다. 주석이 "We fell off the end of the chain"이라고 적은 곳입니다. service()가 끝나면 호출이 쌓인 순서의 반대로 하나씩 돌아갑니다. 필터의chain.doFilter()다음 줄이 이때 실행됩니다.
"마지막 필터 다음에 서블릿을 부른다"는 일이 필터 쪽이 아니라 체인 쪽에 있다는 점이 설계의 핵심입니다. 필터는 자기 뒤에 필터가 더 있는지, 서블릿이 무엇인지 몰라도 됩니다. 그래서 같은 필터 클래스를 어느 서블릿 앞에든 끼울 수 있습니다.
래퍼를 넘기면 그 뒤로는 래퍼가 간다
필터가 chain.doFilter(new MyRequestWrapper(req), resp)처럼 다른 객체를 넘기면, 그 뒤의 필터와 서블릿은 그 객체를 받습니다. 체인은 받은 인자를 그대로 다음에 넘기기 때문입니다(111행, 132행의 "Use potentially wrapped request from this point"). 요청 본문을 여러 번 읽게 해 주는 래퍼, 헤더를 바꾸는 래퍼가 이 방식으로 동작합니다.
예외는 감싸지 않고 그대로 던진다
필터나 서블릿이 던진 IOException, ServletException, RuntimeException은 잡자마자 그대로 다시 던집니다(112–113행, 133–134행). 그 밖의 Throwable(예: Error)만 ServletException으로 감쌉니다. 받는 쪽은 1절의 StandardWrapperValve이고, 6절에서 이어서 봅니다.
헷갈리기 쉬운 곳
- 체인 객체는 한 요청 안에서만 유효합니다.
pos를 들고 있는 상태 있는 객체이므로, 필터가 받은chain을 필드에 저장해 두었다가 다른 요청에서 쓰면 엉뚱한 위치에서 시작합니다. 같은 이유로 한 필터 안에서chain.doFilter()를 두 번 부르면 두 번째 호출은pos가 이미 끝까지 가 있어 서블릿만 한 번 더 부릅니다(소스의 흐름에서 읽은 것이며, 이 경우를 예제로 돌려 보지는 않았습니다). - 필터 하나라도
asyncSupported가 아니면 그 요청은 비동기를 못 씁니다. 108–110행이 체인을 지나며ASYNC_SUPPORTED_ATTR을false로 내려 둡니다. 비동기 서블릿은 12편에서 봅니다. lastServicedRequest는 기본으로 쓰이지 않습니다.dispatcherWrapsSameObject의 기본값이Globals.STRICT_SERVLET_COMPLIANCE(StandardContext.java809행)라서, 엄격 모드를 켜지 않으면 123–126행의ThreadLocal은 비어 있습니다.
5. RequestDispatcher — forward와 include
서블릿 안에서 req.getRequestDispatcher("/target")을 부르면 ApplicationDispatcher가 돌아옵니다. 대상 경로에 맞는 Wrapper는 이때 7편의 Mapper로 미리 찾아 둡니다(ApplicationContext.java 421행, 437행). forward()와 include()는 마지막에 같은 invoke()로 모입니다.
// Get the FilterChain Here
ApplicationFilterChain filterChain = ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
// Call the service() method for the allocated servlet instance
try {
// for includes/forwards
if (servlet != null && filterChain != null) {
filterChain.doFilter(request, response);
}
java/org/apache/catalina/core/ApplicationDispatcher.java 505–513행
바로 앞(488–503행)에서 wrapper.allocate()로 대상 서블릿을 받고, 여기서 필터 체인을 새로 만들어 부릅니다. StandardWrapperValve.invoke()의 축소판입니다. 다른 점은 밸브를 거치지 않는다는 것입니다. 밸브는 컨테이너의 파이프라인에 걸려 있고, forward는 파이프라인 밖에서 체인을 직접 부릅니다. 그래서 접근 로그 밸브는 forward를 따로 기록하지 않습니다. 이때 넘어오는 요청은 Tomcat이 감싼 래퍼라 내부 Request가 아니므로, createFilterChain()은 재사용하지 않고 새 체인을 만듭니다(ApplicationFilterFactory.java 69–72행).
forward — 응답을 통째로 넘긴다
public void forward(ServletRequest request, ServletResponse response) throws ServletException, IOException {
// Reset any output that has been buffered, but keep headers/cookies
if (response.isCommitted()) {
throw new IllegalStateException(sm.getString("applicationDispatcher.forward.ise"));
}
response.resetBuffer();
java/org/apache/catalina/core/ApplicationDispatcher.java 204–209행
- 이미 커밋된 응답이면 예외입니다. 커밋(commit)은 상태 줄과 헤더가 이미 클라이언트로 나간 상태입니다. 그 뒤에는 "응답을 다른 서블릿에게 통째로 맡긴다"가 불가능합니다.
- 버퍼에 쓴 본문은 지우고 헤더와 쿠키는 남깁니다(
resetBuffer()). - 요청을
ApplicationHttpRequest로 감싸 경로를 대상 것으로 바꾸고, 원래 경로는jakarta.servlet.forward.request_uri같은 속성에 남깁니다(239–261행). processRequest()가 디스패치 종류를FORWARD로 적고invoke()를 부릅니다(332–335행).- 돌아오면 응답을 끝냅니다.
ResponseFacade.finish()로 더 쓸 수 없게 막는 것입니다(277–290행). 소스 주석은 "This is not a real close in order to support error processing"이라고, 진짜로 소켓을 닫는 것은 아니라고 적어 둡니다(272행).
include — 출력만 빌려 온다
include()는 응답을 included 표시가 붙은 ApplicationHttpResponse로 감쌉니다(376행, 751행). 이 래퍼는 상태 코드와 헤더를 바꾸는 메서드를 모두 무시합니다.
@Override
public void setStatus(int sc) {
if (!included) {
((HttpServletResponse) getResponse()).setStatus(sc);
java/org/apache/catalina/core/ApplicationHttpResponse.java 361–365행
setHeader(), addCookie(), sendRedirect(), sendError() 등도 같은 모양으로 if (!included)에 막혀 있습니다(같은 파일 159–365행). 그리고 요청의 경로는 바꾸지 않습니다. 대상 경로는 jakarta.servlet.include.request_uri 같은 속성에만 넣고(386–408행), 디스패치 종류를 INCLUDE로 적은 뒤 invoke()를 부릅니다(411–413행). forward와 달리 돌아온 뒤에 응답을 끝내지 않으므로 부른 서블릿이 이어서 쓸 수 있습니다.
가운데의 invoke()는 같고, 들어가기 전과 나온 뒤가 다릅니다.
예제 3: forward와 include, 그리고 디스패치 종류별 필터
ex/u09-dispatch는 서블릿 front가 본문에 front: before를 쓰고 X-Front 헤더를 단 뒤 /target으로 forward 또는 include하고, 돌아와서 front: after를 씁니다. target은 상태 코드를 202로, X-Target 헤더를 달고 target: body를 씁니다. 필터는 같은 클래스를 <dispatcher>만 달리해 넷 걸었습니다(onRequest는 dispatcher를 적지 않았고, onForward·onInclude·onError는 각각 FORWARD·INCLUDE·ERROR). 응답은 상태 줄, X- 헤더, 본문만 골라 보여 주고, 그 아래가 로그입니다.
===== GET /front?mode=forward =====
HTTP/1.1 202
X-Front: yes
X-Target: yes
target: body
filter onRequest: REQUEST /u09/front
filter onForward: FORWARD /u09/target
target: FORWARD requestURI=/u09/target servletPath=/target forward.request_uri=/u09/front include.request_uri=null
front: back from forward, committed=true
===== GET /front?mode=include =====
HTTP/1.1 200
X-Front: yes
front: before
target: body
front: after
filter onRequest: REQUEST /u09/front
filter onInclude: INCLUDE /u09/front
target: INCLUDE requestURI=/u09/front servletPath=/front forward.request_uri=null include.request_uri=/u09/target
front: back from include, committed=false
- forward: 본문에
front: before와front: after가 둘 다 없습니다. 앞의 것은resetBuffer()가 지웠고, 뒤의 것은 응답이 끝난 뒤라 버려졌습니다(committed=true). 헤더는X-Front가 남았고, 상태 코드와X-Target은 target이 정한 대로입니다. target이 본requestURI는/u09/target이고 원래 주소는forward.request_uri에 있습니다. - include: 본문이
before → body → after순으로 이어졌습니다. target이 정한 202와X-Target은 무시되어 200이고 헤더도 없습니다. target이 본requestURI는 여전히/u09/front이고, 자기 경로는include.request_uri에서 알아내야 합니다. - 필터: 클라이언트 요청에서는
onRequest만, forward에서는onForward만, include에서는onInclude만 불렸습니다. dispatcher를 적지 않은onRequest는 forward와 include에서 불리지 않았습니다.onInclude가 찍은 URI가/u09/front인 것도 include가 경로를 바꾸지 않기 때문입니다.
같은 forward를 target 안에서 호출 스택으로 찍으면, 체인이 두 겹으로 쌓인 것이 보입니다(StandardWrapperValve.invoke까지만 찍었습니다).
at ex.u09.TargetServlet.doGet(TargetServlet.java:19)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at ex.u09.TypeFilter.doFilter(TypeFilter.java:23)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:512)
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:335)
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:264)
at ex.u09.FrontServlet.doGet(FrontServlet.java:22)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at ex.u09.TypeFilter.doFilter(TypeFilter.java:23)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:164)
아래 절반이 클라이언트 요청의 체인(onRequest 하나 → front), 위 절반이 forward가 새로 만든 체인(onForward 하나 → target)입니다. 그 사이에 ApplicationDispatcher.forward() 264행 → processRequest() 335행 → invoke() 512행이 있고, 밸브는 하나도 없습니다. forward는 front의 doGet() 안에서 일어나는 메서드 호출이라는 것이 스택에서 그대로 보입니다.
헷갈리기 쉬운 곳
- forward 뒤의 코드도 실행됩니다. 예제의
front: back from forward로그가 찍혔습니다.forward()는 메서드 호출일 뿐이라 돌아오면 다음 줄이 실행되고, 거기서 쓴 출력만 버려집니다. forward 뒤에return을 두는 습관이 있는 이유입니다. - include한 서블릿에서
sendRedirect()나sendError()를 불러도 아무 일도 일어나지 않습니다. 예외도 나지 않고 조용히 무시됩니다(ApplicationHttpResponse의if (!included)). - Spring 20편의
OncePerRequestFilter가 막으려던 상황이 이것입니다. 필터를FORWARD에도 매핑하면 한 요청이 그 필터를 두 번 지납니다. Tomcat은 디스패치 종류로 거르는 수단만 주고, "한 요청에 한 번"은 Spring이 요청 속성에 표시를 남겨 지킵니다.
6. 예외와 오류 페이지
서블릿이 예외를 던지면 4절에서 본 대로 체인은 그대로 다시 던지고, StandardWrapperValve가 받습니다. 이 밸브는 예외를 다시 던지지 않습니다.
private void exception(Request request, Response response, Throwable exception, int errorCode) {
request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, exception);
response.setStatus(errorCode);
response.setError();
}
java/org/apache/catalina/core/StandardWrapperValve.java 293–297행
catch 블록들은 로그를 남기고 이 메서드를 부를 뿐입니다(169–215행. IllegalStateException 같은 보통의 RuntimeException은 마지막 catch (Throwable t)에 걸립니다. 요청 파라미터 오류용 InvalidParameterException만 따로 잡아 그 오류 코드를 씁니다). 예외 객체는 요청 속성에, 상태 코드는 기본 500으로, 응답에는 "오류"라는 표시만 남깁니다. 실제로 오류 페이지를 고르고 보내는 일은 밸브 사슬을 한참 거슬러 올라간 StandardHostValve 가 합니다.
// Look for (and render if found) an application level error page
if (response.isErrorReportRequired()) {
// If an error has occurred that prevents further I/O, don't waste time
// producing an error report that will never be read
AtomicBoolean result = new AtomicBoolean(false);
response.getCoyoteResponse().action(ActionCode.IS_IO_ALLOWED, result);
if (result.get()) {
if (t != null) {
throwable(request, response, t);
} else {
status(request, response);
}
}
}
java/org/apache/catalina/core/StandardHostValve.java 141–154행
Host 밸브는 115행에서 Context 파이프라인을 부르고, 그것이 돌아온 뒤에 133행에서 요청 속성의 예외를 꺼내 이 코드로 옵니다.
- 예외가 있으면
throwable()—context.findErrorPage(throwable)로 찾습니다. 예외 클래스 이름으로 찾고, 없으면 부모 클래스로 올라가며 찾습니다(ErrorPageSupport.java107–125행).ServletException이면 그 안의 원인(root cause)으로도 한 번 더 찾습니다(239–258행). 그래도 없으면 상태 코드가 400 미만일 때 500으로 바꾸고status()로 넘어갑니다(274–287행). - 예외 없이
sendError()만 불렸으면status()— 상태 코드로 찾고, 없으면 코드0(코드를 적지 않은 기본 오류 페이지)으로 찾습니다(200–204행). - 찾았으면 오류 정보를 요청 속성에 넣고(
setRequestErrorAttributes(), 디스패치 종류도ERROR로, 291–327행)custom()이 5절의RequestDispatcher.forward()로 그 주소에 보냅니다(375–379행). 응답이 이미 커밋됐으면 forward 대신 include하고 연결을 닫습니다(357–373행). - 끝까지 못 찾으면 Host 파이프라인의 앞쪽에 있는
ErrorReportValve가 Tomcat의 기본 오류 HTML을 씁니다(8편).
오류 페이지를 고르는 곳은 Wrapper가 아니라 Host입니다. 예외는 던져지는 대신 요청 속성에 실려 올라갑니다.
예제 4: 예외와 sendError가 오류 페이지로 가는 길
예제 3과 같은 앱에 /boom(IllegalStateException을 던짐), /missing(sendError(404, "nothing here"))을 두고, web.xml에 <exception-type>java.lang.RuntimeException</exception-type>과 <error-code>404</error-code>를 모두 /error로 보내게 했습니다. /error 서블릿은 컨테이너가 넣어 준 속성을 찍고, /boom에서 왔을 때는 호출 스택을 StandardHostValve.invoke까지 찍습니다.
===== GET /missing =====
HTTP/1.1 404
custom error page, status 404
filter onRequest: REQUEST /u09/missing
missing: sendError(404) returned
filter onError: ERROR /u09/error
error page: ERROR status_code=404 request_uri=/u09/missing exception=null
===== GET /boom =====
HTTP/1.1 500
custom error page, status 500
filter onRequest: REQUEST /u09/boom
filter onError: ERROR /u09/error
error page: ERROR status_code=500 request_uri=/u09/boom exception=java.lang.IllegalStateException: boom
at ex.u09.ErrorServlet.doGet(ErrorServlet.java:20)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:673)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:762)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
at ex.u09.TypeFilter.doFilter(TypeFilter.java:23)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:512)
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:337)
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:264)
at org.apache.catalina.core.StandardHostValve.custom(StandardHostValve.java:379)
at org.apache.catalina.core.StandardHostValve.throwable(StandardHostValve.java:266)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:149)
===== SEVERE lines in logs/localhost.*.log =====
org.apache.catalina.core.StandardWrapperValve.invoke Servlet.service() for servlet [boom] in context with path [/u09] threw exception
/missing:sendError()를 부른 뒤에도 서블릿은 계속 실행되어sendError(404) returned가 찍혔습니다.sendError()는 응답에 표시만 남기는 메서드이고, 오류 페이지는 서블릿이 끝나고 Host 밸브로 돌아간 뒤에 처리됩니다./boom:IllegalStateException용 페이지는 없지만, 부모인RuntimeException용 페이지로 갔습니다. 상태 코드는 500입니다.- 두 경우 모두 오류 페이지 쪽에서는
onError필터만 불렸습니다. 디스패치 종류가ERROR이기 때문입니다. 337행은processRequest()에서ERROR일 때 디스패치 종류를FORWARD로 덮어쓰지 않는 갈래입니다(5절 4번의 332–335행과 비교). - 스택의 맨 아래가
StandardHostValve.invoke()149행 →throwable()→custom()→forward()입니다. 오류 페이지는 서블릿이 이미 끝나 Wrapper와 Context의 밸브를 모두 빠져나온 뒤에 그려집니다. - 예외 로그(
SEVERE … threw exception)는catalina.out이 아니라localhost.<날짜>.log에 남았습니다.StandardWrapperValve가 컨테이너의 로거로 남기기 때문입니다(212–213행). 로거별 파일은 16편에서 봅니다.
헷갈리기 쉬운 곳
- 필터에서 예외를 잡을 수 있는 것은 오류 페이지로 가기 전까지입니다. 서블릿의 예외는 체인을 거슬러 필터의
chain.doFilter()에서 튀어나오므로 필터가try/catch로 잡을 수 있습니다. 잡지 않으면StandardWrapperValve에서 속성으로 바뀌고, 그 뒤에는 Java 예외로서는 사라집니다. Spring MVC의@ExceptionHandler는 이보다 훨씬 안쪽,DispatcherServlet의processDispatchResult()에서 처리되므로(Spring 21편 5절) Tomcat까지 올라오지 않는 예외가 대부분입니다. - 오류 페이지 서블릿도 GET으로 불립니다.
ERROR_METHOD속성이 있으면 forward가 메서드를GET으로 바꿉니다(ApplicationDispatcher.java248–252행). POST 요청에서 난 오류라도 오류 페이지 서블릿에는doGet()만 있으면 됩니다.
이 글에서 뺀 것
- 비동기 디스패치(
request.isAsyncDispatching(),AsyncContextImpl.doInternalDispatch()):StandardWrapperValve.java149–151행, 161–163행,ApplicationDispatcher.dispatch()418–444행 — 12편 - 서블릿을 쓸 수 없는 상태(
UnavailableException,available, 503과Retry-After):StandardWrapperValve.java103–108행, 262–272행 - 서블릿 내리기(
StandardWrapper.unload(),destroy()호출과 사용 중 요청 기다리기):StandardWrapper.java912행부터 swallowOutput(서블릿의System.out을 로그로 돌리기):SystemLogHandler,StandardWrapperValve.java146–159행- 디스패처가 감싼 요청·응답 래퍼를 다시 벗기기(
unwrapRequest()/unwrapResponse()):ApplicationDispatcher.java579–663행 - JSP 서블릿을 늘 미리 올리는 특례(
isJspServlet):StandardWrapper.java277–289행 — JSP는 13편 - 11.0.x와의 차이 하나: 11.0.x 브랜치의
FilterMap.addURLPattern()은 web.xml의 URL 패턴을 URL 디코딩해서 저장합니다(Context의urlPatternsProvidedInDecodedForm이 기본값false일 때.true면 디코딩하지 않는addURLPatternDecoded()를 씁니다). main에서는 그 디코딩과addURLPatternDecoded(), 그 설정이 모두 빠졌습니다. 요청 경로를 고르는 이 글의 코드 자체는 두 브랜치가 같습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
StandardWrapperValve |
Wrapper의 기본 밸브. allocate → 체인 만들기 → doFilter → finally에서 release·deallocate |
StandardWrapper.allocate() |
서블릿 인스턴스는 Wrapper마다 하나. 없으면 첫 요청에서 만들고 init() |
load-on-startup |
0 이상이면 Context 시작 때 숫자 작은 것부터 load(). 기본값 -1 |
| 필터 init | filterStart()에서 전부, 서블릿보다 먼저. init 순서는 보장되지 않는다(HashMap) |
createFilterChain() |
매핑 배열을 두 번: URL 패턴 매핑 먼저, 서블릿 이름 매핑 나중. 디스패치 종류가 맞는 것만, 같은 필터는 한 번 |
ApplicationFilterChain |
필터 배열과 pos. doFilter()가 다음 필터를 부르고, 끝에서 servlet.service(). 재귀라 나올 때는 거꾸로 |
forward() |
커밋 전에만. 본문 지우고 경로 바꿔 새 체인으로 부른 뒤 응답을 끝낸다. 밸브는 거치지 않는다 |
include() |
경로는 그대로, 상태 코드·헤더 변경은 무시. 돌아와서 이어 쓴다 |
| 예외 처리 | Wrapper 밸브는 예외를 속성으로 바꿀 뿐. Host 밸브가 <error-page>를 찾아 ERROR로 forward |
다음에 볼 것
요청이 서블릿에 닿기까지의 길을 끝까지 따라왔습니다. 그런데 이 글은 "web.xml에 이렇게 적혀 있으면"에서 출발했습니다. 그 web.xml을 누가 읽어서 FilterDef, FilterMap, StandardWrapper를 만들었는지, @WebServlet과 conf/web.xml의 기본 서블릿은 어떻게 합쳐지는지, WAR 하나가 webapps에 떨어졌을 때 무슨 일이 일어나는지는 10편: 웹앱 배포와 Context 설정 — WAR 하나가 서비스되기까지에서 봅니다. 2절에서 지나친 ServletContainerInitializer도 거기서 다시 만납니다.
필터 체인은 다른 연재에도 닮은 것이 있습니다. Spring MVC의 HandlerInterceptor는 DispatcherServlet 안에서 핸들러 앞뒤에 끼는데, 체인이 다음을 부르는 재귀가 아니라 preHandle을 앞에서부터, postHandle을 뒤에서부터 도는 반복문입니다(Spring 21편 5절, Spring 23편). 그래서 인터셉터는 "다음을 부르지 않는" 방법이 아니라 preHandle에서 false를 돌려주는 방법으로 요청을 멈춥니다. httpd의 필터(httpd 11편)는 이름은 같지만 요청·응답 객체가 아니라 데이터 조각(버킷 브리게이드) 이 흘러가는 사슬이라 성격이 다릅니다. 오류 페이지를 forward로 다시 처리하는 것은 httpd의 ErrorDocument가 내부 리다이렉트로 다시 처리하는 것(httpd 10편 6절)과 같은 발상입니다.