이미 만들어진 프로젝트를 리팩토링하는 과제를 하는 중이다.
어드민 사용자만 접근할 수 있는 특정 API에 대해 접근 로그를 기록하라는 요구사항이 주어졌다.
처음엔 로그 코드를 직접 넣는 방식도 괜찮지만, API가 많아질수록 같은 코드가 반복되고 유지보수가 어려워진다. 이런 상황에서는 공통 기능을 한 곳에 모아두고, 자동으로 실행되게 하는 방식이 훨씬 효율적이다.
이때, 공통 기능을 모듈화하고 필요한 시점에 자동으로 적용하는 방법인 AOP를 선택할 수 있다.
AOP의 배경지식이 전무한 상태였기에, 공부하면서 구현한 내용을 상세하게 기록해보려고 한다.
AOP는 Aspect-Oriented Programming의 약자로, 관점 지향 프로그래밍이라고도 불린다.
어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나눠본다는 말이다.
위 사진을 기준으로 설명해보면
핵심적인 관점
개발자가 적용하고자 하는 핵심 비즈니스 로직 (계좌이체, 대출승인, 이자계산 등)
부가적인 관점
핵심 비즈니스 로직을 수행하기 위해 필요한 부가적인 기능 (로깅, 보안, 트랜잭션 등)
AOP는 비즈니스 로직의 핵심 부분과 부가적인 기능을 분리함으로써, 공통 기능을 효율적으로 처리할 수 있도록 한다.
객체지향은 관심사를 객체 단위로 분리하면서 복잡한 시스템을 구조적으로 잘 다룰 수 있게 해줬다. 하지만 시간이 지나고 시스템이 커질수록, 다음과 같은 문제가 드러났다.
객체지향은 핵심 비즈니스 로직을 잘 나누지만, 공통 기능(로깅, 트랜잭션 등)은 여전히 여러 클래스에 중복되어 반복된다.
이는 유지보수를 어렵게 만들고, 각 클래스에 공통 로직을 추가할 때마다 코드가 복잡해진다.
AOP는 "공통 기능은 따로 모아두고, 필요한 시점에 끼워 넣자"는 접근 방식이다.
즉, 공통 기능을 일일이 코드에 작성하지 않아도 되고, 핵심 로직과 분리되어 코드의 순수성과 유지보수성이 높아진다.
OOP의 장점은 유지하면서, 단점은 보완할 수 있는 방법이라고 할 수 있다.
Spring framework에서도 Spring AOP를 지원해서 트랜잭션 관리, 로깅 등 다양한 공통 기능을 효율적으로 처리할 수 있다.
선언적 방식: 스프링 AOP는 어노테이션이나 XML 설정을 통해 공통 기능을 적용할 수 있어, 코드 변경 없이 비즈니스 로직에 공통 기능을 끼워 넣을 수 있다.
런타임 기반: 스프링 AOP는 런타임 시 동적으로 프록시 객체를 생성하여, 실제 비즈니스 메서드를 감싸고 AOP 기능을 적용한다.
Aspect:
횡단 관심사를 모듈화한 단위. 예를 들어, 로깅이나 트랜잭션 처리 같은 공통 기능을 하나의 Aspect로 모을 수 있다.
Join Point:
AOP에서 Advice가 적용될 수 있는 지점. 메서드 실행이나 예외 발생 등 다양한 지점이 될 수 있다. 스프링 AOP에서는 메서드 실행을 Join Point로 지원한다.
Pointcut:
실제로 Advice를 적용할 Join Point를 선별하는 조건. 어떤 메서드에 Advice를 적용할지 선택하는 역할을 한다. 예를 들어, @Before("execution(* com.example.service.*.*(..))")
와 같이 표현할 수 있다.
Advice:
공통 기능을 실행하는 코드. 언제 실행될지에 따라 Before, After, Around 등의 종류로 나뉜다.
ProceedingJoinPoint
객체의 proceed()
메서드 사용)Weaving:
Advice와 실제 비즈니스 코드(타깃 객체)를 결합하는 과정. 이 과정은 런타임에 프록시 객체를 생성하고, 해당 프록시 객체가 실제 메서드 실행 전후에 공통 기능을 적용하는 방식으로 이루어진다.
@Transactional
어노테이션을 메서드나 클래스에 붙이면, Spring AOP가 해당 메서드 호출을 감싸서 트랜잭션 시작/커밋/롤백을 처리TransactionInterceptor
라는 AOP 어드바이스가 동작AsyncAnnotationBeanPostProcessor
가 AOP 프록시를 만들어주고, AsyncExecutionInterceptor
가 어드바이스 역할@PreAuthorize("hasRole('ADMIN')")
같은 어노테이션이 붙으면, 메서드 실행 전에 권한 체크를 수행하고, 권한이 없으면 예외를 던짐MethodSecurityInterceptor
라는 어드바이스가 작동@Cacheable
은 메서드 실행 전 캐시에 값이 있는지 확인하고, 있으면 실행을 생략@CacheEvict
는 실행 후 캐시를 비우는 동작CacheInterceptor
라는 어드바이스가 작동implementation 'org.springframework.boot:spring-boot-starter-aop'
@Slf4j
@Aspect
@Component
public class Logger {
private final ObjectMapper objectMapper = new ObjectMapper();
@Pointcut("@annotation(org.example.expert.domain.common.aop.AdminLoggingTarget)")
private void adminApi() {}
@Around("adminApi()")
public Object doAdminLog(ProceedingJoinPoint joinPoint) throws Throwable {
// 요청 정보
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
String requestUrl = request.getRequestURL().toString();
Long userId = (Long) request.getAttribute("userId");
// 요청 본문
String requestBody = getRequestBody(joinPoint);
// API 요청 시각
LocalDateTime requestTime = LocalDateTime.now();
// 요청 로그 출력
logRequest(userId, requestTime, requestUrl, requestBody);
// 메서드 실행 후 응답 본문 받기
Object result = joinPoint.proceed();
// 응답 본문
String responseBody = objectMapper.writeValueAsString(result);
// 응답 로그 출력
logResponse(userId, requestTime, responseBody);
return result;
}
private String getRequestBody(ProceedingJoinPoint joinPoint) {
try {
for (Object arg : joinPoint.getArgs()) {
if (arg != null && arg.getClass().getPackageName().contains("dto")) {
return objectMapper.writeValueAsString(arg);
}
}
} catch (IOException e) {
log.error("Request body parsing failed", e);
}
return "No RequestBody";
}
private void logRequest(Long userId, LocalDateTime requestTime, String requestUrl, String requestBody) {
log.info("Request: UserID: {}, Time: {}, URL: {}, RequestBody: {}", userId, requestTime, requestUrl, requestBody);
}
private void logResponse(Long userId, LocalDateTime requestTime, String responseBody) {
log.info("Response: UserID: {}, Time: {}, ResponseBody: {}", userId, requestTime, responseBody);
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminLoggingTarget {
}
@AdminLoggingTarget
@DeleteMapping("/admin/comments/{commentId}")
public ResponseEntity<Void> deleteComment(@PathVariable long commentId) {
commentAdminService.deleteComment(commentId);
return ResponseEntity.noContent().build();
}
(💡+추가) 사용자 정의 어노테이션을 만든 이유?
@Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.deleteComment(..))")
위와 같이 사용할 수도 있다.
하지만 대상 API가 추가될 때마다 저 부분을 수정해줘야 하는 상황이 발생한다. 이는 코드 변경이 필요할 때마다 변경 사항이 전파되는 문제를 일으킨다.
따라서 사용자 정의 어노테이션을 사용하면 기존 코드를 수정하지 않고 어노테이션 추가만으로 기능을 확장하고 유지보수를 용이하게 할 수 있다.
예외 발생 시 AOP에서 예외를 "먹는" 현상이 나타날 수 있다.
joinPoint.proceed()
중 예외 발생 시, catch 블록에서 예외를 삼켜버리면 클라이언트는 오류 메시지도 못 받고, 응답조차 못 받을 수 있다.
try {
return joinPoint.proceed();
} catch (Throwable ex) {
log.error("예외 발생"); // 예외 처리 후 리턴하면, 비즈니스 예외가 무시됨
return null; // 클라이언트는 응답도 못 받음
}
반드시 예외는 다시 던져야 한다.
throw ex;
Spring AOP는 메서드 실행을 기준으로 동작한다. 즉, 메서드 호출 전, 후 또는 예외 처리 등을 AOP로 관리할 수 있지만, 메서드 외의 이벤트에는 적용할 수 없다.
예를 들어,
필드 접근
클래스 내부의 필드를 읽거나 수정하는 작업에 대해서는 AOP가 적용되지 않는다.
(위와 같은 작업은 메서드가 실행되지 않기 때문에 AOP의 Advice가 적용되지 않음.)
생성자 호출
객체 생성 시 생성자에 대한 AOP는 지원하지 않는다.
(AOP가 동작하는 메서드 실행 시점에 해당하지 않음.)
Spring AOP는 프록시 기반으로 동작한다. 이로 인해 몇 가지 한계가 발생한다.
인터페이스 기반 메서드
클래스가 인터페이스를 구현하는 경우, JDK 동적 프록시를 사용하여 AOP가 적용된다. 이 경우, 인터페이스를 구현한 메서드만 AOP 대상이 되고 인터페이스에 정의되지 않은 메서드에는 AOP가 적용되지 않는다. (동적 바인딩이랑 관련이 있다.)
구체 클래스에서 AOP 적용: 인터페이스 없이 구체적인 클래스에서만 메서드가 실행되는 경우 CGLIB 프록시가 사용된다. 만약 메서드가 final로 선언된 경우 오버라이드 할 수 없기 때문에 AOP가 적용되지 않는다.
AspectJ라는 AOP를 구현하기 위한 전용 프레임워크가 있다.
1. 성능 문제
2. 코드 흐름의 복잡성 증가
3. 학습 곡선
4. 디자인 패턴과의 충돌
5. 테스트 어려움