[Spring] Spring AOP로 관리자 API 로그 자동화하기

thezz9·2025년 4월 17일
5

개요

이미 만들어진 프로젝트를 리팩토링하는 과제를 하는 중이다.

어드민 사용자만 접근할 수 있는 특정 API에 대해 접근 로그를 기록하라는 요구사항이 주어졌다.

처음엔 로그 코드를 직접 넣는 방식도 괜찮지만, API가 많아질수록 같은 코드가 반복되고 유지보수가 어려워진다. 이런 상황에서는 공통 기능을 한 곳에 모아두고, 자동으로 실행되게 하는 방식이 훨씬 효율적이다.

이때, 공통 기능을 모듈화하고 필요한 시점에 자동으로 적용하는 방법인 AOP를 선택할 수 있다.
AOP의 배경지식이 전무한 상태였기에, 공부하면서 구현한 내용을 상세하게 기록해보려고 한다.


1. AOP란?

AOPAspect-Oriented Programming의 약자로, 관점 지향 프로그래밍이라고도 불린다.
어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나눠본다는 말이다.

위 사진을 기준으로 설명해보면

  • 핵심적인 관점
    개발자가 적용하고자 하는 핵심 비즈니스 로직 (계좌이체, 대출승인, 이자계산 등)

  • 부가적인 관점
    핵심 비즈니스 로직을 수행하기 위해 필요한 부가적인 기능 (로깅, 보안, 트랜잭션 등)

AOP는 비즈니스 로직의 핵심 부분과 부가적인 기능을 분리함으로써, 공통 기능을 효율적으로 처리할 수 있도록 한다.


2. AOP가 등장하게 된 배경

1. 객체지향(OOP)의 한계

객체지향은 관심사를 객체 단위로 분리하면서 복잡한 시스템을 구조적으로 잘 다룰 수 있게 해줬다. 하지만 시간이 지나고 시스템이 커질수록, 다음과 같은 문제가 드러났다.

횡단 관심사(Cross-Cutting Concern)의 문제

  • 로깅, 트랜잭션, 인증/인가, 예외 처리, 성능 측정 등은 많은 클래스에서 필요한 공통 기능이다.
  • 이런 공통 기능은 여러 클래스에 중복 코드로 반복되면서 비즈니스 로직을 흐리게 만든다.

객체지향은 핵심 비즈니스 로직을 잘 나누지만, 공통 기능(로깅, 트랜잭션 등)은 여전히 여러 클래스에 중복되어 반복된다.
이는 유지보수를 어렵게 만들고, 각 클래스에 공통 로직을 추가할 때마다 코드가 복잡해진다.

2. AOP가 이걸 어떻게 해결할 수 있나?

AOP는 "공통 기능은 따로 모아두고, 필요한 시점에 끼워 넣자"는 접근 방식이다.
즉, 공통 기능을 일일이 코드에 작성하지 않아도 되고, 핵심 로직과 분리되어 코드의 순수성과 유지보수성이 높아진다.

  • 공통 기능(횡단 관심사)은 별도 모듈로 분리
  • 필요한 지점에 자동으로 적용 (Weaving)
  • 핵심 로직은 순수하게 유지

OOP의 장점은 유지하면서, 단점은 보완할 수 있는 방법이라고 할 수 있다.
Spring framework에서도 Spring AOP를 지원해서 트랜잭션 관리, 로깅 등 다양한 공통 기능을 효율적으로 처리할 수 있다.


3. Spring AOP

1. Spring AOP: 주요 특징

  • 선언적 방식: 스프링 AOP는 어노테이션이나 XML 설정을 통해 공통 기능을 적용할 수 있어, 코드 변경 없이 비즈니스 로직에 공통 기능을 끼워 넣을 수 있다.

  • 런타임 기반: 스프링 AOP는 런타임 시 동적으로 프록시 객체를 생성하여, 실제 비즈니스 메서드를 감싸고 AOP 기능을 적용한다.


