Spring Filter vs Interceptor 차이점 완벽 정리 – 동작 원리와 실전 활용법


Spring 필터 인터셉터 차이, 면접에서 단골로 등장하는 질문이고 실무에서도 “이건 필터로 해야 하나, 인터셉터로 해야 하나” 고민하는 순간이 반드시 옵니다. 둘 다 요청과 응답을 가로채서 공통 처리를 할 수 있다는 점은 같지만, 동작하는 위치와 시점, 접근할 수 있는 자원이 근본적으로 다릅니다. 이 차이를 모르고 선택하면 Spring 빈에 접근해야 하는데 필터를 쓰거나, 멀티파트 처리가 필요한데 인터셉터를 쓰는 등 의도치 않은 문제가 생깁니다. 이 글에서는 필터와 인터셉터가 각각 어디서, 어떻게 동작하는지부터 실전 코드, 선택 기준까지 완벽하게 정리합니다.


목차

  1. 필터와 인터셉터란 무엇인가 – 기초 개념 정리
  2. Spring 필터 인터셉터 차이 – 핵심 원리 비교
  3. 필터와 인터셉터가 가져다주는 장점
  4. 잘못된 사용 패턴과 주의점
  5. 실전 단계별 활용법 – 코드로 보는 구현 방법
  6. 전문가 관점 – 선택 기준과 AOP와의 비교

1. 필터와 인터셉터란 무엇인가 – 기초 개념 정리

웹 애플리케이션에서 공통 처리(로깅, 인증, 인코딩 등)를 요청마다 컨트롤러에 직접 작성하면 코드가 중복되고 유지보수가 어려워집니다. 이 문제를 해결하기 위해 요청이 컨트롤러에 도달하기 전, 또는 응답이 클라이언트에 반환되기 전에 공통 로직을 끼워 넣는 장치가 필터와 인터셉터입니다.

요청 처리 전체 흐름 한눈에 보기

[클라이언트 HTTP 요청]
        │
        ▼
┌───────────────────────────────────────┐
│         서블릿 컨테이너 영역           │
│   ┌─────────────────────────────┐    │
│   │      Filter Chain           │    │
│   │  Filter1 → Filter2 → ...   │    │
│   └─────────────────────────────┘    │
└───────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────┐
│         Spring 컨테이너 영역           │
│   DispatcherServlet                   │
│        │                              │
│        ▼                              │
│   ┌─────────────────────────────┐    │
│   │       Interceptor Chain     │    │
│   │  preHandle → Controller     │    │
│   │  Controller 실행            │    │
│   │  postHandle → afterCompletion│   │
│   └─────────────────────────────┘    │
└───────────────────────────────────────┘
        │
        ▼
[클라이언트 HTTP 응답]

가장 핵심적인 차이는 동작하는 컨테이너입니다. 필터는 서블릿 컨테이너(Tomcat) 레벨에서 동작하고, 인터셉터는 Spring 컨테이너(DispatcherServlet) 레벨에서 동작합니다.

필터(Filter)란?

필터(Filter) 는 Java EE 표준 스펙(javax.servlet.Filter / Jakarta EE는 jakarta.servlet.Filter)에 정의된 인터페이스입니다. DispatcherServlet에 요청이 도달하기 이전과 응답이 클라이언트에 반환된 이후에 실행됩니다. Spring과 무관하게 서블릿 컨테이너가 관리하므로, 원칙적으로는 Spring 빈에 직접 접근하기 어렵습니다.

java

// Filter 인터페이스의 세 가지 메서드
public interface Filter {
    // 필터 초기화 (서블릿 컨테이너 시작 시 1회 호출)
    default void init(FilterConfig filterConfig) throws ServletException {}

    // 요청마다 실행되는 핵심 메서드
    void doFilter(ServletRequest request, ServletResponse response,
                  FilterChain chain) throws IOException, ServletException;

    // 필터 소멸 (서블릿 컨테이너 종료 시 1회 호출)
    default void destroy() {}
}

인터셉터(Interceptor)란?

인터셉터(Interceptor) 는 Spring MVC가 제공하는 HandlerInterceptor 인터페이스입니다. DispatcherServlet이 컨트롤러를 호출하기 전후, 그리고 뷰가 렌더링된  총 세 시점에 실행됩니다. Spring 컨테이너 내부에서 동작하므로 Spring 빈에 자유롭게 접근할 수 있습니다.

