subindev 님의 블로그

[Spring] Filter, Interceptor, AOP의 차이점과 실제 프로젝트 사례 본문

기술 면접 준비

[Spring] Filter, Interceptor, AOP의 차이점과 실제 프로젝트 사례

subindev 2025. 9. 24. 14:31

 

웹 애플리케이션을 개발하다 보면, 공통 기능을 한 번에 처리해야 할 때가 많습니다.

예를 들어 모든 요청에 대해 보안 검사, 로그인 체크, 로깅을 하고 싶을 때가 그렇죠.

 

Spring에서는 이런 상황을 해결하기 위해 Filter, Interceptor, AOP라는 세 가지 방법이 있습니다.

저도 처음에는 헷갈렸는데, 정리해보니 호출 시점과 적용 범위가 달라서 용도가 구분이 되더라구요.

실제 프로젝트에서 어떤 걸 어떤 기능에 사용했는지 함께 정리해보도록 하겠습니다.

 

 

🚦 실행 순서

요청이 들어오면 동작 순서는 다음과 같습니다.

 

Filter → Interceptor → AOP → Interceptor → Filter

  • Filter는 가장 바깥단
  • Interceptor는 Controller 전, 후
  • AOP는 비즈니스 로직(Service) 단

 

 

1.  Filter

  • 스프링 컨텍스트 외부에 존재하여 스프링에 무관한 자원에 대해 동작
  • HTTP 프로토콜로 들어오는 모든 요청을 가장 먼저 받아 처리 함
  • Dispatcher Servlet 영역에 들어가기 전 Front Controller 앞 범위에서 수행
  • 필터 적용시 필터가 호출된 후에 서블릿이 호출된다
  • 위치: DispatcherServlet 앞 (스프링 영역 밖인 서블릿 전 단계)

사용 사례

  • XSS 방어 같은 보안 검사
  • 인증/인가 (Spring Security도 필터 기반)
  • 암/복호화 처리
  • 이미지·데이터 압축

 

프로젝트 사용 사례

제가 맡았던 기능 중에서 직접 필터를 만들어 적용한 경험은 없지만, Spring Security에서 제공하는 OncePerRequestFilter를 커스터마이징하면서 필터의 역할을 체감할 수 있었습니다.

 

이 필터는 Controller에 진입하기 전에 동작하기 때문에, 요청이 컨트롤러에 도달하기 전에 인증/인가 처리를 할 수 있었습니다.

 

이 필터에서 JwtProvider를 활용하여 JWT 토큰을 파싱하고 검증했으며, 유효한 경우에는 SecurityContextHolder에 인증 객체를 등록하여 인증/인가 처리를 진행했습니다. 이 과정에서 보안 로직은 Controller나 Service 단으로 내려가지 않고, Filter 단에서 미리 걸러지도록 설계할 수 있었습니다.

