AOP?

2025. 3. 12. 23:57Develop/Spring

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

 

객체지향프로그래밍처럼 처음에 이것만 놓고 보면 무슨 소린지 이해가 안간다.

 

쉽게 말해서 프로그래밍을 측면적인 관점에서 바라보는 것이다. 공통적인 측면, 핵심로직 측면 등등...

 

공통 관심사를 한 곳에서 관리할 수 없을까…?

공통 관심사 -> 공통적이긴 한데, ‘공통’적이기 때문에 해당 로직에서 ‘핵심’ 로직은 아닐 가능성이 높다.

 

따라서 AOP를 사용한다면 코드에서는 핵심 로직에만 집중할 수 있는 장점이 있다.

 

비유를 들자면

 

🎭 비유: 연극과 무대 관리자

프로그램을 연극에 비유해 보자.

  1. 연극 배우(클래스 & 메서드)
    • 연극에는 여러 배우(클래스와 메서드)가 등장하고, 각자의 대사와 행동(핵심 로직)을 수행한다.
    • 예를 들어, UserService 클래스에는 회원가입(), 로그인() 같은 기능이 있을 것이다.
  2. 무대 관리자(Aspect, 공통 기능)
    • 하지만 연극을 하려면 조명, 음향, 무대 장치 등 배우들이 신경 쓰지 않아도 되는 추가적인 작업이 필요하다.
    • 이 역할을 하는 사람이 바로 무대 관리자(AOP에서의 Aspect)다.
    • 무대 관리자는 특정 상황(회원가입 전후, 로그인 성공 후 등)에 따라 자동으로 조명을 켜거나 효과음을 넣을 수 있다.
  3. 무대 관리자가 하는 일 (AOP가 처리하는 공통 기능)
    • 로그 기록: 배우가 대사를 칠 때마다(메서드 실행될 때마다) 자동으로 기록을 남긴다.
    • 권한 검사: 특정 장면(특정 메서드 실행) 전에 배우가 자격이 있는지 확인한다.
    • 트랜잭션 관리: 연극이 끝나면(메서드 실행 완료 후) 무대 정리를 자동으로 해준다.

즉, 배우들은 자신의 연기에만 집중하면 되고, 무대 관리자가 공통적인 일들을 대신 처리해 주는 것이 AOP의 개념이다.

 

스프링 AOP는 프록시를 기반으로 동작한다.

스프링 AOP가 프록시 기반으로 동작한다는 뜻은, 원래 클래스를 상속받은 새로운(대리) 클래스를 사용한다는 뜻이다.

 

위빙

핵심 비즈니스 로직과 부가 기능을 결합하는 과정을 말합니다.

어려워보이지만, 간단히 말하면, 프록시를 만드는 것 자체가 위빙입니다.

위빙=프록시 생성

 

동적 프록시

인터페이스가 있을 때의 프록시 생성 방법 (implements 후 구현체 생성)

 

CGLIB

인터페이스가 없을 때의 프록시 생성 방법 (extends 후 override)

 


📌 위빙 (Weaving)

"핵심 비즈니스 로직 + 부가 기능을 결합하는 과정"
→ 쉽게 말하면 "프록시를 만드는 것 자체가 위빙"

💡 예시:

  • 핵심 로직: MemberService 클래스의 signup() 메서드
  • 부가 기능: 실행 시간을 측정하는 로직

위빙을 통해 signup()을 호출할 때 자동으로 실행 시간 측정 코드가 함께 실행되도록 만들 수 있음.

즉, 위빙을 하면:

  1. 원래 signup()만 실행하던 코드가
  2. "실행 시간 측정 코드" + signup() 이렇게 실행되도록 변함.
  3. 이 과정을 가능하게 하는 게 "프록시"

📌 동적 프록시 vs CGLIB

위빙을 하려면 프록시 객체를 만들어야 하는데, 방법이 2가지 있음.

1. 동적 프록시 (Dynamic Proxy)

인터페이스가 있을 때 사용하는 방법
implements를 사용해서 인터페이스 기반 프록시 객체를 생성

📌 예제

public interface Service {
    void run();
}

public class RealService implements Service {
    @Override
    public void run() {
        System.out.println("RealService 실행");
    }
}

// 동적 프록시 생성
Service proxy = (Service) Proxy.newProxyInstance(
        Service.class.getClassLoader(),
        new Class[]{Service.class},
        (proxy1, method, args) -> {
            System.out.println("부가 기능 실행");
            return method.invoke(new RealService(), args);
        }
);

proxy.run(); 

📝 실행 결과

부가 기능 실행
RealService 실행
  • 인터페이스 Service가 있으므로 implements 방식으로 프록시 생성
  • proxy.run()을 호출하면 부가 기능(출력문) → 원래 로직 실행 순서로 실행됨