java

// HandlerInterceptor 인터페이스의 세 가지 메서드
public interface HandlerInterceptor {
    // 컨트롤러 실행 전 – false 반환 시 요청 처리 중단
    default boolean preHandle(HttpServletRequest request,
                               HttpServletResponse response,
                               Object handler) throws Exception {
        return true;
    }

    // 컨트롤러 실행 후, 뷰 렌더링 전
    default void postHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler,
                             ModelAndView modelAndView) throws Exception {}

    // 뷰 렌더링 완료 후 (예외 발생 여부와 무관하게 항상 실행)
    default void afterCompletion(HttpServletRequest request,
                                  HttpServletResponse response,
                                  Object handler,
                                  Exception ex) throws Exception {}
}

2. Spring 필터 인터셉터 차이 – 핵심 원리 비교

Spring 필터 인터셉터 차이를 항목별로 구체적으로 비교합니다.

항목별 상세 비교표

비교 항목필터(Filter)인터셉터(Interceptor)
관리 주체서블릿 컨테이너 (Tomcat)Spring 컨테이너 (DispatcherServlet)
동작 위치DispatcherServlet 앞DispatcherServlet 뒤, 컨트롤러 앞뒤
스펙 출처Java EE / Jakarta EE 표준Spring MVC 전용
Spring 빈 접근기본적으로 어려움 (DelegatingFilterProxy로 가능)자유롭게 가능
실행 시점doFilter() 1개preHandle / postHandle / afterCompletion 3개
요청 객체ServletRequest / ServletResponseHttpServletRequest / HttpServletResponse
예외 처리@ControllerAdvice 적용 안 됨@ControllerAdvice 적용 가능
적용 범위모든 요청 (정적 리소스 포함)DispatcherServlet이 처리하는 요청만
등록 방법@WebFilter / FilterRegistrationBeanWebMvcConfigurer.addInterceptors()
URL 패턴web.xml / 어노테이션addInterceptors()에서 addPathPatterns()

동작 시점 상세 분석

필터와 인터셉터가 각각 언제 실행되는지 HTTP 요청 하나를 기준으로 단계별로 따라가 보겠습니다.

[GET /api/orders 요청 기준 실행 순서]

① Filter.doFilter() 진입           ← 필터 시작
   └── chain.doFilter() 호출 전 코드 실행 (전처리)

② DispatcherServlet 진입

③ HandlerInterceptor.preHandle()   ← 인터셉터 시작
   └── false 반환 시 컨트롤러 호출 중단, 즉시 응답

④ Controller 메서드 실행
   └── OrderController.getOrders()

⑤ HandlerInterceptor.postHandle()
   └── 컨트롤러 정상 실행 후 호출
   └── ModelAndView 수정 가능
   └── 예외 발생 시 호출되지 않음

⑥ View 렌더링 (또는 JSON 직렬화)

⑦ HandlerInterceptor.afterCompletion()
   └── 항상 실행 (예외 발생해도 호출됨)
   └── 리소스 정리, 로그 기록에 적합

⑧ DispatcherServlet 처리 완료

⑨ Filter.doFilter() 복귀           ← 필터로 돌아옴
   └── chain.doFilter() 호출 후 코드 실행 (후처리)

예외 처리 위치의 차이가 중요한 이유

필터에서 발생한 예외는 @ControllerAdvice가 잡지 못합니다. @ControllerAdvice는 Spring MVC 레이어에서만 동작하는데, 필터는 그보다 바깥(서블릿 컨테이너)에서 실행되기 때문입니다.

java

// 인터셉터에서 발생한 예외 – @ControllerAdvice가 처리 가능
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UnauthorizedException.class)
    public ResponseEntity<ErrorResponse> handleUnauthorized(UnauthorizedException e) {
        // 인터셉터에서 throw된 UnauthorizedException을 여기서 처리 가능
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .body(new ErrorResponse("UNAUTHORIZED", e.getMessage()));
    }
}

// 필터에서 발생한 예외 – @ControllerAdvice가 처리 불가
// 필터에서 예외를 직접 처리해야 함
public class ExceptionHandlingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain)
            throws ServletException, IOException {
        try {
            chain.doFilter(request, response);
        } catch (Exception e) {
            // 필터에서 발생한 예외는 직접 응답을 작성해야 함
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}");
        }
    }
}

3. 필터와 인터셉터가 가져다주는 장점

