conPig.md

[Spring] Spring AOP, 핵심기능과 부가기능 분리하기 본문

Spring/Spring 기본

[Spring] Spring AOP, 핵심기능과 부가기능 분리하기

piggyback 2026. 7. 18. 00:54

로깅, 실행 시간 측정, 보안 검사처럼 여러 기능에 공통으로 들어가는 코드가 있다. 이걸 각 메서드마다 복붙하면 코드가 중복되고, 나중에 고칠 때 모든 메서드를 다 찾아 수정해야 한다.

 

AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)는 이러한 공통 기능(부가기능)을 한 곳으로 모아 관리하는 방법이다.

1. AOP의 핵심 아이디어

애플리케이션의 코드는 크게 두 종류로 나눌 수 있다.

  • 핵심기능(Core Concern): 상품 등록, 폴더 조회처럼 비즈니스 본연의 로직
  • 부가기능(Cross-cutting Concern): 로깅, 실행 시간 측정처럼 여러 핵심기능에 걸쳐 반복되는 로직

AOP는 부가기능을 별도의 클래스로 분리하고 어디에 적용할지만 지정한다. 그러면 Spring이 지정한 위치에서 부가기능을 실행해준다. 핵심기능 코드는 수정하지 않는다. 여기서 두 가지 용어만 기억하면 된다.

  • Advice : 무엇을 할 것인가(부가기능)
  • Pointcut : 어디에 적용할 것인가(적용 위치)

2. 프록시(Proxy)

Spring AOP는 프록시(대리 객체) 를 이용해 동작한다.

 

원래는 이렇게 호출된다.

호출자 → 핵심기능

 

AOP가 적용되면 Spring은 AOP 대상 Bean 대신 프록시 객체를 생성해 등록한다.

호출자 → 프록시 → 핵심기능

 

 

호출자는 실제 객체 대신 프록시를 호출하게 된다. 프록시는 부가기능을 먼저 실행한 뒤 진짜 객체를 호출하고, 실행이 끝난 후 필요한 부가기능을 추가로 수행한다. 프록시는 원래 객체와 같은 타입처럼 동작하므로 호출하는 쪽은 프록시의 존재를 알 필요가 없다. 덕분에 기존 코드를 수정하지 않고도 부가기능을 추가할 수 있다.

 

3. Spring AOP 애너테이션

(1) @Aspect

부가기능을 정의하는 클래스에 붙인다. 반드시 @Component 등으로 등록된 Spring Bean이어야 한다.

(2) Advice 종류

애너테이션 실행 시점 활용 예
@Around 메서드 실행 전·후를 감싸며 실행 실행 시간 측정
@Before 메서드 실행 전 입력값 검증
@After 성공·실패와 관계없이 항상 finally처럼 자원 정리
@AfterReturning 정상 종료 후 반환값 가공
@AfterThrowing 예외 발생 시 에러 로그, 메일 알림

 

@Around는 가장 자주 쓰이는 Advice이다.

 

메서드 실행 전후를 모두 제어할 수 있으며, 필요하면 원래 메서드를 실행하지 않거나 여러 번 호출하는 것도 가능하다. 실무에서는 실행 시간 측정이나 트랜잭션, 공통 로깅 등에 가장 많이 사용된다.

(3) 포인트컷 표현식

execution(접근제어자? 반환타입 클래스경로?.메서드명(파라미터) 예외?)

 

?가 붙은 부분은 생략할 수 있다. 예를 들

@Around("execution(public * com.sparta.myselectshop.controller..*(..))")

 

각 부분의 의미는 다음과 같다.

  • public : public 메서드만
  • * : 반환 타입은 무엇이든 가능
  • com.sparta.myselectshop.controller.. : 해당 패키지와 모든 하위 패키지
  • *(..) : 모든 메서드, 모든 파라미터

패키지에서

  • . : 현재 패키지만
  • .. : 하위 패키지까지 포함

을 의미한다.

 

파라미터 패턴

  • () : 인수 없음
  • (*) : 인수 정확히 1개 (타입 무관)
  • (..) : 인수 0개 이상 (타입 무관)
  • (...FolderRequestDto) : 특정 타입 인수만

(4) @Pointcut

