단톡방에서 나왔던 얘기 중에 "특정 API에 대해 로그를 남기려고 하는데 AOP와 필터 중에 어떤 걸 쓰는 게 좋겠냐?"라는 질문이 하나 있었다.
나는 저 질문을 봤을 때 "API에 대한 로그면 컨트롤러 로직을 의미하고 -> 그러면 인터셉터와 AOP 중에 고민하는 게 맞지 않나?"라는 생각이 들었다.
그리고 나도 회사에서 로깅을 위해 기존에 API마다 존재했던 로깅 로직을 AOP로 묶어서 처리했던 경험도 있었다.
당시 작업을 진행할 때는 로깅 작업이 컨트롤러마다 중복으로 처리되고 있어서 취준생때의 경험을 살려 AOP로 묶기로 결정했었다.
그래서 로깅을 진행할 때 'AOP vs 인터셉터 vs 필터' 라고 한다면 무엇을 사용하면 좋을지에 대해 정확한 답변을 못하게 되었다.
우선 간단하게 각각의 개념이 무엇인지 다시 한번 가볍게 짚고 넘어가 보자.
필터
필터는 디스패쳐 서블릿에 들어가기 전 요청과 응답을 컨트롤할 수 있게 해주는 방법이다.
그림을 보듯이 클라이언트가 요청을 보내면 서블릿 컨테이너에 위치한 필터가 요청과 응답을 가로채서 어떠한 작업을 진행할 수 있다.
디스패쳐 서블릿 전에 수행하기에 스프링 컨테이너 바깥에 위치한다는 것이 큰 특징이다.
인터셉터
인터셉터는 디스패쳐 서블릿과 컨트롤러 사이에 위치해서 특정 컨트롤러의 요청과 응답을 컨트롤할 수 있는 방법이다.
순수 자바만 사용한다면 직접 인터셉터라는 인터페이스를 만들어서 사용할 수도 있지만, 스프링에서는 '스프링 인터셉터'라는 것을 미리 만들어두고 사용하고 있다.
그래서 스프링에서는 'HandlerInterceptor'라는 인터페이스를 미리 만들어 두었으며 이걸 구현해서 인터셉터를 쉽게 생성할 수 있다.
package org.springframework.web.servlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return true;
}
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
}
}
AOP
AOP는 Aspect Oriented Programming의 약어로 관점 지향 프로그래밍이라고 번역한다.
로직을 작성하다 보면 수많은 비즈니스 로직에 공통적으로 들어가는 관심사가 존재하는 경우가 생기게 된다.
(ex, 트랜잭션, 로깅)
아래 사진에서는 3가지 서비스에 비즈니스 로직을 제외한 나머지가 공통적으로 들어가 있다.(트랜잭션, 로깅)
그래서 부가적으로 수행되는 공통적인 로직을 묶어서 앞단에서 관리하고 수행하도록 만드는 방법이 AOP인 것이다.
우리가 기본적으로 사용하는 @Transactional 어노테이션도 공통적으로 수행되는 트랜잭션을 쉽게 사용하도록 만든 AOP 어노테이션이다.
위 설명만 봤을 때는 필터, 인터셉터, AOP 전부 로깅 작업을 하기 위해서 문제가 없어 보인다.
한 가지 요구사항을 바탕으로 직접 적용하면서 차이점을 구분해 보자.
요구사항 : 사용자가 로그인하면 로그인 이력을 남겨야 한다.
우선 요구사항에서는 로그인 이력을 어떻게 남겨야 한다는 말이 없어서 콘솔에 로그를 찍는 것으로 작업을 수행하려고 한다.
필터 사용
package com.example.demo.domain.member.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.IOException;
@Slf4j
@WebFilter(urlPatterns = "/login")
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("로그인 필터 초기화 진행");
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
// 클라이언트 아이피
String clientId = httpServletRequest.getRemoteAddr();
// 요청 API 경로
String requestURI = httpServletRequest.getRequestURI();
// JSON 읽기
String email = httpServletRequest.getParameter("email");
String password = httpServletRequest.getParameter("password");
log.info("요청 아이피 : {}, 요청 경로 : {}, 요청 데이터 : {}, {}", clientId, requestURI, email, password);
filterChain.doFilter(request, response);
}
@Override
public void destroy() {
Filter.super.destroy();
}
}
필터는 init 메서드로 초기화를 진행, doFilter로 요청과 응답을 가로채서 추가 작업, destory로 필터 파괴를 진행하게 된다.
@WebFilter를 통해 서블릿 컨테이너에 필터를 등록해 주고, @ServletComponentScan을 메인에 등록해서 서블릿 컨테이너가 필터를 인식하고 등록할 수 있게 해 준다.
원하던 로깅은 정상적으로 출력되는 것을 확인할 수 있다.
doFilter의 파라미터에는 ServletRequest, ServletResponse가 있어서 서블릿 단에서의 요청과 응답을 컨트롤하기 위해 만들어졌다는 것을 추측할 수 있다. 즉, 가장 맨 앞단에서 무언가를 처리해야 할 때(모든 요청 로깅, 토큰 확인, 인코딩, CORS 등등) 사용하는 것이 좋다는 생각이 든다.
인터셉터 사용
package com.example.demo.domain.member.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
@Slf4j
public class MemberInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 클라이언트 아이피
String clientId = request.getRemoteAddr();
// 요청 API 경로
String requestURI = request.getRequestURI();
// JSON 읽기
String email = request.getParameter("email");
String password = request.getParameter("password");
log.info("요청 아이피 : {}, 요청 경로 : {}, 요청 데이터 : {}, {}", clientId, requestURI, email, password);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}
HandlerInterceptor를 구현했으며 preHandle 메서드를 이용했다.
(preHandler -> 타겟 메서드 수행 전에 실행되는 메서드)
preHandle에서는 request와 response, handler를 컨트롤할 수 있다.
request와 response는 필터와 동일하지만 handler 안에는 어떤 메서드를 호출했는지, 어떤 컨트롤러를 호출했는지 등등 정보를 가지고 있다.
login 이름을 가지는 메서드를 호출했다는 정보도 확인이 가능하고
어떤 빈을 주입받았는지(?)도 확인이 가능한 것 같다.
preHandle은 반환타입이 boolean이어서 true를 주면 다음으로 넘기고, false를 주면 인터셉터에서 막히게 만들 수 있다.
그래서 이런 목적을 따르자면 로깅을 위해서는 적합하지 않아 보인다??라고도 생각이 든다. 물론 바로 true를 리턴해서 로깅용으로도 사용은 할 수 있다.
또 postHandle에는 ModelAndView라는 타입의 파라미터가 들어있는데 여기에는 반환하는 뷰의 이름과 모델이 들어있어 이걸 가지고 추가적인 동작을 컨트롤하기에 유용하다고 생각한다.
(postHandle -> 타겟 메서드를 실행한 이후 실행하는 메서드)
AOP 활용
package com.example.demo.domain.member;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class MemberAOP {
@Pointcut("execution(* com.example.demo.domain.member.controller.MemberController.*(..))")
public void memberControllerMethods(){
}
@Around("memberControllerMethods()")
public Object log(ProceedingJoinPoint joinPoint){
HttpServletRequest request = null;
HttpServletResponse response = null;
Object[] args = joinPoint.getArgs();
for(Object arg: args){
if(arg instanceof HttpServletRequest){
request = (HttpServletRequest) arg;
}else if(arg instanceof HttpServletResponse){
response = (HttpServletResponse) arg;
}
}
if(request != null){
String clientIp = request.getRemoteAddr();
// 요청 API 경로
String requestURI = request.getRequestURI();
// 요청 파라미터 (예시: email과 password)
String email = request.getParameter("email");
String password = request.getParameter("password");
log.info("요청 아이피 : {}, 요청 경로 : {}, 요청 데이터 : email={}, password={}", clientIp, requestURI, email, password);
}
try {
log.info("멤버 컨트롤러 실행 전");
Object result = joinPoint.proceed();
log.info("멤버 컨트롤러 실행 후");
return result;
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
}
AOP를 적용하게 되면 실제 target을 호출할 때 바로 target으로 가는 게 아니라 Proxy 객체가 호출되고 그 안에서 target을 호출하게 된다.
현재는 MemberController 자체에 AOP를 걸어놓은 상태이므로 디버깅을 통해 객체를 확인하면 joinPoint 안에 proxy와 target이 각각 존재하는 것을 확인할 수 있다.
proxy 객체를 보면 SpringCGLIB을 통해 프록시 객체를 주입받은 상태이고 target은 기존의 MemberController인 것을 확인할 수 있다.
AOP에서는 필터와 인터셉터랑은 다르게 ProceedingJoinPoint 타입을 가지는 파라미터 하나만 가지고 있다.
아무것도 구현되지 않은 인터페이스지만 실제로 로직을 실행시켜 보면 아래와 같이 MethodInvocationProceedingJoinPoint라는 클래스를 주입받아서 되게 많은 메서드를 구현하고 있는 상태다.
여태까지 필터, 인터셉터, AOP 내용을 살펴보면 전부 로깅을 위해 사용하기에는 큰 문제가 없다고 생각이 든다.
필터는 가장 맨 앞단에서 모든 요청을 검증할 수 있기 때문에 모든 요청에 대한 로깅 작업이나, 인코딩 설정, CORS 같은 작업을 처리하기에 가장 적합하다는 생각이 든다.
인터셉터는 디스패쳐 서블릿과 컨트롤러 사이에서 수행 전, 수행 후의 데이터에 접근할 수 있어서 특정 API에 대해 권한 체크나 전처리, 후처리가 필요한 경우 적합하다는 생각이 든다.
AOP는 어노테이션, 메서드, 클래스 등등 다양한 방법으로 제어를 할 수 있고 예외가 발생한 상황에 대해서도 따로 관리가 가능해서 특정 API에 대한 세밀한 로깅을 찍는다면 가장 적합하다는 생각이 들었다.
만약에 DB에 로그 데이터를 저장해야 한다고 하면 이런 경우에는 인터셉터나 AOP를 활용하는 것이 좋다고 생각이 들었다.
DB에 접근하기 위해서는 스프링 빈으로 등록된 Repository를 주입할 것이고, 그러기 위해서는 서블릿 단에서 사용하는 필터보다는 스프링에 포함된 인터셉터나 AOP를 사용하는 게 맞지 않을까(?)라고 생각한다.
물론 예전과 달리 필터도 DelegatingFilterProxy를 통해 의존성 주입 기능을 사용할 수 있다고 하지만 아무래도 서블릿 단에서 사용하기도 하고 필터에서 애플리케이션 레벨의 로직을 처리하는 것에 대한 거부감도 있다는 의견을 봤었다.
결국 정리하면 로깅을 위해서는 필터, 인터셉터, AOP 전부 사용할 수 있지만 어떤 목적을 가지냐에 따라 사용 방법을 달리 선택해야 한다.
단순 console 로깅을 위해서는 세 개 전부 사용할 수 있을 것이고, 추가적인 db 작업이 필요하다면 인터셉터나 AOP가 적합할 것이라고 생각한다. 또 어노테이션 같은 편의성이나 특정한 API를 타겟으로 세부적인 작업이 필요하다면 AOP가 적합할 것 같다.
물론 인터넷 찾아보니 정말 의견들이 다양했다. 위에 작성한 내용은 대부분 나의 주관이 섞여 있는 내용이기에 본인의 상황에 따라 적절한 로깅 방법을 사용하는 것이 가장 적합한 로깅 방법이지 않을까 싶다.
'기타' 카테고리의 다른 글
마이바티스 H2를 이용한 통합 테스트 환경 구축 방법 (1) | 2024.10.03 |
---|---|
PK를 설정하는 방법(AutoIncrement, UUID) (2) | 2024.09.29 |
SSAFY를 수료하고 7개월만에 취업 (취준 1년 7개월) (7) | 2024.07.24 |
Openai에서 발표했던 GPT-4o에 대한 간단한 소감 (0) | 2024.05.14 |