기본 콘텐츠로 건너뛰기

스프링 MVC에서 Filter와 Interceptor를 언제 어떻게 나눠 써야 할까

스프링 MVC에서 Filter와 Interceptor를 언제 어떻게 나눠 써야 할까

빠른 답

  • 서블릿 단위 공통 처리라면 Filter, 컨트롤러 전후 제어라면 Interceptor가 먼저 후보입니다.
  • 요청 본문/응답 자체를 직접 다뤄야 하면 Filter가 더 유연합니다.
  • 핸들러 정보나 애노테이션 기반 분기가 필요하면 Interceptor가 더 적합합니다.
  • 순서 문제와 중복 실행은 실제 장애로 이어지기 쉬우니 등록 위치와 실행 로그를 함께 확인해야 합니다.

빠른 답

  • 서블릿 레벨에서 모든 요청과 응답을 공통 처리해야 하면 Filter, 컨트롤러 실행 전후를 제어해야 하면 Interceptor가 먼저 후보입니다.
  • 헤더, 인코딩, CORS, 요청 본문, 응답 감싸기처럼 HTTP 자체를 다뤄야 하면 Filter가 더 맞습니다.
  • 어떤 컨트롤러 메서드가 호출되는지, 특정 애노테이션이 붙었는지 보고 분기해야 하면 Interceptor가 더 적합합니다.
  • 스프링 시큐리티를 쓰는 프로젝트라면 인증과 인가의 중심은 보통 시큐리티 필터 체인에 두고, FilterInterceptor는 보조 역할로 좁히는 편이 안전합니다.

왜 둘이 자꾸 헷갈릴까

FilterInterceptor는 둘 다 "공통 로직을 한곳에 모아 넣는다"는 점에서 비슷합니다. 그래서 로깅, 인증, 권한 확인, 성능 측정 같은 요구가 나오면 둘 중 어디에 넣어도 될 것처럼 보입니다.

하지만 둘은 동작하는 계층이 다릅니다. Filter는 서블릿 컨테이너 쪽에서 동작하고, Interceptor는 스프링 MVC 내부에서 동작합니다. 이 차이 때문에 접근할 수 있는 정보도 다르고, 잘 맞는 책임도 달라집니다.

실무에서는 기능 이름보다 질문을 바꾸는 편이 더 정확합니다.

  • 이 로직이 HTTP 요청과 응답 자체를 다뤄야 하는가?
  • 아니면 스프링이 선택한 컨트롤러와 핸들러 메서드를 알아야 하는가?
  • 컨트롤러 밖에서도 항상 적용되어야 하는가?
  • 특정 API, 특정 애노테이션, 특정 핸들러에만 적용되어야 하는가?

이 질문에 답하면 대부분의 선택은 자연스럽게 정리됩니다.

요청이 들어왔을 때 실제로 어떤 순서로 실행될까

위치를 감으로 이해하는 것보다 요청 흐름에 얹어 보는 편이 훨씬 쉽습니다. 일반적인 스프링 MVC 요청은 대략 아래 순서로 흘러갑니다.

  1. 클라이언트가 HTTP 요청을 보냅니다.
  2. 서블릿 컨테이너가 요청을 받습니다.
  3. 등록된 Filter들이 순서대로 실행됩니다.
  4. DispatcherServlet이 요청을 전달받습니다.
  5. 핸들러 매핑으로 어떤 컨트롤러 메서드를 호출할지 결정합니다.
  6. HandlerInterceptor#preHandle이 실행됩니다.
  7. 컨트롤러 메서드가 실행됩니다.
  8. HandlerInterceptor#postHandle이 실행됩니다.
  9. 응답 직렬화 또는 뷰 렌더링이 진행됩니다.
  10. HandlerInterceptor#afterCompletion이 실행됩니다.
  11. 마지막으로 Filter를 빠져나가며 후처리가 끝납니다.

여기서 바로 보이는 차이가 있습니다.

Filter는 스프링 MVC에 들어가기 전 단계에 있으므로, 컨트롤러가 무엇인지 몰라도 되는 작업에 강합니다. 반대로 Interceptor는 이미 핸들러가 결정된 뒤이므로, 메서드 정보와 애노테이션을 활용하는 작업에 강합니다.

또 하나 중요한 점은 예외 상황입니다. postHandle은 예외가 터지면 실행되지 않을 수 있지만, afterCompletion은 요청이 끝나는 시점에 호출됩니다. 종료 로그나 정리 작업은 보통 afterCompletion 쪽이 더 안전합니다.

어떤 경우에 Filter를 써야 할까

Filter는 HTTP 레벨의 공통 처리에 잘 맞습니다. 대표적인 예는 다음과 같습니다.

  • 요청과 응답 로깅
  • 문자 인코딩 설정
  • CORS 헤더 추가
  • 압축, 캐싱, 공통 보안 헤더
  • 요청 본문이나 응답을 감싸는 래퍼 적용
  • 스프링 MVC에 들어가기 전 조기 차단

