namalzi

← 글 목록

Tomcat 소스 파헤치기 7: CoyoteAdapter와 Mapper — 요청은 어느 서블릿으로 가나

Tomcat · 게시

다 읽은 HTTP 요청이 Coyote에서 Catalina로 넘어가는 다리인 CoyoteAdapter, 그 안에서 URI를 디코딩하고 정규화하는 postParseRequest(), Host·Context·Wrapper를 고르는 Mapper의 규칙 순서를 소스로 따라갑니다. 서블릿 여섯 개로 매핑 우선순위를, curl로 %2F·..·;jsessionid가 어떻게 처리되는지를, 붙잡아 둔 요청 객체로 RequestFacade가 왜 있는지를 직접 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 일곱 번째 글입니다. 6편에서는 Http11Processor가 소켓의 바이트를 읽어 요청 줄과 헤더를 org.apache.coyote.Request 하나로 만드는 데까지 봤습니다. 그 요청은 아직 "HTTP 요청"일 뿐, 어느 웹앱의 어느 서블릿이 맡을지는 정해지지 않았습니다. 이번 글은 그 요청이 커넥터(Coyote)에서 서블릿 컨테이너(Catalina)로 넘어가는 다리와, 다리를 건너는 동안 "이 요청은 어느 서블릿으로 가는가" 가 정해지는 과정을 따라갑니다.

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

항목 내용
대상 Apache Tomcat
버전 12.0.0-M1 (main)
커밋 17aa1769b0 (2026-10-23)
주로 읽는 클래스 connector/CoyoteAdapter, mapper/Mapper, mapper/MapperListener, connector/Request·RequestFacade

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

CoyoteAdapter.java는 1,300줄, Mapper.java는 1,800줄이 넘습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

Coyote와 Catalina는 Tomcat의 두 큰 덩어리입니다. Coyote는 소켓과 HTTP를 다루는 커넥터 쪽(org.apache.coyote, org.apache.tomcat.util.net), Catalina는 웹앱과 서블릿을 다루는 서블릿 컨테이너 쪽(org.apache.catalina)입니다. 둘을 나눈 이유와 그 사이의 Adapter 인터페이스는 2편에서 봤습니다. 이 글의 주인공 CoyoteAdapter가 Catalina 쪽에서 그 Adapter를 구현한 클래스입니다.

컨테이너 넷은 server.xml에서 보던 <Engine> → <Host> → <Context>와, 서블릿 하나를 감싸는 Wrapper입니다. <Host>는 localhost나 shop.example.com 같은 가상 호스트, <Context>는 /app 같은 경로에 올라간 웹앱 하나, Wrapper는 web.xml의 <servlet> 하나입니다(2편). "매핑한다"는 것은 요청 하나에 대해 이 셋(Host, Context, Wrapper)을 고르는 일입니다.

