경험은 나의 것

[Spring] Filter의 요청 가로채기 본문

Dev

[Spring] Filter의 요청 가로채기

sangkins 2025. 12. 28. 00:50

1. 배경: 세션 탈취 글을 쓰고 나서

spring:
  session:
    store-type: redis

지난 글에서 Spring Session이 Filter와 Decorator 패턴으로 세션 저장소를 Redis로 바꿔치기하는 과정을 알아봤다.

"Filter가 요청을 가로챈다고 했는데, 그럼 요청은 정확히 어디서부터 시작해서 Controller까지 갈까"

그래서 이번엔 Spring 코드를 직접 까보면서 HTTP 요청의 전체 흐름을 파악해보자.


2. 디버깅으로 흐름 추적하기

일단 간단한 Controller를 하나 만들고, Get 요청 후 Call Stack을 확인해봤다.

@RestController
public class TraceController {

    @GetMapping("/trace")
    public String traceEndpoint() {
        return "Trace endpoint Response";  // breakpoint
    }
}

/trace로 요청을 보내고 breakpoint에서 멈췄을 때 Call Stack을 보면:

at com.auction.controller.TraceController.traceEndpoint(TraceController. java:11)
    ...   (Reflection)
at org.springframework.web.servlet. mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(...)
at org.springframework.web.servlet. DispatcherServlet.doDispatch(DispatcherServlet.java:1089)     ← Spring MVC
at org.springframework.web.servlet.DispatcherServlet. doService(DispatcherServlet.java:979)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
at org.springframework.web.servlet. FrameworkServlet.service(FrameworkServlet.java:885)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(...)                         ← Tomcat Filter Chain
at org.apache.catalina.core.ApplicationFilterChain. doFilter(ApplicationFilterChain. java:140)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
at org.apache.catalina.core.ApplicationFilterChain. internalDoFilter(...)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(...)
    ...  (Spring Security Filter Chain)
at org.springframework.security.web.FilterChainProxy. doFilterInternal(FilterChainProxy.java:233) ← Spring Security
at org. springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:191)
    ... 
at org.springframework.web.filter. DelegatingFilterProxy. doFilter(DelegatingFilterProxy. java:278)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(...)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(...)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(...)                  ← 인코딩 필터
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(...)
at org.apache.catalina.core. ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)           ← Tomcat 시작점
    ...

Call Stack을 아래에서 위로 읽으면 요청의 흐름이 보인다:

  1. ApplicationFilterChain.doFilter() - Tomcat의 Filter Chain
  2. FrameworkServlet.service() - Spring 진입점
  3. DispatcherServlet.doDispatch() - Spring MVC 핵심
  4. RequestMappingHandlerAdapter.handle() - Controller 실행
  5. TestController.test() - 내 코드

이걸 바탕으로 전체 흐름을 정리하면:

HTTP Request
    ↓
[Servlet Container - Tomcat]
    ↓
Filter Chain (Filter1 → Filter2 → ... → DispatcherServlet)
    ↓
[Spring MVC]
    ↓
FrameworkServlet.service()
    ↓
DispatcherServlet.doDispatch()
    ↓
Interceptor.preHandle() → Controller → Interceptor.postHandle()
    ↓
HTTP Response

핵심은 이거다:

  • Filter는 Tomcat이 관리한다. Spring 바깥이다.
  • Interceptor는 DispatcherServlet 안에서 돌아간다. Spring 내부다.

이제 코드로 하나씩 까보자.


3. Filter: Servlet 스펙이다 (Spring 아님)

Filter는 Jakarta Servlet API에 정의되어 있다. Spring이 만든 게 아니라 Tomcat이 관리한다.

// jakarta.servlet.Filter
public interface Filter {

    void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException;
}
// jakarta.servlet.FilterChain
public interface FilterChain {

    // 다음 필터 호출, 마지막이면 Servlet 호출
    void doFilter(ServletRequest request, ServletResponse response) 
        throws IOException, ServletException;
}

Filter 구현하면 이런 식이다:

public class MyFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                        FilterChain chain) throws IOException, ServletException {

        System.out.println("전처리");

        chain.doFilter(request, response);  // 다음으로 넘김

        System.out. println("후처리");
    }
}

지난 글의 SessionRepositoryFilter도 이 구조였다. chain.doFilter() 호출 전에 Request를 Wrapper로 감싸서 넘긴 것이다.


4. FilterChain의 실체: Tomcat의 ApplicationFilterChain

근데 chain.doFilter()를 호출하면 다음 Filter가 어떻게 실행되는 걸까? FilterChain은 인터페이스일 뿐이다. 실제 구현체는 Tomcat의 ApplicationFilterChain이다.

4-1. 필드 구조