공통 처리 코드의 단일화

필터와 인터셉터 모두 컨트롤러마다 반복 작성하던 공통 로직을 한 곳으로 모을 수 있습니다. 인증 확인, 요청 로깅, 응답 압축 같은 코드가 각 컨트롤러에서 사라지고 공통 처리 레이어에 집중됩니다.

java

// 인터셉터 없이 모든 컨트롤러에 인증 코드를 반복 작성하는 경우 (나쁜 예)
@RestController
public class OrderController {
    @GetMapping("/orders")
    public List<Order> getOrders(HttpServletRequest request) {
        String token = request.getHeader("Authorization");
        if (token == null || !jwtService.isValid(token)) {
            throw new UnauthorizedException("인증이 필요합니다."); // 매번 반복
        }
        return orderService.findAll();
    }

    @PostMapping("/orders")
    public Order createOrder(HttpServletRequest request, @RequestBody OrderDto dto) {
        String token = request.getHeader("Authorization");
        if (token == null || !jwtService.isValid(token)) {
            throw new UnauthorizedException("인증이 필요합니다."); // 또 반복
        }
        return orderService.create(dto);
    }
    // ... 모든 메서드마다 동일한 인증 코드 반복
}

// 인터셉터를 사용하면 인증 로직이 한 곳에 집중됨 (좋은 예)
// 컨트롤러는 비즈니스 로직에만 집중
@RestController
public class OrderController {
    @GetMapping("/orders")
    public List<Order> getOrders() {
        return orderService.findAll(); // 인증 걱정 없이 비즈니스 로직만
    }

    @PostMapping("/orders")
    public Order createOrder(@RequestBody OrderDto dto) {
        return orderService.create(dto); // 인증 걱정 없이 비즈니스 로직만
    }
}

관심사 분리(Separation of Concerns)

필터는 서블릿 컨테이너 레벨의 관심사(인코딩, CORS, 보안 헤더, 요청/응답 래핑)를, 인터셉터는 Spring MVC 레벨의 관심사(인증·인가, 로깅, 권한 검사, 요청 파라미터 검증)를 각각 독립적으로 처리합니다. 이 분리 덕분에 각 레이어의 역할이 명확해지고 테스트 작성도 쉬워집니다.

필터 체인과 인터셉터 체인의 순서 제어

여러 필터와 인터셉터를 등록할 때 실행 순서를 명시적으로 지정할 수 있습니다. 로깅 → 인증 → 권한 확인 순서를 보장하여 의존성 있는 공통 처리 로직을 안전하게 구성할 수 있습니다.

java

// 필터 순서 지정 – @Order 또는 setOrder()
@Bean
public FilterRegistrationBean<LoggingFilter> loggingFilter() {
    FilterRegistrationBean<LoggingFilter> bean = new FilterRegistrationBean<>();
    bean.setFilter(new LoggingFilter());
    bean.setOrder(1); // 가장 먼저 실행
    return bean;
}

@Bean
public FilterRegistrationBean<EncodingFilter> encodingFilter() {
    FilterRegistrationBean<EncodingFilter> bean = new FilterRegistrationBean<>();
    bean.setFilter(new EncodingFilter());
    bean.setOrder(2); // 두 번째 실행
    return bean;
}

// 인터셉터 순서 지정 – addInterceptors()에서 순서대로 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoggingInterceptor()).order(1);     // 1순위
        registry.addInterceptor(new AuthInterceptor()).order(2);        // 2순위
        registry.addInterceptor(new RateLimitInterceptor()).order(3);   // 3순위
    }
}

4. 잘못된 사용 패턴과 주의점

실수 1 – 필터에서 Spring 빈을 직접 주입받으려는 시도

필터는 서블릿 컨테이너가 관리하기 때문에, 기본 방식으로는 Spring의 @Autowired가 동작하지 않습니다.

java

// 잘못된 패턴 – 순수 Filter에서 @Autowired 사용
public class AuthFilter implements Filter {

    @Autowired
    private JwtService jwtService; // 서블릿 컨테이너가 관리하면 주입 안 됨!

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        // jwtService가 null일 수 있음 → NullPointerException 위험
        String token = ((HttpServletRequest) req).getHeader("Authorization");
        if (!jwtService.isValid(token)) { // NPE 발생 가능!
            ((HttpServletResponse) res).setStatus(401);
            return;
        }
        chain.doFilter(req, res);
    }
}