요청 URI의 세 조각. 서블릿 API는 요청 경로를 세 조각으로 나눠 줍니다. /app/catalog/items/42를 /catalog/items/*에 매핑된 서블릿이 받으면 이렇습니다.

메서드 값 뜻
getContextPath() /app 웹앱(Context)의 경로
getServletPath() /catalog/items 서블릿을 고르는 데 쓰인 부분
getPathInfo() /42 그 뒤에 남은 부분 (없으면 null)

이 세 값이 어디서 정해지는지가 이 글의 절반입니다.

url-pattern의 네 가지 모양. 서블릿 스펙은 web.xml의 url-pattern을 네 종류로 나눕니다. /catalog/list처럼 정확한 경로, /catalog/*처럼 /*로 끝나는 경로 접두사, *.do처럼 *.로 시작하는 확장자, 그리고 / 하나뿐인 기본 서블릿입니다. 여기에 빈 문자열 ""이 "컨텍스트 루트"만 가리키는 특별한 모양으로 하나 더 있습니다.

MessageBytes 는 Tomcat이 문자열 대신 쓰는 그릇입니다. 받은 바이트를 그대로 들고 있다가 필요할 때만 문자로 바꿉니다(6편). 이 글에서는 requestURI()(받은 그대로)와 decodedURI()(풀고 다듬은 것) 두 개만 따라다닙니다.

1. 다리: CoyoteAdapter.service()

Connector는 초기화될 때 자기 자신을 넘겨 CoyoteAdapter를 하나 만들고, 그것을 프로토콜 핸들러에 꽂아 둡니다.

        // Initialize adapter
        adapter = new CoyoteAdapter(this);
        protocolHandler.setAdapter(adapter);

java/org/apache/catalina/connector/Connector.java 1300–1302행

6편의 Http11Processor는 요청 줄과 헤더를 다 읽으면 getAdapter().service(request, response)를 부릅니다(Http11Processor.java 404행). Coyote 쪽 코드는 그 너머에 무엇이 있는지 모르고 Adapter 인터페이스 하나만 압니다. 커넥터와 컨테이너가 이 인터페이스 하나로만 이어져 있다는 뜻입니다. 넘겨받는 쪽의 뼈대는 이렇습니다.

    public void service(org.apache.coyote.Request req, org.apache.coyote.Response res) throws Exception {

        Request request = (Request) req.getNote(ADAPTER_NOTES);
        Response response = (Response) res.getNote(ADAPTER_NOTES);

        if (request == null) {
            // Create objects
            request = connector.createRequest(req);
            response = connector.createResponse(res);

            // Link objects
            request.setResponse(response);
            response.setRequest(request);

            // Set as notes
            req.setNote(ADAPTER_NOTES, request);
            res.setNote(ADAPTER_NOTES, response);

        }
        // ...
        try {
            // Parse and set Catalina and configuration specific
            // request parameters
            postParseSuccess = postParseRequest(req, request, res, response);
            if (postParseSuccess) {
                // check valves if we support async
                request.setAsyncSupported(connector.getService().getContainer().getPipeline().isAsyncSupported());
                // Calling the container
                connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
            }

java/org/apache/catalina/connector/CoyoteAdapter.java 305–348행

CoyoteAdapter를 지나는 흐름. Http11Processor.service()가 CoyoteAdapter.service()를 부르면, 처음 한 번 만들어 note에 걸어 둔 connector Request와 Response를 꺼내고, postParseRequest()가 URI를 풀고 다듬은 뒤 Mapper.map()으로 Host, Context, Wrapper를 골라 MappingData에 적는다. postParseRequest가 true면 Engine 파이프라인의 첫 밸브로 넘어가 Host, Context, Wrapper 밸브를 거쳐 필터 체인과 서블릿에 닿고, false면 리다이렉트 등으로 바로 끝난다. 마지막 finally에서 접근 로그를 남기고 request와 response를 recycle한다. 파란 상자가 이 글에서 읽는 곳, 회색 상자는 앞뒤 글에서 보는 곳입니다.

세 가지를 읽을 수 있습니다.

  1. Catalina 쪽 요청 객체는 요청마다 만들지 않습니다. coyote Request에는 아무 객체나 걸어 둘 수 있는 메모 칸(note)이 있고, 처음 지나갈 때 만든 org.apache.catalina.connector.Request를 거기 걸어 둡니다. 프로세서와 그 안의 coyote Request는 연결이 바뀌어도 재사용되므로(5편), Catalina 쪽 객체도 같이 재사용됩니다. 그래서 요청이 끝나면 반드시 비워야 합니다(5절).
  2. 이름이 둘인 Request가 짝을 이룹니다. coyote Request는 HTTP 요청의 원재료(바이트, 헤더), connector Request는 그것을 감싸 HttpServletRequest를 구현하는 쪽입니다(Request.java 126–129행 — 클래스 주석이 "Wrapper object for the Coyote request"입니다).
  3. postParseRequest()가 true를 돌려줘야 컨테이너로 들어갑니다. 들어가는 입구는 Service에 달린 Engine의 파이프라인 첫 밸브입니다. 파이프라인과 밸브는 8편에서 봅니다.

요청이 끝나면 finally 블록에서 접근 로그를 남기고 두 객체를 비웁니다.

            req.getRequestProcessor().setWorkerThreadName(null);
            req.clearRequestThread();

            // Recycle the wrapper request and response
            if (!async) {
                updateWrapperErrorCount(request, response);
                request.recycle();
                response.recycle();
            }

java/org/apache/catalina/connector/CoyoteAdapter.java 419–427행

비동기 요청(async)이면 아직 끝난 게 아니므로 비우지 않습니다. 비동기는 12편에서 봅니다.

예제 1: 서블릿에서 본 호출 스택

정말 이 길로 오는지, 서블릿 안에서 new Throwable().printStackTrace()로 스택을 찍어 확인했습니다. 이 예제와 이 글의 다른 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. 예제 폴더는 ex/u07-mapping, 서블릿은 Keep.java입니다(코드는 5절에 있습니다). catalina.out에서 at 줄만 골랐습니다.

Keep.doGet(Keep.java:23)
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:59)
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:164)
org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77)
org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:540)
org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)
org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:82)
org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:777)
org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:71)
org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:347)
org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:404)
org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:71)
org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1317)
org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:2218)
org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:74)
org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:968)
org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:481)
org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:74)
java.base/java.lang.Thread.run(Thread.java:1583)

(HttpServlet.service 두 줄은 grep으로 뺐습니다.) 아래에서 위로 읽으면 작업 스레드(5편) → Http11Processor.service()의 404행(6편) → CoyoteAdapter.service()의 347행, 곧 위에서 본 getFirst().invoke() → Engine·Host·Context·Wrapper의 밸브들(8편) → 필터 체인(9편) → 서블릿입니다. CoyoteAdapter 한 줄이 Coyote 패키지와 Catalina 패키지의 경계입니다.

헷갈리기 쉬운 곳: 스택에 Mapper가 없습니다

매핑은 분명히 일어났는데 스택에 Mapper도 postParseRequest()도 없습니다. 둘 다 밸브를 부르기 전에 일을 끝내고 돌아왔기 때문입니다. 매핑의 결과는 request 안의 MappingData에 적혀 있고, 밸브들은 그것을 꺼내 쓰기만 합니다. "찾는 일"과 "찾은 곳으로 보내는 일"이 나뉘어 있습니다.

2. postParseRequest() — URI를 풀고 다듬는다

postParseRequest()는 이름 그대로 "파싱 뒤의 정리"입니다. 스킴(http/https)과 포트를 정하고, 프록시 설정(proxyName/proxyPort)이 있으면 덮어쓰고, OPTIONS *와 CONNECT를 따로 처리한 다음 URI를 다룹니다. 핵심은 이 부분입니다.

        MessageBytes decodedURI = req.decodedURI();

        // Filter CONNECT method
        if (Method.CONNECT.equals(req.getMethod())) {
            response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED, sm.getString("coyoteAdapter.connect"));
        } else {
            // No URI for CONNECT requests
            if (undecodedURI.getType() == MessageBytes.T_BYTES) {
                if (connector.getRejectSuspiciousURIs()) {
                    if (checkSuspiciousURIs(undecodedURI.getByteChunk())) {
                        response.sendError(400, sm.getString("coyoteAdapter.invalidURI"));
                    }
                }

                // Copy the raw URI to the decodedURI
                decodedURI.duplicate(undecodedURI);

                // Parse (and strip out) the path parameters
                parsePathParameters(req, request);

                // URI decoding
                // %xx decoding of the URL
                try {
                    req.getURLDecoder().convert(decodedURI.getByteChunk(),
                            connector.getEncodedSolidusHandlingInternal(),
                            connector.getEncodedReverseSolidusHandlingInternal());
                } catch (IOException ioe) {
                    response.sendError(400, sm.getString("coyoteAdapter.invalidURIWithMessage", ioe.getMessage()));
                }
                // Normalization
                if (normalize(req.decodedURI(), connector.getAllowBackslash())) {
                    // Character decoding
                    convertURI(decodedURI, request);
                    // URIEncoding values are limited to US-ASCII supersets.
                    // Therefore, it is not necessary to check that the URI remains
                    // normalized after character decoding
                } else {
                    response.sendError(400, sm.getString("coyoteAdapter.invalidURI"));
                }

java/org/apache/catalina/connector/CoyoteAdapter.java 617–655행

postParseRequest가 URI를 다루는 순서. 받은 그대로의 requestURI를 decodedURI에 복사하고, parsePathParameters가 세미콜론 뒤의 name=value를 떼어 따로 보관하고, UDecoder.convert가 %xx를 푼다. 이때 %2F가 있으면 기본 설정에서 400이 된다. 다음으로 normalize가 역슬래시, 겹친 슬래시, /./, /../를 정리하는데, 루트 위로 올라가거나 역슬래시나 널 바이트가 있으면 400이 된다. 통과하면 convertURI가 URIEncoding으로 바이트를 문자로 바꾸고 Mapper.map이 decodedURI로 찾는다. 400이 난 경우에도 Host 매핑까지는 간다. 노란 상자 두 개가 이 글 내내 따라다니는 두 URI입니다. 받은 그대로의 것과 다듬은 것이 따로 있습니다.

순서대로 봅니다.

  1. 복사. 받은 URI(requestURI)는 건드리지 않고, 복사본(decodedURI)만 다듬습니다. 서블릿의 getRequestURI()는 앞의 것을 돌려줍니다(Request.java 2118–2120행).
  2. 경로 파라미터 떼기. /catalog;foo=bar/list의 ;foo=bar처럼 세미콜론 뒤에 붙은 것을 경로 파라미터(path parameter)라고 합니다. parsePathParameters()는 이것을 URI에서 잘라 내고 name=value는 따로 보관합니다(901–977행). 쿠키를 못 쓸 때 세션 ID를 URL에 싣는 ;jsessionid=…가 이 모양이라서 여기서 떼어 둡니다. 주석도 "지금 관심 있는 것은 세션 ID뿐"이라고 적어 두었습니다(895행).
  3. %xx 풀기. UDecoder.convert()가 %63을 c로 바꾸는 식으로 퍼센트 인코딩을 풉니다. 단, %2F(인코딩된 /)는 커넥터의 encodedSolidusHandling 설정을 따르는데, 기본값이 REJECT라서 예외가 납니다(Connector.java 331행).
  4. 정규화. normalize()가 \를 /로 바꾸고(허용했을 때만), //를 /로 줄이고, /./와 /../를 풉니다. 루트보다 위로 올라가려 하면 false를 돌려줍니다.
  5. 문자로 바꾸기. 마지막으로 convertURI()가 커넥터의 URIEncoding(기본 UTF-8)으로 바이트를 문자로 바꿉니다. 매퍼는 이 문자열로 찾습니다.

%2F를 막는 곳은 UDecoder의 이 부분입니다.

                int res = x2c(b1, b2);
                if (res == '/') {
                    switch (encodedSolidusHandling) {
                        case DECODE: {
                            buff[idx] = (byte) res;
                            break;
                        }
                        case REJECT: {
                            throw EXCEPTION_SLASH;
                        }

java/org/apache/tomcat/util/buf/UDecoder.java 141–150행

/../가 루트를 넘으면 막는 곳은 normalize()의 이 부분입니다.

        // Resolve occurrences of "/../" in the normalized path
        while (true) {
            index = uriBC.indexOf("/../", 0, 4, index);
            if (index < 0) {
                break;
            }
            // Prevent from going outside our context
            if (index == 0) {
                return false;
            }

java/org/apache/catalina/connector/CoyoteAdapter.java 1198–1207행

왜 이 순서인가

디코딩을 정규화보다 먼저 합니다. %2e%2e를 먼저 ..로 풀어 놓고 나서 /../를 정리해야, 인코딩으로 ..를 숨겨 웹앱 밖으로 나가려는 요청이 정규화를 빠져나가지 못합니다. 반대로 %2F는 풀어 버리면 "경로 구분자인 /"와 "이름 안에 들어 있던 /"를 구별할 수 없게 되므로 아예 거절하는 것이 기본입니다. 그리고 매핑과 보안 검사(예: WEB-INF 막기)는 전부 다듬은 decodedURI로 합니다. 받은 그대로의 URI로 검사하면 /app/x/../WEB-INF/… 같은 요청이 검사를 비껴갈 수 있기 때문입니다.

httpd에서도 같은 일이 ap_process_request_internal()의 맨 앞에서 일어났습니다. httpd 10편의 "이 글에서 뺀 것"에 적어 둔 AllowEncodedSlashes가 Tomcat의 encodedSolidusHandling과 같은 자리입니다.

예제 2: 다듬어지는 URI, 막히는 URI

ex/u07-mapping의 Where 서블릿은 자기가 뽑힌 이름과 경로 조각, 그리고 받은 그대로의 URI를 찍습니다(서블릿 코드와 web.xml은 4절에 있습니다). curl --path-as-is(curl이 ..를 미리 지우지 않게 하는 옵션)로 요청을 보냈습니다. 400일 때는 Tomcat의 오류 페이지에서 메시지 줄만 뽑았습니다.

----- GET /app/%63atalog/list
HTTP 200
servlet=exact match=EXACT pattern=/catalog/list servletPath=/catalog/list pathInfo=null
  requestURI=/app/%63atalog/list contextPath=/app sessionIdFromURL=-
----- GET /app/x/../catalog/./list
HTTP 200
servlet=exact match=EXACT pattern=/catalog/list servletPath=/catalog/list pathInfo=null
  requestURI=/app/x/../catalog/./list contextPath=/app sessionIdFromURL=-
----- GET /app//catalog//list
HTTP 200
servlet=exact match=EXACT pattern=/catalog/list servletPath=/catalog/list pathInfo=null
  requestURI=/app//catalog//list contextPath=/app sessionIdFromURL=-
----- GET /app/x/%2e%2e/catalog/list
HTTP 200
servlet=exact match=EXACT pattern=/catalog/list servletPath=/catalog/list pathInfo=null
  requestURI=/app/x/%2e%2e/catalog/list contextPath=/app sessionIdFromURL=-
----- GET /app/catalog;foo=bar/list
HTTP 200
servlet=exact match=EXACT pattern=/catalog/list servletPath=/catalog/list pathInfo=null
  requestURI=/app/catalog;foo=bar/list contextPath=/app sessionIdFromURL=-
----- GET /app/catalog/list;jsessionid=ABC123
HTTP 200
servlet=exact match=EXACT pattern=/catalog/list servletPath=/catalog/list pathInfo=null
  requestURI=/app/catalog/list;jsessionid=ABC123 contextPath=/app sessionIdFromURL=ABC123
----- GET /app/catalog%2Flist
HTTP 400
  message: Invalid URI: [The encoded slash character is not allowed]
----- GET /app/catalog%5Clist
HTTP 400
  message: Invalid URI
----- GET /../app/catalog/list
HTTP 400
  message: Invalid URI

헷갈리기 쉬운 곳: 400을 보내고도 계속 갑니다

sendError(400, …)를 부른 뒤에 return false가 없습니다. 코드는 그대로 매핑으로 내려갑니다. 대신 매핑 직전에 이런 처리가 있습니다.

        if (response.isError()) {
            // An error this early means the URI is invalid. Ensure invalid data
            // is not passed to the mapper. Note we still want the mapper to
            // find the correct host.
            decodedURI.recycle();
        }

java/org/apache/catalina/connector/CoyoteAdapter.java 691–696행

URI를 비워서 매퍼가 Host까지만 찾게 합니다. 그래야 오류 응답도 그 Host에 걸린 밸브(오류 페이지를 만드는 ErrorReportValve, 접근 로그)를 지나갈 수 있습니다. 위 예제의 400 응답에 Tomcat의 오류 페이지가 붙어 나온 것이 그 덕분입니다. 응답 상태는 이미 400으로 정해져 있으므로, 뒤의 밸브들은 "이미 오류이면 덮어쓰지 않는다"는 조건으로 지나갑니다(4절).

3. Mapper — Host, Context, Wrapper를 고르는 곳

URI가 다듬어지면 매핑입니다.

        while (mapRequired) {
            // This will map the latest version by default
            connector.getService().getMapper().map(serverName, decodedURI, version, request.getMappingData());

            // If there is no context at this point, either this is a 404
            // because no ROOT context has been deployed or the URI was invalid
            // so no context could be mapped.
            if (request.getContext() == null) {
                // Allow processing to continue.
                // If present, the rewrite Valve may rewrite this to a valid
                // request.
                // The StandardEngineValve will handle the case of a missing
                // Host and the StandardHostValve the case of a missing Context.
                // If present, the error reporting valve will provide a response
                // body.
                return true;
            }

java/org/apache/catalina/connector/CoyoteAdapter.java 698–714행

serverName은 6편에서 Host 헤더를 읽어 정한 값입니다(포트는 떼어 따로 둡니다. AbstractProcessor.parseHost(), AbstractProcessor.java 330–388행). 매퍼는 Service마다 하나입니다(StandardService의 필드, StandardService.java 103행). 결과는 돌려주지 않고 요청에 붙은 MappingData에 적습니다. request.getContext(), request.getWrapper(), getServletPath(), getPathInfo()가 전부 이 MappingData의 필드를 그대로 읽는 메서드입니다(Request.java 630–632행, 2063–2065행, 2130–2132행).

while인 까닭은 병렬 배포(parallel deployment) 때문입니다. 같은 경로에 app##1, app##2처럼 버전이 다른 웹앱을 함께 올려 두면, 매퍼는 먼저 최신 버전을 고르고, 요청에 실린 세션 ID가 옛 버전의 세션이면 그 버전으로 다시 매핑합니다(745–780행). 이 글에서는 더 들어가지 않습니다.

매퍼는 무엇을 들고 있나

Mapper의 자료 구조. Mapper는 Service 하나에 하나이고, 이름순으로 정렬된 hosts 배열과 Engine의 defaultHost를 가진다. 별칭도 hosts 배열에 한 칸씩 들어간다. MappedHost 하나는 경로순으로 정렬된 contexts 배열을 가진 ContextList를 들고, MappedContext 하나는 병렬 배포 버전마다 하나씩인 ContextVersion 배열을 든다. ContextVersion은 exactWrappers, wildcardWrappers, extensionWrappers, defaultWrapper, welcomeResources를 가진다. 빈 문자열 패턴은 exactWrappers에 /로 들어가고, /catalog/*는 /catalog로, *.do는 do로 저장된다. 노란 상자는 매퍼가 컨테이너와 따로 들고 있는 데이터입니다. 이름으로 찾는 배열은 정렬해 두고 이진 탐색으로 찾습니다.

Mapper는 컨테이너 객체(StandardHost, StandardContext …)를 직접 뒤지지 않습니다. 매핑에 필요한 것만 뽑아 이름순으로 정렬한 배열로 따로 들고 있다가 이진 탐색(find(), 1168–1206행)으로 찾습니다. 요청마다 불리는 코드라 빨라야 하기 때문입니다.

배열을 바꿀 때는 기존 배열을 고치지 않고 한 칸 큰 새 배열을 만들어 갈아 끼웁니다(insertMap(), 1488–1497행). hosts 필드는 volatile이고(77행), 바꾸는 코드는 synchronized 메서드(addHost() 등)나 synchronized 블록(addWrapper() 등) 안에 있습니다. 그래서 요청을 처리하는 수많은 스레드는 락 없이 읽고, 웹앱을 배포·제거하는 드문 쓰기만 줄을 섭니다.

url-pattern이 네 가지 배열 가운데 어디에 들어가는지는 addWrapper()가 패턴의 모양을 보고 정합니다.

            if (path.endsWith("/*")) {
                // Wildcard wrapper
                String name = path.substring(0, path.length() - 2);
                // ...
            } else if (path.startsWith("*.")) {
                // Extension wrapper
                String name = path.substring(2);
                // ...
            } else if (path.equals("/")) {
                // Default wrapper
                context.defaultWrapper = new MappedWrapper("", wrapper, jspWildCard, resourceOnly);
            } else {
                // Exact wrapper
                final String name;
                if (path.isEmpty()) {
                    // Special case for the Context Root mapping which is
                    // treated as an exact match
                    name = "/";
                } else {
                    name = path;
                }
                // ...
            }

java/org/apache/catalina/mapper/Mapper.java 488–529행

/catalog/*는 /catalog라는 이름으로, *.do는 do라는 이름으로 저장됩니다. 빈 문자열 ""은 정확히 일치 쪽에 /라는 이름으로 들어갑니다. 기본 서블릿은 배열이 아니라 칸 하나라서 /에 매핑된 서블릿은 Context마다 하나뿐입니다.

Host 고르기

        // Virtual host mapping
        MappedHost[] hosts = this.hosts;
        MappedHost mappedHost = exactFindIgnoreCase(hosts, host);
        if (mappedHost == null) {
            // Note: Internally, the Mapper does not use the leading * on a
            // wildcard host. This is to allow this shortcut.
            int firstDot = host.indexOf('.');
            if (firstDot > -1) {
                int start = host.getStart();
                try {
                    host.setStart(firstDot + start);
                    mappedHost = exactFindIgnoreCase(hosts, host);
                } finally {
                    // Make absolutely sure this gets reset
                    host.setStart(start);
                }
            }
            if (mappedHost == null) {
                mappedHost = defaultHost;
                if (mappedHost == null) {
                    return;
                }
            }
        }
        mappingData.host = mappedHost.object;

java/org/apache/catalina/mapper/Mapper.java 749–773행

  1. 대소문자를 가리지 않고 이름(과 별칭)이 정확히 같은 Host를 찾습니다.
  2. 없으면 첫 점 앞을 떼고 한 번 더 찾습니다. a.example.org는 .example.org로 찾습니다. 별칭 *.example.org는 등록할 때 *를 떼고 .example.org로 저장해 두었으므로(renameWildcardHost(), 1520–1526행) 여기서 맞습니다. 주석의 "shortcut"이 이것입니다.
  3. 그래도 없으면 server.xml <Engine defaultHost="localhost">의 그 Host로 갑니다.

Context 고르기 — 가장 긴 경로

        while (pos >= 0) {
            context = contexts[pos];
            if (uri.startsWith(context.name)) {
                int length = context.name.length();
                if (uri.getLength() == length) {
                    found = true;
                    break;
                } else if (uri.startsWithIgnoreCase("/", length)) {
                    found = true;
                    break;
                }
            }
            if (lastSlash == -1) {
                lastSlash = nthSlash(uri, contextList.nesting + 1);
            } else {
                lastSlash = lastSlash(uri);
            }
            uri.setEnd(lastSlash);
            pos = find(contexts, uri);
        }

java/org/apache/catalina/mapper/Mapper.java 794–813행

find()는 "같거나 바로 앞에 오는 이름"의 자리를 돌려줍니다. 그 Context의 경로가 URI의 앞부분과 같고 바로 뒤가 끝이거나 / 이면 찾은 것입니다. 아니면 URI의 끝을 마지막 / 앞까지 잘라 다시 찾습니다. 이렇게 뒤에서부터 한 조각씩 줄여 가므로 /shop과 /shop/admin이 둘 다 있을 때 /shop/admin/users는 더 긴 /shop/admin으로 갑니다. 끝까지 못 찾으면 경로가 ""인 ROOT 웹앱으로 갑니다(816–822행). Host 이름과 달리 경로 비교(startsWith)는 대소문자를 가립니다.

예제 3: Host와 Context 고르기

내장 Tomcat(org.apache.catalina.startup.Tomcat, 16편)으로 Host 둘, Context 넷을 만들고, curl의 -H "Host: …"로 Host 헤더를 바꿔 가며 요청했습니다. 예제 폴더는 ex/u07-hosts입니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... import 와 클래스 설명 주석 생략 ...

public class Hosts {

    /** 어느 Host·Context 의 서블릿이 불렸는지 한 줄로 돌려준다. */
    static class Who extends HttpServlet {
        // ... host=, context=, servletPath= 를 찍는다 (출력 참고) ...
    }

    static void addApp(Tomcat tomcat, Host host, String path) {
        // docBase 는 쓰지 않으므로 빈 임시 폴더면 된다
        Context ctx = tomcat.addContext(host, path, System.getProperty("java.io.tmpdir"));
        Tomcat.addServlet(ctx, "who", new Who());
        ctx.addServletMapping("/", "who");
    }

    public static void main(String[] args) throws Exception {
        Tomcat tomcat = new Tomcat();
        tomcat.setBaseDir("/tmp/embedded");
        tomcat.setPort(8080);
        tomcat.getConnector();               // 기본 HTTP 커넥터를 만든다

        Host localhost = tomcat.getHost();   // 이름 "localhost", Engine 의 기본 Host
        addApp(tomcat, localhost, "");
        addApp(tomcat, localhost, "/shop");
        addApp(tomcat, localhost, "/shop/admin");

        StandardHost shop = new StandardHost();
        shop.setName("shop.example.com");
        shop.addAlias("*.example.org");      // 와일드카드 별칭
        tomcat.getEngine().addChild(shop);
        addApp(tomcat, shop, "");

        tomcat.start();
        System.out.println("STARTED");
        tomcat.getServer().await();
    }
}

