Deep Dive /Java Web /3

3. CoyoteAdapter → Pipeline → ApplicationFilterChain

CoyoteAdapter에서 Valve 파이프라인을 거쳐 최종적으로 ApplicationFilterChain에 도달하고, doFilter()로 필터 체인 실행을 시작하는 단계입니다.

여기서 중요한 점은 Spring Security가 Spring MVC 앞단이 아니라, 바로 이 ApplicationFilterChain 내부 필터 중 하나로 동작한다는 것입니다. 즉 Security 검증이 먼저 끝나야 DispatcherServlet으로 진입할 수 있습니다.


왜 Valve 파이프라인이 필요한가

요청이 거쳐야 할 결정 지점들

HTTP 요청이 서블릿에 도달하기 전에, 먼저 결정해야 할 것들이 있습니다.

이 요청은 어느 호스트(가상호스트)로 갈까? 
    → Host 선택 (StandardEngineValve)
        ↓
이 호스트 안에서 어느 웹 앱(Context)로 갈까?
    → Context 선택 (StandardHostValve)
        ↓
이 Context 안에서 어느 서블릿(Wrapper)로 갈까?
    → Servlet 선택 (StandardContextValve)
        ↓
요청이 이 서블릿에 맞는가? (URL 접근 검증)
    → 보안 검사, 필터 체인 구성 (StandardWrapperValve)
        ↓
필터들을 순서대로 거친 후
    → 서블릿 실행 (ApplicationFilterChain)

이렇게 계층화된 라우팅 결정을 해야 하는 이유는 다중 테넌시(Multi-Tenancy) 때문입니다.

  • 같은 Tomcat 인스턴스 안에 여러 개의 가상호스트(예: example.com, test.com) 가 동시에 실행됩니다.
  • 같은 호스트 안에 여러 개의 웹 앱(Context)이 독립적으로 실행됩니다.
  • 같은 웹 앱 안에 여러 개의 서블릿/JSP가 있습니다.

왜 Chain of Responsibility 패턴(Valve)을 사용했는가

// CoyoteAdapter.service() - Tomcat 소스
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);

각 라우팅 결정을 별도의 클래스(StandardEngineValve, StandardHostValve, …)로 분리해서:

  1. 책임 분리: Engine은 Host 선택만, Host는 Context 선택만 담당
  2. 확장성: 새로운 Valve를 추가해도 기존 코드 수정 없음
  3. 테스트 용이: 각 Valve를 독립적으로 테스트 가능
  4. 재사용 가능성: 다른 프로토콜 핸들러(HTTP/2, AJP)도 같은 파이프라인 사용

Valve 파이프라인의 구조

파이프라인 설계도

┌─────────────────────────────────────────────────────────────┐
│ CoyoteAdapter.service()                                     │
│   postParseRequest(req, request, res, response)             │
│   pipeline.getFirst().invoke()  ← 체인 시작                │
└──────────────────────┬──────────────────────────────────────┘
                       │
       ┌───────────────▼──────────────┐
       │  StandardEngineValve         │
       │  · Host 선택                 │
       │  · valve.next.invoke() 호출  │
       └───────────────┬──────────────┘
                       │
       ┌───────────────▼──────────────┐
       │  StandardHostValve           │
       │  · Context 선택              │
       │  · ClassLoader 캐싱          │
       │  · valve.next.invoke() 호출  │
       └───────────────┬──────────────┘
                       │
       ┌───────────────▼──────────────┐
       │  StandardContextValve        │
       │  · Wrapper 선택              │
       │  · WEB-INF 보안 검사        │
       │  · valve.next.invoke() 호출  │
       └───────────────┬──────────────┘
                       │
       ┌───────────────▼──────────────┐
       │  StandardWrapperValve        │
       │  · 필터 체인 생성             │
       │  · Servlet 할당              │
       │  · filterChain.doFilter()    │
       │  · 필터 체인 반납(풀링)      │
       └─────────────────────────────┘

Valve 인터페이스

public interface Valve {
    public void invoke(Request request, Response response) throws IOException, ServletException;
    public Valve getNext();
    public void setNext(Valve valve);
}

