본문 바로가기

Spring

[Spring] AOP (Aspect Oriented Programming)란? (+ 구현 예제)

이번에 스터디 자율 발표 주제로 AOP를 맡으며 공부했던 내용들을 간단하게 글로 정리해보고자 한다.


💡AOP (Aspect Oriented Programming, 관점 지향 프로그래밍)

AOP의 개념을 간단하게 설명해보면 다음과 같다.

  • 관점을 기준으로 다양한 기능들을 분리하여 보는 프로그래밍
  • 횡단 관심사의 분리를 허용함으로써 모듈성을 증가시키는 것이 목적인 프로그래밍 패러다임
  • 모듈화된 객체를 편하게 적용할 수 있게 함으로써 개발자가 비지니스 로직을 구현하는 데만 집중할 수 있게 도와줌
  • 활용 예시 : 로깅, 트랜잭션, 보안, 캐싱, 예외처리 등

이렇게 글로만 적어서는 AOP가 무엇인지 감이 잘 오지 않을 것이다.

아래에서 그림과 함께 간단한 예시를 확인해보겠다.


먼저, 상품 등록 / 검색 / 구매 시 소요되는 시간을 측정해달라는 요청이 들어왔다고 가정하자.

 

이 요청을 처리하기 위해 위와 같이 각 메소드에 시작 시간/종료 시간 기록최종 소요 시간 로깅을 담당하는 로직을 추가해줄 수 있을 것이다.

 

여기서 상품 등록, 검색, 구매와 같이 비지니스 로직이 처리하고자하는 "목적"이 되는 기능들을 핵심 기능(Core Concern), 여러 모듈/클래스에서 "공통으로 필요"로 하지만 "핵심 기능과는 관련이 없는" 기능들을 공통 기능 (Cross-cutting concern)이라 한다.

 

그러나 위와 같은 코드에서는 중복이 많고, 유지보수가 어렵다는 문제가 있다. 예를 들어 시간 검사를 하고 싶은 메소드가 새롭게 추가되거나, 시간 계산 방법이나 출력 형식에 변화가 생기면 메소드마다 하나하나 직접 수정을 해줘야 하는 것이다.

 

이를 해결하기 위해 각 공통 기능을 메소드로 분리하거나, 시간을 담당하는 Util 객체 등을 만들 수도 있다.

여기서는 시간 측정을 위한 TimeUtils 객체를 생성하였다고 가정해보자.

이전에 비해 중복 코드의 라인 수는 줄어들겠지만, 아직도 중복을 완전히 제거하지는 못했다. 또한, 만약 각 메소드가 서로 다른 클래스에 존재한다면 클래스마다 새롭게 TimeUtils 객체를 생성해줘야한다는 번거로움이 존재한다.

 

이러한 문제를 해결하기 위해 등장한 것이 AOP이다.

위의 그림은 공통 기능을 핵심 관심사와 분리 및 모듈화한 것이다. 왼쪽의 비즈니스 로직에 해당하는 부분에서 우리는 원하는 비즈니스 로직을 실행할 수 있다. 더 정확히는 각 메소드가 Aspect의 로직들로 감싸진다고 이해할 수 있다.

 

이런 구조를 사용한다면 앞선 방법들의 중복 코드 문제도 해결할 수 있으며, 추후 형식 등의 변화가 생기더라도 Aspect만 수정을 하면 되기 때문에 수월한 유지보수가 가능하다.

 

이처럼 AOP를 사용함으로써, 우리는 Service 단의 코드를 따로 손대지 않고도 로깅을 적용시킬 수 있게 되었다.

 

이제 AOP에 자주 등장하는 주요 개념들을 알아보자.


