Spring Boot

Spring Boot에서 Spring MVC 요청 처리 흐름 한눈에 보기 (DispatcherServlet, HandlerMapping, Filter/Interceptor)

IT Lab 2026. 3. 9. 10:00

Spring Boot 3.x 기준으로 Spring MVC 요청이 들어온 뒤 DispatcherServlet이 HandlerMapping/HandlerAdapter를 거쳐 컨트롤러를 호출하고 응답이 나가기까지의 흐름과 Filter vs Interceptor 차이를 정리합니다.

도입 (문제 상황)

Spring Boot로 API를 개발하다 보면 “요청이 컨트롤러까지 오기 전에 어디서 막히지?”, “인터셉터에 넣었는데 왜 인증이 안 먹지?” 같은 상황을 한 번쯤 겪게 됩니다. 로그를 찍어도 호출 순서가 머릿속에 그려지지 않으면, 디버깅이 오래 걸리고 수정도 조심스러워져요.

핵심 개념: Spring MVC 요청 처리 흐름에서 꼭 잡아야 할 포인트

Spring MVC의 핵심은 Front Controller인 DispatcherServlet이 모든 HTTP 요청을 받아서, ‘누가 처리할지(HandlerMapping)’와 ‘어떻게 호출할지(HandlerAdapter)’를 통해 컨트롤러를 실행한다는 점입니다. 이 구조가 중요한 이유는, 문제가 생겼을 때 “지금 내 요청이 어느 레이어에서 처리 중인지”를 정확히 분리해서 볼 수 있기 때문이에요.

1) DispatcherServlet: 요청을 모아서 분배하는 ‘교통 정리’

DispatcherServlet은 들어오는 요청을 직접 처리하지 않고, 아래 순서로 “적절한 담당자”에게 위임합니다.

  • HandlerMapping: 이 요청 URL/메서드를 처리할 “핸들러(보통 컨트롤러 메서드)”를 찾습니다.
  • HandlerAdapter: 찾은 핸들러를 “실제로 호출할 수 있는 방식”으로 실행합니다.
    (예: @RequestMapping 기반 컨트롤러는 RequestMappingHandlerAdapter가 호출)
  • HandlerInterceptor: 컨트롤러 실행 전/후/완료 시점에 끼어들어 공통 로직을 수행합니다.
  • ViewResolver(주로 MVC 템플릿): 뷰 이름을 실제 View로 바꿉니다.
    (REST API는 보통 HttpMessageConverter로 바로 JSON 응답이 나가므로 ViewResolver 비중이 작습니다.)

2) “매핑”과 “어댑터”를 분리한 이유

비유로 보면, HandlerMapping은 “어느 기사님이 배달할지 찾는 콜센터”이고, HandlerAdapter는 “기사님 호출 방식이 오토바이든 트럭이든 실제 출발시키는 출동 시스템”에 가깝습니다.
이 분리 덕분에 Spring은 다양한 스타일의 핸들러를 지원하면서도(애노테이션 기반, 함수형 등) DispatcherServlet은 크게 바뀌지 않습니다.

3) Filter vs Interceptor: 어디에 걸어야 하는지가 성패를 가릅니다

둘 다 “요청 앞뒤에 공통 로직을 넣는다”는 점은 같지만, 동작 위치와 책임 범위가 다릅니다.

구분 Filter (서블릿 필터) Interceptor (스프링 MVC)
소속/표준 Servlet 스펙(Jakarta) Spring MVC
실행 위치 DispatcherServlet (더 앞단) DispatcherServlet
적용 범위 모든 요청(정적 리소스 포함 가능) Spring MVC로 매핑된 핸들러 중심
주요 용도 인코딩, CORS(환경에 따라), 보안 전처리, 로깅/트레이싱, 요청 래핑 인증/인가 보조, 컨트롤러 공통 처리, 모델/뷰 관련 후처리
예외/에러 처리 영향 필터 체인 단계에서 예외가 나면 MVC까지 못 옴 컨트롤러/핸들러 실행 흐름에 자연스럽게 결합
주의점 요청/응답을 여러 번 읽으면 문제(바디) 핸들러가 없으면 호출되지 않을 수 있음

정리하면, “MVC에 들어오기 전 공통 정책”은 Filter, “컨트롤러 실행 전후의 공통 로직”은 Interceptor가 더 자연스럽습니다.
(참고로 Spring Security를 쓰면 보안은 대개 Security Filter Chain에서 처리되므로, 보안 로직을 인터셉터로 ‘대체’하려는 접근은 피하는 편이 안전합니다.)

코드 예제: Filter + Interceptor 호출 순서 눈으로 확인하기 (복붙 실행)