각 Valve는 자신의 책임을 수행한 후 next.invoke() 를 호출합니다. 마지막 Valve(StandardWrapperValve)에서만 실제 요청 처리(필터 체인 → 서블릿)가 시작됩니다.


각 Valve의 역할

1️⃣ StandardEngineValve - 가상호스트 라우팅

// 소스: catalina/core/StandardEngineValve.java
@Override
public final void invoke(Request request, Response response) 
        throws IOException, ServletException {
    
    // 요청의 Host 헤더를 보고 어느 Host로 라우팅할지 결정
    Host host = request.getHost();
    if (host == null) {
        response.sendError(400);
        return;
    }
    host.getPipeline().getFirst().invoke(request, response);
}

역할:

  • HTTP Host 헤더 파싱: Host: example.com:8080
  • 가상호스트 검색
  • 해당 Host의 파이프라인으로 위임

설계 의도:

  • 같은 Tomcat 인스턴스가 여러 도메인 서빙 가능
  • DNS 라운드 로빈으로 여러 도메인을 한 IP로 지적 가능

2️⃣ StandardHostValve - 웹 앱(Context) 라우팅

// 소스: catalina/core/StandardHostValve.java
@Override
public final void invoke(Request request, Response response) 
        throws IOException, ServletException {
    
    Context context = request.getContext();
    if (context == null) {
        response.sendError(404);
        return;
    }
    
    // ClassLoader를 스레드 로컬에 캐싱
    ClassLoader oldCCL = Thread.currentThread().getContextClassLoader();
    try {
        ClassLoader newCCL = context.getLoader().getClassLoader();
        Thread.currentThread().setContextClassLoader(newCCL);
        
        context.getPipeline().getFirst().invoke(request, response);
    } finally {
        Thread.currentThread().setContextClassLoader(oldCCL);
    }
}

역할:

  • URI 경로로 Context 선택: /app1, /app2
  • RequestInitEvent 발행
  • ClassLoader 설정

왜 ClassLoader를 캐싱하는가:

  • 각 웹 앱은 독립된 ClassLoader를 가짐
  • 매 요청마다 getClassLoader() 호출 생략
  • 필터/서블릿 실행 중 클래스 로딩이 필요할 때 이 ClassLoader 사용

3️⃣ StandardContextValve - 서블릿(Wrapper) 라우팅

// 소스: catalina/core/StandardContextValve.java
@Override
public final void invoke(Request request, Response response) 
        throws IOException, ServletException {
    
    Wrapper wrapper = request.getWrapper();
    if (wrapper == null) {
        response.sendError(404);
        return;
    }
    
    // WEB-INF, META-INF 접근 차단
    if (request.getRequestPathInfo().startsWith("/WEB-INF")) {
        response.sendError(404);
        return;
    }
    
    // HTTP 100 Continue 처리
    if ("100-continue".equals(request.getHeader("Expect"))) {
        response.setStatus(100);
        response.flushBuffer();
    }
    
    wrapper.getPipeline().getFirst().invoke(request, response);
}

역할:

  • URL 패턴 매칭으로 Wrapper(서블릿) 선택
  • 보안: WEB-INF, META-INF 직접 접근 차단
  • HTTP 100 Continue 응답

왜 WEB-INF를 차단하는가:

  • WEB-INF 내부 파일(web.xml, .class, .jar)은 브라우저에서 직접 접근 불가
  • 자바 서블릿 스펙 요구사항

4️⃣ StandardWrapperValve - 필터 체인 생성 및 서블릿 실행

// 소스: catalina/core/StandardWrapperValve.java
@Override
public final void invoke(Request request, Response response) 
        throws IOException, ServletException {
    
    Servlet servlet = wrapper.allocate();
    
    // 필터 체인 생성 — ApplicationFilterFactory.createFilterChain()
    ApplicationFilterChain filterChain = 
        ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
    
    try {
        // 필터 체인 실행
        filterChain.doFilter(request.getRequest(), response.getResponse());
    } finally {
        // 필터 체인 반납 — 재사용을 위해 풀에 반환
        filterChain.release();
        wrapper.deallocate(servlet);
    }
}