Spring 빈이 필요하다면 다음 두 가지 방법을 사용합니다.

java

// 해결책 1 – OncePerRequestFilter 상속 (Spring이 관리하는 필터로 변환)
@Component  // Spring Bean으로 등록하면 @Autowired 동작
public class AuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService; // 생성자 주입 사용 권장

    public AuthFilter(JwtService jwtService) {
        this.jwtService = jwtService; // 정상 주입
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain)
            throws ServletException, IOException {
        String token = request.getHeader("Authorization");
        if (token == null || !jwtService.isValid(token)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }
        chain.doFilter(request, response);
    }
}

// 해결책 2 – DelegatingFilterProxy 사용
// Spring Security가 내부적으로 사용하는 방식
// FilterRegistrationBean에 Spring Bean을 등록
@Bean
public FilterRegistrationBean<AuthFilter> authFilterRegistration(AuthFilter authFilter) {
    FilterRegistrationBean<AuthFilter> registration = new FilterRegistrationBean<>();
    registration.setFilter(authFilter); // Spring Bean인 AuthFilter 주입
    registration.addUrlPatterns("/api/*");
    registration.setOrder(1);
    return registration;
}

실수 2 – 인터셉터에서 멀티파트 요청 바디를 읽으려는 시도

인터셉터의 preHandle에서는 요청 바디(request.getInputStream())를 직접 읽으면 스트림이 소진되어 컨트롤러에서 @RequestBody로 바디를 읽을 수 없게 됩니다. 스트림은 한 번만 읽을 수 있기 때문입니다.

java

// 잘못된 패턴 – 인터셉터에서 바디를 직접 읽음
public class RequestBodyLoggingInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws Exception {
        // 이 코드 실행 후 컨트롤러에서 @RequestBody가 빈 값을 받게 됨!
        String body = new String(request.getInputStream().readAllBytes());
        log.info("Request body: {}", body);
        return true;
    }
}

요청 바디를 로깅해야 한다면, 필터에서 ContentCachingRequestWrapper로 스트림을 캐싱하는 방법을 사용합니다.

java

// 올바른 패턴 – 필터에서 ContentCachingRequestWrapper로 바디 캐싱
@Component
public class RequestBodyCachingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain)
            throws ServletException, IOException {
        // 요청 바디를 여러 번 읽을 수 있도록 래핑
        ContentCachingRequestWrapper wrappedRequest =
            new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappedResponse =
            new ContentCachingResponseWrapper(response);

        chain.doFilter(wrappedRequest, wrappedResponse);

        // chain.doFilter() 이후 – 컨트롤러 처리 완료 후 바디 로깅
        byte[] requestBody = wrappedRequest.getContentAsByteArray();
        byte[] responseBody = wrappedResponse.getContentAsByteArray();

        log.info("Request  body: {}", new String(requestBody, StandardCharsets.UTF_8));
        log.info("Response body: {}", new String(responseBody, StandardCharsets.UTF_8));

        // 응답 바디를 실제 클라이언트에 전송 (필수!)
        wrappedResponse.copyBodyToResponse();
    }
}

실수 3 – postHandle이 예외 시 호출되지 않는다는 사실을 망각

postHandle은 컨트롤러가 정상적으로 실행된 경우에만 호출됩니다. 컨트롤러에서 예외가 발생하면 postHandle은 건너뛰고 afterCompletion으로 바로 넘어갑니다. 리소스 정리나 로그 기록은 반드시 afterCompletion에 작성해야 합니다.

java

public class TimingInterceptor implements HandlerInterceptor {

    private static final String START_TIME_KEY = "startTime";

    @Override
    public boolean preHandle(HttpServletRequest request,
                               HttpServletResponse response,
                               Object handler) {
        request.setAttribute(START_TIME_KEY, System.currentTimeMillis());
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler,
                            ModelAndView modelAndView) {
        // 예외 발생 시 이 메서드는 호출되지 않음!
        // 처리 시간 로깅을 여기에 두면 예외 케이스에서 로그가 누락됨
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                 HttpServletResponse response,
                                 Object handler,
                                 Exception ex) {
        // 예외 발생 여부와 무관하게 항상 실행됨 – 리소스 정리 적합
        Long startTime = (Long) request.getAttribute(START_TIME_KEY);
        if (startTime != null) {
            long elapsed = System.currentTimeMillis() - startTime;
            log.info("[{}] {} → {}ms | 예외: {}",
                request.getMethod(),
                request.getRequestURI(),
                elapsed,
                ex != null ? ex.getClass().getSimpleName() : "없음");
        }
    }
}