AOP 주요 개념

  • Aspect : 공통 기능을 모아둔 객체, 공통 기능을 구현할 객체 자체
  • Advice : Aspect 내부의 ‘공통 기능 각각의 로직’, 즉 객체 내에서 실제로 구현되는 각각의 로직
    • @Around : 메서드 실행 전/후에 동작 추가
    • @Before : 메서드 실행 전에 동작 추가
    • @After : 메서드가 실행된 후 동작 추가 (성공/예외 발생 상관X)
    • @AfterReturning : 메서드가 성공적으로 실행된 후 동작 추가
    • @AfterThrowing : 메서드가 예외를 던진 후 동작 추가
  • JoinPoint : '공통 기능을 적용해야 하는 메소드'의 실행 시점을 의미
  • Pointcut : '공통 기능을 적용할 대상'을 의미 (1개 또는 그 이상)
  • Weaving : 공통 기능을 적용 하는 '행위'를 의미

+) Advisor : Advice와 Pointcut을 하나씩 가지고 있는 오브젝트 (Spring AOP에서 사용되는 개념)


AOP 구현 방법

아래에서는 .java나 .class로 예시를 들었지만, AOP는 각 언어마다 구현체가 존재한다.

아래는 AOP를 구현하는 대표적인 방식 중 일부이며, 이외에도 여러 가지 방법이 활용될 수 있다.

 

1. 컴파일 타임(Compile-Time) 방식

    - 컴파일 타임에서 AOP적용이 이루어지는 방식

    - file.java → file.class로 컴파일하는 과정에서 해당하는 Aspect를 끼워넣음

 

2. 로드 타임(Load-Time) 방식

    -  로드 타임에서 AOP가 이루어지는 방식

    -  클래스 로더가 file.class라는 클래스를 메모리에 로드하는 시점에 Aspect를 끼워넣음

 

3. 런타임(Run-Time) 방식

    -  런타임에서 AOP적용이 이루어지는 방식

    -  file이라는 클래스를, 부가 기능을 제공하는 프록시로 감싸서 실행

    -  런타임 중에 프록시 객체를 생성하여 관점을 적용


AspectJ vs Spring AOP

AspectJ는 Java 언어에서 AOP를 구현하기 위한 프레임워크이며, Spring AOP는 Spring이 관리하는 객체에 대해 보다 간단한 방식으로 AOP를 적용할 수 있도록 제공되는 기능이다.

 

이 둘의 차이에 대해 간단히 알아보자.

 

AspectJ

  • Aspect 개념을 자바에 도입하여 AOP를 적용하도록 만든 프레임워크
  • 자바 코드에서 동작하는 모든 객체에 대해, 완벽한 AOP 기능 제공을 목표로 함
  • compile-time / post-compile / load-time 제공 (런타임 제공 X)
  • JoinPoint : 생성자, 필드, 메소드 등 다양하게 지원
  • 장점 : 성능 뛰어나고 기능이 강력
  • 단점 : 비교적 사용법이나 내부 구조가 복잡

 

Spring AOP

  • Aspect 개념을 Spring에 도입하여 AOP를 적용하도록 만든 프레임워크
  • AspectJ를 내부적으로 구현하고 있음 (Spring AOP도 AspectJ를 기반으로 함)
  • Spring Container가 관리하는 Bean에 한정하여, 간단한 AOP 기능만을 제공
  • 런타임 시에만 weaving 가능
  • JoinPoint : 메소드 레벨만 지원
  • 런타임 시점에 동적으로 변할 수 있는 프록시 객체를 이용 → 앱 성능에 영향을 줄 수도 있음

 

그렇다면 Spring에서 AOP를 적용한다면 무엇이 달라지게 될까?

여기서 우리는 프록시(Proxy)라는 개념을 사용하게 된다.

AOP 적용 시 흐름 변화

 

원래는 클라이언트가 타겟 서비스를 직접적으로 바로 호출하여 작업을 수행하는 것이 일반적이다. 그러나 AOP를 적용하게되면 이 호출이 프록시를 한번 거친 후 전달된다.

 

즉, 여기서의 프록시는 간단하게 말하자면 "서버와 클라이언트 사이에서 중계 역할을 하는 것"이라 볼 수 있다.

전체적인 흐름은 아래와 같다.

 

