Spring HandlerInterceptor 심화

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별 성능 모니터링도 간편하다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux