1. Spring AOP (Aspect-Oriented Programming)
(1) AOP란?
관점 지향 프로그래밍으로, 비즈니스 로직과 공통기능(로깅, 트랜잭션, 보안)등을 분리하여
유지보수를 쉽게 할 수 있도록 돕는 프로그래밍 기법입니다.
AOP에서는 관점을 핵심적인 관점과, 부가적인 관점으로 나누어 각각을 모듈화합니다.
핵심적인 관점은 애플리케이션의 주요 기능(비즈니스 로직)을 담당하며,
부가적인 관점은 로깅, 보안, 예외처리 같은 공통 기능을 의미합니다.
🤔 모듈화란?
소프트웨어를 기능별로 독립적인 단위(모듈)로 분리하여 개발하는 방식이다.
이를 통해 코드의 구조가 명확해지고 유지보수와 확장이 용이해진다.
핵심 비즈니스 로직에 로깅, 트랜잭션 등 공통 기능을 직접 작성하면,
기능들이 여러 로직에 중복되어 흩어지게 되어 코드의 유지보수가 어려워집니다.
흩어진 관심사란,
여러 모듈이나 컴포넌트등 여러 곳에 분산되어 존재하는 공통 기능을 의미합니다.
이로 인해, 각각의 부분을 일일이 수정해야 하는 번거로움과 오류 발생의 가능성이 커집니다.
반면에 횡단 관심사(cross-cutting concerns)는 핵심 로직과 별개로 존재하지만,
애플리케이션 전반에 걸쳐 공통적으로 적용되어야 하는 기능을 말합니다.
이러한 공통 관심사들을 관점별로 모듈화하여 별도의 Aspect로 분리하면,
재사용성을 높이고 유지보수를 쉽게 할 수 있습니다.
이것이 바로 AOP(Aspect Oriented Programming)의 핵심입니다.
(2) AOP의 주요 용어
2. Spring AOP 환경 설정 하기
(1) 개발환경
개발환경 | 버전 |
Java | 17 |
OpenJDK | 23.0.2 |
Spring Boot | 3.3.3 |
Spring Framework | 6.x |
Gradle | Gradle 8.12 |
AOP | 3.3.3 |
IntelliJ IDEA | 2024.3.1.1 (Ultimate Edition) |
(2) build.gradle 내에 AOP 의존성 추가하기
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
✅ Spring AOP 가 설치되었음을 확인했습니다.
(3) 패키지와 파일 구성하기
✅ 프로젝트 최상위에 `aspects` 패키지를 구성하고, `LoggingAspect.java` 파일을 생성하였습니다.
3. AOP를 적용하여 요청정보 로깅하기
🔍 로깅 내용에 포함되어야 할 사항
- 요청한 사용자의 ID
- API 요청 시각
- API 요청 URL
- 요청 본문(RequestBody)
- 응답 본문(ResponseBody)
(1) 요청 본문을 읽을 수 있도록 ContentCachingFilter 적용하기
일반 서블릿 요청 및 응답 객체는 스트림이 단 한 번만 사용 할 수 있어 재활용이 불가능합니다.
따라서 로그에 요청과 응답 본문을 기록하기 위해서는 별도의 처리가 필요합니다.
이를 위해 `OncePerRequestFilter`를 상속한 커스텀 필터를 구현하고,
서블릿 객체를 래핑하여 스트림 내용을 메모리에 저장하는 방식을 통해 동일한 데이터를 여러 번 참조할 수 있습니다.
@Component
@WebFilter(urlPatterns = "/*", description = "Wrapping Request")
public class ContentCachingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
try {
filterChain.doFilter(requestWrapper, responseWrapper);
} finally {
responseWrapper.copyBodyToResponse();
}
}
}
🔍 어노테이션 설명
@Component
해당 클래스를 Spring 컴포넌트 스캔 대상으로 지정하여,
Spring 컨테이너가 빈으로 등록하도록 합니다.
이를 통해 다른 빈들과 자동으로 주입 및 관리됩니다.
@WebFilter(urlPatterns = "/*', description = "Wrapping Request")
클래스가 서블릿 필터임을 나타내며, 모든 URL 패턴("*/")에 대해 필터가 적용됩니다.
또한 description을 통해 필터의 역할을 설명하였습니다.
🔍 클래스 및 상속 구조
ContentCachingFilter extends OncePerRequestFilter
해당 클래스는 OncePerRequestFilter를 상속받습니다.
이 클래스는 한 요청 당 필터 로직이 단 한 번만 실행 되도록 보장하여
중복 실행을 방지합니다.
🔍 doFilterInternal 메소드
이 메소드는 `OncePerRequestFilter`를 상속받은 필터에서 실제 필터링 로직을 구현하기 위해 오버라이드한 메소드이다.
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException
🔍 요청 및 응답 래핑
ContentCachingRequestWrapper는 스프링 프레임워크에서 제공하는 `HttpServletRequestWrapper`의 확장 클래스입니다.
이 클래스는 HTTP 요청의 본문(InputStream이나 Reader)을 최초 한 번 읽어들일 때,
해당 내용을 내부 버퍼(보통 바이트 배열)에 저장하여 이후에도 재사용할 수 있도록 합니다.
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
🔍 필터 체인 실행
filterChain.doFilter(requestWrapper, responseWrapper);
래핑된 객체를 사용하여 필터 체인을 계속 진행합니다.
이 과정에서 이후의 필터나 최종 서블릿은 캐싱된 요청과 응답 객체를 사용하게 됩니다.
🔍 응답 데이터 복사
responseWrapper.copyBodyToResponse();
finally 블록 내에서 호출되는 이 메서드는 캐싱된 응답 본문을 원래의 응답 객체로 복사합니다.
이를 통해 클라이언트에게 실제 응답 데이터가 정상적으로 전달되도록 합니다.
(2) AOP를 적용한 Logging Aspect 작성하기
@Aspect
@Component
@Slf4j
public class LoggingAspect {
private final ObjectMapper objectMapper = new ObjectMapper();
@Pointcut("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
private void changeUserRole() {
}
@Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.deleteComment(..))")
private void deleteComment() {
}
@Around("changeUserRole() || deleteComment()")
public Object adminApiLog(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request;
String userId = request.getAttribute("userId").toString();
String requestUrl = request.getRequestURL().toString();
LocalDateTime requestTime = LocalDateTime.now();
String requestBody = new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
log.info("======== [Request] ========");
log.info("요청한 사용자의 ID : {}", userId);
log.info("API 요청 URL : {}", requestUrl);
log.info("API 요청 시각 : {}", requestTime);
log.info("requestBody : {}", requestBody);
log.info("============================");
Object response = joinPoint.proceed();
String responseBody = objectMapper.writeValueAsString(response);
log.info("======== [Response] ========");
log.info("Response Body: {}", responseBody);
log.info("============================");
return response;
}
}
🔍 어노테이션 설명
@Aspect
해당 클래스가 AOP의 Aspect임을 선언합니다
특정 Join Point에 부가기능을 적용할 수 있도록 합니다.
@Slf4j
Lombok 어노테이션으로, 내부에 log 객체를 자동으로 생성해주어 로깅을 간편하게 할 수 있도록합니다.
🔍 Pointcut 정의 및 지정
@Pointcut("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
private void changeUserRole() {}
@Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.deleteComment(..))")
private void deleteComment() {}
@Around("changeUserRole() || deleteComment()")
public Object adminApiLog(ProceedingJoinPoint joinPoint) throws Throwable
@Pointcut
advice를 실행시킬 지점을 정의합니다.
@Around
AOP의 Around 어드바이스를 사용하여 포인트 컷에 대해 실행 전 후로 추가 로직을 삽입합니다.
public Object adminApiLog(ProceedingJoinPoint joinPoint) throws Throwable
실제 메소드 실행 전 후에 호출 정보를 담고 있으며, jointPoint.proceed()를 호출하면 원래의 메소드가 실행됩니다.
반환타입이 Object인 이유는, 원래 대상 메소드의 반환 값을 그대로 반환하기 위함이며
이를통해 애플리케이션의 정상적인 흐름이 유지됩니다.
예외처리를 위해 throws Throwable을 명시하여 대상 메소드 실행 중 발생한 예외를 그대로 전달할 수 있습니다.
🔍 요청 정보 획득
HttpServletRequest request = ((ServletRequestAttributes) Objects
.requireNonNull(RequestContextHolder
.getRequestAttributes()))
.getRequest();
ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request;
요청 객체를 ContentCachingRequestWrapper로 캐스팅함으로써,
스트림의 내용을 메모리에 캐싱해 이후에도 재사용하거나 로깅할 수 있게 합니다.
🔍 응답 객체 반환
`private final ObjectMapper objectMapper = new ObjectMapper();`를 사용하는 이유는
응답 객체를 JSON 문자열로 변환하기 위함입니다.
ObjectMapper는 Jackson 라이브러리의 핵심 클래스 중 하나로,
Java 객체를 JSON 형식으로 직렬화 하거나 반대로 역직렬화 하는 역할을 합니다.
또한, final로 선언하여 객체가 불변이고, 클래스에서 사용가능하도록 합니다.
🔍 응답 데이터 로깅
String responseBody = objectMapper.writeValueAsString(response);
ObjectMapper를 사용하여 응답 객체를 JSON 문자열로 변환합니다.
변환된 응답 데이터를 로그로 출력하여, 클라이언트에게 반환되는 내용을 기록합니다.