역할:

  • Servlet 인스턴스 할당 (또는 새로 생성)
  • URL 패턴 매칭 필터 수집
  • Servlet 이름 매칭 필터 수집
  • ApplicationFilterChain 생성
  • doFilter() 호출
  • 자원 정리 및 풀링

필터 체인 생성 알고리즘:

// ApplicationFilterFactory.createFilterChain()
public static ApplicationFilterChain createFilterChain(
        Request request, Wrapper wrapper, Servlet servlet) {
    
    ApplicationFilterChain filterChain = new ApplicationFilterChain();
    filterChain.setServlet(servlet);
    
    // 1. URL 패턴 필터 수집
    for (FilterMap filterMap : filterMaps) {
        if (filterMap.getDispatcherTypes().contains(DispatcherType.REQUEST)) {
            if (matchesUrlPattern(request.getRequestURI(), filterMap)) {
                filterChain.addFilter(filterMap.getFilterConfig());
            }
        }
    }
    
    // 2. Servlet 이름 필터 수집
    for (FilterMap filterMap : filterMaps) {
        if (filterMap.getDispatcherTypes().contains(DispatcherType.REQUEST)) {
            if (wrapper.getName().equals(filterMap.getServletName())) {
                filterChain.addFilter(filterMap.getFilterConfig());
            }
        }
    }
    
    return filterChain;
}

ApplicationFilterChain.doFilter() - 필터 실행

// 소스: catalina/core/ApplicationFilterChain.java
public final void doFilter(ServletRequest request, ServletResponse response) 
        throws IOException, ServletException {
    
    internalDoFilter(request, response);
}

private void internalDoFilter(ServletRequest request, ServletResponse response) 
        throws IOException, ServletException {
    
    // 모든 필터를 거쳤으면 서블릿 실행
    if (pos == n) {
        servlet.service(request, response);
        return;
    }
    
    // 다음 필터 실행
    ApplicationFilterConfig filterConfig = filters[pos++];
    Filter filter = filterConfig.getFilter();
    filter.doFilter(request, response, this);
    // FilterChain.doFilter() (이 메서드) 를 호출하면 루프 계속
}

동작 원리:

  1. 첫 번째 필터에서 chain.doFilter() 호출
  2. 내부 pos 카운터 증가
  3. 다음 필터 실행
  4. 모든 필터를 거친 후 servlet.service() 호출
  5. 응답은 역순으로 필터를 거침 (책의 양쪽 표지 같은 구조)

Spring Security가 끼어드는 위치

Spring Boot 기본 구성에서는 DelegatingFilterProxy가 Tomcat 필터 체인에 등록되고, 실제 보안 로직은 Spring Bean인 FilterChainProxy가 수행합니다.

실제 오픈소스 기준 호출 흐름은 아래 순서입니다.

  1. Spring Boot가 SecurityFilterAutoConfiguration.securityFilterChainRegistration(...)에서 DelegatingFilterProxyRegistrationBean을 생성하고, 타깃 빈 이름을 DEFAULT_FILTER_NAME(= springSecurityFilterChain)으로 등록
  2. Tomcat의 ApplicationFilterChain이 해당 프록시 필터의 DelegatingFilterProxy.doFilter(...) 호출
  3. DelegatingFilterProxy.invokeDelegate(...)가 Spring 컨테이너에서 찾은 springSecurityFilterChain 빈의 doFilter(...) 호출
  4. 이 빈은 FilterChainProxy이고, 내부에서 doFilterInternal(...)getFilters(...)로 요청에 맞는 SecurityFilterChain 선택
  5. VirtualFilterChain.doFilter(...)가 Security 필터들을 순서대로 실행한 뒤, 마지막에 원래 서블릿 체인(chain.doFilter(...))으로 복귀
ApplicationFilterChain.doFilter()
    -> DelegatingFilterProxy.doFilter()
            -> FilterChainProxy.doFilter()
                    -> (내부) doFilterInternal()
                            -> getFilters(request) 로 SecurityFilterChain 선택
                            -> VirtualFilterChain.doFilter() 로 보안 필터 순회
    -> 다음 필터 또는 DispatcherServlet