실수 4 – 인터셉터 URL 패턴 설정 오류

addPathPatterns()와 excludePathPatterns()를 혼용할 때 순서와 범위를 잘못 설정하면 보호해야 할 URL이 인터셉터를 통과하거나, 공개해야 할 URL이 차단됩니다.

java

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthInterceptor())
            .addPathPatterns("/api/**")          // /api/ 하위 전체 적용
            .excludePathPatterns(
                "/api/auth/**",                  // 로그인·회원가입 제외
                "/api/public/**",                // 공개 API 제외
                "/api/health",                   // 헬스체크 제외
                "/error"                         // Spring 에러 페이지 제외
            );
    }
}

5. 실전 단계별 활용법 – 코드로 보는 구현 방법

실전 예제 1 – 필터: 요청·응답 전체 로깅

요청 메서드, URI, 클라이언트 IP, 처리 시간, 응답 상태 코드를 한 번에 로깅하는 실용적인 필터입니다.

java

@Component
@Slf4j
public class RequestLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain)
            throws ServletException, IOException {

        long startTime = System.currentTimeMillis();
        String requestId = UUID.randomUUID().toString().substring(0, 8);

        // MDC에 요청 ID 등록 (로그 추적용)
        MDC.put("requestId", requestId);

        try {
            chain.doFilter(request, response);
        } finally {
            long elapsed = System.currentTimeMillis() - startTime;

            log.info("[{}] {} {} | IP: {} | Status: {} | {}ms",
                requestId,
                request.getMethod(),
                request.getRequestURI(),
                getClientIp(request),
                response.getStatus(),
                elapsed);

            MDC.clear(); // MDC 반드시 정리
        }
    }

    private String getClientIp(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim(); // 프록시 뒤에 있을 때
        }
        return request.getRemoteAddr();
    }

    // 정적 리소스 요청은 로깅에서 제외
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith("/static/")
            || uri.startsWith("/css/")
            || uri.startsWith("/js/")
            || uri.endsWith(".ico");
    }
}

실전 예제 2 – 필터: CORS 및 보안 헤더 설정

java

@Component
public class SecurityHeaderFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain)
            throws ServletException, IOException {

        // 보안 응답 헤더 설정
        response.setHeader("X-Content-Type-Options", "nosniff");
        response.setHeader("X-Frame-Options", "DENY");
        response.setHeader("X-XSS-Protection", "1; mode=block");
        response.setHeader("Strict-Transport-Security",
            "max-age=31536000; includeSubDomains");
        response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");

        // CORS Preflight 요청 처리
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setHeader("Access-Control-Allow-Origin", "https://example.com");
            response.setHeader("Access-Control-Allow-Methods",
                "GET, POST, PUT, DELETE, PATCH, OPTIONS");
            response.setHeader("Access-Control-Allow-Headers",
                "Authorization, Content-Type, X-Requested-With");
            response.setHeader("Access-Control-Max-Age", "3600");
            response.setStatus(HttpServletResponse.SC_OK);
            return; // Preflight는 여기서 응답 완료
        }

        chain.doFilter(request, response);
    }
}

실전 예제 3 – 인터셉터: JWT 인증 처리

java

@Component
@Slf4j
public class JwtAuthInterceptor implements HandlerInterceptor {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    public JwtAuthInterceptor(JwtService jwtService,
                               UserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                               HttpServletResponse response,
                               Object handler) throws Exception {

        // 어노테이션 기반으로 인증 skip 처리
        if (handler instanceof HandlerMethod handlerMethod) {
            if (handlerMethod.hasMethodAnnotation(PublicApi.class)
             || handlerMethod.getBeanType().isAnnotationPresent(PublicApi.class)) {
                return true; // @PublicApi가 붙은 메서드는 인증 생략
            }
        }

        String token = extractToken(request);

        if (token == null) {
            log.warn("인증 토큰 없음: {}", request.getRequestURI());
            throw new UnauthorizedException("인증 토큰이 필요합니다.");
        }

        if (!jwtService.isValid(token)) {
            log.warn("유효하지 않은 토큰: {}", request.getRequestURI());
            throw new UnauthorizedException("유효하지 않은 토큰입니다.");
        }

        // 토큰에서 사용자 정보 추출 후 요청 속성에 저장
        String username = jwtService.extractUsername(token);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        request.setAttribute("authenticatedUser", userDetails);

        log.debug("인증 성공: {} → {}", username, request.getRequestURI());
        return true;
    }

    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

실전 예제 4 – 인터셉터: API 응답 시간 측정 + 느린 API 경보

java

@Component
@Slf4j
public class PerformanceInterceptor implements HandlerInterceptor {