2. CGLIB (Code Generation Library)

인터페이스가 없을 때 사용하는 방법
extends(상속)를 사용하여 클래스 기반 프록시 객체를 생성

📌 예제

public class RealService {
    public void run() {
        System.out.println("RealService 실행");
    }
}

// CGLIB을 사용하여 프록시 생성
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(RealService.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> {
    System.out.println("부가 기능 실행");
    return proxy.invokeSuper(obj, args);
});

RealService proxy = (RealService) enhancer.create();
proxy.run();

📝 실행 결과

부가 기능 실행
RealService 실행
  • 인터페이스가 없기 때문에 extends 방식으로 프록시 생성
  • proxy.run()을 호출하면 부가 기능 → 원래 로직 실행 순서로 실행됨

📌 정리

구분 동적 프록시 (Dynamic Proxy) CGLIB

언제 사용? 인터페이스가 있을 때 인터페이스가 없을 때
구현 방식 implements 후 인터페이스 기반 프록시 생성 extends 후 클래스 기반 프록시 생성
제한 사항 인터페이스가 필요함 final 클래스는 프록시 생성 불가
대표 예제 java.lang.reflect.Proxy org.springframework.cglib.proxy.Enhancer
스프링에서 사용? @Transactional, AOP 적용 시 기본적으로 사용 인터페이스가 없으면 자동으로 CGLIB 사용

🔍 한 줄 요약

  • 위빙(Weaving): 원래 코드 + 부가 기능을 결합하는 과정 → 결국 프록시를 만드는 것
  • 동적 프록시: 인터페이스가 있을 때 implements 방식으로 프록시 생성
  • CGLIB: 인터페이스 없이 extends 방식으로 프록시 생성

 

AOP 구현 방법

먼저 의존성을 추가해준다.

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

 

AOP를 적용할 대상을 고른다.

Spring AOP는 Spring Bean에만 적용된다는 것을 주의하자.

 

AOP를 이용해서 공통으로 처리해보자.

1. 단일 책임 원칙 (SRP: Single Responsibility Principle)

핵심 아이디어:

클래스는 한 가지 책임만 가져야 하며, 여러 관심사를 혼합하면 변경 시 부수 효과가 커집니다.

 

기존코드가 이러하였다면,

public class OrderService {
    public void placeOrder(Order order) {
        // 주문 처리 로직
        System.out.println("주문 처리: " + order.getId());
        // 로그 기록 코드 (공통 관심사)
        System.out.println("주문 기록 로그");
    }
}

 

로깅하는 Aspect를 작성해 로깅기능을 따로 뺀다.

@Aspect
@Component
public class LoggingAspect {
    @Before("execution(* com.example.OrderService.placeOrder(..))")
    public void logBefore(JoinPoint joinPoint) {
         System.out.println("주문 처리 시작: " + joinPoint.getSignature().getName());
    }
}

 

먼저 @Aspect와 @Component를 붙여준다.

그리고 해당 Aspect가 언제 실행될지 정해준다. 현재는 핵심로직이 실행되기 전 로그를 출력하려고 한다.

따라서 @Before 어노테이션을 통해 placeOrder가 실행되기 전 로깅이 되도록 설정해준다.

 

AOP의 효과:

비즈니스 로직과 로깅 같은 부가 기능을 분리함으로써, 각 클래스가 한 가지 책임만 가지게 되어 SRP를 준수할 수 있습니다.

 

2. 개방-폐쇄 원칙 (OCP: Open/Closed Principle)

핵심 아이디어:

시스템은 확장에는 열려 있고, 기존 코드를 변경하지 않고 새로운 기능을 추가할 수 있어야 합니다.

 

또한 성능을 측정하는 Aspect도 생각해 볼 수 있다.

다음과 같은 결제를 하는 메서드가 있을 때,

@Service
public class PaymentService {
    public void processPayment(Payment payment) {
        System.out.println("결제 처리: " + payment.getAmount());
    }
}

 

성능을 측정하는 Aspect를 적용할 수 있다.

@Aspect
@Component
public class PerformanceAspect {
    @Around("execution(* com.example.PaymentService.processPayment(..))")
    public Object measurePerformance(ProceedingJoinPoint pjp) throws Throwable {
         long start = System.currentTimeMillis();
         Object result = pjp.proceed();
         long end = System.currentTimeMillis();
         System.out.println("실행 시간: " + (end - start) + "ms");
         return result;
    }
}

 

@Around 어노테이션은 해당 메서드 실행 전과 후에 모두 동작한다.

 

AOP의 효과:

