AOP (Aspect-Oriented Programming)
관점 지향 프로그래밍
애플리케이션 로직은 크게 핵심 기능과 부가 기능으로 나눌 수 있는데, 두 코드를 같은 곳에 작성하게 되면 다음과 같은 문제가 발생한다.
- 중복되는 부가 기능 코드가 여러 곳에 흩어져 존재
- 핵심 기능과 부가 기능이 섞여 코드가 복잡해짐
- 부가 기능을 변경할 때 핵심 기능 코드도 함께 수정해야 함
때문에 이를 분리하려는 시도를 하기 시작했고, 부가적인 기능을 분리하여 설계하여 개발 하는 방법을 관점 지향 프로그래밍 (AOP)이라고 한다.
용어 정리
섹션 제목: “용어 정리”- 애스펙트 (Aspect)
- 여러 객체에 공통으로 적용되는 관심사 (부가 기능)의 모듈화 (어드바이스 + 포인트컷)
- 스프링에서는
@Aspect애노테이션을 사용한 클래스로 구현
- 조인 포인트 (Join point)
- 추상적인 개념으로, 어드바이스가 적용될 수 있는 모든 지점을 의미
- 메소드 실행 시점, 생성자 호출 시점, 필드 값 접근 시점 등 애플리케이션 실행 흐름의 특정 지점 모두가 해당
- 어드바이스 (Advice)
- 조인 포인트에서 실행되어야 할 부가 기능 로직
- 포인트컷 (Pointcut)
- 수많은 조인 포인트 중에서 어드바이스를 적용할 지점을 선별하는 기능
- AspectJ 표현식을 사용하여 구체적으로 지정
- 타겟 (Target)
- 어드바이스가 적용되는 대상 객체
- 어드바이저 (Advisor)
- 하나의 어드바이스와 하나의 포인트컷으로 구성된 객체
- 스프링 AOP에서만 사용되는 용어
- 위빙 (Weaving)
- 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용하는 과정
- 위빙을 통해 핵심 기능 코드의 변경 없이 부가 기능을 추가할 수 있음
- AOP 프록시 (AOP Proxy)
- AOP 기능을 구현하기 위해 만든 프록시 객체
JDK 동적 프록시 / CGLIB
섹션 제목: “JDK 동적 프록시 / CGLIB”런타임에 프록시 객체를 동적으로 생성하기 위해 스프링이 지원하는 두 가지 방식이 존재한다.
JDK 동적 프록시
섹션 제목: “JDK 동적 프록시”- 자바 표준 라이브러리 (
java.lang.reflect.Proxy)가 제공하는 기술 - 인터페이스 기반으로 프록시를 생성하므로, 타겟이 반드시 하나 이상의 인터페이스를 구현 필요
InvocationHandler인터페이스를 구현하여 프록시의 동작 (어드바이스)을 정의
class TestInvocationHandler implements InvocationHandler {
private TargetInterface target; // Target, 적용이 될 실제 객체
public TestInvocationHandler(TargetInterface target) { this.target = target; }
@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { log.info("log start"); Object result = method.invoke(target, args); // 실제 객체의 메서드 호출 log.info("log end");
return result; }}CGLIB (Code Generation Library)
섹션 제목: “CGLIB (Code Generation Library)”- 외부 라이브러리로, 바이트코드를 조작하여 프록시를 동적으로 생성
- 클래스 상속 기반으로 프록시를 생성하므로 인터페이스가 필요 없음
- 타겟 클래스를 상속받아 자식 클래스를 만들기 때문에, 클래스나 메서드에
final키워드가 있으면 오버라이딩이 불가능해 적용할 수 없음 MethodInterceptor인터페이스를 구현하여 프록시의 동작을 정의
class TestMethodInterceptor implements MethodInterceptor {
private TargetImpl target; // Target, 적용이 될 실제 객체
public TestMethodInterceptor(TargetImpl target) { this.target = target; }
@Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { log.info("log start"); Object result = proxy.invoke(target, args); // 실제 객체의 메서드 호출 log.info("log end");
return result; }}예시 코드에서 볼 수 있듯이 프록시 패턴을 구현해야하는 인터페이스가 다르기 때문에 구현하는데 복잡성을 가지는데, 스프링에서는 이를 추상화시켜 사용할 수 있도록 Proxy Factory를 제공한다.
Proxy Factory
섹션 제목: “Proxy Factory”Proxy Factory는 대상 객체에 인터페이스 유무를 판단하여 자동으로 적절한 프록시 기술 (JDK or CGLIB)을 선택해 생성해 주는 팩토리 클래스다.
flowchart LR subgraph proxy_factory["Proxy Factory Flow"] direction LR C[client] -->|1 . 프록시 요청| PF[ProxyFactory<br/>2 . 프록시 기술 선택] PF -->|3 . 프록시 생성| JDK[JDK 동적 프록시] PF -->|3 . 프록시 생성| CGLIB[CGLIB] JDK -->|4 . jdk proxy 반환| C CGLIB -->|4 . cglib proxy 반환| C endflowchart LR subgraph advice_call["Advice Call Flow"] direction LR C1[client] --> JDP[jdk Proxy] JDP --> AIH[adviceInvocationHandler] AIH -->|Advice 호출| A1[Advice] A1 --> T1[target] C2[client] --> CGP[cglib Proxy] CGP --> AMI[adviceMethodInterceptor] AMI -->|Advice 호출| A1[Advice] end인터페이스 유무 혹은 옵션에 따라 JDK 동적 프록시와 CGLIB를 사용하여 프록시 패턴을 구현해주는 기능을 제공한다.
Proxy Factory를 이용한 예시 코드
섹션 제목: “Proxy Factory를 이용한 예시 코드”// 부가 기능을 제공할 Adviceclass LogTraceAdvice implements MethodInterceptor {
@Override public Object invoke(MethodInvocation invocation) throws Throwable { log.info("log start"); Object result = invocation.proceed(); // 실제 객체의 메서드 호출 log.info("log end");
return result; }}
class ExampleProxyFactory {
public Object getProxy(Object target) { // Pointcut NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); pointcut.setMappedNames("request*", "order*", "save*");
// Advice LogTraceAdvice advice = new LogTraceAdvice();
// Advisor Advisor advisor = new DefaultPointcutAdvisor(pointcut, advice);
return new ProxyFactory() .setTarget(target) .addAdvisor(advisor) .getProxy(); }}
class Main {
public static void main(String[] args) { Example example = new Example(); ExampleProxyFactory proxyFactory = new ExampleProxyFactory(); Example proxy = (Example) proxyFactory.getProxy(example); proxy.execute(); }}이 방법으로 생성 방법은 통일되었지만, 다음과 같은 단점이 존재한다.
- 부가 기능이 필요한 대상 (Target)마다 설정 코드에서
ProxyFactory를 직접 호출해 빈으로 등록해야 함 - 컴포넌트 스캔 (
@Component)을 통해 자동 등록되는 빈에는 수동으로 프록시 객체를 끼워 넣을 방법이 없음
때문에 컴포넌트 스캔을 포함한 등록되는 빈들에 대해 프록시 객체를 적용하는 빈 후처리기 방법이 등장하게 되었다.
빈 후처리기 (BeanPostProcessor)
섹션 제목: “빈 후처리기 (BeanPostProcessor)”빈 후처리기를 이용하면 컴포넌트 스캔으로 빈이 컨테이너에 등록되기 직전에 원본 객체를 프록시 객체로 바꿔치기 (조작)할 수 있다.
class PackageLogTracePostProcessor implements BeanPostProcessor {
private final String basePackage; // 프록시 적용 패키지 private final Advisor advisor; // Advice + Pointcut
public PackageLogTracePostProcessor(String basePackage, Advisor advisor) { this.basePackage = basePackage; this.advisor = advisor; }
@Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { String packageName = bean.getClass().getPackageName();
// 프록시 적용 대상 여부 체크 if (!packageName.startsWith(basePackage)) { return bean; // 프록시 적용 대상이 아니면 원본을 반환 }
// 프록시 대싱이면 프록시를 만들어서 반환 return new ProxyFactory() .setTarget(bean) .addAdvisor(advisor) .getProxy(); }}
// Config.java@Configuration@Import({AppV1Config.class, AppV2Config.class})class BeanPostProcessorConfig {
@Bean public PackageLogTracePostProcessor logTracePostProcessor() { return new PackageLogTracePostProcessor("hello.proxy.app", getAdvisor()); }
@Bean public DefaultPointcutAdvisor advisor() { return new DefaultPointcutAdvisor(Pointcut.TRUE, new MyAdvice()); }}하지만 이 방법을 사용하더라도 어드바이저 (포인트컷+어드바이스)를 직접 코드로 생성하고 후처리기를 직접 등록해야 하므로 여전히 관리 비용이 높다는 단점이 존재한다.
@Aspect 애노테이션
섹션 제목: “@Aspect 애노테이션”스프링 프레임워크가 제공하는 @Aspect 애노테이션 기반의 AOP는 가장 선언적이고 간결하게 프록시 기반 부가 기능을 적용할 수 있는 방식이다.
@Aspectclass ExampleAspect {
@Around(value = "execution(* hello.proxy.app..*(..))") // Pointcut public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { // Advice 로직 // ...
Object result = joinPoint.proceed(); // 실제 객체의 메서드 호출
// ... // Advice 로직 return result; }}
// Config.java@Configurationclass AopConfig {
@Bean public LogTraceAspect logTraceAspect(LogTrace logTrace) { return new LogTraceAspect(logTrace); }}포인트컷 (Pointcut)과 어드바이스 (Advice)로 구성된 어드바이저 (Advisor)의 생성을 편리하게 해주는 기능을 가진 어노테이션을 통해 훨씬 간단하게 부가 기능을 제공할 수 있다.
@Aspect의 동작 방식
섹션 제목: “@Aspect의 동작 방식”@Aspect를 사용하여 프록시 객체를 적용할 때, 어드바이저 생성과 프록시 객체 적용은 다음과 같은 흐름으로 동작하게 된다.
- 어드바이저 자동 탐색 및 생성
- 스프링 컨테이너 초기화 시
@Aspect애노테이션이 붙은 빈 조회 @Aspect애노테이션 정보를 기반으로 어드바이저 생성- 어드바이저 등록
- 스프링 컨테이너 초기화 시
flowchart LR A[1. 빈 대상 객체 생성] -->|2 . 빈 후처리기로 전달| B[빈 후처리기] B -->|3 . Advisor 조회| SC[스프링 컨테이너] B -->|4 . 프록시 적용 대상 체크| ADV[Advisor] ADV --- P[Pointcut] ADV --- AD[Advice] B -->|5 . 프록시 생성| PRX[프록시] PRX -->|6 . 빈 등록| SC- 프록시 객체 적용
- 스프링 빈 대상이 되는 원본 객체 생성
- 빈 후처리기가 동작하여 빈을 컨테이너에 등록하기 전 가로챔
- 컨테이너에서 모든 어드바이저를 조회하여 현재 생성된 빈이 포인트컷 (적용 대상)에 부합하는지 검사
- 대상이라면 프록시 팩토리를 통해 해당 빈의 프록시 객체를 동적 생성함
- 원본 객체 대신 프록시 객체를 빈으로 등록 (실제 실행 시 프록시가 먼저 호출)
프록시 내부 호출 (Self-Invocation) 문제
섹션 제목: “프록시 내부 호출 (Self-Invocation) 문제”AOP 프록시가 적용된 객체의 내부에서 다른 메서드를 호출할 때 AOP가 적용되지 않는 문제가 발생할 수 있다. 이는 프록시를 거치지 않고 this 참조를 통해 원본 객체의 메서드를 직접 호출하기 때문이다.
@Serviceclass MyService {
public void external() { // ... 로직 ... this.internal(); // 프록시를 거치지 않고 원본 객체의 메서드를 직접 호출 }
@Transactional public void internal() { // 트랜잭션이 적용되기를 기대하는 로직 }}위 코드에서 external()을 호출하면 internal()은 트랜잭션 어드바이스를 타지 않는다. 이 문제를 해결하는 방법은 다음과 같다.
- 자기 자신 주입: 생성자나
@Autowired를 통해 프록시 객체인 자기 자신을 주입받아 호출하는 방식 - 지연 조회:
ApplicationContext나ObjectProvider를 사용해 실제 필요한 시점에 프록시 객체를 조회 (lookup)하여 사용하는 방식 - 구조 변경: 스프링이 가장 권장하는 방법 내부 호출이 필요한 로직을 별도의 클래스로 분리하여 의존성을 주입받아 사용하는 방식
스프링과 CGLIB
섹션 제목: “스프링과 CGLIB”최신 버전의 스프링 부트에서는 프록시 객체를 생성할 때 기본적으로 CGLIB를 사용하는데, 이를 통해 아래와 같은 장점을 가지게 된다.
- 인터페이스가 없는 클래스도 프록시로 만들 수 있음
- 의존관계 주입 시 문제가 발생하지 않음 (JDK 동적 프록시는 발생)
하지만 CGLIB는 클래스를 상속받아 생성하는 방식이기 때문에 다음과 같은 제약사항이 존재했지만 3번을 제외하고는 이미 해결되었다. (objenesis라는 라이브러리를 사용하여 해결)
- 대상 클래스에 기본 생성자가 반드시 존재해야 함
- 생성자 호출이 두 번 발생 (실제 객체를 생성할 때 + 프록시 객체를 생성할 때)
- final 클래스, final 메서드가 있는 경우 프록시를 생성할 수 없음
3번 문제는 아직 해결하지 못했으나 대부분의 경우 final 키워드를 잘 사용하지 않기 때문에 큰 문제가 되지 않는다.