2. Spring AOP: 핵심 용어

  • Aspect:
    횡단 관심사를 모듈화한 단위. 예를 들어, 로깅이나 트랜잭션 처리 같은 공통 기능을 하나의 Aspect로 모을 수 있다.

  • Join Point:
    AOP에서 Advice가 적용될 수 있는 지점. 메서드 실행이나 예외 발생 등 다양한 지점이 될 수 있다. 스프링 AOP에서는 메서드 실행을 Join Point로 지원한다.

  • Pointcut:
    실제로 Advice를 적용할 Join Point를 선별하는 조건. 어떤 메서드에 Advice를 적용할지 선택하는 역할을 한다. 예를 들어, @Before("execution(* com.example.service.*.*(..))")와 같이 표현할 수 있다.

  • Advice:
    공통 기능을 실행하는 코드. 언제 실행될지에 따라 Before, After, Around 등의 종류로 나뉜다.

    • Before: 메서드 실행 전에 수행
    • After: 메서드 실행 후에 수행
    • Around: 메서드 실행 전후에 모두 수행, 메서드를 실행하는지 여부를 제어할 수도 있다.
      (실행 시ProceedingJoinPoint 객체의 proceed() 메서드 사용)
  • Weaving:
    Advice와 실제 비즈니스 코드(타깃 객체)를 결합하는 과정. 이 과정은 런타임에 프록시 객체를 생성하고, 해당 프록시 객체가 실제 메서드 실행 전후에 공통 기능을 적용하는 방식으로 이루어진다.


3. Spring AOP: 적용 사례

1. 트랜잭션 관리 (@Transactional)

  • 가장 대표적인 AOP 적용 사례
  • @Transactional 어노테이션을 메서드나 클래스에 붙이면, Spring AOP가 해당 메서드 호출을 감싸서 트랜잭션 시작/커밋/롤백을 처리
  • 내부적으로는 TransactionInterceptor라는 AOP 어드바이스가 동작

2. 비동기 처리 (@Async)

  • 해당 메서드는 별도의 쓰레드에서 실행되고, 이걸 AOP가 프록시로 감싸서 처리
  • 내부적으로는 AsyncAnnotationBeanPostProcessor가 AOP 프록시를 만들어주고, AsyncExecutionInterceptor가 어드바이스 역할

3. 보안 처리 (@PreAuthorize, @Secured)

  • Spring Security도 AOP 기반
  • @PreAuthorize("hasRole('ADMIN')") 같은 어노테이션이 붙으면, 메서드 실행 전에 권한 체크를 수행하고, 권한이 없으면 예외를 던짐
  • 내부적으로는 MethodSecurityInterceptor라는 어드바이스가 작동

4. 캐싱 처리 (@Cacheable, @CacheEvict)

  • @Cacheable은 메서드 실행 전 캐시에 값이 있는지 확인하고, 있으면 실행을 생략
  • @CacheEvict는 실행 후 캐시를 비우는 동작
  • 내부적으로는 CacheInterceptor라는 어드바이스가 작동

4. Spring AOP: 구현 - 로깅

1. build.gradle 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-aop'

2. Aspect 클래스 생성

@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);
    }

}

3. 사용자 정의 어노테이션 생성

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminLoggingTarget {
}

4. 실제 사용

@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 적용 시 유의사항

예외 발생 시 AOP에서 예외를 "먹는" 현상이 나타날 수 있다.
joinPoint.proceed() 중 예외 발생 시, catch 블록에서 예외를 삼켜버리면 클라이언트는 오류 메시지도 못 받고, 응답조차 못 받을 수 있다.

try {
    return joinPoint.proceed();
} catch (Throwable ex) {
    log.error("예외 발생"); // 예외 처리 후 리턴하면, 비즈니스 예외가 무시됨
    return null;           // 클라이언트는 응답도 못 받음
}

반드시 예외는 다시 던져야 한다. throw ex;


5. Spring AOP: 한계

1. 메서드 실행 기준으로 동작