1. 먼저 클라이언트가 호출을 하면, 클라이언트와 타겟 서비스 사이에서 프록시가 이를 대신 받는다.

2. Pointcut을 사용하여 해당 요청이 AOP 적용 대상인지 확인한다.

3. 만약 적용 대상이라 판단되면 해당하는 Advice를 실행한다.

4. Advice 적용 후, 타겟 서비스를 호출하여 작업을 수행한다.

 

그렇다면 이 프록시는 어떻게 만들어지는 것일까?

 

Spring AOP에서는 프록시 객체를 Bean으로 관리하기 때문에, 빈 생성 과정을 살펴봐야 한다.

일반적인 Bean 생성 과정

 

위의 그림은 일반적인 빈 생성 과정을 나타낸 것이다.

먼저 개발자가 @Bean이나 @Component 등을 사용하여 빈을 등록하면, Spring은 해당 객체를 생성하고, 이를 스프링 컨테이너의 빈 저장소에 등록하여 관리한다.

 

그러나 AOP가 적용되면 생성된 객체가 바로 저장되는 것이 아니라, 빈 후처리기가 이를 감지하여 프록시 객체를 생성하고, 최종적으로 스프링 컨테이너에는 이 프록시 객체가 등록된다.

 

+) 추가 용어

  • 빈 후처리기 (BeanPostProcessor)
    : 빈을 등록하기 전에 원하는대로 조작할 수 있는 기능을 제공해주는 것
    : 전달받은 객체를 새로운 객체로 바꿔치기한 후 빈 저장소에 등록

  • 자동 프록시 생성기(AnnotationAwareAspectJAutoProxyCreator)
    : 빈 후처리기(BeanPostProcessor)를 구현한 클래스

  • @Aspect 어드바이저 빌더(BeanFactoryAspectJAdvisorsBuilder)
    : 어드바이저를 생성하는 역할, 생성된 어드바이저는 이 안에 저장됨

이 빈 후처리기, 즉 자동 프록시 생성기는 프록시 객체를 생성하기 위해 아래와 같은 과정을 먼저 거치게 된다.

@Aspect 기반 Advisor 생성 과정

 

 

1. 스프링이 실행되면

2. 자동 프록시 생성기가 스프링 컨테이너에서 @Aspect 어노테이션이 달린 빈을 조회한다.

3. 조회된 @Aspect 빈을 기반으로, @Aspect 어드바이저 빌더가 Advisor를 생성하고

4. 생성된 Advisor를 내부적으로 저장한다.

 

 

이렇게 저장된 어드바이저들은 프록시 생성을 위해 필요한 정보라 할 수 있다.

프록시 생성 과정

 

이제 위의 정보들을 토대로, 객체에 어드바이저를 적용한 프록시가 생성되고 빈으로 등록되는 과정을 알아보자.

 

1. 먼저, Spring은 빈으로 등록된 객체를 생성한 후

2. 자동 프록시 생성기에게 전달한다.

3. 자동 프록시 생성기는 먼저 @Aspect 어노테이션이 붙은 빈을 기반으로 생성된 Advisor 정보를 조회한다.

4. @Aspect가 붙지 않은 일반 Advisor도 고려하기 위해, 스프링 컨테이너에 빈으로 등록된 Advisor들도 함께 조회한다.

5. 이렇게 3 & 4에서 가져온 Advisor들의 Pointcut (적용 대상)을 하나하나 확인해가며, 각 Advisor를 해당 객체에 적용해야하는지 여부를 체크한다.

6. 만약 적용 대상이라면, 해당하는 Advice들을 적용하여 프록시를 생성하여 반환한다. 만약 프록시 적용 대상이 아니라면 원래 객체를 그대로 반환한다.

7. 이렇게 반환된 객체(프록시 객체 또는 원본 객체)가 스프링 컨테이너에 빈으로 등록된다.


🛠️실습

이제 위의 내용을 실제 코드로 구현해보자.

 

기본 구조

먼저, AOP를 사용하기 위해 의존성을 추가해준다.