인가 실패면 여기서 바로 401/403 또는 로그인 리다이렉트가 반환되고, 통과한 요청만 이후 MVC 라우팅으로 진행됩니다.


왜 이렇게 설계했는가 — 성능 최적화

1. 필터 체인 재사용 (Object Pooling)

// StandardWrapperValve
filterChain.release();  // 풀에 반환

// ApplicationFilterChain
public void release() {
    // 내부 필터 배열 초기화
    Arrays.fill(filters, null);
    pos = 0;
    n = 0;
    // 다음 요청에서 재사용
}

이유:

  • 요청마다 새로운 ApplicationFilterChain 객체를 new 하면 GC 압력 증가
  • Keep-Alive 연결에서 같은 필터 체인을 재사용해서 GC 회피

2. 동적 필터 배열 확장

// ApplicationFilterChain
private static final int INCREMENT = 10;

private void addFilter(ApplicationFilterConfig filterConfig) {
    if (n == filters.length) {
        // 10개씩 확장
        ApplicationFilterConfig[] newFilters = 
            new ApplicationFilterConfig[n + INCREMENT];
        System.arraycopy(filters, 0, newFilters, 0, n);
        filters = newFilters;
    }
    filters[n++] = filterConfig;
}

이유:

  • 대부분의 요청은 3~5개 정도의 필터만 매칭
  • 처음부터 큰 배열을 할당하면 메모리 낭비
  • 필요할 때만 10개씩 확장

3. Host별 ClassLoader 캐싱

// StandardHostValve
ClassLoader newCCL = context.getLoader().getClassLoader();
Thread.currentThread().setContextClassLoader(newCCL);

이유:

  • 매 요청마다 getClassLoader() 호출 피함
  • Thread-local에 설정해서 필터/서블릿에서 바로 참조

4. 빠른 경로 (Fast Path)

// 필터가 없으면 바로 서블릿 실행
if (n == 0) {
    servlet.service(request, response);
    return;
}

이유:

  • 특정 서블릿에 필터가 없으면 필터 루프 스킵
  • 마이크로초 단위의 오버헤드 제거

시퀀스 다이어그램

단일 요청의 흐름

sequenceDiagram participant CA as CoyoteAdapter participant EV as Engine
Valve participant HV as Host
Valve participant CV as Context
Valve participant WV as Wrapper
Valve participant AFC as ApplicationFilterChain participant SProxy as DelegatingFilterProxy participant SChain as FilterChainProxy participant F1 as Filter 1 participant F2 as Filter 2 participant S as Servlet CA->>EV: invoke(request, response) EV->>EV: Host 선택 EV->>HV: next.invoke() HV->>HV: ClassLoader 설정
Context 선택 HV->>CV: next.invoke() CV->>CV: WEB-INF 검사
Wrapper 선택 CV->>WV: next.invoke() WV->>WV: Servlet 할당
필터 체인 생성 WV->>AFC: doFilter(request, response) AFC->>SProxy: doFilter() SProxy->>SChain: doFilter() SChain->>SChain: doFilterInternal()
getFilters(request) SChain-->>AFC: 인증/인가 통과 AFC->>F1: doFilter(..., chain) F1->>F1: 전처리 F1->>AFC: chain.doFilter() AFC->>F2: doFilter(..., chain) F2->>F2: 전처리 F2->>AFC: chain.doFilter() AFC->>S: service(request, response) S->>S: 요청 처리 S-->>AFC: 응답 반환 F2-->>F2: 후처리 F1-->>F1: 후처리 AFC-->>WV: 완료 WV->>WV: 필터 체인 반납(풀) WV->>WV: Servlet 반납

여러 호스트가 존재할 때 - 동시 요청 라우팅

