[Spring] 스프링 인터셉터(Interceptor)란? 개념과 사용법, 등록 방법 총정리

지난 글에서는 스프링 필터에 대해 알아봤습니다. 필터는 서블릿 컨테이너 레벨, 즉 스프링 바깥에서 모든 요청을 가장 먼저 처리하는 도구였는데요. 웹 요청은 이렇게 필터를 지나 DispatcherServlet으로 들어오고, 그 안에서 컨트롤러에 도착하기 직전에 한 번 더 처리 지점을 거칩니다. 이 지점이 스프링 MVC가 제공하는 인터셉터(Interceptor)입니다.

인터셉터는 필터와 하는 일은 비슷하지만 스프링 안에서 동작하기 때문에 할 수 있는 일이 조금 다릅니다. 이번 글에서는 인터셉터가 무엇인지, 어떻게 만들고 등록하는지, 그리고 세 가지 메서드가 각각 언제 실행되는지를 예제와 함께 정리하겠습니다.

 

[Spring] 스프링 필터(Filter)란? 개념과 사용법, 등록 방법 총정리

[Spring] 스프링 필터(Filter) vs 인터셉터(Interceptor) 차이점과 선택 기준 총정리

 

인터셉터가 왜 필요할까요?

쇼핑몰의 장바구니 기능을 만든다고 생각해 보겠습니다. 장바구니에 상품을 담는 API와 장바구니를 보여주는 API가 있고, 둘 다 로그인한 사람만 쓸 수 있어야 합니다. 그러면 코드가 이렇게 됩니다.

@PostMapping("/cart")
public void addCart(HttpServletRequest request, @RequestBody CartRequest body) {
    // 로그인 확인
    String token = request.getHeader("Authorization");
    if (token == null || !tokenValidator.isValid(token)) {
        throw new UnauthorizedException();
    }
    cartService.add(body);
}

@GetMapping("/cart")
public List<CartItem> getCart(HttpServletRequest request) {
    // 로그인 확인 (또 반복)
    String token = request.getHeader("Authorization");
    if (token == null || !tokenValidator.isValid(token)) {
        throw new UnauthorizedException();
    }
    return cartService.findAll();
}

로그인을 확인하는 네 줄이 두 메서드에 똑같이 들어가 있습니다. 로그인이 필요한 기능이 10개면 10번, 100개면 100번 복사해야 합니다. 그러다 한 군데라도 깜빡하면 로그인 없이 쓸 수 있는 구멍이 생깁니다. 이렇게 여러 기능이 똑같이 신경 써야 하는 일을 공통 관심사라고 부릅니다. 공통 관심사는 한곳에 모아두고, 컨트롤러는 자기 할 일만 하게 만드는 것이 좋습니다.

 

@PostMapping("/cart")
public void addCart(@RequestBody CartRequest body) {
    cartService.add(body);          // 장바구니에 담기만 하면 됩니다
}

@GetMapping("/cart")
public List<CartItem> getCart() {
    return cartService.findAll();   // 조회만 하면 됩니다
}

여기서 로그인 확인은 어디로 갔을까요? 여기서 인터셉터를 사용하면 좋습니다. 인터셉터가 컨트롤러 앞에서 로그인 여부를 먼저 확인하고, 통과한 요청만 컨트롤러로 보내주는것입니다.

 

인터셉터란?

인터셉터는 스프링이 제공하는 기능입니다. 요청이 컨트롤러에 도착하기 직전과 직후에 끼어들어서 필요한 일을 처리해 줍니다. "가로채다"라는 뜻의 intercept에서 이름이 왔습니다.

 

 

여기서 필터는 스프링 바깥에 있고, 인터셉터는 스프링 안(DispatcherServlet 내부)에 있습니다. 이 위치 차이 때문에 인터셉터는 필터가 할 수 없는 두 가지를 할 수 있습니다.

  • 첫째, 스프링 빈을 마음대로 쓸 수 있습니다 : 인터셉터 자체가 스프링 빈이라서 토큰 검증기나 레파지토리 같은 다른 빈을 그냥 주입받으면 됩니다.
  • 둘째, 요청이 어떤 컨트롤러 메서드로 가는지 알 수 있습니다 : 그래서 "이 API는 관리자만", "저 API는 누구나" 같은 세밀한 제어가 가능합니다.

 

인터셉터 만들기

인터셉터를 만들려면 HandlerInterceptor 인터페이스를 구현하면 됩니다. 아까 장바구니의 로그인 확인 코드를 옮겨 보겠습니다.

@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {

    private final TokenValidator tokenValidator;   // 스프링 빈 주입

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) {
        String token = request.getHeader("Authorization");

        if (token == null || !tokenValidator.isValid(token)) {
            response.setStatus(401);   // 인증 실패
            return false;              // 여기서 멈춤, 컨트롤러로 안 감
        }
        return true;                   // 통과, 컨트롤러로 진행
    }
}

