이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 일곱 번째 글입니다. 6편에서는 Http11Processor가 소켓의 바이트를 읽어 요청 줄과 헤더를 org.apache.coyote.Request 하나로 만드는 데까지 봤습니다. 그 요청은 아직 "HTTP 요청"일 뿐, 어느 웹앱의 어느 서블릿이 맡을지는 정해지지 않았습니다. 이번 글은 그 요청이 커넥터(Coyote)에서 서블릿 컨테이너(Catalina)로 넘어가는 다리와, 다리를 건너는 동안 "이 요청은 어느 서블릿으로 가는가" 가 정해지는 과정을 따라갑니다.
답하려는 질문은 네 가지입니다.
- Coyote의 요청은 어디서 어떻게 서블릿 API의
HttpServletRequest가 되는가 /app/x/../catalog;foo=bar/list처럼 지저분한 URI는 서블릿에 닿기 전에 어떻게 다듬어지고, 어떤 URI는 왜 400으로 막히는가- web.xml의
url-pattern이 여럿 겹치면 누가 이기는가 — 그 규칙은 소스의 어디에 있는가 - 서블릿이 받는 요청 객체가
org.apache.catalina.connector.Request가 아니라RequestFacade인 이유는 무엇인가
| 항목 | 내용 |
|---|---|
| 대상 | 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행
파란 상자가 이 글에서 읽는 곳, 회색 상자는 앞뒤 글에서 보는 곳입니다.
세 가지를 읽을 수 있습니다.
- Catalina 쪽 요청 객체는 요청마다 만들지 않습니다. coyote
Request에는 아무 객체나 걸어 둘 수 있는 메모 칸(note)이 있고, 처음 지나갈 때 만든org.apache.catalina.connector.Request를 거기 걸어 둡니다. 프로세서와 그 안의 coyoteRequest는 연결이 바뀌어도 재사용되므로(5편), Catalina 쪽 객체도 같이 재사용됩니다. 그래서 요청이 끝나면 반드시 비워야 합니다(5절). - 이름이 둘인
Request가 짝을 이룹니다. coyoteRequest는 HTTP 요청의 원재료(바이트, 헤더), connectorRequest는 그것을 감싸HttpServletRequest를 구현하는 쪽입니다(Request.java126–129행 — 클래스 주석이 "Wrapper object for the Coyote request"입니다). 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행
노란 상자 두 개가 이 글 내내 따라다니는 두 URI입니다. 받은 그대로의 것과 다듬은 것이 따로 있습니다.
순서대로 봅니다.
- 복사. 받은 URI(
requestURI)는 건드리지 않고, 복사본(decodedURI)만 다듬습니다. 서블릿의getRequestURI()는 앞의 것을 돌려줍니다(Request.java2118–2120행). - 경로 파라미터 떼기.
/catalog;foo=bar/list의;foo=bar처럼 세미콜론 뒤에 붙은 것을 경로 파라미터(path parameter)라고 합니다.parsePathParameters()는 이것을 URI에서 잘라 내고name=value는 따로 보관합니다(901–977행). 쿠키를 못 쓸 때 세션 ID를 URL에 싣는;jsessionid=…가 이 모양이라서 여기서 떼어 둡니다. 주석도 "지금 관심 있는 것은 세션 ID뿐"이라고 적어 두었습니다(895행). %xx풀기.UDecoder.convert()가%63을c로 바꾸는 식으로 퍼센트 인코딩을 풉니다. 단,%2F(인코딩된/)는 커넥터의encodedSolidusHandling설정을 따르는데, 기본값이REJECT라서 예외가 납니다(Connector.java331행).- 정규화.
normalize()가\를/로 바꾸고(허용했을 때만),//를/로 줄이고,/./와/../를 풉니다. 루트보다 위로 올라가려 하면false를 돌려줍니다. - 문자로 바꾸기. 마지막으로
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
- 위의 여섯은 생김새가 전부 다르지만 모두 같은 서블릿(
exact)에 같은servletPath로 닿았습니다. 한편requestURI는 받은 그대로입니다. 서블릿에서 경로를 비교할 때getRequestURI()가 아니라getServletPath()/getPathInfo()를 써야 하는 까닭입니다. %2e%2e도..로 풀린 뒤 정규화되었습니다. 디코딩이 먼저라는 증거입니다.;jsessionid=ABC123은 경로에서 떨어져 나가 요청된 세션 ID가 되었습니다. 매핑으로 Context를 찾은 뒤에getPathParameter()로 꺼내 쓰는 코드는 720–728행에 있습니다. 세션은 11편에서 봅니다.- 400 셋은 메시지가 다릅니다.
%2F는UDecoder의 예외 메시지가 붙은invalidURIWithMessage이고,%5C(인코딩된\)와/../는 메시지 없는invalidURI입니다.%5C는 디코딩 단계는 통과합니다(encodedReverseSolidusHandling의 기본값이DECODE,Connector.java325행). 풀려서 생긴\를normalize()가 보고,allowBackslash가 기본값false(157행)라서 거절한 것입니다.
헷갈리기 쉬운 곳: 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는 컨테이너 객체(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행
- 대소문자를 가리지 않고 이름(과 별칭)이 정확히 같은 Host를 찾습니다.
- 없으면 첫 점 앞을 떼고 한 번 더 찾습니다.
a.example.org는.example.org로 찾습니다. 별칭*.example.org는 등록할 때*를 떼고.example.org로 저장해 두었으므로(renameWildcardHost(), 1520–1526행) 여기서 맞습니다. 주석의 "shortcut"이 이것입니다. - 그래도 없으면 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/이 앞에 붙습니다.
/shop/admin/users는/shop이 아니라 더 긴/shop/admin으로 갔습니다./shopping은/shop으로 시작하지만 바로 뒤가/가 아니라서/shop이 아닙니다. ROOT로 갔습니다.LOCALHOST는 대소문자를 무시하고localhost로 갔습니다.a.example.org는 별칭*.example.org로shop.example.com에 닿았지만,a.b.example.org는 닿지 않고 기본 Host로 갔습니다. 첫 점 앞을 한 번만 떼므로.b.example.org를 찾고, 그런 이름은 없기 때문입니다. 와일드카드 별칭은 한 단계 하위 도메인만 받습니다.- 모르는 Host
unknown.test는 기본 Host로 갔습니다.
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행)이 이어집니다.
회색 상자 둘은 서블릿을 고르는 대신 리다이렉트 주소를 적는 길입니다.
- 규칙 1 — 정확히 일치.
exactWrappers에서 이진 탐색 한 번입니다. 빈 경로(컨텍스트 루트)는/라는 이름으로 찾습니다(1038–1059행). - 규칙 2 — 경로 접두사. Context를 고를 때와 같은 방법으로, 경로 끝을
/단위로 줄여 가며 찾으므로 가장 긴 접두사가 이깁니다(1065–1107행). 접두사 뒤에 남은 것이pathInfo가 됩니다. - 컨텍스트 루트 리다이렉트. 여기까지 못 찾았고 컨텍스트 경로 뒤에 아무것도 없으면(
/app), 서블릿을 고르는 대신/app/으로 가라는 리다이렉트 주소를 적고 끝냅니다.StandardContext의mapperContextRootRedirectEnabled가 기본true입니다(StandardContext.java787행). - 규칙 3 — 확장자. 마지막
/뒤 조각에서 마지막.뒤를 확장자로 봅니다(1118–1152행). - 규칙 4 — 환영 파일. 경로가
/로 끝나면index.html,index.htm,index.jsp(conf/web.xml4752–4754행의<welcome-file>) 같은 이름을 붙여 정확히 일치·접두사로 다시 찾고, 그 이름의 실제 파일이 있으면 확장자 서블릿이나 기본 서블릿을 고릅니다(918–987행). - 규칙 7 — 기본 서블릿. 아무것도 맞지 않으면
/에 매핑된 서블릿입니다. 웹앱이 따로 정하지 않으면conf/web.xml에 등록된DefaultServlet(정적 파일을 내주는 서블릿)이 이 자리에 있습니다(conf/web.xml436–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
/catalog/list는 정확히 일치(규칙 1)가 이겼습니다. 같은 경로에/list/more가 붙으면 정확히 일치가 아니므로 접두사(규칙 2)로 갑니다./catalog/items/42는/catalog/*와/catalog/items/*둘 다 맞지만 더 긴 쪽이 이겼습니다./catalog/report.do는*.do가 아니라/catalog/*로 갔습니다. 접두사(규칙 2)가 확장자(규칙 3)보다 먼저이기 때문입니다. web.xml에 적은 순서와는 상관이 없습니다.- 어디에도 맞지 않는
/shop/cart는 기본 서블릿(규칙 7)slash로 갔습니다. 이때servletPath가 경로 전체이고pathInfo는null입니다. /app/과/app은 둘 다 빈 문자열 패턴의root로 갔고,servletPath는 빈 문자열,pathInfo는/입니다./plain은 302로/plain/에 보내졌고,/plain/은 환영 파일(규칙 4)로index.html이 나왔습니다./APP은 대소문자가 달라 Context를 못 찾았고, 이CATALINA_BASE에는 ROOT 웹앱이 없으므로 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를 구현하는데 왜 한 겹을 더 씌울까요. 소스에서 확인되는 이유는 두 가지입니다.
- 안쪽을 감춘다. connector
Request에는getMappingData(),setUserPrincipal(),getContext()처럼 컨테이너만 써야 하는public메서드가 많습니다. 서블릿에게 그 객체를 그대로 주면(Request) req로 바꿔 매핑 결과나 인증 정보를 고칠 수 있습니다. 파사드는 서블릿 API에 있는 메서드만 내놓고, 안쪽 객체는protected필드에 둡니다(RequestFacade.java58행). - 재사용되는 객체를 지킨다. 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편).
파란 칸은 요청 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.startInternal()은 두 가지를 합니다(84–105행).
- 지금 있는 것을 등록한다.
NEW상태가 아닌 Host마다(Engine이 먼저 시작했으므로 사실상 시작된 Host마다)registerHost()→ 그 안의 시작된 Context마다registerContext().registerContext()는 Context의 Wrapper마다findMappings()로 url-pattern을 모아Mapper.addContextVersion()에 한꺼번에 넘깁니다(352–378행). - 앞으로 바뀌는 것을 듣는다.
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가 되지 않고 잠깐 느려지기만 하는 까닭입니다(이 동작은 실행해서 확인하지는 않았습니다).
이 글에서 뺀 것
- 병렬 배포(
app##1)에서 세션으로 버전을 다시 고르는 루프:CoyoteAdapter.java745–780행 rejectSuspiciousURIs(기본 꺼짐)가 켜졌을 때 보는 "의심스러운 URI":checkSuspiciousURIs(), 1246–1322행- 쿠키와 SSL 세션에서 세션 ID 찾기(
parseSessionCookiesId(),parseSessionSslId()): 985–1047행, 11편 TRACE거절과 커넥터 단의 인증(doConnectorAuthenticationAuthorization()): 824–890행QUERY메서드에Content-Type이 없으면 400(main에만 있고 11.0.x에는 없는 검사): 797–801행- JSP의
jsp-property-group와일드카드와 환영 파일(Bugzilla 27664):Mapper.java884–899행 RequestDispatcher가 쓰는 Context 안 매핑map(Context, MessageBytes, MappingData): 721–728행, 9편RewriteValve가 URL을 고친 뒤Adapter.prepare()로 다시 매핑하는 길:RewriteValve.java649행- 비동기 요청이 컨테이너로 돌아오는
asyncDispatch():CoyoteAdapter.java113행부터, 12편 - 응답 쪽 파사드
ResponseFacade:java/org/apache/catalina/connector/ResponseFacade.java
정리
| 개념 | 한 줄 요약 |
|---|---|
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은 후보를 모은 뒤 비교기로 "가장 구체적인 것"을 고른다는 것입니다.