아래 의존성을 추가하면 빈 후처리기와 같은 AOP 관련 클래스들이 자동으로  Bean으로 등록된다.

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

 

 

다음으로, AOP를 적용할 대상이 되는 서비스 클래스를 작성한다.

매개변수로 받은 cmd 값이 1이면 성공, 아니면 예외를 던지도록 설정하였다.

package ureca.com.study.aop.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class AroundService {

    // cmd가 1이면 성공, 아니면 실패하는 메소드
    public String method(int cmd) throws Exception {
        if(cmd == 1) {
            log.info("메소드 실행~");
        } else {
            log.info("!!!예외 발생!!!");
            throw new Exception("");
        }
        return "반환값";
    }
}

 

 

결과 확인을 위해 서비스 메소드를 호출해주는 간단한 테스트 코드도 작성하였다.

@Test
void successTest() {
    String result = "초기값";
    try {
        result = aroundService.method(1);
    } catch (Exception e) {
        log.info("예외가 발생했습니다.");
    }
    log.info(result);
}

 

 

이제 Aspect 파일을 작성해준다.

호출 순서만 확인할 예정이므로, 각 Advice에는 로깅 기능만을 추가하였다.

 

먼저, 어노테이션을 달아준다. 각각의 역할은 아래와 같다.

 - @Component : 스프링 컨테이너가 해당 클래스를 빈으로 등록하고 AOP 기능을 적용할 수 있도록 하기 위해 필요
 - @Aspect : 해당 클래스가 AOP의 관점(Aspect)으로 동작한다는 것을 Spring에 알리기 위해 필요

 

다음으로 Pointcut을 설정해준다.

사실 @Around() 내부에 바로 적어줘도 되지만,
중복 사용이나 유지보수를 위해 필드 선언을 해주는 경우가 많은 것 같다. 또한, 여기서는 패키지 경로를 사용하였지만, Bean의 이름이나 어노테이션 등으로도 설정할 수 있다.

 

마지막으로, Advice 및 구체적인 로직을 작성해준다. 여기서 각각의 적용 대상(Pointcut)을 설정해주게 된다.
각 Advice의 실행 순서는 기본적으로 보장되지 않기 때문에, 순서를 설정해주고 싶다면 @Order를 사용해야 한다.

@Slf4j
@Aspect
@Component
public class LoggingAspect {

    // service 패키지 내부 전체에 대해
    @Pointcut("execution(* ureca.com.study.aop.service.*.*(..))")
    private void allService(){};

    @Around("allService(){}")
    public Object aroundLogging(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("@Around: 실행(전)");
        Object result = joinPoint.proceed();
        log.info("@Around: 실행(후)");
        return result;
    }

    @Before("allService(){}")
    public void beforeLogging() {
        log.info("@Before: 실행");
    }

    @After("allService(){}")
    public void afterLogging() {
        log.info("@After: 실행");
    }

    @AfterReturning("allService(){}")
    public void afterReturningLogging() {
        log.info("@AfterReturning: 실행");
    }

    @AfterThrowing ("allService(){}")
    public void afterThrowingLogging() {
        log.info("@AfterThrowing: 실행");
    }
}

@Around 테스트

먼저, @Around를 살펴보자.

 

다른 Advice들과 달리, @Around는 비즈니스 로직의 전/후로 원하는 로직을 추가할 수 있다.

그렇다면 로직 중 어디까지가 "전"이고, 어디부터가 "후"인지를 어떻게 구분할 수 있을까?

 

이런 비즈니스 로직의 수행 시점을 설정할 수 있도록 도와주는 것이 바로 ProceedingJoinPoint이다.

@Around("allService(){}")
public Object aroundLogging(ProceedingJoinPoint joinPoint) throws Throwable {
    log.info("@Around: 실행(전)");
    Object result = joinPoint.proceed();
    log.info("@Around: 실행(후)");
    return result;
}

 

위의 코드처럼 .proceed()를 통해 수행 순서를 직접 제어할 수 있다.