[예제 ex/u07-hosts/Hosts.java에서 줄임]

Host: localhost        /shop/admin/users    -> host=Tomcat/localhost context="/shop/admin" servletPath=/users
Host: localhost        /shop/cart           -> host=Tomcat/localhost context="/shop" servletPath=/cart
Host: localhost        /shopping            -> host=Tomcat/localhost context="" servletPath=/shopping
Host: localhost        /                    -> host=Tomcat/localhost context="" servletPath=/
Host: LOCALHOST        /shop/cart           -> host=Tomcat/localhost context="/shop" servletPath=/cart
Host: shop.example.com /cart                -> host=Tomcat/shop.example.com context="" servletPath=/cart
Host: a.example.org    /cart                -> host=Tomcat/shop.example.com context="" servletPath=/cart
Host: a.b.example.org  /cart                -> host=Tomcat/localhost context="" servletPath=/cart
Host: unknown.test     /shop/cart           -> host=Tomcat/localhost context="/shop" servletPath=/cart

host=에는 ServletContext.getVirtualServerName()을 찍었습니다. 이 값은 Engine 이름/Host 이름이라(ApplicationContext.java 1123–1128행) 내장 Tomcat의 Engine 이름 Tomcat/이 앞에 붙습니다.

4. Wrapper 고르기 — 규칙의 순서