// org.apache.catalina.core. ApplicationFilterChain
public final class ApplicationFilterChain implements FilterChain {

    // 필터 배열
    private ApplicationFilterConfig[] filters = new ApplicationFilterConfig[0];

    // 현재 실행 중인 필터의 위치
    private int pos = 0;

    // 등록된 필터의 총 개수
    private int n = 0;

    // 필터 체인 끝에서 실행할 Servlet
    private Servlet servlet = null;
}

핵심은 posn이다. pos는 현재 몇 번째 필터를 실행 중인지, n은 총 필터 개수다.

4-2. doFilter() 구현

@Override
public void doFilter(ServletRequest request, ServletResponse response) 
        throws IOException, ServletException {

    // 아직 실행할 필터가 남아있으면
    if (pos < n) {
        // pos 증가시키면서 다음 필터 가져옴
        ApplicationFilterConfig filterConfig = filters[pos++];
        Filter filter = filterConfig.getFilter();

        // 필터 실행 (this를 넘겨서 재귀 호출 가능하게)
        filter.doFilter(request, response, this);
        return;
    }

    // 모든 필터를 통과했으면 Servlet 실행
    servlet.service(request, response);
}

동작 원리를 정리하면:

  1. pos < n 이면 아직 실행할 필터가 남아있다는 뜻이다
  2. filters[pos++]로 현재 필터를 가져오고 pos를 1 증가시킨다
  3. filter.doFilter(request, response, this)를 호출한다. 여기서 this가 핵심이다
  4. 필터 내부에서 chain.doFilter()를 호출하면 다시 이 메서드가 실행된다
  5. 이번엔 pos가 증가했으니 다음 필터가 실행된다
  6. 모든 필터를 통과하면(pos >= n) 드디어 servlet.service()가 호출된다

4-3. 그림으로 보면

[ApplicationFilterChain]
filters = [Filter1, Filter2, Filter3]
n = 3, pos = 0, servlet = DispatcherServlet

1) doFilter() 호출 (pos=0)
   → pos++ → Filter1.doFilter(req, res, this) 호출

2) Filter1 내부에서 chain.doFilter() 호출 (pos=1)
   → pos++ → Filter2.doFilter(req, res, this) 호출

3) Filter2 내부에서 chain.doFilter() 호출 (pos=2)
   → pos++ → Filter3.doFilter(req, res, this) 호출

4) Filter3 내부에서 chain.doFilter() 호출 (pos=3)
   → pos(3) >= n(3) 이므로
   → servlet.service(req, res) 호출!

결국 재귀 호출 구조다. 각 필터가 chain.doFilter()를 호출하면 다음 필터로 넘어가고, 마지막에 Servlet이 실행된다.

지난 글의 SessionRepositoryFilter가 chain.doFilter(wrappedRequest, response)를 호출했을 때, 이 wrappedRequest가 다음 필터들과 최종적으로 DispatcherServlet까지 전달된 것이다.


5. HttpServlet: Servlet의 진입점

Filter Chain 끝에서 servlet.service()가 호출된다. Spring의 DispatcherServlet은 HttpServlet을 상속받는다.

HttpServlet (Jakarta Servlet) - HTTP 메서드별 분기
    ↑
HttpServletBean (Spring) - Spring Bean 속성 바인딩
    ↑
FrameworkServlet (Spring) - WebApplicationContext 관리
    ↑
DispatcherServlet (Spring) - 요청 디스패칭

HttpServlet의 service() 메서드를 보면:

// jakarta.servlet.http.HttpServlet
protected void service(HttpServletRequest req, HttpServletResponse resp)
        throws ServletException, IOException {

    String method = req. getMethod();

    if (method.equals("GET")) {
        doGet(req, resp);
    } else if (method.equals("POST")) {
        doPost(req, resp);
    } else if (method.equals("PUT")) {
        doPut(req, resp);
    }
    // ... 
}

HTTP 메서드에 따라 doGet, doPost 등으로 분기한다.


6. FrameworkServlet: Spring 진입

FrameworkServlet에서 doGet, doPost 등을 오버라이드한다.

// org.springframework.web.servlet. FrameworkServlet
@Override
protected final void doGet(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    processRequest(request, response);
}

@Override
protected final void doPost(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    processRequest(request, response);
}

결국 다 processRequest()로 모인다.