    private static final long SLOW_API_THRESHOLD_MS = 1000L; // 1초 이상은 경고

    @Override
    public boolean preHandle(HttpServletRequest request,
                               HttpServletResponse response,
                               Object handler) {
        request.setAttribute("startTime", System.currentTimeMillis());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                 HttpServletResponse response,
                                 Object handler,
                                 Exception ex) {

        Long startTime = (Long) request.getAttribute("startTime");
        if (startTime == null) return;

        long elapsed = System.currentTimeMillis() - startTime;
        String handlerName = getHandlerName(handler);

        if (elapsed >= SLOW_API_THRESHOLD_MS) {
            log.warn("[SLOW API] {} {} | Handler: {} | {}ms (임계값: {}ms)",
                request.getMethod(),
                request.getRequestURI(),
                handlerName,
                elapsed,
                SLOW_API_THRESHOLD_MS);
            // 슬랙 알림, 메트릭 수집 등 추가 가능
        } else {
            log.debug("[API] {} {} | {}ms",
                request.getMethod(),
                request.getRequestURI(),
                elapsed);
        }
    }

    private String getHandlerName(Object handler) {
        if (handler instanceof HandlerMethod hm) {
            return hm.getBeanType().getSimpleName()
                + "." + hm.getMethod().getName() + "()";
        }
        return handler.toString();
    }
}

// 인터셉터 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final JwtAuthInterceptor jwtAuthInterceptor;
    private final PerformanceInterceptor performanceInterceptor;

    public WebConfig(JwtAuthInterceptor jwtAuthInterceptor,
                     PerformanceInterceptor performanceInterceptor) {
        this.jwtAuthInterceptor = jwtAuthInterceptor;
        this.performanceInterceptor = performanceInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 성능 측정은 모든 요청에 적용 (인증보다 먼저)
        registry.addInterceptor(performanceInterceptor)
            .addPathPatterns("/**")
            .order(1);

        // JWT 인증은 API 요청에만 적용
        registry.addInterceptor(jwtAuthInterceptor)
            .addPathPatterns("/api/**")
            .excludePathPatterns("/api/auth/**", "/api/public/**")
            .order(2);
    }
}

6. 전문가 관점 – 선택 기준과 AOP와의 비교

필터 vs 인터셉터 선택 기준 한눈에 보기

실무에서 “이 기능은 필터로 구현해야 하나, 인터셉터로 구현해야 하나”를 판단하는 기준입니다.

필터를 선택해야 하는 경우:
  ✓ Spring 빈 접근이 불필요한 공통 처리
  ✓ 모든 요청에 적용해야 하는 처리 (정적 리소스 포함)
  ✓ 요청/응답 바디 자체를 래핑·수정해야 할 때
  ✓ 멀티파트 요청 처리 전에 선행 작업이 필요할 때
  ✓ CORS 헤더, 인코딩, 보안 헤더 설정
  ✓ Spring Security 연동 (Security Filter Chain)

인터셉터를 선택해야 하는 경우:
  ✓ Spring 빈(@Service, @Repository 등)에 접근이 필요한 처리
  ✓ 컨트롤러 핸들러 정보(메서드명, 어노테이션)가 필요한 처리
  ✓ 인증·인가 검사 (JWT 검증, 세션 확인 등)
  ✓ 컨트롤러 실행 후 ModelAndView를 수정해야 할 때
  ✓ @ControllerAdvice와 통합된 예외 처리가 필요할 때
  ✓ 요청 단위 로깅 (핸들러 메서드 이름, 실행 시간 등)

AOP vs 인터셉터 – 언제 무엇을 쓰는가

공통 처리 도구가 또 하나 있습니다. 바로 AOP(관점 지향 프로그래밍) 입니다. 인터셉터와 AOP 모두 공통 처리에 쓰이지만 적용 레이어가 다릅니다.

