본문 바로가기

spring

Spring AOP - Aspect, Proxy, Self-Invocation

스프링에서 디버깅을 하며 실행 흐름을 추적하다 보면 내가 짠 적 없는 클래스와 메서드들을 마주하게 됩니다.

...Proxy, ...Intercepter 등의 클래스들이 실행 스택 트레이스의 상당 부분을 차지하는 것은 AOP가 동작하고 있다는 증거가 됩니다!

 

이번 글에서는 애스팩트와 프록시가 무엇인지, 왜 디커플링을 가능하게 하는지에 대해 정리합니다.

 

 

애스팩트(Aspect)

 

코드를 짜다보면 핵심 비즈니스 로직이 아니더라도 로깅, 트랜잭션 관리 등 여기저기서 공통적으로 필요한 기능들이 생깁니다.

애스팩트(Aspect)는 이러한 공통 기능들을 한 곳에 모아 관리하는 모듈입니다.

즉, 부가 기능의 내용과 해당 부가 기능을 어디에 적용할지를 정의한 것!

 

이렇게 애스팩트를 사용하는 프로그래밍 방식은 AOP(관점 지향 프로그래밍)이라고 합니다.

OOP vs AOP
AOP는 OOP를 대체하는 것이 아니며, OOP를 사용하다가 공통 기능들을 한 번에 처리하기 어려운 부분에 있어 AOP를 보조적으로 사용합니다. 

 

 

애스팩트를 사용하면 코드를 디커플링(Decoupliing)할 수 있다

 

스프링에서 애스팩트를 사용하면 비즈니스 로직과 공통 로직이 디커플링됩니다.

예를 들어, 애스팩트를 사용하는 대표적인 경우인 로깅, 트랜잭션 관리의 경우를 들어볼 수 있습니다.

 

1) 특정 메서드의 실행 시간을 측정하기 위해 메서드 시작부터 끝날 때까지의 실행 시간을 로깅할 수 있습니다.

프로젝트의 모든 메서드의 실행 시간을 측정하고 싶지만 메서드마다 실행 시간을 측정하고 로깅하는 코드를 포함한다면 코드가 지저분해질 것입니다.

이를 애스팩트로 처리한다면

  1. 비즈니스 코드는 깔끔하게 유지하면서
  2. 로깅 방식을 바꾸고 싶다면 하나만 수정하면 전체에 적용되어 유지보수성을 향상시킬 수 있습니다.

결과적으로 소스 코드 레벨에서는 서로의 존재를 모르고 독립적으로 작성되지만 실행할 땐 스프링이 이를 적용합니다!

 

2) 트랜잭션 관리 역시 개발자는 트랜잭션 적용이 필요한 곳에 @Transactional 어노테이션만 붙임으로써 비즈니스 로직에만 집중할 수 있습니다.

데이터베이스 연동 시 필요한 트랜잭션 시작, 성공시의 커밋 및 실패 시의 롤백 같은 과정은 스프링이 관리하는 애스팩트가 대행하게 됩니다.

 

 

프록시(Proxy)

 

소스 코드는 디커플링되어 있는데 스프링이 어떻게 이를 같이 실행시키는 걸까요?

스프링은 애스팩트를 적용해야 하는 객체가 있다면 같은 역할을 하는 가짜 객체 프록시(Proxy)를 만들어 실제 대신 빈으로 등록합니다.

어딘가에서 애스팩트가 적용된 해당 객체의 메서드를 호출했다고 가정해 봅시다.

  1. 메서드가 호출되면 프록시가 호출을 가로챕니다.
  2. 프록시는 실제 개발자가 정의한 내부 비즈니스 로직을 실행하기 전후에 애스팩트에 정의된 공통 기능을 먼저 실행시킵니다.
  3. 공통 기능 처리가 끝나면 실제 비즈니스 로직 객체를 호출합니다.
프록시와 실제 객체의 관계
프록시 내부에는 실제 비즈니스 로직이 없습니다. 대신 실제 객체에 대한 참조값을 필드로 가지고 있습니다.

호출하는 주체는 자신이 프록시를 호출하는지, 실제 객체를 호출하는지 알 수 없지만
스프링은 애스팩트의 적용이 필요한 경우 프록시 객체를 빈으로 등록합니다.

프록시 객체는 실제 비즈니스 로직을 수행하는 객체를 참조하여 순서에 따라 애스팩트 로직과 비즈니스 로직을 실행시키며,
비즈니스 로직을 실행시킬 땐 참조하고 있는 실제 객체의 메서드를 호출합니다.
public class OrderService {
    public void order() {
    	// 주문 로직
    }
}

public class OrderServiceProxy extends OrderService {
    private final OrderService target; // 실제 객체에 대한 참조
    
    public OrderServiceProxy(OrderService target) {
    	this.target = target;
    }
    
    @Override
    public void order() {
    	long start = System.currentTimeMillis();
        
        target.order();
        
        long executionTime = System.currentTimeMillis() - start;
        System.out.println("메서드 실행 시간: " + executionTime + "ms");
    }
}

 

실행 흐름을 표현한 간단한 코드입니다.

 

  1. 개발자가 @Aspect를 사용하여 공통 로직을 정의하고 실행 시점(Pointcut)을 설정합니다.
  2. 스프링은 설정된 대상 메서드를 가진 클래스를 위해 위와 같은 프록시를 동적으로 생성합니다.
  3. 실행 시점에 프록시는 참조하고 있는 실제 객체의 메서드를 호출하면서, 그 앞뒤로 애스팩트 로직을 끼워 넣어 실행합니다.

 

이러한 구조 덕분에 우리는 비즈니스 로직을 전혀 수정하지 않고도 실행 시점에 다양한 공통 기능을 자유롭게 추가하거나 변경할 수 있는 디커플링을 완성할 수 있습니다.

 

 

자기 호출 문제(Self-Invocation)

 

스프링 AOP를 사용할 때 흔히 겪을 수 있는 실수는 클래스 내부에서 자신의 메서드를 호출하는 경우입니다.

 

내부 호출은 왜 문제가 될까요?

스프링 AOP는 프록시가 외부의 호출을 가로채는 방식으로 동작하기 때문에

  1. 외부에서 호출 시: 호출이 프록시를 거쳐 프록시가 애스팩트 로직들을 끼워 넣을 수 있습니다.
  2. 클래스 내부에서 호출 시: 이미 프록시를 거친 후, 진짜 객체를 호출한 상태입니다. 여기서 자기 자신의 다른 메서드를 호출할 때는 본인의 코드를 직접 실행하는 것이므로 @Transactional 등 애스팩트 로직이 적용되지 않습니다.
@Service
public class OrderService {

    // 외부에서 호출하는 메서드 (애스팩트 없음)
    public void registerOrder() {
    	// 주문 로직
        
        // 클래스 내부에서 자신의 다른 메서드를 호출
        completePayment(); 
    }

    // 트랜잭션 애스팩트가 적용되어야 하는 메서드
    @Transactional
    public void completePayment() {
        // 결제 처리
    }
}

 

이 경우 클래스 내부에서 completePayment 메서드를 호출하기 때문에 트랜잭션이 적용되지 않습니다.

이 현상을 자기 호출 문제라고 합니다.

 

 

따라서 우리는 AOP로 구현된 공통 로직이 정상적으로 작동하게 하려면 해당 메서드는 반드시 외부의 다른 객체에서 호출되도록 설계해야 한다는 점을 기억해야 합니다 !