아래 예제는 Spring Boot 3.x(= Servlet 기반)에서 필터와 인터셉터가 어떤 순서로 실행되는지를 로그로 확인하는 최소 구성입니다. 브라우저나 curl로 /hello를 호출해 보세요.

// build.gradle (Groovy)
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.2'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}
# application.yml
spring:
  application:
    name: mvc-flow-demo

logging:
  level:
    root: info
// src/main/java/com/example/mvcflow/MvcFlowDemoApplication.java
package com.example.mvcflow;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MvcFlowDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(MvcFlowDemoApplication.class, args);
    }
}
// src/main/java/com/example/mvcflow/HelloController.java
package com.example.mvcflow;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}
// src/main/java/com/example/mvcflow/filter/RequestLogFilter.java
package com.example.mvcflow.filter;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 필터 순서 제어
public class RequestLogFilter implements Filter {

    private static final Logger log = LoggerFactory.getLogger(RequestLogFilter.class);

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

        HttpServletRequest req = (HttpServletRequest) request;
        log.info("[Filter] BEFORE {} {}", req.getMethod(), req.getRequestURI());

        try {
            chain.doFilter(request, response);
        } finally {
            log.info("[Filter] AFTER  {} {}", req.getMethod(), req.getRequestURI());
        }
    }
}
// src/main/java/com/example/mvcflow/interceptor/RequestLogInterceptor.java
package com.example.mvcflow.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class RequestLogInterceptor implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(RequestLogInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        log.info("[Interceptor] preHandle handler={}", handler);
        return true; // false면 컨트롤러로 진행하지 않음
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        log.info("[Interceptor] afterCompletion ex={}", ex == null ? "null" : ex.getClass().getSimpleName());
    }
}
// src/main/java/com/example/mvcflow/config/WebConfig.java
package com.example.mvcflow.config;

import com.example.mvcflow.interceptor.RequestLogInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final RequestLogInterceptor requestLogInterceptor;

    public WebConfig(RequestLogInterceptor requestLogInterceptor) {
        this.requestLogInterceptor = requestLogInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(requestLogInterceptor)
                .addPathPatterns("/**");
    }
}

호출:

curl -i http://localhost:8080/hello

예상 로그 흐름(대략):

  • [Filter] BEFORE ...
  • [Interceptor] preHandle ...
  • (컨트롤러 실행)
  • [Interceptor] afterCompletion ...
  • [Filter] AFTER ...

즉, Filter가 더 바깥에서 감싸고, 그 안에서 DispatcherServlet → Interceptor → Controller가 실행된다고 이해하시면 됩니다.

flowchart TD
  A["Client"] --> B["Filter Chain"]
  B --> C["DispatcherServlet"]
  C --> D["HandlerMapping"]
  D --> E["HandlerAdapter"]
  E --> F["HandlerInterceptor preHandle"]
  F --> G["Controller"]
  G --> H["HandlerInterceptor afterCompletion"]
  H --> I["HttpMessageConverter or ViewResolver"]
  I --> B
  B --> A

Spring MVC request lifecycle overview with Filter and DispatcherServlet

요청은 Filter를 통과해 DispatcherServlet로 들어가고, 응답은 역방향으로 Filter를 다시 지나 클라이언트로 나갑니다.

실무 팁

💡 실무에서는
**“인증/인가를 인터셉터로 처리해도 되나요?”**를 자주 묻는데, Spring Boot 3.x에서 보안을 제대로 하려면 보통 Spring Security의 Filter Chain을 쓰는 편이 맞습니다. 인터셉터는 보안의 ‘보조 신호’(예: 컨트롤러 단 로깅, 사용자 컨텍스트 세팅 확인)로 두고, 실제 차단/인증은 Security 쪽에 두면 예외 처리와 우회 케이스가 줄어듭니다.

💡 실무에서는
Filter에서 요청 바디를 로깅하려고 request.getInputStream()을 읽어버리면 컨트롤러에서 바디를 못 읽는 문제가 생기기 쉽습니다. 꼭 필요하다면 ContentCachingRequestWrapper 같은 래퍼를 사용해 “읽기 가능한 상태”를 유지해야 하고, 민감정보 마스킹/로그 레벨 분리도 같이 고려해 보세요.


핵심 요약

  • Spring MVC는 DispatcherServlet이 요청을 받아 HandlerMapping으로 찾고 HandlerAdapter로 실행합니다.
  • Filter는 DispatcherServlet 바깥, Interceptor는 Spring MVC 내부에서 동작합니다.
  • 디버깅이 꼬일 때는 “필터 단계인지, MVC 매핑/어댑터 단계인지, 인터셉터/컨트롤러 단계인지”를 먼저 분리해 보세요.

다음 글: #10 @Controller vs @RestController 제대로 구분하기