비교 항목인터셉터(Interceptor)AOP
적용 레이어HTTP 요청·응답 레이어메서드 호출 레이어
적용 대상컨트롤러(Handler)에 한정모든 Spring 빈의 메서드
접근 정보HttpServletRequest/Response메서드 파라미터, 반환값, 예외
주요 사용처인증, HTTP 로깅, URL 기반 제어트랜잭션, 메서드 실행 시간 측정, 캐싱
설정 방법WebMvcConfigurer@Aspect + @Around 등

java

// 인터셉터: HTTP 요청 단위 처리 (URL 기반 인증)
// 컨트롤러에 도달하기 전에 JWT 토큰 검증

// AOP: 메서드 단위 처리 (서비스 레이어 성능 측정)
@Aspect
@Component
@Slf4j
public class ServicePerformanceAspect {

    // 컨트롤러가 아닌 서비스 레이어 메서드 실행 시간 측정
    @Around("execution(* com.example.service.*.*(..))")
    public Object measureServiceTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            log.debug("[Service] {}.{}() → {}ms",
                joinPoint.getTarget().getClass().getSimpleName(),
                joinPoint.getSignature().getName(),
                elapsed);
        }
    }
}

세 가지 공통 처리 도구 최종 비교

항목필터(Filter)인터셉터(Interceptor)AOP
동작 위치서블릿 컨테이너Spring MVCSpring Bean
HTTP 정보 접근✗ (직접 접근 어려움)
Spring 빈 접근△ (OncePerRequestFilter)
적용 범위모든 요청컨트롤러 요청모든 빈 메서드
주요 용도인코딩, CORS, 보안 헤더인증·인가, HTTP 로깅트랜잭션, 캐싱, 메서드 로깅

추천 학습 도구 및 리소스

도구 / 리소스용도
Spring 공식 문서 (docs.spring.io)Filter, HandlerInterceptor 공식 스펙
IntelliJ IDEA 디버거필터·인터셉터 실행 순서 직접 추적
Spring Boot Actuator필터 체인 등록 현황 확인
인프런 김영한 – 스프링 MVC필터·인터셉터 실습 중심 한국어 강의
Baeldung (baeldung.com)OncePerRequestFilter, HandlerInterceptor 심화

면접 대비 핵심 답변

“필터와 인터셉터의 가장 큰 차이점은?” 동작하는 컨테이너가 다릅니다. 필터는 서블릿 컨테이너 레벨에서 DispatcherServlet 앞뒤에 동작하고, 인터셉터는 Spring 컨테이너 레벨에서 DispatcherServlet과 컨트롤러 사이에 동작합니다. 이 차이로 인해 필터는 Spring 빈에 기본적으로 접근하기 어렵고, 인터셉터는 Spring 빈을 자유롭게 사용할 수 있습니다.

“인터셉터에서 발생한 예외를 @ControllerAdvice로 처리할 수 있나요?” 네, 가능합니다. 인터셉터는 Spring MVC 레이어에서 동작하므로 @ControllerAdvice가 예외를 잡을 수 있습니다. 반면 필터에서 발생한 예외는 @ControllerAdvice 범위 밖에 있어 직접 응답을 작성하거나 별도 예외 처리 필터를 앞에 배치해야 합니다.

“postHandle과 afterCompletion의 차이는?” postHandle은 컨트롤러가 정상 실행된 경우에만 호출되고 뷰 렌더링 전에 실행됩니다. afterCompletion은 예외 발생 여부와 무관하게 항상 실행되며 뷰 렌더링 완료 후에 실행됩니다. 리소스 정리나 실행 시간 로깅처럼 반드시 실행되어야 하는 로직은 afterCompletion에 작성해야 합니다.


결론

Spring 필터 인터셉터 차이의 핵심은 어느 컨테이너에서 동작하느냐입니다. 필터는 서블릿 컨테이너 레벨에서 모든 요청을 대상으로 인코딩·CORS·보안 헤더를 처리하고, 인터셉터는 Spring MVC 레벨에서 Spring 빈에 접근하며 인증·인가·로깅을 처리합니다. 이 구분을 명확히 알면 “어디에 공통 로직을 두어야 하는가”를 자신 있게 결정할 수 있습니다. 오늘 소개한 예제 코드를 바탕으로 직접 로깅 필터와 인증 인터셉터를 구현해보세요. 요청 흐름을 디버거로 따라가다 보면 두 개념의 차이가 손끝에 새겨집니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다