예를 들어 Content-Type, User-Agent, X-Request-Id 같은 헤더를 기록하거나, 모든 응답에 공통 헤더를 넣거나, 요청 시작과 종료 시간을 남기는 작업은 Filter가 자연스럽습니다.

다만 유연한 만큼 실수 여지도 큽니다. 특히 요청 본문을 Filter에서 먼저 읽으면 뒤의 @RequestBody가 비어 버리는 문제가 자주 발생합니다. 본문 로깅이 필요하면 ContentCachingRequestWrapper 같은 래퍼를 고려해야 하고, 단순 조회용 로그와 실제 소비를 구분해야 합니다.

Filter는 정적 리소스나 에러 경로까지 걸릴 수 있습니다. URL 패턴과 디스패처 타입을 어떻게 등록했는지 반드시 확인해야 합니다.

어떤 경우에 Interceptor를 써야 할까

Interceptor는 스프링 MVC 핸들러에 가까운 공통 처리에 적합합니다. 대표적인 예는 다음과 같습니다.

  • 컨트롤러 진입 전 인증 확인
  • 특정 애노테이션 기반 검사
  • API별 접근 정책 분기
  • 핸들러 단위 실행 시간 측정
  • 요청 종료 시 핸들러 기준 로그 정리

가장 큰 장점은 handler 객체에 접근할 수 있다는 점입니다. HandlerMethod로 캐스팅하면 어떤 컨트롤러 클래스와 메서드가 실행되는지 알 수 있고, 메서드나 클래스에 붙은 애노테이션도 검사할 수 있습니다.

예를 들어 @AuthRequired, @AdminOnly, @AuditLog 같은 애노테이션을 기준으로 분기하는 로직은 Interceptor가 훨씬 자연스럽습니다. 반대로 응답 바디를 직접 감싸거나, 서블릿 레벨에서 요청을 바꾸는 작업은 Interceptor보다 Filter가 더 잘 맞습니다.

한 가지 주의할 점은 Interceptor가 권한 정책의 최종 종착지가 되어서는 안 된다는 것입니다. "이 사용자가 이 자원을 수정할 수 있는가" 같은 도메인 인가는 서비스 계층이나 스프링 시큐리티의 메서드 보안에서 다시 검증하는 편이 안전합니다. Interceptor는 주로 진입 조건 검사와 공통 정책 적용에 쓰는 것이 좋습니다.

Spring Boot에서 함께 등록하는 방법

설정은 단순해 보여도 순서와 제외 경로 때문에 실제 장애가 자주 납니다. FilterFilterRegistrationBean으로 등록하면 URL 패턴과 순서를 명시하기 쉽고, InterceptorWebMvcConfigurer에서 등록하면 적용 범위를 읽기 좋게 유지할 수 있습니다.

먼저 로그를 보기 쉽게 맞춰 두면 실행 순서를 확인하기 편합니다.

# application.yml
logging:
  level:
    root: INFO
    com.example.demo: INFO
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n"