@Log4j2
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    private static final String AUTH_HEADER = "Authorization";
    private static final String TOKEN_PREFIX = "Bearer";

    // 로그인 또는 공개 API 경로는 필터 제외
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith("/public/") || uri.equals("/api/user/login")
                || uri.startsWith("/api/ws")    // WebSocket 관련
                || uri.startsWith("/ws")        // WebSocket endpoint
                || uri.startsWith("/topic")     // STOMP topic
                || uri.startsWith("/app");      // STOMP destination prefix;
    }

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

        // 토큰 추출 (헤더 또는 쿠키에서)
        String token = resolveToken(request);
        log.info("토큰 추출 완료: {}", token);

        // 토큰이 있을 경우에만 검증
        if (token != null) {
            try {
                jwtProvider.validateToken(token); // 토큰 유효성 검증
                Authentication authentication = jwtProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication); // 인증 정보 설정
                log.info("인증 성공: {}", authentication);
            } catch (Exception e) {
                log.info("토큰 검증 실패: {}", e.getMessage());
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.setContentType("application/json");
                response.getWriter().write("{\"error\": \"유효하지 않은 토큰\", \"message\": \"" + e.getMessage() + "\"}");
                return;
            }
        } else {
            log.info("요청에 토큰이 없습니다.");
        }

        // 다음 필터로 요청 전달
        filterChain.doFilter(request, response);
    }

    // 토큰 추출 로직 (헤더와 쿠키 처리)
    private String resolveToken(HttpServletRequest request) {
        // 1. Authorization 헤더에서 Access Token 추출
        String header = request.getHeader(AUTH_HEADER);
        if (header != null && header.startsWith(TOKEN_PREFIX)) {
            log.info("Authorization 헤더에서 토큰 추출 성공");
            return header.substring(TOKEN_PREFIX.length()).trim();
        }

        // 2. 쿠키에서 Refresh Token 추출
        if (request.getCookies() != null) {
            for (Cookie cookie : request.getCookies()) {
                if ("refreshToken".equals(cookie.getName())) {
                    log.info("쿠키에서 Refresh Token 추출 성공");
                    return cookie.getValue();
                }
            }
        }

        log.info("토큰을 찾을 수 없습니다.");
        return null; // 토큰이 없으면 null 반환
    }
}

 

2. Interceptor

  • 요청을 가로채기 (작업 전/후)
  • 스프링 컨텍스트 영역 내부에서 Controller에 관한 요청과 응답에 대해 처리
  • Interceptor는 Dispatcher Servlet에 N개 등록 될 수 있다.
  • 스프링의 모든 @Bean에 접근이 가능하다.
  • 위치: Spring 컨텍스트 내부, Controller 전후
  • 메서드:
    • preHandle() : 컨트롤러 실행 전
    • postHandle() : 컨트롤러 실행 직후, 뷰 렌더링 전
    • afterCompletion() : 모든 뷰 렌더링 후

 

사용 사례

  • 로그인/권한 체크
  • API 호출 로깅
  • Controller로 전달할 데이터 가공

 

프로젝트 사용 사례

Mybatis를 활용한 프로젝트에서 거의 모든 페이지에서 공통으로 필요한 데이터가 있었습니다.

예를 들어 헤더/푸터 정보, 카테고리, 배너 같은 데이터인데요.

 

만약 CSR 구조였다면, 프론트단의 전역 상태에 이러한 정보를 보관하고 변경 시 갱신을 해 줄 수 있습니다.

하지만 해당 프로젝트는 SpringBoot 와 Mybatis를 기반의 SSR 구조였기에, 서버에서 매 요청마다 데이터를 내려줬야 했습니다.

 

모든 컨트롤러에서 정보를 매번 조회하는 코드를 넣을 순 없으니 Interceptor를 활용하여 공통 데이터를 Model에 자동 주입하도록 했습니다. 또한, 해당 데이터들은 별도로 캐싱 처리를 하여 매번 DB에 접근하지 않도록 처리하였습니다.

@RequiredArgsConstructor
public class AppInfoInterceptor implements HandlerInterceptor {

      /*
        Interceptor
        - 클라이언트의 요청과 컨트롤러 사이에서 특정 작업을 수행하기 위한 컴포넌트
        - HTTP 요청을 가로채고, 요청이 컨트롤러에 도달전과 후에 추가 작업 수행
        - 헤더나 푸터의 정보를 넣어줄 사용할 예정
     */

    private final AppInfo appInfo;
    private final InfoService infoService;
    private final BannerService bannerService;
    private final CategoryService categoryService;
    private final ProductService productService;
    private final VisitorService visitorService;