이 코드가 하는 일은 헤더에서 토큰을 꺼내 확인하고, 문제가 있으면 401을 세팅하고 false를 돌려줍니다. 문제가 없으면 true를 돌려주는 코드입니다.

 

여기서 가장 중요한 것은 반환값입니다.

  • true → "통과시켜라." 요청이 컨트롤러로 넘어갑니다.
  • false → "여기서 막아라." 컨트롤러는 실행되지 않고 바로 응답이 나갑니다.

필터에서는 chain.doFilter()를 호출해야 다음으로 넘어갔다면, 인터셉터는 true/false 하나로 통과 여부를 결정합니다. 훨씬 직관적이죠.

 

인터셉터 등록하기

인터셉터를 만들었으면 "어떤 URL에 적용할지"를 스프링에게 알려줘야 합니다. WebMvcConfigurer를 구현한 설정 클래스에서 등록합니다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor)
                .addPathPatterns("/api/**")                              // 여기에 적용
                .excludePathPatterns("/api/auth/login", "/api/auth/signup"); // 여기는 제외
    }
}

addPathPatterns()에는 인터셉터를 적용할 경로를, excludePathPatterns()에는 제외할 경로를 적습니다. 여기서 제외 경로가 왜 필요할까요? 로그인 API에까지 "로그인 했는지 확인하는" 인터셉터가 걸리면 어떻게 될까요? 아직 로그인하지 않은 사람은 토큰이 없으니 로그인 API조차 호출할 수 없게 됩니다. 아무도 로그인할 수 없는 서비스가 되는 것입니다. 그래서 로그인, 회원가입처럼 토큰 없이 들어와야 하는 경로는 반드시 제외해야 합니다.

 