Context를 찾으면 URI에서 컨텍스트 경로를 뗀 나머지로 Wrapper(서블릿)를 고릅니다. internalMapWrapper()의 주석이 규칙에 번호를 매겨 두었습니다.

        // Rule 1 -- Exact Match
        MappedWrapper[] exactWrappers = contextVersion.exactWrappers;
        internalMapExactWrapper(exactWrappers, path, mappingData);

        // Rule 2 -- Prefix Match
        boolean checkJspWelcomeFiles = false;
        MappedWrapper[] wildcardWrappers = contextVersion.wildcardWrappers;
        if (mappingData.wrapper == null) {
            internalMapWildcardWrapper(wildcardWrappers, contextVersion.nesting, path, mappingData);
            // ...
        }

        if (mappingData.wrapper == null && noServletPath &&
                contextVersion.object.getMapperContextRootRedirectEnabled()) {
            // The path is empty, redirect to "/"
            path.append('/');
            pathEnd = path.getEnd();
            mappingData.redirectPath.setChars(path.getBuffer(), pathStart, pathEnd - pathStart);
            path.setEnd(pathEnd - 1);
            return;
        }

        // Rule 3 -- Extension Match
        MappedWrapper[] extensionWrappers = contextVersion.extensionWrappers;
        if (mappingData.wrapper == null && !checkJspWelcomeFiles) {
            internalMapExtensionWrapper(extensionWrappers, path, mappingData, true);
        }