이제 FilterInterceptor를 함께 등록해 보겠습니다. Filter는 전역 요청 로깅에, Interceptor/api/** 인증 검사에 두는 예시입니다.

package com.example.demo.config;

import com.example.demo.web.AuthInterceptor;
import com.example.demo.web.RequestLoggingFilter;
import jakarta.servlet.DispatcherType;
import java.util.EnumSet;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    @Bean
    public FilterRegistrationBean<RequestLoggingFilter> requestLoggingFilter() {
        FilterRegistrationBean<RequestLoggingFilter> bean =
                new FilterRegistrationBean<>(new RequestLoggingFilter());

        bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        bean.addUrlPatterns("/*");
        bean.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST));
        bean.setName("requestLoggingFilter");
        return bean;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/public/**", "/actuator/health", "/error");
    }
}

여기서 핵심은 두 가지입니다. Filter는 대개 범위가 넓고, Interceptor는 범위를 더 좁게 가져갑니다. 그리고 /error, 헬스 체크, 공개 API를 제외하지 않으면 장애 분석이 오히려 어려워질 수 있습니다.

실전 코드로 보기: 로깅은 Filter, 핸들러 기반 인증은 Interceptor

먼저 요청 전체를 감싸는 로깅 Filter입니다. 이 코드는 컨트롤러 성공 여부와 관계없이 시작과 종료를 모두 남길 수 있어서 운영 로그에 유용합니다.

package com.example.demo.web;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.filter.OncePerRequestFilter;

public class RequestLoggingFilter extends OncePerRequestFilter {

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

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

        long startedAt = System.currentTimeMillis();
        String method = request.getMethod();
        String uri = request.getRequestURI();

        log.info("[Filter] start {} {}", method, uri);
        try {
            filterChain.doFilter(request, response);
        } finally {
            long elapsed = System.currentTimeMillis() - startedAt;
            log.info("[Filter] end {} {} status={} {}ms",
                    method, uri, response.getStatus(), elapsed);
        }
    }
}

다음은 HandlerMethod와 애노테이션을 활용하는 Interceptor입니다. 이 예시는 @AuthRequired가 붙은 핸들러만 검사하므로, 단순 URL 매칭보다 의도가 더 분명합니다.

package com.example.demo.web;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@interface AuthRequired {
}

@RestController
@RequestMapping("/api")
class UserController {

    @GetMapping("/public/ping")
    public String ping() {
        return "ok";
    }

    @AuthRequired
    @GetMapping("/private/me")
    public String me() {
        return "user";
    }
}

@Component
public class AuthInterceptor implements HandlerInterceptor {

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

        if (!(handler instanceof HandlerMethod handlerMethod)) {
            return true;
        }

        boolean needAuth =
                handlerMethod.hasMethodAnnotation(AuthRequired.class) ||
                handlerMethod.getBeanType().isAnnotationPresent(AuthRequired.class);

        if (!needAuth) {
            return true;
        }

        String token = request.getHeader("X-AUTH-TOKEN");
        if (!"secret-token".equals(token)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write("{\"message\":\"Unauthorized\"}");
            return false;
        }

        return true;
    }
}

이렇게 나누면 역할이 깔끔해집니다. Filter는 요청 전체 흐름을 감싸고, Interceptor는 실제로 보호가 필요한 핸들러에만 개입합니다. 나중에 "어디서 막혔는지"를 추적할 때도 계층이 분리되어 있어서 원인 파악이 빨라집니다.

curl과 로그로 실행 순서를 검증하는 방법

설정이 맞아 보여도 실제로는 제외 경로가 빠졌거나, 로그가 중복되거나, 인증이 생각보다 넓게 걸리는 경우가 흔합니다. 그래서 curl로 공개 경로와 보호 경로를 각각 호출해 보는 것이 좋습니다.

curl -i http://localhost:8080/api/public/ping

curl -i http://localhost:8080/api/private/me

curl -i \
  -H "X-AUTH-TOKEN: secret-token" \
  http://localhost:8080/api/private/me

이때 기대하는 흐름은 다음과 같습니다.

공개 경로인 /api/public/pingFilter 로그만 찍히거나, Interceptor가 등록되어 있더라도 인증 차단 없이 통과해야 합니다. 보호 경로인 /api/private/me는 보통 Filter start -> Interceptor preHandle -> Controller -> Interceptor afterCompletion -> Filter end 순으로 관찰됩니다. 토큰이 없으면 컨트롤러까지 가지 않고 401로 끊겨야 정상입니다.

검증할 때 특히 봐야 할 포인트는 아래와 같습니다.

  • 공개 경로인데 인증 차단이 된다면 excludePathPatterns를 다시 확인합니다.
  • 동일 요청 로그가 두 번 찍히면 DispatcherType, 포워드, 에러 디스패치, 프록시 중복을 의심합니다.
  • 요청 종료 로그가 없다면 예외 처리 흐름이나 응답 커밋 시점을 확인합니다.
  • @RequestBody가 비어 있다면 앞단 Filter에서 본문을 먼저 소비했는지 봅니다.

자주 생기는 실수와 선택 기준

실무에서 가장 많이 생기는 문제는 "가능하니까 한곳에 몰아넣는 것"입니다. 예를 들어 인증, 권한, 로깅, 예외 가공을 모두 Interceptor 하나에 넣기 시작하면 금방 책임이 섞입니다. 반대로 요청 헤더, 응답 헤더, 본문 로깅, 인증 차단을 모두 Filter로 처리하면 스프링 MVC의 장점을 살리기 어렵습니다.

안전한 기준은 아래처럼 잡을 수 있습니다.

  • HTTP 요청과 응답 자체를 다루면 Filter
  • 핸들러 메서드 정보가 필요하면 Interceptor
  • 비즈니스 권한 판단은 서비스 계층 또는 스프링 시큐리티
  • 전역 예외 응답 변환은 @ControllerAdvice

스프링 시큐리티를 쓰는 경우에는 이 기준을 한 번 더 조정해야 합니다. JWT 인증, 세션 인증, 권한 인가는 대개 시큐리티 필터 체인에서 처리하는 것이 정석입니다. 이런 프로젝트에서 별도 Interceptor로 인증을 또 걸면 중복 검사와 책임 충돌이 생기기 쉽습니다. 그 경우 Filter는 진단성 로깅과 공통 헤더 처리에, Interceptor는 MVC 전용 부가 정책에 집중시키는 편이 낫습니다.

결국 핵심은 "어디가 더 강력한가"가 아니라 "이 로직이 어느 계층의 책임인가"입니다. 이 기준으로 보면 선택은 생각보다 단순합니다. HTTP 스펙에 가까우면 Filter, 스프링 MVC 핸들러에 가까우면 Interceptor입니다.

원문 참고

https://www.maeil-mail.kr/question/10

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...