    //postHandle은  controller의 요청 메서드에서
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // 컨트롤러 (요청 메서드)를 수행 후 실행
        if (modelAndView != null) {
            modelAndView.addObject("appInfo", appInfo);  // 기존 AppInfo 객체 추가
            InfoDTO info = infoService.selectInfoDTO();      // InfoDTO 객체 추가
            modelAndView.addObject("info", info);         // 헤더에서 사용할 info 객체 추가
            List<BannerDTO> banners = bannerService.getAllBannersWithLocation();    //모든 배너리스트 불러오기
            modelAndView.addObject("banners", banners);     // 모든 배너 추가
            List<ProductCateChildDTO> productCateDTOList = categoryService.getProductCateListWithDepth(1); //1계층 카테고리 추가
            modelAndView.addObject("productCateDTOList", productCateDTOList);
            List<ProductListDTO> SoldList = productService.getProductBest();
            modelAndView.addObject("SoldList", SoldList);
            visitorService.registerVisit(request);

        }
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //컨트롤러 를 수행 전 실행

        return HandlerInterceptor.super.preHandle(request, response, handler);
    }

}

 


 

3. AOP (관점 지향 프로그래밍)

  • 프로그램의 핵심 로직과 공통 관심사를 분리
  • 로깅, 트랜잭션, 보안, 성능 모니터링처럼 여러 모듈에 흩어지는 공통 기능을 모듈화 
  • Filter/Interceptor은 요청 단위에서 동작하고, AOP는 메서드 단위에서 동작한다는 차이가 있습니다.
  • 위치: Service 계층 또는 Repository 계층의 메서드 실행 전/후에 주로 동작합니다.

사용 사례

  • 로깅
  • 트랜잭션 처리
  • 에러처

 

프로젝트 사용 사례

 

저는 이커머스 프로젝트에서 사용자 로그 수집 및 분석 기능을 담당했습니다.

처음에는 컨트롤러와 서비스 단에 직접 로그 코드를 넣으려 했지만, 코드 중복이 많아지고 핵심 로직과 섞여 유지보수가 어려워졌습니다.

 

그래서 Spring AOP의 @Aspect를 활용해 핵심 기능과 부가 기능을 분리했습니다.

상품 조회, 장바구니 추가·삭제, 주문, 검색과 같은 이벤트가 발생하면, AOP를 통해 자동으로 로그를 남기도록 구현했습니다.

 

로그 데이터는 각 이벤트마다 컬럼 구조가 달라지는 비정형 데이터였기 때문에 RDBMS보다는 MongoDB에 적합하다고 판단했습니다.

MongoDB에 로그를 저장하고, 이후에는 Aggregation 파이프라인을 활용해 사용자 행동을 분석했습니다.

이를 기반으로 ‘연관 상품 추천’ 기능을 구현하기도 했습니다.

@Aspect
@Component
@RequiredArgsConstructor
public class LoggingAspect {
    private final ProductService productService;
    private final UserLogService userLogService;
    private final CartService cartService;

    // 공통 메서드
    private void recordUserLog(String eventType, Integer prodId, String keyword, Integer price, Integer rating, Integer quantity) {
        // Authentication을 통한 사용자 ID 가져오기 (로그인 시에만 값이 있음)
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String userId = null;

        if (authentication != null && authentication.isAuthenticated() && authentication.getPrincipal() instanceof MyUserDetails) {
            MyUserDetails userDetails = (MyUserDetails) authentication.getPrincipal();
            userId = userDetails.getUsername(); // UID 가져오기
        }

        // 검색 이벤트는 UID 없이도 저장, 다른 이벤트는 UID가 있을 때만 저장
        if ("search".equals(eventType) || userId != null) {
            UserLogDTO.UserLogDTOBuilder builder = UserLogDTO.builder()
                    .uid(userId) // UID가 없는 경우 null로 설정됨
                    .eventType(eventType)
                    .timestamp(LocalDateTime.now());

            Optional.ofNullable(prodId).ifPresent(builder::prodId);
            Optional.ofNullable(keyword).ifPresent(builder::keyword);
            Optional.ofNullable(price).ifPresent(builder::price);
            Optional.ofNullable(rating).ifPresent(builder::rating);
            Optional.ofNullable(quantity).ifPresent(builder::quantity);

            UserLogDTO userLogDTO = builder.build();
            userLogService.insertLog(userLogDTO);
            System.out.println("Log recorded for event: " + eventType + (userId != null ? " by user: " + userId : "") + (keyword != null ? " with keyword: " + keyword : ""));
        }
    }

