이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 여덟 번째 글입니다. 7편에서는 CoyoteAdapter가 요청 URI를 정리하고, Mapper로 이 요청을 맡을 Host·Context·Wrapper(서블릿)를 미리 정해 두는 데까지 봤습니다. 정해 두기만 했지 아직 아무도 부르지 않았습니다. 이번 글은 그다음, CoyoteAdapter가 컨테이너를 부른 순간부터 서블릿 앞에 닿을 때까지 요청이 지나는 길을 따라갑니다.
답하려는 질문은 네 가지입니다.
- server.xml의
<Valve>는 소스에서 무엇이고, 어디에 어떤 순서로 붙는가 - 요청은 Engine → Host → Context → Wrapper를 어떻게 건너가는가. 누가 다음 컨테이너를 고르는가
- 밸브가 요청을 막거나 오류가 나면 오류 페이지는 누가 그리는가
- 접근 로그(
AccessLogValve)는 언제 쓰이고, 프록시 뒤에서 클라이언트 주소가 엉뚱하게 찍히는 것은 왜인가
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | Pipeline, Valve, core/StandardPipeline, core/Standard{Engine,Host,Context,Wrapper}Valve, valves/ValveBase, valves/ErrorReportValve, valves/AbstractAccessLogValve, valves/RemoteIpValve |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
valves 패키지에만 밸브가 스무 개 남짓 있고, 접근 로그 밸브들의 부모 클래스(AbstractAccessLogValve) 하나가 2,400줄입니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
컨테이너(container)는 Catalina에서 요청을 받아 처리하는 층 하나입니다. server.xml의 <Engine>, <Host>, <Context>(웹앱 하나), 그리고 서블릿 하나를 감싸는 Wrapper, 이렇게 넷이 부모·자식으로 이어져 있습니다. 넷 모두 ContainerBase를 물려받습니다. 계층 전체는 2편에서 봤습니다.
밸브(valve)는 요청 하나를 받아 무언가를 하고, 원하면 다음 밸브에게 넘기는 객체입니다. 소스의 설명에 따르면 이름은 실제 배관에서 흐름을 조절하는 밸브에서 왔습니다(Valve.java 31–32행). 접근 로그, 오류 페이지, 인증, 프록시 주소 바꾸기가 모두 밸브로 되어 있습니다.
파이프라인(pipeline)은 밸브를 한 줄로 세운 것입니다. 컨테이너마다 하나씩 있습니다. 그러니 요청 하나는 파이프라인 넷을 차례로 지납니다.
기본 밸브(basic valve)는 파이프라인 맨 끝에 언제나 있는 밸브입니다. 컨테이너가 만들어질 때 스스로 달고, 설정으로 바꾸지 않습니다. 하는 일은 대부분 "다음 컨테이너를 골라 그 파이프라인을 부르기"입니다.
접근 로그(access log)는 요청 하나마다 한 줄씩 "누가, 언제, 무엇을 요청했고, 응답 코드가 무엇이었나"를 남기는 파일입니다. 기본 설정에서는 logs/localhost_access_log.<날짜>.txt입니다.
비동기 지원(async support)은 서블릿이 startAsync()로 요청을 잠시 놓아두고 스레드를 돌려줄 수 있느냐는 것입니다. 밸브마다 "나는 비동기 요청이 지나가도 괜찮다"를 따로 밝힙니다. 비동기 서블릿 자체는 12편에서 봅니다.
1. 큰 그림: 컨테이너 넷, 파이프라인 넷
7편의 끝에서 CoyoteAdapter.service()는 매핑을 마치고(postParseRequest()) 이렇게 컨테이너를 부릅니다.
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 342–348행
getService().getContainer()는 Service에 딸린 Engine입니다. Engine의 파이프라인에서 첫 밸브를 꺼내 invoke()를 부르는 것, 이것이 Catalina로 들어가는 문의 전부입니다. 그 뒤로는 밸브가 밸브를 부릅니다.
Engine 파이프라인의 맨 끝에는 기본 밸브 StandardEngineValve가 있습니다. 이 밸브가 하는 일이 짧습니다.
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
// Select the Host to be used for this Request
Host host = request.getHost();
if (host == null) {
// No Host found for this request (unknown server name or no default host defined)
// Don't overwrite an existing error
if (!response.isError()) {
response.sendError(404);
}
return;
}
if (request.isAsyncSupported()) {
request.setAsyncSupported(host.getPipeline().isAsyncSupported());
}
// Ask this Host to process this request
host.getPipeline().getFirst().invoke(request, response);
}
java/org/apache/catalina/core/StandardEngineValve.java 53–72행
request.getHost()는 7편의 Mapper가 미리 적어 둔 Host입니다. 이 밸브는 고르지 않고 꺼내기만 합니다. 그리고 그 Host의 파이프라인 첫 밸브를 부릅니다. Host의 기본 밸브는 Context를, Context의 기본 밸브는 Wrapper를 꺼내 똑같이 합니다. 그래서 전체 모양은 이렇습니다.
파란 상자는 설정으로 거는 밸브, 색 없는 상자는 컨테이너가 스스로 다는 기본 밸브입니다.
기본 밸브는 각 컨테이너의 생성자에서 setBasic()으로 답니다.
| 컨테이너 | 기본 밸브 | 다는 곳 | 하는 일 |
|---|---|---|---|
StandardEngine |
StandardEngineValve |
StandardEngine.java 60행 |
Host를 꺼내 넘긴다. 없으면 404 |
StandardHost |
StandardHostValve |
StandardHost.java 66행 |
Context를 꺼내 넘긴다. 클래스로더 바꾸기, 요청 리스너, 웹앱의 <error-page> |
StandardContext |
StandardContextValve |
StandardContext.java 157행 |
WEB-INF·META-INF 막기, Wrapper를 꺼내 넘긴다 |
StandardWrapper |
StandardWrapperValve |
StandardWrapper.java 86–87행 |
서블릿 인스턴스를 빌리고, 필터 체인을 만들어 부른다 |
예제 1: 밸브를 Engine·Host·Context에 걸어 순서 보기
정말 이 순서로 가는지 보려고, 다음 밸브를 부르기 전에 >를, 돌아온 뒤에 <를 찍는 밸브를 만들었습니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
package lab;
// ... import 생략 ...
public class TraceValve extends ValveBase {
private String name = "trace";
private boolean dump = false;
public TraceValve() {
super(true); // 비동기 요청도 지나갈 수 있다고 알린다
}
// <Valve> 의 속성은 Digester 가 같은 이름의 setter 로 넣어 준다
public void setName(String name) {
this.name = name;
}
// ... setDump(), 그리고 dump="true" 면 기동할 때 파이프라인을 찍는 startInternal() ...
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
System.out.println("> " + name + " " + describe() + " " + request.getRequestURI());
getNext().invoke(request, response);
System.out.println("< " + name + " status=" + response.getStatus());
}
// ... describe() 는 "StandardHost[localhost]" 처럼 컨테이너 이름을 만든다 ...
}
[예제 ex/u08-valve-order/lab/TraceValve.java에서 줄임]
이 밸브를 jar로 묶어 $CATALINA_BASE/lib에 두고(공통 클래스로더가 읽는 곳, 4편), 기본 server.xml에 두 군데를 더했습니다. Context에는 컨텍스트 설명 파일 conf/Catalina/localhost/app.xml로 겁니다.
<!-- server.xml -->
<Engine name="Catalina" defaultHost="localhost">
<Valve className="lab.TraceValve" name="engine-1" dump="true"/>
<Valve className="lab.TraceValve" name="engine-2"/>
<!-- ... Realm ... -->
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">
<Valve className="lab.TraceValve" name="host" dump="true"/>
<Valve className="lab.LogProbeValve"/>
<!-- ... 기본 설정에 원래 있던 AccessLogValve ... -->
</Host>
</Engine>
<!-- conf/Catalina/localhost/app.xml -->
<Context>
<Valve className="lab.TraceValve" name="context" dump="true"/>
<Valve className="lab.BlockValve"/>
</Context>
BlockValve는 5절, LogProbeValve는 6절에서 씁니다. 웹앱 app에는 서블릿 하나가 있고, 이 서블릿은 자기를 부른 Catalina 쪽 invoke()/service() 메서드를 StackWalker로 찍습니다.
이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. ex/u08-valve-order/run.sh가 위 설정을 만들고 catalina.sh start 뒤 curl을 보냅니다. /app/hello의 결과입니다.
===== curl /app/hello =====
HTTP 200
> engine-1 StandardEngine[Catalina] /app/hello
> engine-2 StandardEngine[Catalina] /app/hello
> host StandardHost[localhost] /app/hello
> context StandardContext[/app] /app/hello
servlet: service() reached, called through:
at org.apache.catalina.core.StandardWrapperValve.invoke:164
at org.apache.catalina.core.StandardContextValve.invoke:77
at org.apache.catalina.authenticator.AuthenticatorBase.invoke:540
at lab.BlockValve.invoke:28
at lab.TraceValve.invoke:52
at org.apache.catalina.core.StandardHostValve.invoke:115
at org.apache.catalina.valves.ErrorReportValve.invoke:82
at org.apache.catalina.valves.AbstractAccessLogValve.invoke:777
at lab.LogProbeValve.invoke:27
at lab.TraceValve.invoke:52
at org.apache.catalina.core.StandardEngineValve.invoke:71
at lab.TraceValve.invoke:52
at lab.TraceValve.invoke:52
at org.apache.catalina.connector.CoyoteAdapter.service:347
< context status=200
< host status=200
< engine-2 status=200
< engine-1 status=200
읽을 것이 세 가지 있습니다.
>는 바깥에서 안으로,<는 안에서 바깥으로 찍혔습니다. 밸브는 다음 밸브를 자기 메서드 안에서 부르므로, 앞 밸브의invoke()는 뒤 밸브가 전부 끝날 때까지 끝나지 않습니다. 양파 껍질처럼 감싸는 구조입니다.- 스택은 아래에서 위로 읽습니다.
CoyoteAdapter.service의 347행(위 코드)에서 시작해, Engine의 밸브 둘 →StandardEngineValve71행 → Host의 밸브 넷 →StandardHostValve115행 → Context의 밸브 셋 →StandardContextValve77행 →StandardWrapperValve164행입니다. 줄 번호는 이 글이 인용하는 소스 줄과 같습니다. - Host에는 내가 걸지 않은
ErrorReportValve가, Context에는AuthenticatorBase(실제 클래스는NonLoginAuthenticator)가 끼어 있습니다. 누가 언제 넣었는지는 3절에서 봅니다.
StandardWrapperValve 164행은 filterChain.doFilter(...)입니다. 그 위로 필터들과 서블릿의 service()가 이어지는데, 이 예제는 Catalina의 invoke/service만 걸러 찍어서 보이지 않습니다. 그 구간은 9편에서 봅니다.
헷갈리기 쉬운 곳
Pipeline에는invoke()가 없습니다. 인터페이스 설명에는 "invoke()가 불리면 밸브들을 차례로 실행한다"고 적혀 있지만(Pipeline.java22–24행), 메서드 목록에는 없습니다. 부르는 쪽은 언제나getPipeline().getFirst().invoke(...)로 첫 밸브를 직접 부릅니다. 파이프라인은 사슬을 들고 있을 뿐, 돌리지 않습니다.- Wrapper에는 설정으로 밸브를 걸 수 없습니다. server.xml과 context.xml을 읽는 규칙 가운데 컨테이너에 밸브를 거는 것은
Engine/Valve,Host/Valve,Context/Valve뿐입니다(<Cluster>안의<Valve>도 있지만 클러스터가 자기가 속한 Engine이나 Host에 붙입니다.HostRuleSet.java77–83행,EngineRuleSet·ContextRuleSet도 같은 모양). Wrapper는 web.xml의<servlet>에서 만들어지는데 거기에는 밸브를 적는 자리가 없습니다. 서블릿 하나에만 무언가를 끼우고 싶으면 필터를 씁니다.
2. Pipeline과 Valve — 연결 리스트 하나
두 인터페이스에서 요청 처리에 쓰는 메서드만 추리면 이렇습니다.
public interface Pipeline extends Contained {
// ...
Valve getBasic();
// ...
void setBasic(Valve valve);
// ...
void addValve(Valve valve);
// ...
Valve[] getValves();
// ...
Valve getFirst();
java/org/apache/catalina/Pipeline.java 32–108행
public interface Valve {
// ...
Valve getNext();
// ...
void setNext(Valve valve);
// ...
void backgroundProcess();
// ...
void invoke(Request request, Response response) throws IOException, ServletException;
java/org/apache/catalina/Valve.java 34–109행
밸브가 getNext()/setNext()를 가지고 있다는 것이 핵심입니다. "다음이 누구인지"는 파이프라인이 아니라 밸브 하나하나가 기억합니다. 배열이나 List가 아니라 단일 연결 리스트(singly linked list)입니다. 구현 StandardPipeline이 밸브를 가리키려고 들고 있는 필드는 두 개, 맨 앞 first와 맨 끝 basic뿐입니다(그 밖에는 자기 컨테이너를 가리키는 container가 있습니다, 83–95행).
addValve()가 새 밸브를 어디에 끼우는지 보면 구조가 보입니다.
// Add this Valve to the set associated with this Pipeline
if (first == null) {
first = valve;
valve.setNext(basic);
} else {
Valve current = first;
while (current != null) {
if (current.getNext() == basic) {
current.setNext(valve);
valve.setNext(basic);
break;
}
current = current.getNext();
}
}
container.fireContainerEvent(Container.ADD_VALVE_EVENT, valve);
java/org/apache/catalina/core/StandardPipeline.java 286–302행
앞에서부터 걸어가 "다음이 기본 밸브인 밸브"를 찾고, 그 사이에 끼웁니다. 그러니 새 밸브는 언제나 기본 밸브 바로 앞, 곧 설정한 밸브들의 맨 뒤에 붙습니다. 밸브가 하나도 없으면 first가 null이고, getFirst()는 기본 밸브를 돌려줍니다(400–407행). 그래서 밸브를 하나도 걸지 않은 Wrapper에서도 getFirst().invoke()가 곧바로 StandardWrapperValve에 닿습니다.
점선이 addValve(C) 뒤에 바뀐 연결입니다. 기본 밸브의 next는 늘 null입니다.
밸브가 해도 되는 일과 하면 안 되는 일
Valve.invoke()의 설명 주석은 밸브의 약속을 목록으로 적어 둡니다(66–99행). 줄이면 이렇습니다.
| 해도 된다 | 하면 안 된다 |
|---|---|
| 요청·응답을 들여다보고 고친다 | 이미 처리 방향을 정하는 데 쓴 값을 바꾼다 (예: Host 파이프라인에서 가상 호스트 바꾸기) |
| 응답을 직접 다 만들고 다음을 부르지 않고 돌아간다 | 응답을 다 만들고 또 다음 밸브로 넘긴다 |
| 요청·응답을 감싸서 넘긴다 | 응답을 만들 것도, 감쌀 것도 아니면서 요청 본문을 읽는다 |
getNext().invoke()로 다음을 부른다 |
getNext().invoke()가 돌아온 뒤 응답 헤더를 고치거나 출력 스트림에 쓴다 |
| 돌아온 뒤 응답을 읽기만 한다 |
첫 번째 "하면 안 된다"가 1절의 그림과 이어집니다. Engine의 밸브가 지나갈 때 이미 7편의 Mapper가 Host·Context·Wrapper를 정해 두었습니다. 밸브에서 URI를 바꿔도 다시 매핑되지 않습니다.
ValveBase — 밸브를 만들 때 물려받는 것
직접 밸브를 만들 때는 보통 ValveBase를 물려받고 invoke() 하나만 씁니다. next와 container 필드, 그리고 Lifecycle(시작·정지, 3편)과 JMX 등록을 이 클래스가 맡습니다(35행).
헷갈리기 쉬운 곳: super()만 부르면 비동기가 꺼집니다
ValveBase의 생성자는 둘입니다.
public ValveBase() {
this(false);
}
// ...
public ValveBase(boolean asyncSupported) {
this.asyncSupported = asyncSupported;
}
java/org/apache/catalina/valves/ValveBase.java 48–60행
인자 없는 생성자는 asyncSupported를 false 로 둡니다. 그리고 파이프라인의 비동기 지원 여부는 밸브 전부를 훑어 하나라도 false면 false입니다.
@Override
public boolean isAsyncSupported() {
Valve valve = (first != null) ? first : basic;
boolean supported = true;
while (supported && valve != null) {
supported = valve.isAsyncSupported();
valve = valve.getNext();
}
return supported;
}
java/org/apache/catalina/core/StandardPipeline.java 100–109행
1절에서 본 CoyoteAdapter 345행과 StandardEngineValve 66–68행이 이 값을 request.setAsyncSupported()에 넣습니다. Host·Context의 기본 밸브도 같은 일을 합니다. 그러니 ValveBase()를 물려받은 밸브 하나를 Engine에 걸면, 그 서버의 모든 서블릿이 @WebServlet(asyncSupported = true)여도 startAsync()가 거절됩니다. 기본 밸브 넷과 이 글에서 보는 ErrorReportValve, AccessLogValve는 모두 true를 넘기고 있습니다. 예제의 밸브들도 그래서 super(true)로 시작합니다. 이 함정은 소스로 확인한 것이고, 비동기 요청으로 직접 돌려 보지는 않았습니다. 비동기는 12편에서 이어집니다.
헷갈리기 쉬운 곳: 순서는 적은 순서뿐입니다
httpd의 훅은 모듈이 APR_HOOK_FIRST 같은 순서 값과 "어느 모듈보다 앞" 같은 조건으로 자리를 고릅니다(httpd 6편). Tomcat의 밸브에는 그런 것이 없습니다. 설정 파일에 적힌 순서대로 addValve()가 불리고, 그 순서가 곧 실행 순서입니다. 같은 컨테이너에 접근 로그 밸브와 주소를 바꾸는 밸브를 함께 걸 때는 적는 순서를 직접 맞춰야 합니다(7절).
또 하나, StandardPipeline의 설명 주석은 "요청을 처리하는 동안 addValve()/removeValve()가 불리지 않는다고 가정한다"고 적어 둡니다(42–44행). 연결 리스트를 잠그지 않고 걷기 때문입니다. 밸브는 기동할 때 붙이는 것입니다.
3. 밸브는 언제 붙나 — 설정, 그리고 Tomcat이 스스로 넣는 것
server.xml의 <Valve>
<Valve className="..."> 한 줄은 3편에서 본 Digester 규칙 세 개로 처리됩니다. className의 클래스를 만들고(addObjectCreate), 나머지 속성을 같은 이름의 setter로 넣고(addSetProperties — 예제의 name="host"가 setName("host")가 됩니다), 부모 컨테이너의 addValve()를 부릅니다(addSetNext).
digester.addObjectCreate(prefix + "Host/Valve", null, // MUST be specified in the element
"className");
digester.addSetProperties(prefix + "Host/Valve");
digester.addCallMethod(prefix + "Host/Valve/init-param", "addInitParam", 2);
digester.addCallParam(prefix + "Host/Valve/init-param/param-name", 0);
digester.addCallParam(prefix + "Host/Valve/init-param/param-value", 1);
digester.addSetNext(prefix + "Host/Valve", "addValve", "org.apache.catalina.Valve");
java/org/apache/catalina/startup/HostRuleSet.java 77–83행
여기서 불리는 addValve는 ContainerBase.addValve()이고, 안에서 pipeline.addValve(valve)를 부를 뿐입니다(918–921행). 주석에 "Digester를 위한 편의 메서드"라고 적혀 있습니다.
Tomcat이 스스로 넣는 밸브 둘
예제 1에서 내가 걸지 않은 밸브가 둘 있었습니다. 하나는 Host의 ErrorReportValve입니다. StandardHost가 시작할 때 넣습니다.
// Set error report valve
String errorValve = getErrorReportValveClass();
if ((errorValve != null) && (!errorValve.isEmpty())) {
try {
boolean found = false;
Valve[] valves = getPipeline().getValves();
for (Valve valve : valves) {
if (errorValve.equals(valve.getClass().getName())) {
found = true;
break;
}
}
if (!found) {
Valve valve = ErrorReportValve.class.getName().equals(errorValve) ? new ErrorReportValve() :
(Valve) Class.forName(errorValve).getConstructor().newInstance();
getPipeline().addValve(valve);
}
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
log.error(sm.getString("standardHost.invalidErrorReportValveClass", errorValve), t);
}
}
super.startInternal();
java/org/apache/catalina/core/StandardHost.java 785–807행
errorReportValveClass의 기본값은 org.apache.catalina.valves.ErrorReportValve입니다(146행). server.xml을 다 읽은 뒤, Host가 시작할 때 addValve()로 붙으므로 설정한 밸브들의 맨 뒤, 기본 밸브 바로 앞에 섭니다. 같은 클래스가 이미 있으면 넣지 않으므로, server.xml에 ErrorReportValve를 직접 적으면 그 자리와 그 속성이 쓰입니다. <Host errorReportValveClass="">처럼 빈 값을 주면 아예 넣지 않습니다.
다른 하나는 Context의 NonLoginAuthenticator입니다. web.xml에 <login-config>가 없어도 ContextConfig가 "NONE"이라는 가짜 로그인 설정을 넣고(341–346행), 인증 밸브가 따로 걸려 있지 않으면 Authenticators.properties에서 NONE에 맞는 클래스를 찾아 파이프라인에 붙입니다(395–398행). 주석은 HttpServletRequest.login()을 지원하려면 인증 밸브가 있어야 해서라고 설명합니다. 이 밸브는 Realm에 기대어 인증하므로, Realm이 하나도 없으면 ContextConfig가 contextConfig.missingRealm 오류를 남기고 그 웹앱은 시작하지 못합니다(354–358행). 예제는 기본 server.xml의 Engine에 걸린 Realm을 그대로 썼습니다. 인증도 밸브입니다. 이 밸브가 security-constraint를 어떻게 지키는지는 14편에서 봅니다.
기동할 때 파이프라인을 찍어 보면
예제 1의 TraceValve는 dump="true"면 자기가 시작할 때(startInternal()) 그 컨테이너의 파이프라인을 처음부터 끝까지 찍습니다.
===== startup: pipelines =====
pipeline of StandardHost[localhost]: TraceValve LogProbeValve AccessLogValve ErrorReportValve StandardHostValve
pipeline of StandardContext[/app]: TraceValve BlockValve NonLoginAuthenticator StandardContextValve
pipeline of StandardEngine[Catalina]: TraceValve TraceValve StandardEngineValve
- Host: server.xml에 적은 셋(
TraceValve,LogProbeValve, 기본 설정의AccessLogValve) 다음에ErrorReportValve, 그리고 기본 밸브입니다. - Context: app.xml에 적은 둘 다음에
NonLoginAuthenticator, 그리고 기본 밸브입니다. - 찍힌 순서가 Host → Context → Engine입니다. 바깥 컨테이너가 먼저일 것 같지만 그렇지 않습니다.
위 출력의 두 줄을 그대로 옮기고 밸브마다 출처를 적었습니다. 파란 칸은 설정에 적은 것, 노란 칸은 Tomcat이 시작할 때 스스로 넣은 것, 회색 칸은 기본 밸브입니다.
마지막 것은 ContainerBase.startInternal()의 순서 때문입니다.
// Start our child containers, if any
Container[] children = findChildren();
// ...
// Start the Valves in our pipeline (including the basic), if any
if (pipeline instanceof Lifecycle) {
((Lifecycle) pipeline).start();
}
setState(LifecycleState.STARTING);
java/org/apache/catalina/core/ContainerBase.java 728–759행
자식을 먼저 시작하고, 자기 파이프라인은 그 뒤에 시작합니다. 그래서 Engine의 밸브는 Host가 다 시작한 뒤에 시작합니다. 그런데 Host에는 기동할 때 아직 자식(Context)이 없습니다. 웹앱은 Host가 setState(STARTING)으로 START_EVENT를 낼 때 HostConfig가 그제야 배포하기 때문입니다(HostConfig.java 286행, 배포는 10편). 그래서 Host의 파이프라인(756행)이 Context보다 먼저 시작합니다.
4. 기본 밸브 넷이 하는 일
StandardEngineValve는 1절에서 봤습니다. 나머지 셋을 봅니다.
StandardHostValve — 웹앱의 세계로 넘어가는 자리
// Ask this Context to process this request. Requests that are
// already in error must have been routed here to check for
// application defined error pages so DO NOT forward them to the
// application for processing.
try {
if (!response.isErrorReportRequired()) {
context.getPipeline().getFirst().invoke(request, response);
}
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
container.getLogger().error(sm.getString("standardHostValve.exception", request.getRequestURI()), t);
// If a new error occurred while trying to report a previous
// error allow the original error to be reported.
if (!response.isErrorReportRequired()) {
request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, t);
throwable(request, response, t);
}
}
java/org/apache/catalina/core/StandardHostValve.java 109–126행
Context를 부르는 한 줄(115행) 앞뒤에 하는 일이 많습니다(78–169행).
- 스레드의 컨텍스트 클래스로더를 웹앱의 것으로 바꿉니다(
context.bind(), 99행). 여기서부터 아래의 Context 밸브, 필터, 서블릿은 웹앱의 클래스로더 아래에서 돕니다.finally에서unbind()로 되돌립니다(167행). 클래스로더는 4편에서 봤습니다. ServletRequestListener.requestInitialized()를 부릅니다(101행). 리스너가 예외를 던지면 요청은 거기서 끝납니다.- Context 파이프라인을 부르고, 돌아온 뒤 오류 상태면 웹앱의 web.xml에 적힌
<error-page>를 찾아 그리로forward합니다(142–154행,status()와throwable()). 웹앱이 정한 오류 페이지는 이 밸브가 맡습니다. 아무 오류 페이지도 없을 때 Tomcat 기본 화면을 그리는 것은 5절의ErrorReportValve입니다.
Context가 없으면(매핑된 웹앱이 없으면) 404를 보내고 돌아갑니다(81–88행).
StandardContextValve — WEB-INF를 막는 곳
// 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;
}
// Acknowledge the request
response.sendAcknowledgement(ContinueResponseTiming.IMMEDIATELY);
if (request.isAsyncSupported()) {
request.setAsyncSupported(wrapper.getPipeline().isAsyncSupported());
}
wrapper.getPipeline().getFirst().invoke(request, response);
java/org/apache/catalina/core/StandardContextValve.java 56–77행
/app/WEB-INF/web.xml을 요청하면 404가 나는 이유가 이 몇 줄입니다. 웹앱 안 경로(getRequestPathMB(), 컨텍스트 경로를 뺀 부분)가 /WEB-INF/나 /META-INF/로 시작하면 대소문자를 가리지 않고 막습니다. 주목할 것은 자리입니다. Context의 기본 밸브이니, 같은 Context에 건 밸브(인증 밸브 포함)를 모두 지난 뒤, 서블릿을 고르기 직전입니다. 그다음 Expect: 100-continue를 보낸 클라이언트에게 "계속 보내라"는 100 응답을 보낼 기회를 주고(72행), Wrapper 파이프라인으로 넘깁니다.
StandardWrapperValve — 서블릿 앞의 마지막 밸브
Wrapper 파이프라인에는 설정으로 걸 수 있는 밸브가 없으니, 사실상 이 기본 밸브 하나입니다. 하는 일은 서블릿 인스턴스를 빌리고(wrapper.allocate(), 113행), 필터 체인을 만들어(138행) 부르고(164행), 끝나면 돌려주는 것입니다(85–260행). 자세한 것은 9편에서 봅니다. 이 글에서는 하나만 기억해 둡니다. 서블릿이 던진 예외는 이 밸브가 잡습니다(169–215행의 catch들). 잡은 예외는 요청 속성 jakarta.servlet.error.exception에 넣고 상태를 (대개) 500으로 바꿀 뿐, 위로 다시 던지지 않습니다(293–297행). 5절의 예제에서 이것이 무슨 뜻인지 봅니다.
위 세 소절을 한 장에 모았습니다. ✗ 표시는 거기서 404를 보내고 다음 파이프라인을 부르지 않고 돌아가는 곳입니다.
5. 사슬 끊기와 오류 보고
다음을 부르지 않으면 거기서 끝납니다
밸브가 요청을 막는 방법은 하나입니다. getNext().invoke()를 부르지 않고 돌아가는 것입니다. 예제의 BlockValve가 그렇게 합니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
if (request.getRequestURI().endsWith("/blocked")) {
System.out.println(" BlockValve: 403, next valve is NOT called");
response.sendError(403);
return; // getNext().invoke() 를 부르지 않는다 — 사슬이 여기서 끊긴다
}
getNext().invoke(request, response);
}
[예제 ex/u08-valve-order/lab/BlockValve.java에서 줄임]
/app/blocked는 서블릿에도 매핑되어 있는 주소입니다. 결과입니다.
===== curl /app/blocked =====
HTTP 403
> engine-1 StandardEngine[Catalina] /app/blocked
> engine-2 StandardEngine[Catalina] /app/blocked
> host StandardHost[localhost] /app/blocked
> context StandardContext[/app] /app/blocked
BlockValve: 403, next valve is NOT called
< context status=403
< host status=403
< engine-2 status=403
< engine-1 status=403
----- body (title) -----
<title>HTTP Status 403 – Forbidden</title>
(이 출력과 아래 /app/boom·/nope 출력에서는 6절에서 볼 LogProbeValve.log() 줄을 뺐습니다.) 서블릿의 줄이 없습니다. BlockValve 뒤의 인증 밸브, StandardContextValve, Wrapper, 필터, 서블릿 모두 불리지 않았습니다. 그런데도 바깥 밸브들의 <는 전부 찍혔습니다. 끊긴 것은 안쪽으로 가는 길뿐이고, 이미 들어와 있던 밸브들은 평소처럼 자기 invoke()의 나머지를 마칩니다.
BlockValve는 sendError(403)만 했고 본문은 쓰지 않았습니다. 그런데 클라이언트는 HTTP Status 403 – Forbidden이라는 제목의 HTML을 받았습니다. 그것을 쓴 것이 Host의 ErrorReportValve입니다.
빨간 상자에서 안쪽 길이 끊깁니다. 바깥 밸브들은 돌아오는 길에 할 일을 그대로 합니다.
ErrorReportValve — 돌아오는 길에 일하는 밸브
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
// Perform the request
getNext().invoke(request, response);
if (response.isCommitted()) {
// ...
return;
}
Throwable throwable = (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
// ...
// One way or another, response.sendError() will have been called before
// execution reaches this point and suspended the response. Need to
// reverse that so this valve can write to the response.
response.setSuspended(false);
try {
report(request, response, throwable);
} catch (Throwable tt) {
ExceptionUtils.handleThrowable(tt);
}
}
java/org/apache/catalina/valves/ErrorReportValve.java 78–138행
이 밸브는 맨 먼저 다음 밸브를 부르고, 모든 일이 끝나 돌아온 뒤에 응답을 봅니다. 이미 클라이언트에게 나간 응답(isCommitted())이면 손댈 수 없으니 연결을 끊는 쪽으로 처리하고 돌아갑니다(84–108행). 그렇지 않으면 report()가 오류 화면을 씁니다. report()는 상태 코드가 400 미만이거나, 본문이 이미 조금이라도 쓰였거나, 이미 다른 곳에서 오류를 보고했으면 아무것도 하지 않습니다(183–185행).
sendError()를 부르면 응답이 "정지"(suspended)되어 이후 쓰기가 버려집니다. 그래서 오류 화면을 쓰기 전에 131행에서 정지를 풉니다. BlockValve가 sendError() 뒤에 무엇을 쓰려 했어도 클라이언트에게 가지 않았을 것이라는 뜻이기도 합니다.
이 밸브가 Host에 있다는 것이 중요합니다. Context 파이프라인 안에서 무슨 일이 있었든(밸브가 막았든, 서블릿이 오류를 냈든, 웹앱이 아예 없든) 돌아오는 길에 반드시 지나는 자리입니다.
서블릿이 예외를 던지면
/app/boom은 서블릿이 IllegalStateException을 던지는 주소입니다.
===== curl /app/boom =====
HTTP 500
> engine-1 StandardEngine[Catalina] /app/boom
> engine-2 StandardEngine[Catalina] /app/boom
> host StandardHost[localhost] /app/boom
> context StandardContext[/app] /app/boom
servlet: throwing IllegalStateException
< context status=500
< host status=500
< engine-2 status=500
< engine-1 status=500
----- body (title) -----
<title>HTTP Status 500 – Internal Server Error</title>
TraceValve는 getNext().invoke()를 try로 감싸지 않았는데도 <가 모두 찍혔습니다. 예외가 밸브까지 올라오지 않았기 때문입니다. 4절에서 본 대로 StandardWrapperValve가 잡아 상태를 500으로 바꾸고 예외를 요청 속성에 넣었고, ErrorReportValve는 그 속성(위 코드 110행)을 읽어 500 화면을 그렸습니다. 예외의 스택 트레이스는 StandardWrapperValve가 로그로 남깁니다(210–215행).
웹앱이 없으면
===== curl /nope =====
HTTP 404
> engine-1 StandardEngine[Catalina] /nope
> engine-2 StandardEngine[Catalina] /nope
> host StandardHost[localhost] /nope
< host status=404
< engine-2 status=404
< engine-1 status=404
이 서버에는 app 하나만 있고 루트 웹앱(ROOT)이 없어서, /nope은 어느 Context에도 매핑되지 않습니다. StandardHostValve가 request.getContext()에서 null을 받아 404를 보냈고, Context의 TraceValve는 아예 불리지 않았습니다. Context에 건 밸브는 그 웹앱으로 매핑된 요청만 지납니다. 모든 요청에 무언가를 하려면 Host나 Engine에 겁니다.
헷갈리기 쉬운 곳
- 밸브의
try/catch로는 서블릿의 예외를 잡을 수 없습니다. 위에서 본 대로StandardWrapperValve가 먼저 잡습니다. 밸브가 볼 수 있는 것은 상태 코드와 요청 속성jakarta.servlet.error.exception입니다. - 웹앱의
<error-page>와 Tomcat의 기본 오류 화면은 다른 밸브가 그립니다. 앞은StandardHostValve, 뒤는ErrorReportValve입니다. 웹앱이 오류 페이지로forward해 본문을 썼으면,ErrorReportValve의report()는 "이미 썼다"(getContentWritten() > 0)거나 "이미 보고했다"는 조건에 걸려 아무것도 하지 않습니다.
6. AccessLogValve — 로그는 사슬이 끝난 뒤에 씁니다
기본 server.xml의 Host에는 이 밸브가 하나 걸려 있습니다.
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
prefix="localhost_access_log" suffix=".txt"
pattern="%h %l %u %t "%r" %s %b" />
%h는 클라이언트 주소, %t는 시각, %r은 요청 줄, %s는 상태 코드, %b는 본문 바이트 수입니다. 이 패턴은 pattern="common"과 같습니다(setPattern()이 common과 combined를 정해진 패턴으로 바꿉니다, AbstractAccessLogValve.java 661–670행).
그러면 이 밸브의 invoke()가 로그를 쓸까요. 아닙니다.
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
if (tlsAttributeRequired) {
// The log pattern uses TLS attributes. Ensure these are populated
// before the request is processed because with NIO2 it is possible
// for the connection to be closed (and the TLS info lost) before
// the access log requests the TLS info. Requesting it now causes it
// to be cached in the request.
request.getAttribute(Globals.CERTIFICATES_ATTR);
}
if (cachedElements != null) {
for (CachedElement element : cachedElements) {
element.cache(request);
}
}
getNext().invoke(request, response);
}
java/org/apache/catalina/valves/AbstractAccessLogValve.java 762–778행
나중에 못 읽을 수도 있는 값 몇 개를 미리 읽어 두고(cache()) 다음 밸브로 넘길 뿐입니다. 돌아온 뒤에도 아무것도 하지 않습니다. 로그 한 줄을 만드는 것은 따로 있는 log(Request, Response, long) 메서드이고(782–804행), 이것은 AccessLog 인터페이스의 메서드입니다. AccessLogValve는 밸브이면서 AccessLog이기도 합니다(AbstractAccessLogValve 129행).
누가 log()를 부르나
예제 1의 LogProbeValve는 invoke()에서는 그냥 지나가고, log()가 불리면 자기를 부른 Tomcat 메서드를 다섯 개 찍습니다.
< context status=200
< host status=200
< engine-2 status=200
< engine-1 status=200
LogProbeValve.log() status=200 called from:
at org.apache.catalina.core.AccessLogAdapter.log:58
at org.apache.catalina.core.ContainerBase.logAccess:867
at org.apache.catalina.core.ContainerBase.logAccess:874
at org.apache.catalina.connector.CoyoteAdapter.service:409
at org.apache.coyote.http11.Http11Processor.service:404
(/app/hello 출력의 뒷부분입니다.) log()는 모든 밸브의 <가 찍힌 뒤, 곧 파이프라인 호출이 완전히 끝난 뒤에 CoyoteAdapter.service()의 409행에서 불렸습니다. 그 자리입니다.
// Access log
if (!async && postParseSuccess) {
// Log only if processing was invoked.
// If postParseRequest() failed, it has already logged it.
Context context = request.getContext();
Host host = request.getHost();
// ...
long time = System.nanoTime() - req.getStartTimeNanos();
if (context != null) {
context.logAccess(request, response, time, false);
} else if (response.isError()) {
if (host != null) {
host.logAccess(request, response, time, false);
} else {
connector.getService().getContainer().logAccess(request, response, time, false);
}
}
}
java/org/apache/catalina/connector/CoyoteAdapter.java 393–417행
이 코드는 service()의 finally 블록 안에 있고, 그 앞의 try에서 이미 response.finishResponse()로 응답을 다 내보냈습니다(375–376행). 그러니 접근 로그는 응답이 나간 뒤에 씁니다. 그래야 %s(최종 상태 코드)와 %b(실제로 나간 바이트 수)를 알 수 있습니다. 비동기 요청은 여기서 쓰지 않고, 비동기 처리가 끝날 때 씁니다(!async 조건).
logAccess()는 Context에서 시작해 부모로 올라갑니다.
@Override
public void logAccess(Request request, Response response, long time, boolean useDefault) {
boolean logged = false;
if (getAccessLog() != null) {
getAccessLog().log(request, response, time);
logged = true;
}
if (getParent() != null) {
// No need to use default logger once request/response has been logged
// once
getParent().logAccess(request, response, time, (useDefault && !logged));
}
}
java/org/apache/catalina/core/ContainerBase.java 861–876행
getAccessLog()는 처음 불릴 때 자기 파이프라인의 밸브를 훑어 AccessLog를 구현한 것을 모두 모읍니다(878–901행). 하나든 여럿이든 AccessLogAdapter로 묶어, log()가 불리면 하나씩 부릅니다. 스택의 ContainerBase.logAccess가 두 번(874행 → 867행)인 것은 Context에서 AccessLog를 찾지 못해 부모인 Host로 올라가, Host에서 찾았기 때문입니다.
밸브의 invoke()는 준비만 하고, 실제 기록은 파이프라인이 다 끝난 뒤 컨테이너를 거슬러 올라가며 합니다.
/nope처럼 Context가 없는 요청도 로그에 남습니다. 위 코드의 else if (response.isError()) 쪽, 412행의 host.logAccess()입니다. 예제 출력에서도 /nope의 log()는 CoyoteAdapter.service:412에서 불렸습니다. 예제가 끝난 뒤 기본 AccessLogValve가 남긴 파일입니다.
127.0.0.1 - - [24/Oct/2026:13:41:13 +0000] "GET /app/hello HTTP/1.1" 200 6
127.0.0.1 - - [24/Oct/2026:13:41:14 +0000] "GET /app/hello HTTP/1.1" 200 6
127.0.0.1 - - [24/Oct/2026:13:41:15 +0000] "GET /app/blocked HTTP/1.1" 403 625
127.0.0.1 - - [24/Oct/2026:13:41:15 +0000] "GET /app/boom HTTP/1.1" 500 1099
127.0.0.1 - - [24/Oct/2026:13:41:16 +0000] "GET /nope HTTP/1.1" 404 689
시각은 실행마다 다릅니다. 첫 줄은 run.sh가 Tomcat이 다 떴는지 확인하려고 보낸 요청입니다. 403·500·404 줄의 바이트 수(625, 1099, 689)는 BlockValve나 서블릿이 쓴 것이 아니라 ErrorReportValve가 쓴 오류 화면의 크기입니다. 로그가 응답이 다 나간 뒤에 쓰인다는 것이 숫자로도 보입니다.
헷갈리기 쉬운 곳
AccessLogValve의 위치는 "어느 컨테이너냐"만 뜻이 있습니다. 같은 파이프라인 안에서 앞에 두든 뒤에 두든 기록하는 때는 같습니다. 다만 Context에 둔 접근 로그는 그 웹앱으로 매핑된 요청만 기록합니다.AccessLog가 하나도 없을 때를 위한 길도 있습니다.StandardEngine.logAccess()는 Engine에 접근 로그가 없으면 기본 Host, 그다음 그 Host의 ROOT 웹앱의 접근 로그를 대신 씁니다(StandardEngine.java212–263행). 매핑 전에 실패한 요청(useDefault가true로 들어오는 경우)을 위한 것입니다.
접근 로그 파일을 날짜별로 바꾸는 일(rotate()), 버퍼링(buffered, 기본 true, AccessLogValve.java 107행), 패턴 문자 하나하나는 이 글에서 다루지 않습니다. 로깅 전체는 16편에서 봅니다.
7. RemoteIpValve — 바꾸고, 되돌려 놓는 밸브
Tomcat 앞에 nginx나 httpd 같은 리버스 프록시를 두면, Tomcat이 보는 클라이언트 주소는 언제나 프록시의 주소입니다. 프록시는 원래 주소를 X-Forwarded-For 헤더에 적어 보내고(httpd 14편에서 mod_proxy가 이 헤더를 붙이는 코드를 봤습니다), Tomcat 쪽에서 그 헤더를 믿고 주소를 바꿔 주는 것이 RemoteIpValve입니다.
동작은 이렇습니다. 연결한 쪽 주소가 믿을 만한 프록시(internalProxies, 기본값은 127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16 같은 사설 주소 대역, 365–367행)이면, X-Forwarded-For를 오른쪽부터 읽으며 믿을 만한 프록시 주소를 건너뛰고, 처음 나온 그렇지 않은 주소를 클라이언트로 정합니다(605–622행). 그리고 요청의 remoteAddr를 바꿉니다(634행).
예제 2: 서블릿과 접근 로그가 다른 주소를 봅니다
Engine에 RemoteIpValve(기본값 그대로)를, Host에 설정 하나만 다른 AccessLogValve 둘을 걸었습니다.
<Engine name="Catalina" defaultHost="localhost">
<Valve className="org.apache.catalina.valves.RemoteIpValve"/>
<!-- ... -->
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">
<Valve className="org.apache.catalina.valves.AccessLogValve" prefix="plain" suffix=".txt" pattern="%a %h %U xff="%{X-Forwarded-For}i" %s"/>
<Valve className="org.apache.catalina.valves.AccessLogValve" prefix="attr" suffix=".txt" requestAttributesEnabled="true" pattern="%a %h %U xff="%{X-Forwarded-For}i" %s"/>
</Host>
</Engine>
%a는 클라이언트 IP, %U는 요청 경로, %{X-Forwarded-For}i는 그 요청 헤더의 값입니다. 웹앱의 서블릿은 getRemoteAddr()와 X-Forwarded-For 헤더를 그대로 응답합니다. 같은 컨테이너 안의 curl이 보내므로 연결한 쪽은 127.0.0.1이고, 프록시를 두 단 지나온 것처럼 헤더를 붙였습니다(ex/u08-remoteip-accesslog/run.sh).
===== curl -H 'X-Forwarded-For: 203.0.113.7, 10.0.0.5' /app/who =====
servlet sees remoteAddr=203.0.113.7 X-Forwarded-For=null
===== logs/plain.*.txt =====
127.0.0.1 127.0.0.1 /app/who xff="203.0.113.7, 10.0.0.5" 200
===== logs/attr.*.txt =====
203.0.113.7 203.0.113.7 /app/who xff="203.0.113.7, 10.0.0.5" 200
- 서블릿은
203.0.113.7을 봅니다. 오른쪽의10.0.0.5는 사설 주소라 프록시로 보고 건너뛰었습니다. 다 쓴 헤더는 지워져 서블릿에는null입니다(661–662행). - 같은 요청인데
plain로그에는127.0.0.1이 찍혔습니다. 헤더도 지워지기 전의 원래 값입니다. requestAttributesEnabled="true"를 준attr로그만203.0.113.7을 찍었습니다.
이유는 RemoteIpValve.invoke()의 끝에 있습니다.
try {
getNext().invoke(request, response);
} finally {
if (!request.isAsync()) {
request.setRemoteAddr(originalRemoteAddr);
request.setRemoteHost(originalRemoteHost);
// ...
}
}
java/org/apache/catalina/valves/RemoteIpValve.java 733–759행
이 밸브는 다음 밸브가 끝나면 바꾼 것을 전부 원래대로 되돌려 놓습니다(주소, 호스트, scheme, 포트, 헤더까지). 그런데 6절에서 본 대로 접근 로그는 파이프라인이 다 끝난 뒤에 씁니다. 그때 요청의 remoteAddr는 이미 127.0.0.1로 돌아와 있습니다.
그래서 RemoteIpValve는 되돌리기 전에 바꾼 값을 요청 속성에도 남겨 둡니다(org.apache.catalina.AccessLog.RemoteAddr 등, 725–732행). 요청 속성은 되돌리지 않습니다. 접근 로그 쪽은 requestAttributesEnabled가 켜져 있을 때만 그 속성을 읽습니다.
if (requestAttributesEnabled) {
Object addr = request.getAttribute(REMOTE_ADDR_ATTRIBUTE);
if (addr == null) {
value = request.getRemoteAddr();
} else {
value = addr.toString();
}
} else {
value = request.getRemoteAddr();
}
java/org/apache/catalina/valves/AbstractAccessLogValve.java 980–989행
AccessLogValve의 requestAttributesEnabled 기본값은 false입니다(555행). 반대로 RemoteIpValve 쪽의 같은 이름 설정은 기본이 true라서(377행) 속성은 늘 남습니다. 프록시 뒤의 Tomcat에서 접근 로그에 진짜 클라이언트 주소를 남기려면 AccessLogValve에 requestAttributesEnabled="true"를 줘야 합니다.
밸브가 바꾼 값은 그 밸브의 invoke()가 끝날 때까지만 삽니다. 그 뒤에 일하는 것은 요청 속성으로 건네받습니다.
헷갈리기 쉬운 곳
- 왜 굳이 되돌릴까요. 2절의 약속 가운데 "이미 처리 방향을 정하는 데 쓴 값을 바꾸지 않는다"와 같은 생각입니다. 밸브가 바꾼 것은 자기 안쪽에만 보이게 하고, 바깥(앞 밸브들과
CoyoteAdapter)에는 원래 요청을 돌려줍니다. 소스로 확인한 사실은 되돌린다는 것까지이고, 되돌리는 이유를 소스 주석이 직접 적어 두지는 않았습니다. RemoteIpValve는 앞쪽에 겁니다. 이 밸브보다 앞에 선 밸브는 프록시 주소를 봅니다. 주소로 막는 밸브(RemoteCIDRValve등)가 진짜 클라이언트 주소로 판단하게 하려면RemoteIpValve가 그보다 앞이어야 합니다. 2절에서 본 대로 순서는 적은 순서뿐입니다.- 11.0.x와 다릅니다. 11.0.x 브랜치의
RemoteIpValve는internalProxies·trustedProxies에 CIDR 목록 또는 정규식을 받습니다. 기준 커밋(main)에서는 정규식 지원이 빠지고 CIDR 목록만 받습니다(설명 주석과setInternalProxies(), 869–880행). 11.0.x에서 정규식으로 적어 두었다면 기준 커밋에서는 CIDR 목록으로 바꿔 적어야 합니다. 이 글에서 인용한 나머지 파일(Pipeline,Valve,StandardPipeline, 기본 밸브 넷,ValveBase,ContainerBase)은 11.0.x 브랜치와 내용이 같습니다.
이 글에서 뺀 것
- 밸브의 백그라운드 작업(
Valve.backgroundProcess(), 컨테이너가 주기적으로 부른다):ContainerBase.java947–955행 - 밸브의 JMX 이름(
type=Valve,...,seq=N):ValveBase.java189–233행, JMX 전체는 16편 - 기본 밸브 바꾸기(
setBasic())와 밸브 빼기(removeValve()):StandardPipeline.java206–372행 ErrorReportValve가 그리는 HTML과showReport·showServerInfo설정,<Host>단위 오류 페이지(errorPageSupport):valves/ErrorReportValve.java- 그 밖의 밸브들:
RemoteCIDRValve·RemoteHostValve(주소·호스트 이름으로 막기),StuckThreadDetectionValve(오래 걸리는 요청 찾기),SemaphoreValve(동시 요청 수 제한),HealthCheckValve,JsonAccessLogValve,rewrite/RewriteValve— 모두java/org/apache/catalina/valves/ - 웹앱의
<error-page>로forward하는 자세한 과정:StandardHostValve.java182–394행
정리
| 개념 | 한 줄 요약 |
|---|---|
| 파이프라인 | 컨테이너마다 하나. 밸브로는 first와 basic만 들고, 순서는 밸브의 next가 기억하는 연결 리스트 |
| 들어가는 문 | CoyoteAdapter가 Engine 파이프라인의 getFirst().invoke()를 부른다. Pipeline에는 invoke()가 없다 |
| 기본 밸브 | 맨 끝에 늘 있다. 매핑된 Host·Context·Wrapper를 꺼내 그 파이프라인의 첫 밸브를 부른다 |
<Valve> |
Digester가 만들어 addValve(). 기본 밸브 바로 앞에 붙으니 적은 순서가 곧 실행 순서 |
| 스스로 붙는 밸브 | Host 시작 때 ErrorReportValve, Context 설정 때 인증 밸브(NonLoginAuthenticator) |
| 사슬 끊기 | getNext().invoke()를 부르지 않고 돌아간다. 바깥 밸브들은 돌아오는 길을 그대로 간다 |
StandardHostValve |
클래스로더를 웹앱 것으로 바꾸고, 웹앱의 <error-page>를 처리한다 |
StandardContextValve |
/WEB-INF/, /META-INF/를 404로 막는다 |
ErrorReportValve |
다음을 먼저 부르고, 돌아와서 오류 상태면 기본 오류 화면을 쓴다 |
| 서블릿 예외 | StandardWrapperValve가 잡아 500과 요청 속성으로 바꾼다. 밸브까지 올라오지 않는다 |
AccessLogValve |
invoke()는 준비만. 기록은 응답이 나간 뒤 CoyoteAdapter가 logAccess()로 |
RemoteIpValve |
주소를 바꾸고 끝나면 되돌린다. 접근 로그에 남기려면 requestAttributesEnabled="true" |
ValveBase() |
인자 없는 생성자는 비동기 지원을 끈다. 밸브 하나가 파이프라인 전체의 비동기를 끈다 |
다음에 볼 것
요청은 이제 Wrapper의 기본 밸브 StandardWrapperValve까지 왔습니다. 이 밸브는 서블릿 인스턴스를 빌리고, 이 요청에 맞는 필터들을 모아 ApplicationFilterChain을 만들어 부릅니다. 필터 체인은 밸브와 닮았지만 다른 점도 있습니다. 밸브는 컨테이너에, 필터는 웹앱의 web.xml에 속하고, 필터 체인은 요청마다 새로 만듭니다. 9편: 필터 체인과 서블릿 호출 — service()가 불리기까지에서 이어집니다.
앞 연재에서 닮은 것을 다뤘습니다. Spring 23편의 HandlerInterceptor는 앞(preHandle)·뒤·끝이 서로 다른 메서드로 나뉘어 있어서, DispatcherServlet이 "누구까지 preHandle을 통과했나"를 번호로 기억해 짝을 맞춰 줍니다. 밸브는 한 메서드 안에서 getNext().invoke()를 감싸서 부르므로 그런 장부가 필요 없습니다. 예제 1의 >와 <가 저절로 짝이 맞았던 이유입니다. 이 모양은 서블릿 필터의 chain.doFilter()(Spring 20편)와 같습니다. httpd 6편의 훅은 코어가 등록된 함수들을 반복문으로 부르고, 함수가 돌려준 값(OK, DECLINED)으로 다음을 부를지 정합니다. Tomcat에서는 그 결정을 밸브 자신이 "다음을 부르느냐"로 합니다. 접근 로그를 요청이 다 끝난 뒤에 쓰는 것은 httpd도 같습니다(httpd 16편의 log_transaction 훅).