또한, 반환되는 실행 결과를 받아 마지막에 return해줌으로써 기존의 실행 흐름을 그대로 가져갈 수 있다.

 

@Around 외의 주석처리해준 후 테스트를 돌려보면 아래와 같이 전/후로 로깅이 잘 된 모습을 확인할 수 있다.

@Around 적용 결과

 

나머지 Advice들도 동일한 방법으로 결과를 확인해줄 수 있다. 예외 발생 시의 결과를 확인하고 싶다면 cmd 값을 바꿔서 테스트해주면 된다.


JoinPoint로 메소드 정보 확인하기

추가적으로, 앞서 언급한 JoinPoint를 활용하면 클라이언트가 호출한 메소드의 시그니처 정보(리턴 타입, 이름, 매개변수 등)가 저장된 Signature 객체를 받아 확인할 수 있다.

@Around("allService(){}")
public Object aroundLogging(ProceedingJoinPoint joinPoint) throws Throwable {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    String[] pNames = signature.getParameterNames();
    Object[] pVals = joinPoint.getArgs();

    for (int i = 0; i < pVals.length; i++) {
        String pInfo = pNames[i] + " = " + pVals[i];
        log.info(pInfo);
    }

    log.info("@Around: 실행(전)");
    Object result = joinPoint.proceed();
    log.info("@Around: 실행(후)");
    return result;
}

 

위와 같이 코드에 파라미터 정보를 받아오는 로직을 추가해주면, 아래와 같은 결과를 확인할 수 있다. (cmd = 1)

JoinPoint를 통한 파라미터 정보 확인 결과


Custom Anotation을 활용한 AOP 구현

이전까지는 패키지 경로를 통해 적용대상, 즉 Pointcut을 설정해주었다.

그러나 앞서 언급하였듯이, 어노테이션이나 빈 이름 등을 통해서도 Pointcut설정이 가능하다.

 

이 중 어노테이션을 활용하는 방법을 실습해보고자 한다.

 

먼저, 간단한 커스텀 Annotation을 만들어준다.

여기서 @Retention은 어노테이션의 유효기간을, @Target은 어노테이션을 달 대상을 설정해주는 역할을 한다.

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

 

다음으로, 어노테이션 적용 결과를 확인하기 위한 서비스 메소드들을 만들어준다.

결과 비교를 위해 동일한 로직의 메소드 A, B를 작성하고, 그 중 B에만 어노테이션을 달아주었다.

public String methodA() {
    log.info("메소드 A 실행~");
    return "반환값 A";
}

@LogAnnotation
public String methodB() {
    log.info("메소드 B 실행~");
    return "반환값 B";
}

 

각각의 결과를 확인하기 위한 테스트 코드도 적어준다.

@Test
void annoLoggingTest() {
    String resultA = mainService.methodA();
    log.info(resultA);

    log.info("=================");

    String resultB = mainService.methodB();
    log.info(resultB);
}

 

이제 마지막으로 LoggingAspect 파일을 작성해보자.

 

Pointcut에서는 execution 대신 @annotation을 사용하고, 내부에는 해당 어노테이션 파일의 경로를 적어준다.

내부에는 어노테이션이 붙어있는 메소드가 실행되면 로깅을 해주는 간단한 로직을 작성하였다.

// 어노테이션이 붙은 메소드들
@Pointcut("@annotation(ureca.com.study.aop.annotation.LogAnnotation)")
private void annoMethod(){};

@After("annoMethod(){}")
public void annoLogging() {
    log.info("Annotation 메소드가 실행되었습니다.");
}

 

실행해보면 아래와 같이 B에서만 AOP가 적용된 모습을 확인할 수 있다.

커스텀 어노테이션을 활용한 AOP 테스트 결과


항상 어렴풋이만 알고 있던 AOP에 대한 개념을 이번 기회에 제대로 정리할 수 있었다.

찾아보던 과정에서 관심이 생긴 개념들에 대해서도 천천히 공부해봐야겠다....

 

'Spring' 카테고리의 다른 글

[Spring] Spring Bean이란?  (4) 2025.04.09