인터셉터가 필터와 다른 점은 위치다. 인터셉터는 DispatcherServlet 안에서, 핸들러 매핑이 어느 컨트롤러가 요청을 처리할지 결정한 뒤에 실행된다. 따라서 인터셉터는 어떤 핸들러가 곧 실행될지 이미 안다. 그 실행을 컨트롤러 호출 전, 호출 후, 뷰 렌더링 후 세 지점에서 가로채고, 첫 지점에서는 요청 자체를 막을 수 있다.

세 개의 훅

HandlerInterceptor는 가로채는 시점이 다른 세 메서드를 제공한다.

preHandle

preHandle은 컨트롤러 실행 전에 호출되고 boolean을 반환한다. true면 다음 인터셉터나 컨트롤러로 진행하고, false면 요청 처리가 즉시 중단되어 컨트롤러는 실행되지 않는다. 인증 확인, 권한 검증, 호출 횟수 제한 같은 게이트키퍼 역할에 맞는 이유다.

postHandle

postHandle은 컨트롤러가 정상적으로 끝난 뒤 뷰가 그려지기 전에 호출된다. ModelAndView를 받으므로 모든 뷰에 공통으로 필요한 모델 데이터를 일괄로 넣을 때 쓴다. 컨트롤러에서 예외가 나면 건너뛴다. @RestController 기반 REST API는 뷰 렌더링이 없으므로 postHandle의 쓸모가 거의 없다.

afterCompletion

afterCompletion은 뷰 렌더링까지 끝나고 응답이 나간 뒤 항상 호출된다. 컨트롤러에서 예외가 났더라도 실행되므로 리소스 정리, 로깅, 실행 시간 측정 같은 후처리에 적합하다. 네 번째 파라미터로 Exception을 받는다. 예외가 없으면 null, 있으면 해당 객체가 들어와 추가 로깅이나 알림을 붙일 수 있다.

세 훅과 차단이 함께 도는 순서

여러 인터셉터를 등록하면 순서가 헷갈리기 쉽다. A, B 순으로 등록하고 둘 다 통과하는 경우는 다음과 같다.

preHandle A → preHandle B → Controller → postHandle B → postHandle A
→ (뷰 렌더링) → afterCompletion B → afterCompletion A

preHandle은 등록 순서대로(A → B), postHandleafterCompletion은 역순으로(B → A) 돈다. 스택처럼 들어간 역순으로 빠져나온다.

차단이 끼면 규칙이 한 번 더 적용된다. B의 preHandlefalse를 반환하면 컨트롤러도 postHandle도 실행되지 않고, 이미 통과한 A의 afterCompletion만 호출된다.

preHandle A(true) → preHandle B(false)
→ afterCompletion A   (B 자신과 그 이후는 모두 생략)

이 규칙 덕분에 preHandle에서 막아도 그전에 시작된 리소스는 afterCompletion에서 정리된다.

구현과 등록

HandlerInterceptor를 구현하고, WebMvcConfigureraddInterceptors에서 InterceptorRegistry에 등록한다.

public class CustomInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 컨트롤러 실행 전
        return true; // false면 요청 처리 중단
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response,
                           Object handler, ModelAndView modelAndView) {
        // 컨트롤러 정상 종료 후, 뷰 렌더링 전
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                               Object handler, Exception ex) {
        // 뷰 렌더링 후 (예외가 나도 실행)
    }
}
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new CustomInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/static/**", "/error")
                .order(1);
    }
}

addPathPatterns로 적용 경로를, excludePathPatterns로 제외 경로를, order로 순서를 지정한다. 필터가 모든 요청에 일률적으로 닿는 것과 달리, 인터셉터는 경로 패턴으로 적용 범위를 좁힐 수 있다. 경로는 Ant 스타일 매칭으로, **는 모든 하위 경로, *는 단일 세그먼트, ?는 단일 문자를 뜻한다. /api/**/api로 시작하는 모든 경로에, /user/*/profile/user/123/profile에 매칭된다.

인증

차단 게이트의 전형적인 쓰임이 인증이다. preHandle에서 토큰을 확인하고, 유효하지 않으면 false를 반환해 요청을 막고 401 응답을 직접 세팅한다. 예외를 던지는 것보다 흐름이 명확하다.

@Component
public class AuthInterceptor implements HandlerInterceptor {

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

        if (token == null || !tokenService.isValid(token)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\": \"Unauthorized\"}");
            return false; // 컨트롤러로 가지 않음
        }

        return true;
    }
}

실행 시간 측정

preHandleafterCompletion은 요청의 시작과 끝을 감싸므로 한 쌍으로 자주 쓴다. 시작 시각을 request attribute에 저장하고 끝에서 차이를 계산한다. afterCompletion은 예외가 나도 실행되므로 측정이 누락되지 않는다.

@Component
public class ExecutionTimeInterceptor implements HandlerInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(ExecutionTimeInterceptor.class);

    @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");
        long duration = System.currentTimeMillis() - startTime;
        logger.info("Request {} {} completed in {}ms", request.getMethod(), request.getRequestURI(), duration);
    }
}

시작 시각을 ThreadLocal이 아니라 request attribute에 두면 요청별로 격리되어 더 안전하다.

주의점

  • 스프링 빈을 쓰려면 인터셉터를 빈으로 등록한다. @Component로 올리거나 @Configuration에서 빈으로 만든 뒤 주입해 등록한다. new로 만들면 DI가 동작하지 않는다.
  • 모든 요청을 타므로 무거운 작업은 피한다. DB 조회나 외부 API 호출은 가급적 캐싱으로 줄인다.
  • REST API에서는 postHandle보다 preHandle/afterCompletion을 쓴다. 뷰 렌더링이 없어 postHandle이 거의 비기 때문이다.
  • 인증 실패는 예외보다 false 반환으로 처리한다. 응답을 직접 세팅하는 편이 흐름이 분명하다.