namalzi

← 글 목록

Tomcat 소스 파헤치기 9: 필터 체인과 서블릿 호출 — service()가 불리기까지

Tomcat · 게시

밸브 사슬의 맨 끝에서 Tomcat이 서블릿 인스턴스를 꺼내고, 이 요청에 맞는 필터를 골라 배열로 묶고, 그 배열을 한 칸씩 넘기며 마지막에 service()를 부르는 과정을 소스로 따라갑니다. 서블릿 init()이 불리는 시점, 필터 순서가 정해지는 규칙, forward와 include의 차이, 예외가 오류 페이지로 바뀌는 길도 예제로 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 아홉 번째 글입니다. 8편에서는 요청이 Engine → Host → Context → Wrapper의 파이프라인을 차례로 지나며 밸브(valve)를 거치는 것을 봤습니다. 이번에는 그 사슬의 맨 끝, Wrapper의 기본 밸브인 StandardWrapperValve에서 시작합니다. 여기서 비로소 우리가 쓴 코드 — 필터와 서블릿 — 가 불립니다. Spring MVC를 쓰는 애플리케이션이라면 이 자리에서 불리는 서블릿이 Spring 21편의 DispatcherServlet입니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 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행

StandardWrapperValve가 서블릿을 부르기까지의 흐름. StandardContextValve가 Wrapper 파이프라인의 첫 밸브를 부르면 Wrapper의 기본 밸브인 StandardWrapperValve.invoke가 불린다. 이 메서드는 wrapper.allocate로 서블릿 인스턴스를 얻고(없으면 만들고 init을 부른다), ApplicationFilterFactory.createFilterChain으로 이 요청에 맞는 필터를 배열에 담은 체인을 만든 뒤, filterChain.doFilter를 부른다. 체인은 필터를 차례로 부르고 끝에서 Servlet.service를 부른다. Spring이면 이 서블릿이 DispatcherServlet이다. 예외가 나면 exception 메서드가 요청 속성에 예외를 넣고 상태를 500으로, 응답을 오류로 표시한다. 어느 쪽이든 finally에서 filterChain.release와 wrapper.deallocate를 부른다. 파란 상자 넷이 이 밸브가 하는 일의 전부입니다. 빨간 상자는 예외가 났을 때만 지나는 길입니다.

순서대로 읽으면 네 단계입니다.

  1. wrapper.allocate() — 서블릿 인스턴스를 받습니다. 아직 없으면 이 자리에서 만들고 init()을 부릅니다(2절).
  2. 디스패치 종류와 경로를 요청 속성에 적습니다(131–136행). 클라이언트에서 바로 온 요청이므로 REQUEST입니다(비동기 디스패치로 다시 온 요청만 ASYNC). 다음 단계에서 필터를 고를 때 이 두 속성을 읽습니다.
  3. ApplicationFilterFactory.createFilterChain() — 이 요청에 맞는 필터들을 골라 체인을 만듭니다(3절).
  4. 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 @… 값은 실행마다 다릅니다. 아래에서 위로 읽으면 이렇습니다.

헷갈리기 쉬운 곳: 필터 안에서 시간이 걸리면 서블릿도 그만큼 늦습니다

<- 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행

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).

필터와 서블릿이 만들어지는 시점. StandardContext.startInternal에서 ServletContainerInitializer.onStartup(Spring의 SpringServletContainerInitializer가 여기 걸린다), listenerStart로 ServletContextListener들, filterStart로 정의된 필터 전부를 만들고 init, loadOnStartup으로 0 이상인 서블릿만 숫자 작은 것부터 만들고 init한다. 그러면 Context 시작이 끝난다. 첫 요청이 오면 StandardWrapper.allocate가 인스턴스가 없을 때 loadServlet과 init을 부르고, 두 번째 요청부터는 같은 인스턴스를 돌려준다. 필터는 모두 시작할 때, 서블릿은 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의 번호는 실행마다 다릅니다.

