namalzi

← 글 목록

Tomcat 소스 파헤치기 8: 파이프라인과 밸브 — 컨테이너마다 걸린 처리 사슬

Tomcat · 게시

server.xml의 <Valve> 한 줄은 소스에서 무엇이 될까요. Engine·Host·Context·Wrapper가 저마다 가진 파이프라인, 그 끝에서 다음 컨테이너를 고르는 기본 밸브 넷, 다음 밸브를 부르지 않아 요청을 끊는 법, 오류 페이지를 그리는 ErrorReportValve, 응답이 끝난 뒤에야 쓰이는 접근 로그와 RemoteIpValve의 함정까지, 직접 쓴 밸브를 세 컨테이너에 걸어 소스와 함께 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 여덟 번째 글입니다. 7편에서는 CoyoteAdapter가 요청 URI를 정리하고, Mapper로 이 요청을 맡을 Host·Context·Wrapper(서블릿)를 미리 정해 두는 데까지 봤습니다. 정해 두기만 했지 아직 아무도 부르지 않았습니다. 이번 글은 그다음, CoyoteAdapter가 컨테이너를 부른 순간부터 서블릿 앞에 닿을 때까지 요청이 지나는 길을 따라갑니다.

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

항목 내용
대상 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를 꺼내 똑같이 합니다. 그래서 전체 모양은 이렇습니다.

요청이 지나는 파이프라인 넷. CoyoteAdapter.service가 Engine 파이프라인의 첫 밸브를 부른다. Engine의 밸브들을 지나 기본 밸브 StandardEngineValve가 Mapper가 미리 고른 Host로 넘기고, Host의 밸브들(AccessLogValve, ErrorReportValve)을 지나 StandardHostValve가 고른 Context로 넘기며 클래스로더를 바꾼다. Context의 밸브들(context.xml의 Valve, 인증 밸브)을 지나 StandardContextValve가 WEB-INF를 막고 고른 Wrapper로 넘긴다. StandardWrapperValve가 서블릿을 빌려 필터 체인을 만들고, 필터들을 지나 Servlet.service가 불린다. 파란 상자는 설정으로 거는 밸브, 색 없는 상자는 컨테이너가 스스로 다는 기본 밸브입니다.

기본 밸브는 각 컨테이너의 생성자에서 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

읽을 것이 세 가지 있습니다.

StandardWrapperValve 164행은 filterChain.doFilter(...)입니다. 그 위로 필터들과 서블릿의 service()가 이어지는데, 이 예제는 Catalina의 invoke/service만 걸러 찍어서 보이지 않습니다. 그 구간은 9편에서 봅니다.

헷갈리기 쉬운 곳

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에 닿습니다.

StandardPipeline의 연결 리스트. 파이프라인은 밸브를 가리키는 필드로 first와 basic 둘만 가진다. first는 밸브 A를, A의 next는 밸브 B를, B의 next는 원래 기본 밸브를 가리킨다. addValve(C)는 next가 basic인 밸브(B)를 찾아, B의 next를 C로, C의 next를 기본 밸브로 바꾼다. 기본 밸브의 next는 null이고, 거기서 다음 컨테이너의 파이프라인으로 넘어간다. 점선이 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

예제 1에서 기동할 때 찍힌 파이프라인 둘과, 밸브마다 누가 넣었는지. Host 파이프라인은 server.xml의 Valve로 적은 TraceValve, LogProbeValve, AccessLogValve 다음에 StandardHost.startInternal()이 넣은 ErrorReportValve, 맨 끝에 기본 밸브 StandardHostValve다. Context 파이프라인은 app.xml의 Valve로 적은 TraceValve, BlockValve 다음에 ContextConfig가 넣은 NonLoginAuthenticator, 맨 끝에 기본 밸브 StandardContextValve다. 위 출력의 두 줄을 그대로 옮기고 밸브마다 출처를 적었습니다. 파란 칸은 설정에 적은 것, 노란 칸은 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행).

  1. 스레드의 컨텍스트 클래스로더를 웹앱의 것으로 바꿉니다(context.bind(), 99행). 여기서부터 아래의 Context 밸브, 필터, 서블릿은 웹앱의 클래스로더 아래에서 돕니다. finally에서 unbind()로 되돌립니다(167행). 클래스로더는 4편에서 봤습니다.
  2. ServletRequestListener.requestInitialized()를 부릅니다(101행). 리스너가 예외를 던지면 요청은 거기서 끝납니다.
  3. 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절의 예제에서 이것이 무슨 뜻인지 봅니다.