Spring AOP는 메서드 실행을 기준으로 동작한다. 즉, 메서드 호출 전, 후 또는 예외 처리 등을 AOP로 관리할 수 있지만, 메서드 외의 이벤트에는 적용할 수 없다.

예를 들어,

  • 필드 접근
    클래스 내부의 필드를 읽거나 수정하는 작업에 대해서는 AOP가 적용되지 않는다.
    (위와 같은 작업은 메서드가 실행되지 않기 때문에 AOP의 Advice가 적용되지 않음.)

  • 생성자 호출
    객체 생성 시 생성자에 대한 AOP는 지원하지 않는다.
    (AOP가 동작하는 메서드 실행 시점에 해당하지 않음.)

2. Proxy 기반으로 동작

Spring AOP는 프록시 기반으로 동작한다. 이로 인해 몇 가지 한계가 발생한다.

  • 인터페이스 기반 메서드
    클래스가 인터페이스를 구현하는 경우, JDK 동적 프록시를 사용하여 AOP가 적용된다. 이 경우, 인터페이스를 구현한 메서드만 AOP 대상이 되고 인터페이스에 정의되지 않은 메서드에는 AOP가 적용되지 않는다. (동적 바인딩이랑 관련이 있다.)

  • 구체 클래스에서 AOP 적용: 인터페이스 없이 구체적인 클래스에서만 메서드가 실행되는 경우 CGLIB 프록시가 사용된다. 만약 메서드가 final로 선언된 경우 오버라이드 할 수 없기 때문에 AOP가 적용되지 않는다.


(💡+추가) 더욱 폭 넓게 적용하려면?

AspectJ라는 AOP를 구현하기 위한 전용 프레임워크가 있다.

  • 런타임에만 적용하는 스프링 AOP와는 달리 컴파일 타임 또는 로드 타임에서도 AOP를 적용할 수 있다.
  • 메서드 실행 외에도 생성자 호출, 필드 접근 등 더 다양한 Join Point에 적용할 수 있다.

4. 과연 AOP에 장점만 있을까? 아니다!

1. 성능 문제

  • AOP는 프록시 객체를 사용하여 메서드를 감싸기 때문에, 메서드를 호출할 때마다 추가적인 오버헤드가 발생한다. 특히 Around Advice가 적용되는 경우, 메서드 실행 전후로 추가적인 작업이 들어가므로 성능에 미치는 영향이 클 수 있다.
    컴파일 타임 AOP는 프록시 객체를 생성하는 비용은 발생하지 않지만, 대신 AOP를 적용하기 위한 추가 작업이 컴파일 과정에서 이루어진다. 따라서 빌드 시간이 증가할 수 있다는 단점을 가지고 있다.

2. 코드 흐름의 복잡성 증가

  • AOP는 코드의 흐름을 변경하는 방식으로 동작하기 때문에, 코드의 실행 흐름을 이해하기 어려울 수 있다. 특히, 많은 AOP가 적용된 시스템에서는 전체적인 흐름을 파악하는 데 어려움이 있을 수 있고, 유지보수 시 예상치 못한 부작용이 발생할 수 있다.

3. 학습 곡선

  • AOP는 처음 사용하는 개발자에게는 개념이 낯설고, 어떻게 활용할지에 대한 충분한 학습이 필요하다. 잘못 사용하면 코드의 가독성이 떨어지고, 불필요한 복잡성을 초래해 유지보수가 어려워질 수 있다.

4. 디자인 패턴과의 충돌

  • AOP는 특별한 설계 패턴을 따르지 않는 경우가 많기 때문에 AOP를 적용하면서 기존의 디자인 패턴이나 설계 방식과 충돌할 가능성이 있다.

5. 테스트 어려움

  • AOP가 적용된 메서드의 테스트를 작성할 때, AOP의 영향으로 코드 실행 흐름을 정확히 예측하기 어려워 테스트가 복잡해지고, AOP가 적용된 부분의 독립적인 테스트가 어려워질 수 있다.
profile
개발 취준생

0개의 댓글