기존 PaymentService 코드를 변경하지 않고도 성능 측정과 같은 부가 기능을 추가할 수 있으므로, 시스템이 확장에는 열려 있고 변경에는 닫혀 있게 됩니다.

 

3. 리스코프 치환 원칙 (LSP: Liskov Substitution Principle)

핵심 아이디어:

서브타입은 언제나 기반 타입으로 대체 가능해야 하며, 클라이언트는 이를 구분하지 않고 사용할 수 있어야 합니다.

 

기존 코드

public interface NotificationService {
    void send(String message);
}

@Service
public class EmailNotificationService implements NotificationService {
    public void send(String message) {
         System.out.println("이메일 전송: " + message);
    }
}

@Service
public class SmsNotificationService implements NotificationService {
    public void send(String message) {
         System.out.println("SMS 전송: " + message);
    }
}

 

알림 전송 전 로그를 남기는 Aspect를 작성해보자.

@Aspect
@Component
public class NotificationAspect {
    @Before("execution(* com.example.NotificationService.send(..))")
    public void logNotification(JoinPoint joinPoint) {
         System.out.println("알림 전송 전에 로그 기록");
    }
}

AOP의 효과:

AOP가 생성하는 프록시는 원래 NotificationService 인터페이스를 그대로 따르므로, EmailNotificationService와 SmsNotificationService 모두 동일한 계약을 유지하며 클라이언트 코드에서 교체 사용이 가능합니다. 이를 통해 LSP를 만족시킵니다.

 

4. 인터페이스 분리 원칙 (ISP: Interface Segregation Principle)

📌 라이브러리를 만드는게 아니라면, 많이 사용되는 원칙은 아닙니다.

 

핵심 아이디어:

클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 합니다. 큰 인터페이스를 여러 개의 구체적인 인터페이스로 분리합니다.

 

분리된 인터페이스들:

public interface ReadableRepository {
    Object findById(Long id);
}

public interface WritableRepository {
    void save(Object entity);
}

 

두 인터페이스를 모두 구현하는 클래스:

@Component
public class UserRepository implements ReadableRepository, WritableRepository {
    public Object findById(Long id) {
         // 데이터 조회 로직
         return new Object();
    }
    public void save(Object entity) {
         // 데이터 저장 로직
         System.out.println("저장 완료");
    }
}

 

조회 기능에만 Aspect 적용해보자.

@Aspect
@Component
public class ReadLoggingAspect {
    @Before("execution(* com.example.ReadableRepository.findById(..))")
    public void logRead(JoinPoint joinPoint) {
         System.out.println("데이터 조회 호출");
    }
}

 

AOP의 효과:

인터페이스를 명확히 분리하고, 필요한 부분에만 Aspect를 적용할 수 있으므로, 클라이언트는 자신이 사용하는 인터페이스에만 의존하게 됩니다. 이는 ISP를 자연스럽게 준수하도록 돕습니다.

 

5. 의존성 역전 원칙 (DIP: Dependency Inversion Principle)

핵심 아이디어:

고수준 모듈은 저수준 모듈의 구체적인 구현에 의존하지 않고, 추상화에 의존해야 합니다.

 

public interface OrderProcessor {
    void process(Order order);
}

@Component
public class OrderProcessorImpl implements OrderProcessor {
    public void process(Order order) {
         System.out.println("주문 처리 중...");
    }
}

 

의존성 주입을 통한 고수준 모듈 구성:

@Service
public class OrderService {
    private final OrderProcessor orderProcessor;

    public OrderService(OrderProcessor orderProcessor) {
         this.orderProcessor = orderProcessor;
    }

    public void placeOrder(Order order) {
         orderProcessor.process(order);
         System.out.println("주문 완료");
    }
}

 

AOP를 활용한 주문 관련 부가 기능을 구현해보자.

@Aspect
@Component
public class OrderAspect {
    @Before("execution(* com.example.OrderService.placeOrder(..))")
    public void logOrder(JoinPoint joinPoint) {
         System.out.println("주문 요청이 들어왔습니다.");
    }
}

 

AOP의 효과:

OrderService는 OrderProcessor라는 추상화에 의존하며, AOP를 통해 주문 처리 전후의 부가 기능(예: 로깅)을 주입할 수 있습니다. 이로써 고수준 모듈이 저수준 구현 세부 사항에 종속되지 않도록 도와 DIP를 충족시킵니다.

'Develop > Spring' 카테고리의 다른 글

QueryDSL의 BooleanExpression, Projections, Pagination  (0) 2025.03.14
QueryDSL  (0) 2025.03.14
테스트 코드  (0) 2025.03.12
Proxy가 도대체 뭔데  (0) 2025.03.11
ORM은 왜 생겨났는가? JDBC부터 ORM까지 여정  (0) 2025.03.11