경로 패턴에서 /api/**의 **는 "그 아래 모든 경로"라는 뜻입니다. /api/cart, /api/cart/items, /api/user/profile 전부 포함됩니다.

 

세 가지 메서드, 언제 실행될까요?

지금까지는 preHandle 하나만 썼습니다. 다만 HandlerInterceptor에는 메서드가 세 개 있습니다.

 

public interface HandlerInterceptor {
    // 컨트롤러 실행 전
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                              Object handler) { return true; }

    // 컨트롤러 실행 후
    default void postHandle(HttpServletRequest request, HttpServletResponse response,
                            Object handler, ModelAndView modelAndView) {}

    // 모든 처리가 끝난 후
    default void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, Exception ex) {}
}

셋 다 default 메서드라서 필요한 것만 골라서 오버라이드하면 됩니다. 이름 그대로 pre(전), post(후), afterCompletion(완료 후)인데, 실행 순서를 그림으로 보면 이렇습니다.

 

 

하나씩 쉽게 풀어보겠습니다.

 

preHandle은 "들어가기 전 검사"입니다. 컨트롤러가 실행되기 직전에 호출됩니다. 로그인 확인, 권한 확인처럼 "들여보낼지 말지"를 결정하는 일에 씁니다. false를 돌려주면 컨트롤러는 실행조차 되지 않습니다.

 

postHandle은 "나온 직후 처리"입니다. 컨트롤러가 일을 마치고 나온 직후, 응답이 완성되기 전에 호출됩니다. 뷰에 데이터를 더 넣어주고 싶을 때 씁니다. 그런데 한 가지 함정이 있습니다. 컨트롤러에서 예외가 터지면 postHandle은 실행되지 않습니다. 컨트롤러가 정상적으로 끝나야만 호출되기 때문입니다. 그래서 "무조건 실행되어야 하는 마무리 작업"을 여기에 두면 안 됩니다.

 

afterCompletion은 "진짜 마지막 정리"입니다. 응답까지 다 만들어진 뒤 가장 마지막에 호출되고, 예외가 났든 안 났든 항상 실행됩니다. 파라미터로 예외 객체가 넘어오기 때문에 "뭔가 잘못됐었는지"도 확인할 수 있습니다. 자원 정리, 마지막 로그 기록처럼 반드시 실행되어야 하는 일은 여기에 둡니다. 자바의 try-finally에서 finally와 같은 역할입니다.

 

인터셉터가 여러 개라면?

로그인 확인 인터셉터와 로깅 인터셉터, 이렇게 두 개를 등록했다고 해보겠습니다. 어떤 순서로 실행될까요? preHandle은 등록한 순서대로, postHandle과 afterCompletion은 거꾸로 실행됩니다. A, B 순서로 등록했다면 아래 처럼 됩니다.

 

인터셉터가 실행되는 순서는 양파 껍질을 생각하면 쉽습니다. 바깥 껍질(A)을 먼저 벗기고 안쪽 껍질(B)을 벗겨서 알맹이(컨트롤러)에 닿은 다음, 나올 때는 안쪽(B)부터 다시 덮고 바깥쪽(A)을 덮는 것입니다. 먼저 들어간 것이 나중에 나옵니다.

 

이 순서를 담당하는 것이 DispatcherServlet 내부의 doDispatch() 메서드입니다. 이 메서드가 등록된 인터셉터 목록을 들고 있다가 preHandle은 앞에서부터, postHandle과 afterCompletion은 뒤에서부터 하나씩 호출해 줍니다. 필터에서는 chain.doFilter()를 개발자가 직접 호출해서 다음으로 넘겼지만, 인터셉터는 스프링이 알아서 다음 인터셉터를 불러주기 때문에 개발자가 신경 쓸 것이 없습니다.

 

인터셉터 예제 - 로그인한 사용자가 누구인지 컨트롤러에 알려주기

로그인 확인만으로는 조금 부족합니다. 장바구니를 조회하려면 "누구의" 장바구니인지 알아야 하기 때문입니다. 인터셉터에서 토큰을 확인하면서 사용자 ID도 함께 꺼내서 컨트롤러에 전달해 주면 좋겠습니다.

@Component
@RequiredArgsConstructor
public class JwtAuthInterceptor implements HandlerInterceptor {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) {
        String header = request.getHeader("Authorization");

        // "Bearer xxxxx" 형태가 아니면 거절
        if (header == null || !header.startsWith("Bearer ")) {
            response.setStatus(401);
            return false;
        }

        String token = header.substring(7);   // "Bearer " 뒤의 토큰 부분만
        if (!jwtTokenProvider.validate(token)) {
            response.setStatus(401);
            return false;
        }

        // 토큰에서 사용자 ID를 꺼내 요청에 실어 보냄
        Long userId = jwtTokenProvider.getUserId(token);
        request.setAttribute("userId", userId);
        return true;
    }
}

핵심은 마지막의 request.setAttribute("userId", userId)입니다. 요청 객체에 사용자 ID를 붙여서 컨트롤러로 보내는 것입니다. 택배 상자에 메모를 붙여 보내는 것과 비슷합니다. 컨트롤러에서는 @RequestAttribute로 그 메모를 읽으면 됩니다.

@GetMapping("/cart")
public List<CartItem> getCart(@RequestAttribute Long userId) {
    return cartService.findByUserId(userId);   // 이 사용자의 장바구니만 조회
}

컨트롤러는 토큰이 뭔지, 어떻게 검증하는지 전혀 모릅니다. 그냥 "확인 끝난 사용자 ID"를 받아서 쓸 뿐입니다. 나중에 인증 방식이 JWT에서 다른 것으로 바뀌어도 인터셉터만 고치면 되고, 컨트롤러는 한 줄도 바꿀 필요가 없습니다.

 

자주 하는 실수

로그인 API를 제외 경로에서 빠뜨리기

앞에서 설명한 그 상황입니다. 로그인 API에 로그인 확인 인터셉터가 걸리면 아무도 로그인할 수 없습니다. 인터셉터를 등록할 때 "토큰 없이 들어와야 하는 API가 뭐가 있지?"를 먼저 정리하고 excludePathPatterns()에 넣어두시기 바랍니다.

 

preHandle에서 실수로 false 반환하기

false는 "막아라"라는 뜻입니다. if문 분기를 짜다가 정상 경로에서 false가 나가도록 만들면 멀쩡한 요청이 전부 막힙니다. 정상 흐름의 끝에 return true가 있는지 꼭 확인하시기 바랍니다.

 

postHandle에서 응답 내용 바꾸려고 하기

REST API는 컨트롤러가 객체를 반환하는 순간 이미 JSON으로 변환되어 응답에 쓰여 버립니다. postHandle은 그 다음에 호출되기 때문에, 여기서 응답을 고치려고 하면 에러가 나거나 깨진 응답이 나갑니다. 응답 내용을 바꿔야 한다면 ResponseBodyAdvice를 쓰거나 필터에서 응답 래퍼 패턴을 써야 합니다. 이 부분은 3편에서 다시 다루겠습니다.

 

정리하면

  • 인터셉터는 스프링이 제공하는 기능으로, 컨트롤러 바로 앞뒤에서 동작합니다.
  • 컨트롤러로 가는 요청만 거치고, 스프링 빈을 자유롭게 주입받을 수 있습니다.
  • HandlerInterceptor를 구현하고 WebMvcConfigurer에서 등록합니다.
  • preHandle(): 컨트롤러 전. false면 요청이 막힙니다.
  • postHandle(): 컨트롤러 후. 예외가 나면 실행 안 됩니다.
  • afterCompletion(): 맨 마지막. 예외가 나도 항상 실행됩니다.
  • 여러 개면 preHandle은 순서대로, 나머지는 거꾸로 실행됩니다.
  • 로그인·회원가입 API는 반드시 excludePathPatterns()에 넣어야 합니다.

이제 필터와 인터셉터를 각각 살펴봤습니다. 둘 다 "컨트롤러 앞에서 공통 작업을 처리한다"는 점은 같은데, 그러면 실제로는 뭘 써야 할까요? 다음 글에서 두 도구를 나란히 비교하면서 선택 기준을 정리하겠습니다.