java/org/apache/catalina/mapper/Mapper.java 875–916행

그 뒤로 // Rule 4 -- Welcome resources processing for servlets(918행)와 // Rule 7 -- Default servlet(990행)이 이어집니다.

Wrapper를 고르는 규칙의 순서. 컨텍스트 경로를 뗀 나머지로 먼저 규칙 1 정확히 일치를 보고, 없으면 규칙 2 경로 접두사를 가장 긴 것부터 본다. 그래도 없고 나머지가 비어 있으면 /app을 /app/으로 302 리다이렉트한다. 아니면 규칙 3 확장자를 보고, 없고 경로가 /로 끝나면 규칙 4 환영 파일을 붙여 다시 찾는다. 그래도 없으면 규칙 7 기본 서블릿으로 간다. 기본 서블릿으로 간 경로가 디렉터리면 끝에 /를 붙여 리다이렉트하는 설정이 있지만 기본은 꺼져 있다. 회색 상자 둘은 서블릿을 고르는 대신 리다이렉트 주소를 적는 길입니다.

  1. 규칙 1 — 정확히 일치. exactWrappers에서 이진 탐색 한 번입니다. 빈 경로(컨텍스트 루트)는 /라는 이름으로 찾습니다(1038–1059행).
  2. 규칙 2 — 경로 접두사. Context를 고를 때와 같은 방법으로, 경로 끝을 / 단위로 줄여 가며 찾으므로 가장 긴 접두사가 이깁니다(1065–1107행). 접두사 뒤에 남은 것이 pathInfo가 됩니다.
  3. 컨텍스트 루트 리다이렉트. 여기까지 못 찾았고 컨텍스트 경로 뒤에 아무것도 없으면(/app), 서블릿을 고르는 대신 /app/으로 가라는 리다이렉트 주소를 적고 끝냅니다. StandardContext의 mapperContextRootRedirectEnabled가 기본 true입니다(StandardContext.java 787행).
  4. 규칙 3 — 확장자. 마지막 / 뒤 조각에서 마지막 . 뒤를 확장자로 봅니다(1118–1152행).
  5. 규칙 4 — 환영 파일. 경로가 /로 끝나면 index.html, index.htm, index.jsp(conf/web.xml 4752–4754행의 <welcome-file>) 같은 이름을 붙여 정확히 일치·접두사로 다시 찾고, 그 이름의 실제 파일이 있으면 확장자 서블릿이나 기본 서블릿을 고릅니다(918–987행).
  6. 규칙 7 — 기본 서블릿. 아무것도 맞지 않으면 /에 매핑된 서블릿입니다. 웹앱이 따로 정하지 않으면 conf/web.xml에 등록된 DefaultServlet(정적 파일을 내주는 서블릿)이 이 자리에 있습니다(conf/web.xml 436–439행).

redirectPath에 주소가 적혔으면 postParseRequest()가 리다이렉트 응답을 보내고 false를 돌려줍니다. 컨테이너(밸브)로는 들어가지 않습니다.

        // Possible redirect
        MessageBytes redirectPathMB = request.getMappingData().redirectPath;
        if (!redirectPathMB.isNull()) {
            String redirectPath = URLEncoder.DEFAULT.encode(redirectPathMB.toString(), StandardCharsets.UTF_8);
            String query = request.getQueryString();
            // ...
            response.sendRedirect(redirectPath);
            request.getContext().logAccess(request, response, 0, true);
            return false;
        }

java/org/apache/catalina/connector/CoyoteAdapter.java 803–822행

예제 4: 서블릿 여섯 개의 우선순위

같은 클래스 Where를 이름만 바꿔 여섯 번 등록하고, 이름마다 다른 url-pattern을 붙였습니다. 서블릿은 HttpServletMapping("어느 패턴으로 매핑됐나"를 알려 주는 서블릿 API)과 경로 조각을 찍습니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... import 생략 ...

/**
 * web.xml 에서 이름을 여러 개 붙여(서블릿 하나를 여러 번 등록) 패턴마다 다른 이름으로 매핑한다.
 * 응답에는 "어느 이름이 뽑혔는지" 와 Mapper 가 정해 준 경로 조각을 찍는다.
 */
public class Where extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        HttpServletMapping m = req.getHttpServletMapping();
        resp.setContentType("text/plain");
        resp.getWriter().println("servlet=" + getServletName()
                + " match=" + m.getMappingMatch()
                + " pattern=" + m.getPattern()
                + " servletPath=" + req.getServletPath()
                + " pathInfo=" + req.getPathInfo());
        // 요청 URI 가 디코딩·정규화 전후로 어떻게 다른지 보려고 둘 다 찍는다
        resp.getWriter().println("  requestURI=" + req.getRequestURI()
                + " contextPath=" + req.getContextPath()
                + " sessionIdFromURL=" + (req.getRequestedSessionId() == null ? "-" : req.getRequestedSessionId()));
    }
}