protected final void processRequest(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {

    // 컨텍스트 설정 (LocaleContext, RequestAttributes)
    initContextHolders(request, localeContext, requestAttributes);

    try {
        doService(request, response);  // DispatcherServlet. doService() 호출
    }
    finally {
        resetContextHolders(request, previousLocaleContext, previousAttributes);
    }
}

Tip: RequestContextHolder. getRequestAttributes()로 어디서든 현재 요청 정보를 가져올 수 있는 이유가 여기서 설정하기 때문이다.


7. DispatcherServlet. doDispatch(): 여기가 핵심이다

Interceptor가 실행되는 곳이다. 코드를 보자.

// org.springframework.web.servlet.DispatcherServlet
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) 
        throws Exception {

    HandlerExecutionChain mappedHandler = null;

    try {
        ModelAndView mv = null;

        try {
            // 1. Handler(Controller) 찾기
            mappedHandler = getHandler(processedRequest);
            if (mappedHandler == null) {
                noHandlerFound(processedRequest, response);
                return;
            }

            // 2. Interceptor - preHandle()
            if (!mappedHandler.applyPreHandle(processedRequest, response)) {
                return;  // false면 여기서 끝
            }

            // 3. Controller 실행
            HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
            mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

            // 4. Interceptor - postHandle()
            mappedHandler.applyPostHandle(processedRequest, response, mv);
        }
        catch (Exception ex) {
            dispatchException = ex;
        }

        // 5. View 렌더링
        processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
    }
    catch (Exception ex) {
        // 6. Interceptor - afterCompletion() (예외 발생해도 실행)
        triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
    }
}

순서를 정리하면:

  1. getHandler() - 요청 URL에 맞는 Controller 찾음
  2. applyPreHandle() - Interceptor의 preHandle() 실행
  3. ha.handle() - Controller 메서드 실행
  4. applyPostHandle() - Interceptor의 postHandle() 실행
  5. processDispatchResult() - View 렌더링
  6. triggerAfterCompletion() - Interceptor의 afterCompletion() 실행

8. HandlerExecutionChain: Interceptor 실행 로직

applyPreHandle()applyPostHandle()이 실제로 뭘 하는지 보자.

// org.springframework.web.servlet.HandlerExecutionChain

// preHandle - 순서대로 실행 (0 → n)
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) 
        throws Exception {

    for (int i = 0; i < this.interceptorList.size(); i++) {
        HandlerInterceptor interceptor = this.interceptorList.get(i);

        if (!interceptor.preHandle(request, response, this.handler)) {
            triggerAfterCompletion(request, response, null);
            return false;  // 하나라도 false면 중단
        }
        this.interceptorIndex = i;
    }
    return true;
}

// postHandle - 역순으로 실행 (n → 0)
void applyPostHandle(HttpServletRequest request, HttpServletResponse response, 
                    ModelAndView mv) throws Exception {

    for (int i = this.interceptorList.size() - 1; i >= 0; i--) {
        HandlerInterceptor interceptor = this.interceptorList.get(i);
        interceptor.postHandle(request, response, this.handler, mv);
    }
}

preHandle은 순서대로, postHandle은 역순으로 실행된다.


9. Filter vs Interceptor

구분 Filter Interceptor
소속 Servlet 스펙 Spring MVC
관리 Tomcat Spring
실행 위치 DispatcherServlet 이전 DispatcherServlet 내부
Spring Bean 접근 @Component 또는 FilterRegistrationBean 필요 바로 접근 가능
예외 처리 @ControllerAdvice 적용 안됨 @ControllerAdvice 적용됨
용도 인코딩, 보안, XSS 필터링 인증/인가, 로깅, 공통 로직

10. 정리

HTTP Request
    ↓
[Tomcat - ApplicationFilterChain]
    │
    ├── pos=0: Filter1.doFilter() → chain.doFilter()
    ├── pos=1: Filter2.doFilter() → chain.doFilter()
    ├── pos=2: SessionRepositoryFilter.doFilter() → chain.doFilter(wrappedRequest)
    └── pos >= n: servlet.service() 호출
    ↓
[DispatcherServlet. doDispatch()]
    ├── getHandler()
    ├── Interceptor.preHandle()
    ├── Controller 실행
    ├── Interceptor.postHandle()
    └── Interceptor.afterCompletion()
    ↓
HTTP Response

지난 글의 SessionRepositoryFilter가 정확히 어느 시점에 동작하는지 이제 알겠다. Tomcat의 ApplicationFilterChainpos를 증가시키면서 필터를 순차 실행하고, 모든 필터를 통과한 후에야 servlet.service()로 DispatcherServlet이 실행된 것이다.

그리고 SessionRepositoryFilterchain.doFilter(wrappedRequest, response)를 호출했을 때, 이 wrappedRequest가 다음 필터들을 거쳐 최종적으로 DispatcherServlet까지 전달되어, Controller에서 request.getSession()을 호출하면 Redis 세션을 반환하게 된 것이다.

Filter는 Spring 바깥, Interceptor는 Spring 내부. 이 차이를 알면 어디에 뭘 넣어야 하는지 판단하기 쉬워진다.


참고