일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
- flutter 믹스인
- SQL
- 운영체제 면접 답변
- ?. ?? ! late
- null check 연산자
- mysql mongo 성능 비교
- 앱개발 가보자고
- 주말도 식지않아
- 주말에도 1일 1쿼리
- 빅분기 판다스 100제
- 오늘은 1일 2쿼리
- 모델 학습 및 예측
- 작업 2유형
- 1일 1쿼리
- 컴포지션과 집합
- FLUTTER
- 네트워크 면접 답변
- late 키워드
- MySQL
- 작업 1유형
- rdbms nosql 차이
- 빅분기 캐글놀이터
- 주말도 한다
- null 억제 연산자
- 빅분기
- 빅분기 1유형
- my_sql
- 빅데이터 분석기사
- 빅분기 필기 pdf
- sqld 시험 정리
- Today
- Total
subindev 님의 블로그
[Spring] Filter, Interceptor, AOP의 차이점과 실제 프로젝트 사례 본문
웹 애플리케이션을 개발하다 보면, 공통 기능을 한 번에 처리해야 할 때가 많습니다.
예를 들어 모든 요청에 대해 보안 검사, 로그인 체크, 로깅을 하고 싶을 때가 그렇죠.
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 |