[예제 ex/u07-mapping/Where.java에서 import만 줄임]

  <servlet-mapping><servlet-name>exact</servlet-name><url-pattern>/catalog/list</url-pattern></servlet-mapping>
  <servlet-mapping><servlet-name>prefix</servlet-name><url-pattern>/catalog/*</url-pattern></servlet-mapping>
  <servlet-mapping><servlet-name>longerPrefix</servlet-name><url-pattern>/catalog/items/*</url-pattern></servlet-mapping>
  <servlet-mapping><servlet-name>extension</servlet-name><url-pattern>*.do</url-pattern></servlet-mapping>
  <!-- 빈 문자열은 "컨텍스트 루트(/app/)" 만 가리키는 특별한 패턴이다 -->
  <servlet-mapping><servlet-name>root</servlet-name><url-pattern></url-pattern></servlet-mapping>
  <!-- "/" 는 기본 서블릿 자리. conf/web.xml 의 default 서블릿 대신 이 앱의 slash 가 이 자리를 맡는다 -->
  <servlet-mapping><servlet-name>slash</servlet-name><url-pattern>/</url-pattern></servlet-mapping>

[예제 ex/u07-mapping/app/WEB-INF/web.xml에서 <servlet-mapping> 부분]

웹앱 app 옆에는 비교용으로, web.xml 없이 index.html 하나만 둔 웹앱 plain도 올렸습니다.

----- GET /app/catalog/list
HTTP 200
servlet=exact match=EXACT pattern=/catalog/list servletPath=/catalog/list pathInfo=null
  requestURI=/app/catalog/list contextPath=/app sessionIdFromURL=-
----- GET /app/catalog/list/more
HTTP 200
servlet=prefix match=PATH pattern=/catalog/* servletPath=/catalog pathInfo=/list/more
  requestURI=/app/catalog/list/more contextPath=/app sessionIdFromURL=-
----- GET /app/catalog/items/42
HTTP 200
servlet=longerPrefix match=PATH pattern=/catalog/items/* servletPath=/catalog/items pathInfo=/42
  requestURI=/app/catalog/items/42 contextPath=/app sessionIdFromURL=-
----- GET /app/catalog/report.do
HTTP 200
servlet=prefix match=PATH pattern=/catalog/* servletPath=/catalog pathInfo=/report.do
  requestURI=/app/catalog/report.do contextPath=/app sessionIdFromURL=-
----- GET /app/shop/cart.do
HTTP 200
servlet=extension match=EXTENSION pattern=*.do servletPath=/shop/cart.do pathInfo=null
  requestURI=/app/shop/cart.do contextPath=/app sessionIdFromURL=-
----- GET /app/shop/cart
HTTP 200
servlet=slash match=DEFAULT pattern=/ servletPath=/shop/cart pathInfo=null
  requestURI=/app/shop/cart contextPath=/app sessionIdFromURL=-
----- GET /app/
HTTP 200
servlet=root match=CONTEXT_ROOT pattern= servletPath= pathInfo=/
  requestURI=/app/ contextPath=/app sessionIdFromURL=-
----- GET /app
HTTP 200
servlet=root match=CONTEXT_ROOT pattern= servletPath= pathInfo=/
  requestURI=/app contextPath=/app sessionIdFromURL=-
----- GET /plain
HTTP 302 -> http://127.0.0.1:8080/plain/
----- GET /plain/
HTTP 200
plain index
----- GET /APP/catalog/list
HTTP 404

헷갈리기 쉬운 곳: /app인데 리다이렉트가 없습니다

plain에서는 /plain이 302였는데 app에서는 /app이 그대로 200이었습니다. 3번 리다이렉트는 "규칙 1·2에서 못 찾았을 때만"입니다. app에는 빈 문자열 패턴이 있어서 규칙 1에서 root를 찾았으므로 리다이렉트 조건에 닿지 않았습니다. 같은 /app 요청이라도 웹앱의 매핑에 따라 결과가 달라집니다.

헷갈리기 쉬운 곳: 규칙 번호가 4에서 7로 건너뜁니다

소스 주석에는 Rule 1, 2, 3, 4(4a·4b·4c), 7만 있고 5와 6은 없습니다. 규칙 4 뒤의 주석 "Welcome file processing - take 2"(964–968행)가 그 사이의 단계입니다. 번호에 의미를 두지 말고 코드의 순서대로 읽으면 됩니다.

매핑이 끝난 뒤: 밸브가 결과를 확인한다

매퍼가 Host나 Context나 Wrapper를 못 찾아도 postParseRequest()는 true를 돌려줍니다(3절 첫 코드). 404를 정하는 것은 각 컨테이너의 기본 밸브입니다. StandardEngineValve는 Host가 없으면, StandardHostValve는 Context가 없으면 404를 보냅니다(이미 오류면 덮어쓰지 않음, StandardEngineValve.java 57–65행, StandardHostValve.java 81–88행). Wrapper를 확인하는 StandardContextValve는 그 전에 하나를 더 봅니다.

        // Disallow any direct access to resources under WEB-INF or META-INF
        MessageBytes requestPathMB = request.getRequestPathMB();
        if ((requestPathMB.startsWithIgnoreCase("/META-INF/", 0)) || (requestPathMB.equalsIgnoreCase("/META-INF")) ||
                (requestPathMB.startsWithIgnoreCase("/WEB-INF/", 0)) || (requestPathMB.equalsIgnoreCase("/WEB-INF"))) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        // Select the Wrapper to be used for this Request
        Wrapper wrapper = request.getWrapper();
        if (wrapper == null || wrapper.isUnavailable()) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

java/org/apache/catalina/core/StandardContextValve.java 56–69행

주석대로 WEB-INF와 META-INF 아래로 바로 들어오는 요청은 서블릿을 고르기 전에 404로 끝냅니다. 검사에 쓰는 requestPath는 매퍼가 적은 값, 곧 다듬은 URI에서 나온 값입니다. 예제 2와 같은 실행에서 확인했습니다(WEB-INF/secret.txt는 실제로 있는 파일입니다).

----- GET /app/WEB-INF/secret.txt
HTTP 404
----- GET /app/x/../WEB-INF/secret.txt
HTTP 404
----- GET /app/WEB-INF;x=y/secret.txt
HTTP 404

..를 끼우든 경로 파라미터를 끼우든 다듬은 뒤에는 모두 /WEB-INF/secret.txt이므로 막힙니다. 이 검사가 매퍼 뒤, 정규화 뒤에 있어야 하는 이유입니다. 파이프라인과 밸브의 자세한 이야기는 8편에서 봅니다.

5. 서블릿이 받는 것은 RequestFacade입니다

서블릿의 doGet(HttpServletRequest req, …)에 들어오는 req는 무엇일까요. 필터 체인을 부르는 StandardWrapperValve는 request.getRequest()를 넘깁니다(StandardWrapperValve.java 164행).

    public HttpServletRequest getRequest() {
        if (facade == null) {
            facade = new RequestFacade(this);
        }
        if (applicationRequest == null) {
            applicationRequest = facade;
        }
        return applicationRequest;
    }

java/org/apache/catalina/connector/Request.java 707–715행

RequestFacade는 connector Request를 하나 들고 모든 메서드를 그대로 넘기는 얇은 껍데기입니다. 파사드(facade)는 "건물 정면"이라는 뜻으로, 안쪽은 감추고 보여 줄 면만 내놓는 설계를 말합니다.

    public RequestFacade(Request request) {
        this.request = request;
    }


    /**
     * Clear facade.
     */
    public void clear() {
        request = null;
    }

java/org/apache/catalina/connector/RequestFacade.java 66–76행