    // 상품 보기
    @AfterReturning("execution(* com.lotte4.controller.pagecontroller.ProductController.view(..)) && args(prodId, ..)")
    public void logAfterProductView(Integer prodId) {
        recordUserLog("view", prodId, null, null, null, null); // view 이벤트에 대한 로그 기록
    }
    // 장바구니 입력
    @AfterReturning("execution(* com.lotte4.controller.pagecontroller.ProductController.addCart(..)) && args(cartResponseDTO, principal)")
    public void logAfterAddCart(CartResponseDTO cartResponseDTO, Principal principal) {

        Integer variantId = cartResponseDTO.getProductVariants().get(0);
        List<Integer> counts = cartResponseDTO.getCounts();

        int quantity = counts.stream().mapToInt(Integer::intValue).sum();
        // cartResponseDTO엔 productID가 없습니다
        Integer prodId = productService.findProductVariantById(variantId).getProduct().getProductId();

        recordUserLog("add_cart", prodId, null, null, null, quantity);
    }

    // 장바구니 삭제
    @Before("execution(* com.lotte4.service.CartService.deleteCartItems(..)) && args(cartId)")
    public void logAfterDeleteCart(int cartId) {
        Cart cart = cartService.selectCartById(cartId);
        Integer prodId = cart.getProductVariants().getProduct().getProductId();
        recordUserLog("delete_cart", prodId, null, null, null, null);
    }
    // 주문 입력
    @AfterReturning("execution(* com.lotte4.service.OrderService.insertOrder(..)) && args(orderDTO)")
    public void logAfterInsertOrder(OrderDTO orderDTO) {
        for (OrderItemsDTO orderItemsDTO : orderDTO.getOrderItems()) {

            Integer prodId = orderItemsDTO.getProductVariants().getVariant_id();  // 각 제품의 ID 가져오기
            Integer quantity = orderItemsDTO.getCount();  // 각 제품의 수량 가져오기
            recordUserLog("order", prodId, null, null, null, quantity);
        }
    }
    // 검색 후 로그 기록
    @AfterReturning("execution(* com.lotte4.controller.pagecontroller.ProductController.search(..)) && args(keyword, filters, minPrice, maxPrice, type, ..)")
    public void logAfterSearch(String keyword, List<String> filters, Integer minPrice, Integer maxPrice, String type) {
        recordUserLog("search", null, keyword, null, null, null);
    }
}

 

 

 


 

 

📊  총 정리 표

구분 Filter Interceptor AOP
실행 위치 DispatcherServlet 이전
(서블릿 단)
DispatcherServlet 이후, Controller 전/후 비즈니스 로직
(Service/Repository)
적용 범위 전역 요청/응답 Spring MVC 흐름 (컨트롤러 단위) 메서드 단위 (횡단 관심사)
동작 방식 doFilter() preHandle(), postHandle(), afterCompletion() @Before, @AfterReturning, @Around
주요 목적 보안 검사, 인코딩, 인증/인가, 데이터 압축 로그인/권한 체크, 로깅, Controller 데이터 가공 로깅, 트랜잭션, 성능 모니터링, 예외 처리
스프링 의존성 ❌ (서블릿 스펙) ✅ (Spring MVC 기능) ✅ (Spring AOP 기능)

 

 

'기술 면접 준비' 카테고리의 다른 글

[CS 지식] 3. 자료구조  (1) 2025.08.30
[CS 지식] 2.네트워크  (3) 2025.08.29
[CS 지식] 1. 운영체제  (2) 2025.08.27
Java #1  (2) 2025.01.10
Spring #1  (0) 2025.01.10