헷갈리기 쉬운 곳

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행). 이 배열을 두 번 훑습니다.

  1. 첫 번째는 URL 패턴(<url-pattern>)이 맞는 매핑만 고릅니다.
  2. 두 번째는 서블릿 이름(<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에만 매핑했기 때문입니다.

예제 1의 web.xml에서 createFilterChain()이 필터를 고르는 두 번의 훑기. filterMaps 배열은 web.xml에 적은 순서대로 [0] B(servlet-name hello), [1] A(url-pattern /*), [2] C(url-pattern /hello), [3] A(servlet-name hello)다. 첫 번째 훑기는 URL 패턴 매핑만 보므로 /hello 요청에 맞는 [1] A와 [2] C를 넣고, servlet-name 매핑인 [0]과 [3]은 건너뛴다. 두 번째 훑기는 서블릿 이름 매핑만 보므로 [0] B를 넣고, [3] A는 이미 있어 addFilter()가 건너뛴다. 체인의 filters 배열은 [A, C, B]가 되고, 예제 1의 출력 순서와 같다. web.xml에서 B를 맨 앞에 적었는데도 마지막에 오는 까닭이 두 번째 훑기입니다.

헷갈리기 쉬운 곳

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행

ApplicationFilterChain.doFilter의 재귀. StandardWrapperValve가 chain.doFilter를 부르면 pos=0이 n=3보다 작으므로 필터 A.doFilter를 부르고, A는 앞 처리를 한 뒤 chain.doFilter를 다시 부른다. pos=1에서 필터 C, pos=2에서 필터 B가 불리고, B가 chain.doFilter를 부르면 pos=3이라 더 부를 필터가 없으므로 servlet.service를 부른다. 서블릿이 돌아오면 B, C, A 순으로 뒤 처리를 하며 돌아간다. pos, n, filters 배열은 체인 객체 하나가 들고 있으며 위치를 기억한다. 노란 상자가 "다음이 누구인가"를 아는 유일한 곳입니다. 필터는 체인을 다시 부를 뿐입니다.

chain.doFilter()가 "다음"을 아는 방법은 필드 pos 하나입니다.

  1. pos < n이면 filters[pos]를 꺼내고 pos를 하나 올린 뒤(pos++), 그 필터의 doFilter(request, response, this)를 부릅니다. 세 번째 인자가 체인 자기 자신입니다.
  2. 필터가 할 일을 하고 chain.doFilter()를 부르면 같은 메서드로 다시 들어옵니다. pos가 하나 올라 있으므로 다음 필터가 불립니다.
  3. 필터를 다 쓰면(pos == n) 아래쪽으로 떨어져 servlet.service()를 부릅니다. 주석이 "We fell off the end of the chain"이라고 적은 곳입니다.
  4. 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절에서 이어서 봅니다.

헷갈리기 쉬운 곳

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행

  1. 이미 커밋된 응답이면 예외입니다. 커밋(commit)은 상태 줄과 헤더가 이미 클라이언트로 나간 상태입니다. 그 뒤에는 "응답을 다른 서블릿에게 통째로 맡긴다"가 불가능합니다.
  2. 버퍼에 쓴 본문은 지우고 헤더와 쿠키는 남깁니다(resetBuffer()).
  3. 요청을 ApplicationHttpRequest로 감싸 경로를 대상 것으로 바꾸고, 원래 경로는 jakarta.servlet.forward.request_uri 같은 속성에 남깁니다(239–261행).
  4. processRequest()가 디스패치 종류를 FORWARD로 적고 invoke()를 부릅니다(332–335행).
  5. 돌아오면 응답을 끝냅니다. 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와 달리 돌아온 뒤에 응답을 끝내지 않으므로 부른 서블릿이 이어서 쓸 수 있습니다.

forward와 include의 비교. 서블릿 front가 req.getRequestDispatcher로 /target을 얻는다. forward는 응답이 커밋됐으면 IllegalStateException을 던지고, 아니면 resetBuffer로 본문만 지우고 헤더는 남긴다. 요청을 감싸 경로를 /target으로 바꾸고 원래 경로는 forward 속성에 넣는다. include는 응답을 included 래퍼로 감싸 setStatus와 setHeader를 무시하게 하고, 경로는 그대로 두며 대상 경로를 include 속성에 넣는다. 둘 다 ApplicationDispatcher.invoke에서 allocate, 새 필터 체인, doFilter를 거친다. forward 뒤에는 응답을 닫아 front의 나머지 출력이 버려지고, include 뒤에는 front가 이어서 쓴다. 가운데의 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를 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() 안에서 일어나는 메서드 호출이라는 것이 스택에서 그대로 보입니다.

헷갈리기 쉬운 곳

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행에서 요청 속성의 예외를 꺼내 이 코드로 옵니다.

예외가 오류 페이지가 되는 길. 서블릿이 예외를 던지거나 sendError를 부르면 StandardWrapperValve가 예외를 잡아 요청 속성에 넣고 상태 500과 setError를 표시한다. Context 파이프라인이 끝나 Host 밸브로 돌아오면 StandardHostValve.invoke가 isErrorReportRequired를 확인한다. 예외 속성이 있으면 throwable이 예외 클래스와 그 부모 클래스로 error-page를 찾고, 없으면 status로 넘어간다. 예외 없이 sendError만 있었으면 status가 상태 코드로, 없으면 코드 0으로 찾는다. 찾으면 custom이 getRequestDispatcher로 location을 얻어 디스패치 종류 ERROR로 forward하고, ERROR로 매핑한 필터만 탄다. 페이지가 없으면 ErrorReportValve의 기본 HTML이 나간다. 오류 페이지를 고르는 곳은 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

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
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절)과 같은 발상입니다.

다른 글