connector Request도 HttpServletRequest를 구현하는데 왜 한 겹을 더 씌울까요. 소스에서 확인되는 이유는 두 가지입니다.

  1. 안쪽을 감춘다. connector Request에는 getMappingData(), setUserPrincipal(), getContext()처럼 컨테이너만 써야 하는 public 메서드가 많습니다. 서블릿에게 그 객체를 그대로 주면 (Request) req로 바꿔 매핑 결과나 인증 정보를 고칠 수 있습니다. 파사드는 서블릿 API에 있는 메서드만 내놓고, 안쪽 객체는 protected 필드에 둡니다(RequestFacade.java 58행).
  2. 재사용되는 객체를 지킨다. 1절에서 본 것처럼 connector Request는 다음 요청에 다시 쓰입니다. 요청이 끝난 뒤에도 서블릿이 그 객체를 들고 있다면, 나중에 다른 사람의 요청을 읽게 됩니다. 그래서 recycle()은 파사드의 연결을 끊고 버립니다.
        if (getDiscardFacades()) {
            if (facade != null) {
                facade.clear();
                facade = null;
            }

java/org/apache/catalina/connector/Request.java 547–551행

커넥터의 discardFacades는 기본 true입니다(Connector.java 215행). 끊긴 파사드의 메서드는 모두 맨 앞에서 checkFacade()를 불러 예외를 던집니다(618–622행). 잘못된 코드가 남의 요청을 조용히 읽는 대신 바로 터지게 한 것입니다.

예제 5: 요청 객체를 붙잡아 두면

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... import 생략 ...

/**
 * 서블릿이 받는 요청 객체가 무엇인지 보고, 요청이 끝난 뒤에도 붙잡아 두면 어떻게 되는지 본다.
 *   /app/keep?step=1 : 요청 객체를 static 필드에 붙잡아 두고, 클래스 이름과 호출 스택을 찍는다
 *   /app/keep?step=2 : 앞 요청에서 붙잡아 둔 객체로 getRequestURI() 를 불러 본다
 */
public class Keep extends HttpServlet {
    // 하면 안 되는 일: 요청 객체를 요청 밖으로 들고 나간다
    private static HttpServletRequest kept;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain");
        if ("1".equals(req.getParameter("step"))) {
            kept = req;
            resp.getWriter().println("class=" + req.getClass().getName());
            // 이 서블릿까지 어떤 메서드들을 거쳐 왔는지 catalina.out 에 찍는다
            new Throwable("stack at Keep.doGet").printStackTrace();
        } else {
            try {
                resp.getWriter().println("kept.getRequestURI()=" + kept.getRequestURI());
            } catch (IllegalStateException e) {
                resp.getWriter().println("IllegalStateException: " + e.getMessage());
            }
        }
    }
}

[예제 ex/u07-mapping/Keep.java에서 import만 줄임]

----- GET /app/keep?step=1
HTTP 200
class=org.apache.catalina.connector.RequestFacade
----- GET /app/keep?step=2
HTTP 200
IllegalStateException: The request object has been recycled and is no longer associated with this facade

서블릿이 받은 것은 RequestFacade였고, 첫 요청이 끝날 때 연결이 끊겼으므로 두 번째 요청에서 옛 객체를 쓰자 예외가 났습니다. 비동기 처리처럼 요청 밖에서 요청 객체를 써야 할 때는 서블릿 API가 정한 방법(AsyncContext)을 써야 합니다(12편).

예제 5에서 요청 객체가 지나는 일생. 요청 1(GET /app/keep?step=1)에서 StandardWrapperValve가 request.getRequest()를 부르면, 파사드가 없을 때 new RequestFacade(this)를 만들고 안쪽 connector Request는 파사드의 protected 필드에 둔다. Keep.doGet(req)가 받은 클래스는 org.apache.catalina.connector.RequestFacade이고, 서블릿은 이것을 static 필드 kept에 붙잡아 둔다. 요청 1이 끝날 때 Request.recycle()은 discardFacades가 기본 true이므로 facade.clear()로 파사드의 request를 null로 만들고 facade 필드도 null로 버린다. 요청 2(GET /app/keep?step=2)에서 끊긴 옛 파사드로 kept.getRequestURI()를 부르면 checkFacade()가 request == null을 보고 IllegalStateException: The request object has been recycled and is no longer associated with this facade를 던진다. 파란 칸은 요청 1 안, 노란 칸은 요청이 끝날 때, 빨간 칸은 요청 2입니다. 메시지는 예제 5의 실제 출력입니다.

헷갈리기 쉬운 곳: getContextPath()의 주석과 코드가 다릅니다

RequestFacade.getContextPath()의 주석은 "Tomcat은 돌려주는 값을 정규화하지도 디코딩하지도 않는다. 사용자 에이전트가 보낸 요청 URI의 그 부분과 똑같다"고 적어 두었습니다(341–351행). 그런데 그 메서드가 부르는 Request.getContextPath()는 이렇습니다.

    /**
     * {@inheritDoc}
     * <p>
     * Tomcat returns the canonical context path for the web application.
     */
    @Override
    public String getContextPath() {
        return getContext().getPath();
    }

java/org/apache/catalina/connector/Request.java 1970–1978행

웹앱의 정식 경로를 그대로 돌려줍니다. 예제 2와 같은 실행에서 /app을 %61(a)로 인코딩해 보냈습니다.

----- GET /%61pp/catalog/list
HTTP 200
servlet=exact match=EXACT pattern=/catalog/list servletPath=/catalog/list pathInfo=null
  requestURI=/%61pp/catalog/list contextPath=/app sessionIdFromURL=-

contextPath는 /app입니다. 실제 동작은 Request 쪽 주석과 같고, 파사드의 주석이 옛 설명으로 남아 있는 것입니다. git 기록으로는 서블릿 6.2가 이 메서드의 요구를 느슨하게 바꾼 뒤 main에서 구현을 바꾸었고(커밋 4e7f6c7572), 파사드 주석은 그보다 앞서 쓰인 것입니다(커밋 511ffac20f). 11.0.x 브랜치의 Request.getContextPath()는 아직 요청 URI에서 그 부분을 잘라 내는 긴 구현이라 /%61pp를 돌려줄 것으로 보이지만, 11.0.x로 실행해 보지는 않았습니다. 주석과 코드가 다르면 코드를 따릅니다.

6. MapperListener — 매퍼는 어떻게 채워지나

매퍼는 배열만 들고 있을 뿐 컨테이너 트리를 모릅니다. 트리의 변화를 매퍼에 옮겨 적는 것이 MapperListener입니다. Service가 시작할 때 순서를 보면 그 자리가 보입니다.

        // Start our defined Container first
        if (engine != null) {
            engine.start();
        }

        for (Executor executor : findExecutors()) {
            executor.start();
        }

        mapperListener.start();

        // Start our defined Connectors second
        for (Connector connector : findConnectors()) {
            // If it has already failed, don't try and start it
            if (connector.getState() != LifecycleState.FAILED) {
                connector.start();
            }
        }

java/org/apache/catalina/core/StandardService.java 431–448행

Engine(과 그 아래 Host·웹앱)이 다 시작된 뒤, 커넥터가 요청을 받기 시작하기 전에 MapperListener가 시작합니다. 그래서 첫 요청이 올 때 매퍼는 이미 차 있습니다. 시작 순서와 Lifecycle은 3편에서 봤습니다.