기본 밸브 셋이 하는 일. StandardHostValve는 Host 파이프라인의 끝에서 request.getContext()가 null이면 404를 보내고 끝낸다. 아니면 ① context.bind()로 스레드의 클래스로더를 웹앱의 것으로 바꾸고, ② ServletRequestListener.requestInitialized()를 부르고, ③ Context 파이프라인의 첫 밸브를 부르고, ④ 돌아와서 오류 상태면 web.xml의 error-page로 forward한 뒤 finally에서 unbind()한다. StandardContextValve는 Context 파이프라인의 끝에서 웹앱 안 경로가 /WEB-INF/나 /META-INF/(대소문자 무시)면 404, request.getWrapper()가 없거나 unavailable이면 404로 끝낸다. 아니면 ① sendAcknowledgement()로 100-continue를 보낼 기회를 주고 ② Wrapper 파이프라인의 첫 밸브를 부른다. StandardWrapperValve는 서블릿 앞의 마지막 밸브로, ① wrapper.allocate()로 서블릿을 빌리고 ② 필터 체인을 만들고 ③ filterChain.doFilter()로 필터들과 서블릿을 부르며, ④ 서블릿이 던진 예외를 잡아 jakarta.servlet.error.exception 속성에 넣고 상태를 대개 500으로 바꿀 뿐 위로 다시 던지지 않는다. 위 세 소절을 한 장에 모았습니다. ✗ 표시는 거기서 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입니다.

BlockValve가 사슬을 끊을 때. Engine의 TraceValve가 다음 밸브를 부르기 전에 >를 찍고, Host의 ErrorReportValve는 먼저 다음 밸브를 부른다. Context의 BlockValve는 주소가 /blocked면 sendError(403) 후 return해 다음 밸브를 부르지 않으므로 인증 밸브와 서블릿까지 가지 않는다. 그 밖의 주소는 서블릿까지 간다(그림에서는 따라가지 않는다). /blocked였다면 ErrorReportValve로 돌아왔을 때 상태가 403이고 본문은 비어 있어서, report()가 HTTP Status 403 HTML을 쓴다. 그다음 TraceValve로 돌아와 <를 찍는다. 빨간 상자에서 안쪽 길이 끊깁니다. 바깥 밸브들은 돌아오는 길에 할 일을 그대로 합니다.

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에 겁니다.

헷갈리기 쉬운 곳

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 &quot;%r&quot; %s %b" />

conf/server.xml 147–149행

%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에서 찾았기 때문입니다.

접근 로그가 쓰이는 때. CoyoteAdapter.service가 파이프라인을 부르는 동안 AccessLogValve.invoke는 값 몇 개를 미리 읽고 지나간다. finishResponse로 응답을 다 내보낸 뒤 finally 블록에서 Context가 있으면 context.logAccess를, 없으면 Host나 Engine의 것을 부른다. ContainerBase.logAccess는 getAccessLog로 처음 한 번 파이프라인을 훑어 AccessLog인 밸브를 모으고, AccessLogAdapter.log가 모은 밸브마다 log를 불러 AccessLogValve.log가 패턴대로 한 줄을 만들어 파일에 쓴다. 그리고 getParent로 Host, Engine의 logAccess도 불러 그 컨테이너의 AccessLog도 쓴다. 밸브의 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가 쓴 오류 화면의 크기입니다. 로그가 응답이 다 나간 뒤에 쓰인다는 것이 숫자로도 보입니다.

헷갈리기 쉬운 곳

접근 로그 파일을 날짜별로 바꾸는 일(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=&quot;%{X-Forwarded-For}i&quot; %s"/>
    <Valve className="org.apache.catalina.valves.AccessLogValve" prefix="attr" suffix=".txt" requestAttributesEnabled="true" pattern="%a %h %U xff=&quot;%{X-Forwarded-For}i&quot; %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

이유는 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"를 줘야 합니다.

RemoteIpValve와 접근 로그. 요청이 도착하면 remoteAddr는 프록시인 127.0.0.1이고 X-Forwarded-For는 203.0.113.7, 10.0.0.5다. RemoteIpValve.invoke가 원래 값을 지역 변수에 적어 두고 remoteAddr를 203.0.113.7로 바꾸며 요청 속성 AccessLog.RemoteAddr에도 같은 값을 넣는다. 서블릿의 getRemoteAddr는 203.0.113.7이다. finally 블록이 remoteAddr를 127.0.0.1로 되돌린다. 파이프라인이 끝난 뒤 AccessLogValve.log가 불리는데, requestAttributesEnabled가 기본값 false면 %a는 127.0.0.1이고, true면 요청 속성을 읽어 203.0.113.7이다. 밸브가 바꾼 값은 그 밸브의 invoke()가 끝날 때까지만 삽니다. 그 뒤에 일하는 것은 요청 속성으로 건네받습니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

정리

개념 한 줄 요약
파이프라인 컨테이너마다 하나. 밸브로는 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 훅).

다른 글