복잡한 표현식은 이름을 붙여 재사용할 수 있다.

@Pointcut("execution(* ...controller.*.*(..))")
private void forAllController() {}

@Around("forAllController() && !forAllViewController()")
public void saveRestApiLog() { ... }

 

위의 코드처럼 &&, ||, ! 를 이용해 조합할 수도 있다.

4. 실전 예제: API 실행 시간 측정

다음은 컨트롤러 실행 시간을 측정하여 DB에 저장하는 AOP이다.

@Slf4j(topic = "UseTimeAop")
@Aspect
@Component
public class UseTimeAop {

    private final ApiUseTimeRepository apiUseTimeRepository;

    public UseTimeAop(ApiUseTimeRepository apiUseTimeRepository) {
        this.apiUseTimeRepository = apiUseTimeRepository;
    }

    @Pointcut("execution(* com.sparta.myselectshop.controller.ProductController.*(..))")
    private void product() {}
    @Pointcut("execution(* com.sparta.myselectshop.controller.FolderController.*(..))")
    private void folder() {}
    @Pointcut("execution(* com.sparta.myselectshop.naver.controller.NaverApiController.*(..))")
    private void naver() {}

    @Around("product() || folder() || naver()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();   // 측정 시작

        try {
            Object output = joinPoint.proceed();        // 핵심기능 수행
            return output;
        } finally {
            long runTime = System.currentTimeMillis() - startTime;  // 측정 종료

            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth != null && auth.getPrincipal().getClass() == UserDetailsImpl.class) {
                User loginUser = ((UserDetailsImpl) auth.getPrincipal()).getUser();

                ApiUseTime apiUseTime = apiUseTimeRepository.findByUser(loginUser).orElse(null);
                if (apiUseTime == null) {
                    apiUseTime = new ApiUseTime(loginUser, runTime);
                } else {
                    apiUseTime.addUseTime(runTime);
                }
                apiUseTimeRepository.save(apiUseTime);
            }
        }
    }
}

 

동작 순서는 다음과 같다.

  1. @Pointcut으로 Product, Folder, Naver 컨트롤러를 지정한다.
  2. @Around로 세 Pointcut을 하나로 묶는다.
  3. 메서드 실행 전에 시작 시간을 기록한다.
  4. joinPoint.proceed()가 원래 호출하려던 핵심기능(실제 컨트롤러 메서드)을 실행한다.
  5. finally에서 실행 시간을 계산한다.
  6. 로그인한 사용자의 누적 실행 시간을 DB에 저장한다.

try-finally를 사용했기 때문에 핵심기능이 정상 종료하든 예외가 발생하든 실행 시간은 항상 기록된다.

5. 시퀀스 다이어그램으로 본 차이

AOP 적용 전

DispatcherServlet → ProductController.createProduct()

 

AOP 적용 후

DispatcherServlet → AOP Proxy → @Around Advice → joinPoint.proceed() → ProductController

 

중간에 프록시와 Advice가 요청을 받아 부가기능을 수행한 뒤, joinPoint.proceed()를 통해 원래 컨트롤러를 호출한다. 핵심기능의 실행 결과는 그대로 호출자에게 반환되므로, 호출하는 쪽과 호출당하는 쪽의 코드는 수정할 필요가 없다.

6. 정리

AOP는 여러 곳에서 반복되는 부가기능을 한 곳에 모아 관리하고, 프록시를 이용해 핵심기능을 수정하지 않고 적용하는 기술이다.

  • 핵심기능(Core Concern)부가기능(Cross-cutting Concern) 을 분리한다.
  • Advice는 무엇을 할지, Pointcut은 어디에 적용할지를 정의한다.
  • Spring은 AOP 대상 Bean에 프록시를 생성하여 지정한 시점에 부가기능을 실행한다.
  • @Around와 joinPoint.proceed() 조합이 가장 많이 사용된다.
  • 부가기능이 여러 클래스에 흩어져 있지 않으므로 유지보수가 쉬워지고, 중복 코드도 크게 줄일 수 있다.

즉, AOP의 목적은 비즈니스 로직은 비즈니스 로직답게 유지하고, 공통 기능은 한 곳에서 관리하도록 만드는 것이다.