MapperListener가 매퍼를 채우는 길. StandardService.startInternal이 Engine 다음에 MapperListener를 시작하면, MapperListener.startInternal은 addListeners로 Engine 아래 트리 전체에 컨테이너 리스너와 Lifecycle 리스너를 걸고, 이미 시작된 Host마다 registerHost를 부른다. registerHost는 registerContext를 부르고, registerContext는 Wrapper들의 매핑을 모아 Mapper.addContextVersion에 넘긴다. 나중에 오는 이벤트 가운데 AFTER_START는 새로 시작된 앱을 registerContext로 등록하고, BEFORE_STOP은 내리거나 멈추는 앱을 unregister하고, ADD_MAPPING은 실행 중에 더해진 매핑을 Mapper.addWrapper로 더한다. 회색 상자는 서버가 도는 동안 웹앱을 배포·제거할 때 일어나는 일입니다.

MapperListener.startInternal()은 두 가지를 합니다(84–105행).

  1. 지금 있는 것을 등록한다. NEW 상태가 아닌 Host마다(Engine이 먼저 시작했으므로 사실상 시작된 Host마다) registerHost() → 그 안의 시작된 Context마다 registerContext(). registerContext()는 Context의 Wrapper마다 findMappings()로 url-pattern을 모아 Mapper.addContextVersion()에 한꺼번에 넘깁니다(352–378행).
  2. 앞으로 바뀌는 것을 듣는다. addListeners()가 Engine부터 Wrapper까지 트리의 모든 컨테이너에 자기 자신을 컨테이너 리스너(자식 추가·제거, 매핑 추가 같은 이벤트)이자 Lifecycle 리스너(시작·정지 이벤트)로 겁니다(481–487행).

나중에 웹앱이 배포되거나 내려가면 Lifecycle 이벤트가 옵니다.

    public void lifecycleEvent(LifecycleEvent event) {
        if (event.getType().equals(AFTER_START_EVENT)) {
            Object obj = event.getSource();
            if (obj instanceof Wrapper w) {
                // Only if the Context has started. If it has not, then it will
                // have its own "after_start" event later.
                if (w.getParent().getState().isAvailable()) {
                    registerWrapper(w);
                }
            } else if (obj instanceof Context c) {
                // Only if the Host has started. If it has not, then it will
                // have its own "after_start" event later.
                if (c.getParent().getState().isAvailable()) {
                    registerContext(c);
                }
            } else if (obj instanceof Host) {
                registerHost((Host) obj);
            }

java/org/apache/catalina/mapper/MapperListener.java 445–462행

시작이 끝난 뒤(AFTER_START) 에 등록하고 멈추기 전(BEFORE_STOP) 에 지웁니다(463–472행). 시작이 덜 된 웹앱으로 요청이 가지 않고, 멈추는 웹앱으로 새 요청이 들어가지 않게 하는 순서입니다. 부모가 아직 시작 전이면 등록하지 않는 조건도 같은 이유입니다. 부모가 시작을 마칠 때 자식까지 한꺼번에 등록됩니다.

웹앱이 이미 시작된 뒤에 Wrapper에 매핑이 더해지면 StandardWrapper.addMapping()이 Wrapper.ADD_MAPPING_EVENT 컨테이너 이벤트를 쏘고(StandardWrapper.java 553–554행 — 부모 Context가 STARTED일 때만), MapperListener가 그것을 받아 Mapper.addWrapper()로 바로 더합니다(170–184행). 시작 전에 더해진 매핑은 이벤트 없이, 시작이 끝날 때 registerContext()가 한꺼번에 모아 갑니다.

헷갈리기 쉬운 곳: 리로드 중인 웹앱으로 온 요청

웹앱을 다시 읽는 동안(reloadable, 4편) 그 Context는 "멈춤(paused)" 상태가 되고, 매퍼에서 지워지는 대신 멈춤 표시만 됩니다(unregisterContext()의 pauseContextVersion(), 392–397행). 그 사이에 온 요청은 매핑이 끝난 뒤 postParseRequest()에서 1초 자고 다시 매핑합니다(782–794행). 리로드 중에 요청이 404가 되지 않고 잠깐 느려지기만 하는 까닭입니다(이 동작은 실행해서 확인하지는 않았습니다).

이 글에서 뺀 것

정리

개념 한 줄 요약
CoyoteAdapter.service() Coyote와 Catalina의 경계. connector Request를 꺼내고 postParseRequest() 뒤에 Engine 파이프라인으로 넘긴다
connector Request coyote Request를 감싼 HttpServletRequest 구현. 프로세서와 함께 재사용되고 요청이 끝나면 recycle()
postParseRequest() 경로 파라미터 떼기 → %xx 풀기 → 정규화 → 문자로 바꾸기 → 매핑
두 URI getRequestURI()는 받은 그대로, 매핑·보안 검사는 다듬은 decodedURI로
%2F, \, /../ 기본 설정에서 400. 400이어도 Host 매핑까지는 해서 오류 페이지 밸브를 지나게 한다
Mapper Service마다 하나. 정렬된 배열과 이진 탐색, 바꿀 때는 새 배열로 갈아 끼운다
Host 고르기 대소문자 무시 → 첫 점 앞을 한 번 떼고(와일드카드) → 기본 Host
Context 고르기 가장 긴 경로, 없으면 ROOT. 경로는 대소문자를 가린다
Wrapper 고르기 정확히 → 가장 긴 접두사 → (루트 리다이렉트) → 확장자 → 환영 파일 → 기본 서블릿
MappingData 매핑 결과. getServletPath(), getPathInfo(), getContext()가 여기서 읽는다
RequestFacade 안쪽 메서드를 감추고, recycle 뒤에는 끊겨 IllegalStateException
MapperListener Engine 시작 뒤·커넥터 시작 전에 매퍼를 채우고, AFTER_START/BEFORE_STOP으로 따라간다

다음에 볼 것

매핑이 끝나면 요청은 getFirst().invoke()로 Engine의 파이프라인에 들어갑니다. 예제 1의 스택에 있던 StandardEngineValve → AbstractAccessLogValve → ErrorReportValve → StandardHostValve → AuthenticatorBase → StandardContextValve → StandardWrapperValve가 무엇이고 왜 이렇게 사슬로 엮여 있는지, 내가 쓴 밸브를 어디에 끼울 수 있는지를 8편: 파이프라인과 밸브 — 컨테이너마다 걸린 처리 사슬에서 봅니다.

같은 문제를 앞 연재에서도 만났습니다. httpd 10편의 translate_name·map_to_storage가 URL을 디스크 경로와 그 경로의 설정으로 바꿨다면, Tomcat의 Mapper는 URL을 Host·Context·서블릿으로 바꿉니다. 둘 다 정규화를 매핑보다 먼저 하고, 매핑과 접근 검사를 다듬은 경로로 합니다. Host 고르기는 httpd 9편의 가상 호스트 선택과 같은 일입니다.

Spring 22편의 RequestMappingHandlerMapping은 Tomcat이 서블릿을 고른 뒤에 한 번 더 고르는 두 번째 매퍼입니다. DispatcherServlet을 /에 매핑하면 다른 서블릿 패턴에 걸리지 않은 요청은 Tomcat 매퍼의 규칙 7(기본 서블릿)로 모두 DispatcherServlet에 가고, 그 안에서 Spring이 @GetMapping 메서드를 고릅니다. "정확한 경로를 먼저 맵에서 찾고, 없을 때만 패턴을 돌린다"는 Spring의 지름길도 Tomcat 매퍼의 규칙 1 → 규칙 2 순서와 닮았습니다. 다른 점은 Tomcat의 순서가 서블릿 스펙에 고정되어 있는 반면, Spring은 후보를 모은 뒤 비교기로 "가장 구체적인 것"을 고른다는 것입니다.

다른 글