sequenceDiagram participant User1 as 유저 A
(example.com) participant User2 as 유저 B
(test.com) participant Poller as Poller
(Selector) participant Engine as StandardEngineValve participant HostA as StandardHostValve
(example.com) participant HostB as StandardHostValve
(test.com) participant ContextA as StandardContextValve
(/app1) participant ContextB as StandardContextValve
(/api) participant AFCA as ApplicationFilterChain
(example.com) participant AFCB as ApplicationFilterChain
(test.com) participant Proxy as DelegatingFilterProxy participant Chain as FilterChainProxy participant ServletA as DispatcherServlet
(example.com) participant ServletB as ApiServlet
(test.com) Note over User1,Poller: 유저 A: GET http://example.com/app1/users User1->>Poller: 요청 1 Poller->>Engine: invoke(request) Engine->>Engine: "Host: example.com" 파싱 Note over User2,Poller: 유저 B: GET http://test.com/api/data (동시) User2->>Poller: 요청 2 Poller->>Engine: invoke(request) Engine->>Engine: "Host: test.com" 파싱 par 병렬 처리 Engine->>HostA: next.invoke() (example.com) HostA->>HostA: ClassLoader 설정
(example.com 전용) HostA->>ContextA: Context 선택: /app1 ContextA->>ContextA: WEB-INF 검사 ContextA->>AFCA: doFilter() AFCA->>Proxy: doFilter() Proxy->>Chain: doFilter() Chain-->>AFCA: 인증/인가 통과 AFCA->>ServletA: DispatcherServlet 할당 ServletA->>ServletA: @GetMapping(/users) 처리 ServletA-->>HostA: 응답 and Engine->>HostB: next.invoke() (test.com) HostB->>HostB: ClassLoader 설정
(test.com 전용) HostB->>ContextB: Context 선택: /api ContextB->>ContextB: WEB-INF 검사 ContextB->>AFCB: doFilter() AFCB->>Proxy: doFilter() Proxy->>Chain: doFilter() Chain-->>AFCB: 인증/인가 통과 AFCB->>ServletB: ApiServlet 할당 ServletB->>ServletB: handleRequest() 처리 ServletB-->>HostB: 응답 end HostA-->>User1: HTTP Response (example.com 응답) HostB-->>User2: HTTP Response (test.com 응답)

같은 호스트 내 여러 Context 라우팅

같은 Tomcat 인스턴스, 같은 example.com 호스트에서:

요청 1: GET http://example.com/app1/users
  ├─ StandardEngineValve: Host = example.com ✓
  ├─ StandardHostValve: Context = /app1 ✓
  ├─ StandardContextValve: Wrapper = DispatcherServlet
  └─ 실행 Context ClassLoader = webapps/example/app1/

요청 2: GET http://example.com/app2/settings
  ├─ StandardEngineValve: Host = example.com ✓
  ├─ StandardHostValve: Context = /app2 ✓
  ├─ StandardContextValve: Wrapper = DispatcherServlet
  └─ 실행 Context ClassLoader = webapps/example/app2/
    (완전히 다른 ClassLoader, 격리된 클래스)

요청 3: GET http://example.com/  (root context)
  ├─ StandardEngineValve: Host = example.com ✓
  ├─ StandardHostValve: Context = / (ROOT) ✓
  ├─ StandardContextValve: Wrapper = DispatcherServlet
  └─ 실행 Context ClassLoader = webapps/example/root/

왜 이게 3번의 본질인가

CoyoteAdapter에서 시작된 요청이:

  1. EngineHostContextWrapper 를 거치면서 점진적으로 구체화됨
    • 추상적: “어느 호스트?”
    • 구체적: “어느 서블릿?”
  2. 각 계층에서 보안·검증·상태 설정을 수행한 후 위임
    • WEB-INF 접근 차단
    • ClassLoader 설정
    • Request/Response 초기화
  3. 마지막에 ApplicationFilterChain.doFilter() 에서 필터들을 거친 후 서블릿 실행

이것이 Valve 파이프라인의 핵심 가치: 요청을 계층적으로 라우팅하면서 각 계층에서 필요한 작업을 수행하는 책임 분리.


다음 탐구

4. ApplicationFilterChain → DelegatingFilterProxy.doFilter()