HandlerInterceptor란?
Spring MVC의 HandlerInterceptor는 Controller 실행 전후에 공통 로직을 삽입하는 메커니즘이다. Servlet Filter와 유사하지만, Spring MVC의 DispatcherServlet 내부에서 동작하므로 Handler 정보(어떤 Controller의 어떤 메서드가 호출되는지)에 접근할 수 있다는 결정적 차이가 있다.
이 글에서는 HandlerInterceptor의 3단계 콜백, Filter와의 차이점, 실전 패턴(인증·로깅·Rate Limiting·MDC), 비동기 처리 시 주의점까지 다룬다.
3단계 콜백 메서드
| 메서드 | 실행 시점 | 주요 용도 |
|---|---|---|
preHandle |
Controller 실행 전 | 인증/인가, 요청 검증, 로깅 시작 |
postHandle |
Controller 실행 후, View 렌더링 전 | 모델 수정, 공통 응답 데이터 추가 |
afterCompletion |
View 렌더링 후 (항상 실행) | 리소스 정리, 메트릭 기록, MDC 클리어 |
// 실행 흐름
preHandle → Controller → postHandle → View Rendering → afterCompletion
// preHandle이 false 반환 시
preHandle(false) → 요청 중단 (Controller 실행 안 함)
기본 구현과 등록
@Component
@Slf4j
public class RequestLoggingInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// Handler 정보 접근 가능
if (handler instanceof HandlerMethod handlerMethod) {
String controller = handlerMethod.getBeanType().getSimpleName();
String method = handlerMethod.getMethod().getName();
log.info("▶ {}.{} | {} {}",
controller, method,
request.getMethod(), request.getRequestURI());
}
request.setAttribute("startTime", System.nanoTime());
return true; // true: 계속 진행, false: 요청 중단
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) {
// REST API에서는 ModelAndView가 null
// View 기반 MVC에서 공통 모델 추가에 유용
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
long startTime = (long) request.getAttribute("startTime");
long elapsed = (System.nanoTime() - startTime) / 1_000_000;
log.info("◀ {} {} | {}ms | status={}",
request.getMethod(), request.getRequestURI(),
elapsed, response.getStatus());
if (ex != null) {
log.error("요청 처리 중 예외 발생", ex);
}
}
}
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final RequestLoggingInterceptor loggingInterceptor;
private final AuthInterceptor authInterceptor;
private final RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 1. 인증 (전체 적용, 일부 제외)
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/**", "/api/health");
// 2. Rate Limiting (API만)
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**");
// 3. 로깅 (전체)
registry.addInterceptor(loggingInterceptor)
.addPathPatterns("/**")
.order(Ordered.LOWEST_PRECEDENCE); // 마지막에 실행
}
}
Filter vs Interceptor: 올바른 선택
| 항목 | Servlet Filter | HandlerInterceptor |
|---|---|---|
| 실행 위치 | DispatcherServlet 외부 | DispatcherServlet 내부 |
| Handler 정보 | 접근 불가 | HandlerMethod로 접근 가능 |
| 요청/응답 래핑 | 가능 (래퍼 패턴) | 불가 |
| 예외 처리 | 직접 처리 | @ExceptionHandler 활용 가능 |
| 적합한 용도 | CORS, 인코딩, 요청 본문 캐싱 | 인증, 로깅, 메트릭, 비즈니스 검증 |
실전 패턴 1: MDC 기반 요청 추적
@Component
public class MdcInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String traceId = request.getHeader("X-Trace-ID");
if (traceId == null) {
traceId = UUID.randomUUID().toString().substring(0, 8);
}
MDC.put("traceId", traceId);
MDC.put("method", request.getMethod());
MDC.put("uri", request.getRequestURI());
MDC.put("clientIp", getClientIp(request));
response.setHeader("X-Trace-ID", traceId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
MDC.clear(); // 반드시 정리 — 스레드 풀 재사용 시 데이터 유출 방지
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
return xff != null ? xff.split(",")[0].trim()
: request.getRemoteAddr();
}
}
실전 패턴 2: 커스텀 어노테이션 기반 제어
// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
String[] value();
}
// Controller에서 사용
@RestController
public class AdminController {
@RequireRole({"ADMIN", "SUPER_ADMIN"})
@GetMapping("/api/admin/users")
public List<UserDto> getUsers() { ... }
}
// Interceptor에서 어노테이션 읽기
@Component
public class RoleInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws IOException {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
RequireRole annotation = handlerMethod.getMethodAnnotation(RequireRole.class);
if (annotation == null) {
return true; // 어노테이션 없으면 통과
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Set<String> userRoles = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
boolean hasRole = Arrays.stream(annotation.value())
.anyMatch(role -> userRoles.contains("ROLE_" + role));
if (!hasRole) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write(
"{"error":"권한이 부족합니다","required":""
+ String.join(",", annotation.value()) + ""}");
return false;
}
return true;
}
}
실전 패턴 3: API 실행 시간 메트릭
@Component
@RequiredArgsConstructor
public class MetricsInterceptor implements HandlerInterceptor {
private final MeterRegistry meterRegistry;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
request.setAttribute("metricsTimer", Timer.start(meterRegistry));
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
Timer.Sample sample = (Timer.Sample) request.getAttribute("metricsTimer");
if (sample != null && handler instanceof HandlerMethod hm) {
sample.stop(Timer.builder("http.server.requests.interceptor")
.tag("class", hm.getBeanType().getSimpleName())
.tag("method", hm.getMethod().getName())
.tag("status", String.valueOf(response.getStatus()))
.tag("exception", ex != null ? ex.getClass().getSimpleName() : "none")
.register(meterRegistry));
}
}
}
여러 Interceptor의 실행 순서
// 등록 순서대로 preHandle 실행, 역순으로 afterCompletion 실행
InterceptorA.preHandle → true
InterceptorB.preHandle → true
InterceptorC.preHandle → true
Controller 실행
InterceptorC.postHandle
InterceptorB.postHandle
InterceptorA.postHandle
View Rendering
InterceptorC.afterCompletion
InterceptorB.afterCompletion
InterceptorA.afterCompletion
// InterceptorB가 false 반환 시
InterceptorA.preHandle → true
InterceptorB.preHandle → false (중단!)
InterceptorA.afterCompletion ← 이미 실행된 것만 정리
AsyncHandlerInterceptor: 비동기 대응
@Component
public class AsyncAwareInterceptor implements AsyncHandlerInterceptor {
@Override
public void afterConcurrentHandlingStarted(
HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// Callable/DeferredResult 등 비동기 처리 시작 시 호출
// 서블릿 스레드가 반환되기 전 정리 작업
MDC.clear();
}
}
주의점과 안티패턴
| 안티패턴 | 문제점 | 해결책 |
|---|---|---|
| afterCompletion에서 MDC 미정리 | 스레드 풀 재사용 시 이전 요청 데이터 유출 | MDC.clear() 필수 |
| preHandle에서 요청 본문 읽기 | InputStream 소비되어 Controller에서 읽기 불가 | Filter에서 ContentCachingRequestWrapper 사용 |
| Interceptor에서 무거운 DB 조회 | 모든 요청에 오버헤드 | 캐시 활용 또는 AOP로 선별 적용 |
| postHandle에서 예외 처리 기대 | 예외 시 postHandle 호출 안 됨 | afterCompletion 사용 (항상 호출) |
마무리
Spring HandlerInterceptor는 AOP 없이도 Controller 전후 공통 관심사를 깔끔하게 분리하는 핵심 도구다. Handler 정보 접근, 커스텀 어노테이션 기반 제어, 실행 순서 제어 등 Filter로는 불가능한 패턴을 구현할 수 있다. Bean Lifecycle과 함께 Spring MVC의 요청 처리 파이프라인을 이해하면 더 정교한 설계가 가능하고, Micrometer 메트릭을 Interceptor에서 수집하면 Controller별 성능 모니터링도 간편하다.