본문 바로가기
백엔드/Spring

[Spring] 스프링 AOP

by LaDiuM 2026. 1. 5.
목차

    [Spring] 스프링 AOP

    지난 글에서는 스프링(Spring)의 3대 핵심 기술 중 하나인 IoC/DI에 대해 알아보았다. 이는 객체의 생명주기와 의존성 관리를 컨테이너에게 위임함으로써, 개발자가 온전히 비즈니스 로직에만 집중할 수 있는 환경을 만들어 POJO(Plain Old Java Object)를 실현하는 기반이 되었다.

    그러나 IoC/DI만으로는 POJO를 완전히 실현하기에 여전히 부족함이 있다. 자바 프로그래밍의 기본이 되는 OOP 방식은 핵심 관심사를 모듈 형태로 관리하는 것에는 최적화되어 있으나, 핵심 관심사를 가로지르는 트랜잭션, 로그 기능 등 공통 관심사의 관리에는 최적화되어 있지 않기 때문이다.

    이러한 코드들은 비즈니스 코드 여기저기에 침투해 있는 방식으로 존재하며, 이는 POJO의 가치를 실현하는 것에 대한 방해 요소이다. 이를 해결하기 위해 관점 지향 프로그래밍인 AOP(Aspect Oriented Programming)의 개념에 대해 알아보고, 이를 스프링에서 어떤 식으로 차용하는지 알아보도록 하자.

    스프링에서 차용한 Spring AOP라고 불리는 이 기술은 스프링 3대 핵심 기술 중 하나를 담당하고 있는 중요한 기술이다.


    1. AOP의 등장 배경과 개념

    이전에 JAVA의 개념 정리 글에서 객체 지향 프로그래밍(OOP)의 기본 단위는 class라고 하였다. class는 어떠한 역할 또는 개념을 나타낸다고 할 수 있으며, 이를 구체화한 객체간의 상호작용을 중심으로 프로그래밍을 하는 것이 OOP이다.

    그리고 객체간의 상호작용은 주로 연관된 기능, 즉 핵심 관심사끼리 모아두는 것이 개발 시 유리하다. 예를 들면 주문 관리 관련 기능, 회원 관리 관련 기능, 상품 관리 관련 기능들은 각각 주문, 회원, 상품이라는 핵심 관심사에 의해 연관된 기능들이 모여있다. 이러한 공통 관심사에 의해 모여있는 기능들을 모듈이라고 하며, 기능을 모듈화하여 내부적으로는 응집도를 높이고, 외부적으로는 결합도를 낮추는 것이 객체지향적으로 바람직한 설계라고 할 수 있다.

    OOP는 객체간의 상호작용으로 프로그래밍되기 때문에 동작이 직관적이고, 재사용성에 아주 유리한 패러다임이다. 이 때문에 OOP는 대부분의 문제를 효율적으로 해결하는 프로그래밍 방식이라고 볼 수 있다.

    1.1 횡단 관심사 (Cross-Cutting Concern)

    하지만 이러한 OOP도 모든 문제를 가장 효율적으로 해결하지는 못한다. 핵심 관심사가 단일 모듈로 구성될 수 있다면 OOP의 장점을 극대화할 수 있으나, 만약 이러한 관심사가 여러 모듈에 공통적으로 들어가는 부가 기능, 즉 로그 기록, 보안 인증, 트랜잭션 관리 등은 여러 핵심 관심사를 가로지르는 형태의 관심사라고 볼 수 있다. 이를 횡단 관심사(Cross-Cutting Concern)라 부른다.

    위 그림을 보자. 주문 관리, 회원 관리, 상품 관리는 동일한 관심사의 다른 모듈로 교체되어도 전체 프로그램 동작에 큰 문제가 없다. 이러한 독립성이 OOP의 강력한 장점이다. 하지만 트랜잭션, 로그, 보안 처리 등은 각 모듈에 침습적으로 침투해 있기 때문에 변화가 발생하면 적용된 모든 모듈을 수정해야 할 수 있다. OOP의 관심사는 주로 객체 단위(모듈)로 이루어져 있기 때문에 여러 모듈(객체)을 가로지르는 횡단 관심사를 관리하기에는 한계가 있다.

    1.2 관점 지향 프로그래밍 (AOP)

    지금까지 살펴본 이러한 관심사(Aspec) 위주의 관점에서 시스템을 바라보는 프로그래밍 패러다임이 바로 AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)이다. 

    AOP의 시작 자체가 위에서 보았듯, OOP에서 관리되기 힘든 '횡단 관심사'를 관리하기 위해 태어났다. 그리고 ' 시스템은 여러 가지 관심사의 집합이다'라고 정의함으로써 AOP가 문제라고 인식하는 관심사가 뒤섞인 문제를 해결하는 것이 AOP 패러다임의 핵심 목적이다.

    그리고 '관심사가 뒤섞여 있다'를 정확히 판단하려면 관심사에 대한 정확한 기준이 정의되어야 한다. AOP에서는 두 가지 주요 관심사가 있으며, 기준은 아래와 같다. 위에서 얘기한 관심사가 뒤섞인 문제를 해결하는 것은 아래의 관심사들을 분리하는 것을 얘기한다.

    • 핵심 관심사 (Core Concern)
      어플리케이션 존재 목적이 되는 기능들이다. 시스템에서 주체가 되어 동작하는 기능들이며, 사용자 입장에서의 주요한 관심사들을 말한다.
    • 횡단 관심사 (Cross-Cutting Concern)
      어플리케이션 전반에 걸쳐 동작하는 부가 기능들을 말한다. 주로 핵심 관심사의 기능들을 보조하며, 여러 모듈을 가로질러 침습적으로 침투해 있는 기능들을 말한다.

    AOP의 목적과 탄생 이유를 보면 알 수 있듯이, AOP와 OOP는 완전히 독립적인 패러다임이 아니다. AOP에서의 핵심 관심사는 OOP에서 이미 효율적으로 관리되는 영역이며, OOP에서 관리하기 힘든 횡단 관심사를 분리하여 효율적으로 관리하는 것이 AOP의 궁극적인 목적이다.


    2. AOP의 주요 용어

    AOP는 OOP가 관리하는 핵심 관심사에서 횡단 관심사를 분리하여 관리하는 것이 주요 목적이라고 하였다. 이를 위해서 AOP에서 사용되는 용어들이 있으며, 아래의 용어들을 이해하는 것이 AOP를 정확히 적용하고 이해하는데 필수적인 지식이라고 할 수 있다.

    위 그림은 AOP 용어를 동작 흐름 형태로 표현한 그림이다.

    2.1 타겟 (Target)

    Target이라는 용어에서 알 수 있듯이, 부가 기능이 적용될 목표(대상 비즈니스 코드, 클래스)를 지칭한다.

    2.2 애스펙트 (Aspect)

    AOP는 횡단 관심사를 모듈화 하는 것이 가장 큰 목적이라고 볼 수 있다. 바로 AOP에서 Aspect라고 부르는 것이 횡단 관심사가 모듈화 된 실체이다. 이 횡단 관심사는 아래의 Advice와 Pointcut으로 이루어져 있으며. 정확히는 어디에(Pointcut) 무엇을(Advice) 할 것인지를 나타낸다고 볼 수 있다.

    2.3 어드바이스 (Advice)

    Aspect가 횡단 관심사의 모듈화라면, Advice는 이 횡단 관심사의 구체적 기능이다. Target 객체에 적용될 부가 코드를 의미하며 실제 동작되는 부가 기능이라고 할 수 있다

    2.4 조인포인트 (JoinPoint)

    프로그램 동작 시 부가 기능이 실행될 수 있는 후보지들이다. JAVA 기준으로 아래 지점이 Advice가 적용될 수 있는 후보지들이며, 말 그대로 후보지(추상화) 개념이기 때문에 아직 어느 시점에 적용할지 결정된 건 아니다.

    구분 시점 1 시점 2
    메서드 메서드 실행 (Execution) 메서드 호출 (Call)
    생성자 생성자 실행 (Execution) 생성자 호출 (Call)
    필드 필드 값 조회 (Get) 필드 값 변경 (Set)
    기타 예외 처리 (Handler)
    클래스 초기화 (Static)
    Spring AOP는 프록시(Proxy) 방식으로 AOP를 적용하기 때문에 위 표에서 오직 '메서드 실행' 시점에만 AOP를 지원한다.

    2.5 포인트컷 (Pointcut)

    조인포인트 중 부가기능이 적용되는 위치를 나타내는 용어이다. 정확히 말하면, Point(지점)를 Cut(자름)함으로써 적용될 위치를 필터링한다. Spring AOP 기준으로는 메서드 실행 시점만 지원하기 때문에 AspectJ의 표현식을 이용하여 주로 메서드가 위치한 패키지, 메서드 명, 반환 타입 등 메서드 기준으로 부가 기능이 적용될 위치가 필터링된다.

    AspectJ는 JAVA에서 관점 지향 프로그래밍(AOP)을 위해 기능을 확장한 프레임워크이다.
    토막 지식: 프로그래밍 기술명에 4j(for java) 또는 J 단문자가 들어가면 보통 JAVA를 의미한다.

    2.6 위빙 (Weaving)

    Target에 실제로 Advice(부가 기능)을 적용하는 것을 말하는 용어이다. Weaving이라는 용어는 실로 엮는다는 의미를 가지는데, 말 그대로 목표에 부가기능을 엮는다는 이미지라고 생각하면 직관적이다. 구체적으로는 JointPoint 후보군 중 Pointcut으로 필터링된 구체적인 객체에 Advice를 적용하는 것이다.


    3. 스프링 AOP

    지금까지 알아본 개념은 결국 AOP에 대한 배경지식이라고 볼 수 있다. 이제 스프링에서는 AOP를 어떻게 구현해 보는지 알아볼 차례이다.

    반복해서 강조했듯, 스프링의 가장 핵심적인 가치는 POJO(Plain Old Java Object)이다. 스프링의 3대 기술 역시 이 POJO를 실현하기 위해 존재한다. 그런데 사실 AOP의 적용은 침습적으로 작용할 여지가 많다. 그 이유는 위에서 설명한 조인포인트가 핵심인데, 조인 포인트에는 AOP가 적용되는 여러 시점이 있으며, 이 적용 시점 중 일부는 코드에 침투하는 성질이 존재한다.

    예를 들면 '컴파일 시점'에 AOP를 적용한다고 생각해 보자. 컴파일은 소스 코드를 읽어 바이트코드로 변환하는 과정이다. 그리고 이 시점에 타겟과 어드바이스가 위빙(결합)을 한다고 생각해 보자. 그러면 결과물인 바이트코드는 타겟과 어드바이스가 혼합된 형태가 된다고 볼 수 있다.

    이런 식으로 부가기능의 적용이 비즈니스 코드를 오염시키는 형태는 스프링이 지향하는 형태가 아니다. 이를 해결하기 위해 스프링은 프록시(Proxy)라는 기술을 사용하여 POJO를 실현한다. 이 프록시에 대한 이해가 스프링 AOP를 이해하는 가장 큰 핵심이 된다.

    3.1 프록시 패턴

    컴퓨터에 관심이 많다면 프록시라는 명칭을 들어본 적이 있을 것이다. 프록시(Proxy)의 사전적 의미는 대리자를 의미한다. 이는 어떠한 행동을 대신하는 것을 의미한다고 할 수 있다.

    프록시 패턴(GOF)은 어떤 객체에 대한 접근을 대리하는 객체를 두는 패턴을 말한다. 즉, 원본 객체는 변경하지 않고 원본 객체를 중개하여 어떠한 작업을 대신 수행해 준다고 할 수 있다.

    실제로는 접근인지, 부가 기능 적용인지에 따른 의도로 프록시인지 데코레이터 패턴인지를 구분하기도 하지만, 여기서는 객체의 행위를 대리한다는 관점에서 프록시 패턴으로 일관적으로 설명하도록 하겠다.

    프록시 패턴의 정의를 살펴보면 스프링에서 AOP 적용을 위해 왜 프록시 기술을 사용하는지 이해할 수 있다. 결론은 원본 객체 변경 없이 부가 기능 적용이 핵심이다.

    프록시 패턴의 정의는 POJO를 위한 만능 해결책처럼 보이지만 사실 만능은 아니다. 왜냐하면 POJO를 유지하며 프록시 패턴을 적용할 수 있는 조인포인트의 지점은 '단 한 개'로 제한되기 때문이다. 바로 '메서드 실행' 시점이다.

    예를 들어 '메서드 호출' 시점에 위빙이 일어난다고 가정해 보자. 그러면 호출부의 호출 코드 자체를 조작해야 될 것이다. 그러면 호출부의 코드가 순수 비즈니스 코드가 아닌 AOP 적용을 위해 오염된다고 볼 수 있다. 이처럼 '메서드 실행' 시점을 제외한 다른 조인포인트들은 비즈니스 코드에 침습적인 형태로 AOP를 적용할 수밖에 없다.

    하지만 '메서드 실행' 시점은 예외이다. 아래에서 살펴볼 JDK Dynamic Proxy, CGLIB라는 두 기술을 이용하여 클라이언트 측 코드는 건드리지 않고 요청을 대리 객체로 변경 가능하기 때문이다.

    이처럼 스프링에서는 비즈니스 코드의 침습적인 작용(POJO 위배) 없이 AOP를 적용하기 위해 프록시 패턴을 통해 AOP를 구현한다고 볼 수 있다.

    3.2 JDK Dynamic Proxy, CGLIB

    여기까지 스프링이 왜 AOP를 프록시 패턴으로 적용하였는지 살펴보았다. 여기까지가 왜(why) 였다면, 이제부터는 어떻게(how)를 살펴볼 차례이다.

    기본적으로 스프링이 프록시를 구현하는 방식은 JDK Dynamic Proxy와 CGLIB라는 두 기술을 이용한다. 이 기술들은 스프링이 독자적으로 개발한 기술이 아니며, 동적 프록시 생성을 위해 기존에 존재하던 기술을 스프링이 차용한 형태라고 볼 수 있다.

    그리고 두 기술의 핵심적인 차이는 아래와 같다.

    • JDK Dynamic Proxy [자바 표준]
      인터페이스 기반의 동적 프록시 적용 기술이다. 자바의 표준 리플렉션 기술을 이용하여 구현되며 프록시 적용을 위해 반드시 인터페이스를 구현하여야 한다.
    • CGLIB [오픈 소스 라이브러리]
      클래스 상속 기반
      의 동적 프록시 적용 기술이다. 바이트코드를 조작하여 타겟 클래스를 상속받은 대리 객체를 만든다.

    두 기술의 가장 핵심적인 차이를 개발자 관점에서 본다면 아래처럼 해석 가능하다.

    타겟이 반드시 인터페이스를 구현해야 하는가? -> JDK Dynamic Proxy
    인터페이스 구현 여부와 상관없이 사용이 가능한가? -> CGLIB

    위 내용만 보면 자유도가 높은 CGLIB만 사용하면 될 것처럼 보인다. 하지만 스프링은 초기에 좋은 객체지향 설계(OOP)를 위해 인터페이스 기반 설계를 권장하였으며 이를 위해 자바 표준인 JDK Dynamic Proxy 기술을 기본값(default)로 사용하였다. 그리고 인터페이스가 없는 경우 부득이하게 CGLIB라는 대안을 제공하는 방식으로 동적 프록시를 제공하였다.

    하지만 시간이 지나며 개발 트랜드가 변했다. 개발자들이 모든 서비스에 인터페이스를 만드는 것이 번거롭다는 실용주의가 퍼졌고, CGLIB가 가진 단점(성능, 생성자 호출 문제 등)이 해결되면서 개발자의 혼란(DI 타입 에러 등)을 줄이는 것이 더 중요하다고 판단하여 현재(Spring Boot 기준)는 CGLIB가 스프링의 동적 프록시의 기본값으로 사용되게 되었다고 볼 수 있다.

    3.3 스프링 AOP Proxy 구현

    스프링이 프록시를 구현하는 방식은 JDK Dynamic Proxy, CGLIB 두 가지 기술을 사용한다고 하였다. 두 기술의 개념을 알아보았으니, 이 기술들을 스프링이 어떻게 추상화하여 사용자에게 제하는지 알아보도록 하자.

    3.3.1 AopProxy

    일단 첫 번째로 알아아 할 사실은 스프링은 최종적으로 프록시를 AopProxy 인터페이스로 추상화했다는 사실이다. 스프링은 AopProxy를 이용해서 필요한 객체에서 프록시를 호출한다.

    1. AopProxy Javadoc
      /**
       * Delegate interface for a configured AOP proxy, allowing for the creation
       * of actual proxy objects.
       *
       * <p>Out-of-the-box implementations are available for JDK dynamic proxies
       * and for CGLIB proxies, as applied by {@link DefaultAopProxyFactory}.
       *
       * @author Rod Johnson
       * @author Juergen Hoeller
       * @see DefaultAopProxyFactory
       */
      public interface AopProxy {
          Object getProxy();
          Object getProxy(@Nullable ClassLoader classLoader);
          Class<?> getProxyClass(@Nullable ClassLoader classLoader);
      }
      AopProxy의 서명을 보면 스프링의 창시자 로드 존슨이 참여했다는 것을 볼 수 있다. 이는 그만큼 스프링 AOP의 핵심 철학이 담긴 인터페이스라고 볼 수 있다. 추상화된 메서드도 단 3종류로 압도적인 간결함을 보인다. 이는 로드 존슨이 원하는 것이 무엇인지 코드를 통해 보여주는 우아한 인터페이스처럼 보인다.
    2. AopProxy 구현체
      AopProxy의 구현체를 보면 3가지 클래스가 보인다. 기술은 2가지인데 하나가 더 있는 이유는 ObjenesisCglibAopProxy 클래스는 3.2에서 마지막에 설명한 CGLIB의 단점(성능, DI 타입 에러 등)을 개선한 구현체이다. 그렇기 때문에 현시점 기준으로 스프링의 CGLIB의 구현체는 ObjenesisCglibAopProxy가 사용된다.

    3.3.2 ProxyFactory

    여기서 한번 돌아가서 JDK Dynamic Proxy, CGLIB의 차이를 생각해 보자. 둘의 차이는 바로 "타겟의 인터페이스 구현 유무"이다. 하지만 AopProxy는 getProxy 하나로 프록시를 호출할 수 있도록 추상화했다. 그러면 누가 복잡한 조건을 확인하며 그에 맞는 기술을 사용할지 결정할까?

    그 주인공은 바로 ProxyFactory라는 클래스(기술)이다. ProxyFactory는 프록시를 생성하는 과정을 내부적으로 숨기고, 사용자에게 생성된 프록시라는 결과만 반환한다. 이름에 Factory가 붙은 이유는 이 과정에 팩토리 패턴을 사용하였기 때문이다.

    팩토리 패턴 (Factory Pattern)

    객체 생성의 구체적인 과정을 클라이언트로부터 분리하여 캡슐화하는 생성 패턴이다. 팩토리 메서드 패턴을 통해 객체 생성을 서브 클래스에 위임하거나, 추상 팩토리 패턴을 통해 연관된 객체들의 군집을 일관된 인터페이스로 생성함으로써, 시스템의 결합도를 낮추고 확장성을 높이는 것을 목적으로 한다.

    ProxyFantory는 팩토리 패턴에 따라 아래의 구성 요소로 이루어져 있다.

    • AdvisedSupport (설정 정보)
      타겟 객체, 구현 인터페이스, 적용할 어드바이스 등 프록시 생성에 필요한 모든 설정을 관리한다.
    • DefaultAopProxyFactory (생성 결정)
      설정 정보를 확인하여 조건에 따라 JDK 동적 프록시를 만들지, CGLIB 프록시를 만들지 결정한다.
    • AopProxy (생성 인터페이스)
      실제 프록시 객체를 생성하는 최상위 인터페이스로, JDK 방식과 CGLIB 방식의 구체적인 생성 로직을 캡슐화한다.

    위 구성 정보에서 ProxyFactory 생성 시점에 사용되는 DefaultAopProxyFactory의 프록시 생성 메서드를 보자.

    @Override
    public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
        if (config.isOptimize() || config.isProxyTargetClass() || !config.hasUserSuppliedInterfaces()) {
            Class<?> targetClass = config.getTargetClass();
            if (targetClass == null && config.getProxiedInterfaces().length == 0) {
                throw new AopConfigException("TargetSource cannot determine target class: " +
                        "Either an interface or a target is required for proxy creation.");
            }
            if (targetClass == null || targetClass.isInterface() ||
                    Proxy.isProxyClass(targetClass) || ClassUtils.isLambdaClass(targetClass)) {
                return new JdkDynamicAopProxy(config);
            }
            return new ObjenesisCglibAopProxy(config);
        }
        else {
            return new JdkDynamicAopProxy(config);
        }
    }

    위 코드를 보면 인터페이스의 유무에 따라 JdkDynamicAopProxy, ObjenesisCglibAopProxy에 설정 정보(AdvisedSupport)를 전달하며 프록시 생성 과정을 위임하는 것을 알 수 있다. 위의 두 구현체는 처음에 설명한 AopProxy의 구현체들이며 반환 객체는 추상화된 AopProxy인 것을 확인할 수 있다.

    위 과정을 보면 ProxyFactory는 스프링의 프록시 생성에 가장 핵심을 담당하고 있는 기술인 것을 알 수 있다. 그리고 이렇게 실제 부가기능과 타겟을 엮는 작업을 하기 때문에 ProxyFactory는 스프링에서의 위빙(런타임 위빙)을 담당한다고 볼 수 있다.

    3.3.3 MethodInterceptor

    프록시를 만들어 보기 전에 한 가지 알아야 할 기술이 있다. 스프링은 AOP 적용 지점(JoinPoint)이 메서드 실행 시점 하나라고 하였다. 그리고 이 메서드 실행 시점을 가로채는(interceptor) 인터페이스가 바로 MethodInterceptor이다. 메서드 사용 설명의 번역본은 아래와 같다.

    MethodInterceptor에는 추상 메서드 invoke가 있으며 타겟이 이를 구현하면 타겟의 메서드 호출을 가로챈다. 이를 통해 다양한 프록시 기능을 구현할 수 있으며 설명을 보면 joinpoint.proceed()로 타겟의 메서드를 실행할 수 있다는 것을 알 수 있다. joinpoint는 invoke의 매개 인터페이스 MethodInvocation의 최상위 인터페이스인 Joinpoint의 추상 메서드이다.

    MethodInterceptor와 그 부모들은 AOP의 상호 운용을 위한 규약인 AOP aopalliance의 표준을 지키도록 작성되었으며, 로드 존슨이 직접 작성하였다. 그리고  AOP aopalliance 프로젝트에도 로드 존슨이 직접 참여하였다고 알려져 있다.

    3.3.4 프록시 생성

    스프링이 프록시를 추상화한 방법과 개념에 대해 알아보았으니 이제, 실제로 스프링의 기능을 이용하여 프록시를 만들어보자. 생성 흐름은 아래와 같이 진행하였다.

    타겟 클래스 작성 ➔ 어드바이스 작성 ➔ 프록시 생성 코드 작성

    1. 타겟 클래스 작성
    타겟 클래스는 인터페이스 구현 유무에 따라 구분하기 위해 아래와 같이 2종류로 작성하였다. 각각 주문 및 회원 생성을 하는 기능으로 가정하였으며 어드바이스 기능을 확인하기 위해 아주 무거운 작업을 가정(1초)하여 간단하게 작성한 코드이다.

    // 1. 인터페이스를 구현하지 않은 OrderService 구체 클래스.
    @Service
    public class OrderService {
        public String order(Long productId) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            return String.format("주문 제품 id: %d", productId);
        }
    }
    
    // 2.인터페이스를 구현한 MemberServiceImpl 클래스
    public class MemberServiceImpl implements MemberService {
        @Override
        public String create(String name) {
            String result;
            try {
                Thread.sleep(1000);
                result = String.format("생성된 회원명: %s", name);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            return result;
        }
    }
    
    // MemberServiceImpl의 인터페이스
    public interface MemberService {
        String create(String memberId);
    }

    위 코드는 결과적으로 인터페이스를 구현하지 않은 OrderService 클래스와 인터페이스를 구현한 MemberServiceImpl 클래스 두 가지를 가정하여 작성되었다.

    2. 어드바이스 작성
    어드바이스(부가 기능)은 메서드 처리 시간을 확인하는 부가 기능으로 가정하였으며 아래와 같이 작성하였다.

    package com.example.advice;
    
    import lombok.extern.slf4j.Slf4j;
    import org.aopalliance.intercept.MethodInterceptor;
    import org.aopalliance.intercept.MethodInvocation;
    
    @Slf4j
    public class TimeAdvice implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            long start = System.currentTimeMillis();
            String methodName = invocation.getMethod().getName();
            Object result = invocation.proceed(); // 타겟 메서드 실행
            long end = System.currentTimeMillis();
            log.info("{} 메서드 처리 시간: {}초", methodName, ((double) (end - start)) / 1000);
            return result;
        }
    }

    위 코드는 아까 설명한 MethodInterceptor를 구현하였으며 invoke 추상 메서드를 구현하여 타겟에 부가 기능(처리 시간 측정)을 구현하였다.

    주의할 점은 MethodInterceptor는 동일한 이름으로 org.springframework.cglib.proxy 패키지에 하나가 더 있는데 이 클래스는 cglib의 실제 구현체이므로 CglibAopProxy를 직접 사용하는 경우가 아니라면 사용에 주의해야 한다

    3. 프록시 생성 코드 작성
    프록시 생성 코드는 테스트 코드 형태로 작성하였으며, 인터페이스 유무에 따라 어떤 프록시 기술을 사용하여 프록시 객체가 생성되었는지 확인하기 위해 작성하였다.

    생성 코드

    @Slf4j
    public class ProxyTest {
        @Test
        void spring_proxy() {
            /* 1. 타겟 인스턴스 */
            // 1. 인터페이스를 구현하지 않은 orderService
            OrderService orderService = new OrderService();
            // 2. 인터페이스를 구현한 memberServiceImpl
            MemberService memberService = new MemberServiceImpl();
    
            /* 2. 부가 기능 인스턴스 */
            TimeAdvice advice = new TimeAdvice();
    
            /* 3. 프록시 설정 정보 구현, 내부적으로 AdvisedSupport가 사용됨. */
            // 1. 인터페이스 미구현 타겟 ProxyFactory
            ProxyFactory withoutInterfaceFactory = new ProxyFactory(orderService);
            withoutInterfaceFactory.addAdvice(advice);
            // 2. 인터페이스 구현 타겟 ProxyFactory
            ProxyFactory withInterfaceFactory = new ProxyFactory(memberService);
            withInterfaceFactory.addAdvice(advice);
    
            /* 4. 프록시 생성 */
            // 1. 인터페이스 미구현 프록시
            OrderService orderServiceProxy = (OrderService) withoutInterfaceFactory.getProxy();
            // 2. 인터페이스 구현 프록시
            MemberService memberServiceProxy = (MemberService) withInterfaceFactory.getProxy();
    
            /* 5. 결과 확인 */
            // 1. 프록시 메서드 실행, 부가 기능 적용 시 메서드 실행 시간 출력
            String orderResult = orderServiceProxy.order(1L);
            String memberResult = memberServiceProxy.create("testName");
            // 2. 각각의 프록시 주소 확인
            log.info("orderServiceProxy={}", orderServiceProxy.getClass());
            log.info("memberServiceProxy={}", memberServiceProxy.getClass());
            // 3. 메서드 반환값 확인
            log.info("orderResult={}", orderResult);
            log.info("memberResult={}", memberResult);
        }
    }

    위 코드를 실행한 결과는 아래와 같다.

    결과를 하나씩 보도록 하자.

    • 부가기능 적용 확인
      // 실행
      String orderResult = orderServiceProxy.order(1L);
      String memberResult = memberServiceProxy.create("testName");
      // 결과
      [Test worker] INFO com.example.advice.TimeAdvice -- order 메서드 처리 시간: 1.006초
      [Test worker] INFO com.example.advice.TimeAdvice -- create 메서드 처리 시간: 1.015초
      맨 위의 두 개의 출력문을 확인해 보면, 프록시의 부가 기능(advice)가 성공적으로 적용됐음을 알 수 있다. 부가 기능에서는 메서드명에 대한 처리 시간을 출력하는 코드가 작성되었으며, 각각의 메서드명으로 약 1초가량의 처리 시간이 찍혀 있음을 확인할 수 있다.
    • 프록시 생성 기술 확인
      // 코드
      log.info("orderServiceProxy={}", orderServiceProxy.getClass());
      log.info("memberServiceProxy={}", memberServiceProxy.getClass());
      
      // 출력
      orderServiceProxy=class com.example.order.OrderService$$SpringCGLIB$$0
      memberServiceProxy=class jdk.proxy3.$Proxy29
      프록시 클래스의 주소 출력을 각각 확인해 보면 orderServiceProxy(인터페이스 미구현 프록시)는 SpringCGLIB라는 단어가, memberServiceProxy (인터페이스 구현 프록시) 에는 jdk라는 단어가 포함되어 있는 것을 알 수 있다. 이를 통해 인터페이스를 미구현한 경우에는 CGLIB, 인터페이스가 구현된 경우에는 JDK의 기술을 사용하여 프록시를 생성했음을 충분히 유추해 볼 수 있다.
    • 타겟의 반환값 확인
      // 코드
      log.info("orderResult={}", orderResult);
      log.info("memberResult={}", memberResult);
      
      // 출력
      [Test worker] INFO com.example.ProxyTest -- orderResult=주문 제품 id: 1
      [Test worker] INFO com.example.ProxyTest -- memberResult=생성된 회원명: testName

      프록시 실행의 반환값을 확인 시 인수로 주었던 값이 반환값으로 성공적으로 반환되었음을 알 수 있다.

    각각의 출력 결과를 통해 ProxyFactory가 각 조건에 맞는 기술을 사용하여 프록시를 생성했음을 알 수 있다. 스프링의 AOP 구현 기술은 다양하지만 내부적으로는 대부분 이 ProxyFactory를 통해 Proxy를 생성한다. ProxyFactory의 이해는 스프링 AOP 내부 작동 원리를 이해하는데 큰 도움을 준다.

    3.3.5 Advisor

    여기까지가 스프링이 프록시를 "생성(위빙)"하는 핵심적인 동작 원리였다. 그런데 AOP에서 아주 중요한 개념 하나가 아직 나오지 않았다. 바로 Pointcut이다. 위에서는 ProxyFactory에 직접 Target을 넣어주었지만, AOP에서는 포인트컷으로 부가 기능(공통 관심사) 적용 지점을 필터링하는 것이 핵심이다. 그리고 스프링에서 Advice + Pointcut 두 개를 합친 것을 지칭하는 용어가 있다. 이를 스프링에서는 Advisor라고 한다.

    Advisor: 어디에(Pointcut), 어떤 부가 기능(Advice)을 적용할지가 설정되어 있는 1:1 관계의 결합체를 스프링에서 부르는 용어 및 그 인터페이스

    Advisor 인터페이스의 개념은 위와 같으나 실제로는 포인트컷이 필요 없는 경우(전체 적용)에도 하나의 인터페이스로 추상화하여 사용하기 위해 최상위 인터페이스인 Advisor에는 getPointcut 메서드는 없다. 실제 Pointcut 호출 기능까지 포함된 인터페이스는 Advisor의 하위 인터페이스인 PointcutAdvisor가 담당한다.

    스프링에서 AOP 부가 기능은 최종적으로 Advice 인터페이스로 추상화되어 있으며 포인트컷은 마찬가지로 Pointcut 인터페이스로 추상화되어 있다. PointcutAdvisor는 부모의 getAdvice와 자신의 getPointcut으로 추상화된 Advice, Pointcut을 호출할 수 있으며 스프링은 이 기능들을 이용해 어디에 어떤 부가기능을 적용할지를 구현한다.

    위 설명에서도 알 수 있듯 Advisor는 하나의 Advice, 하나의 PointCut을 1:1 관계로 가지고 있는 프록시 적용의 최소 단위라고 할 수 있다.

    ProxyFactory는 여러 개의 Advisor를 가질 수 있으며 ProxyFactory의 타겟의 메서드 위치들을 기준으로 Advisor를 순회하며 포인트컷 조건들이 일치 시 체인 형태로 순차적으로 실행한다. 여기서 알 수 있는 건 스프링은 타겟에 대한 프록시는 단 하나만 생성해 두고 Advisor를 통해 해당 프록시의 적용 여부만 선택한다는 것이다.

    이를 코드로 설명하면 아래와 같다.

    // 포인트컷 필터링 적용 확인용 서비스
    public class SimpleService {
        public void method1() { System.out.println("method1 실행"); }
        public void method2() { System.out.println("method2 실행"); }
        public void method3() { System.out.println("method3 실행"); }
        public void method4() { System.out.println("method4 실행"); }
    }
    
    // 필터링 적용 확인 코드
    @Test
    void advisor_proxy() {
        /* 1. 타겟 인스턴스 */
        SimpleService simpleService = new SimpleService();
    
        /* 2. Pointcut 생성 */
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("method1", "method2", "method4");
    
        /* 3. Advice 인스턴스 */
        TimeAdvice advice = new TimeAdvice();
    
        /* 4. Advisor 생성 */
        Advisor advisor = new DefaultPointcutAdvisor(pointcut, advice);
    
        /* 5. ProxyFactory 생성 */
        ProxyFactory proxyFactory = new ProxyFactory(simpleService);
        proxyFactory.addAdvisor(advisor);
    
        /* 6. proxy 생성 */
        SimpleService proxy = (SimpleService) proxyFactory.getProxy();
    
        /* 7. 모든 method 실행 후 부가 기능 적용 확인 */
        proxy.method1();
        proxy.method2();
        proxy.method3();
        proxy.method4();
    }

    위 코드는 Method1 ~ 4까지의 이름의 메서드를 가진 서비스를 만들고 Method1, 2, 4의 이름으로만 매칭하는 포인트컷을 만들었을때 부가 기능 적용을 확인하는 코드이다.

    실행 결과는 아래와 같다.

    실행 결과를 확인해 보면 4개의 메서드는 모두 실행되었으나 포인트컷에 매칭되지 않은 메서드명인 Method3는 부가 기능이 적용되지 않은 것을 확인해 볼 수 있다.

    위 코드를 통해 알 수 있는 사실은 1개의 타겟에 대해 하나의 프록시만 만들고도(ProxyFactory) 타겟에 여러 부가 기능을 적용할 수 있다는 점이다.

    이러한 개념을 사용하여 스프링의 빈 후처리기(BeanPostProcessor)는 타겟마다 하나의 프록시를 생성하고 최적화된 패턴으로 최소화된 프록시 생성 비용으로 부가 기능을 적용하는 전략을 사용한다. 이는 AOP 적용에 있어 강력한 장점이라고 할 수 있다.

    3.3.6 @Aspect

    여기까지가 AOP를 적용 및 설정하는 스프링의 기초 코드였다. 하지만 여전히 실무에 적용하기는 번거롭고 설정이 복잡하다. 스프링에서는 AOP의 효율적인 구현을 위해 AspectJ의 일부 기능을 가져왔으며 어노테이션 형태로 편리하게 AOP를 구현할 수 있도록 지원한다. 실무에서는 대부분 @Aspect 문법으로 AOP를 적용하며 여기서 그 방법을 설명해 보겠다.

    포인트컷 설명 시 잠깐 설명한 AspectJ는 JAVA에서 관점 지향 프로그래밍(AOP)을 위해 기능을 확장한 프레임워크이며 자바 진영에서 사용되는 AOP를 위한 완성형 프레임워크이다. 스프링에서는 메서드 실행 시점으로 조인포인트가 한정적이므로 기능이 제한적이나 AspectJ는 모든 조인포인트로 확장하여 AOP를 적용할 수 있는 강력한 AOP 프레임워크이다.

    1. Aspect 어노테이션 종류

    분류 어노테이션 설명
    설정 / 선언 @Aspect 해당 클래스가 Aspect 모듈임을 선언
    (Advice + Pointcut)
    @Pointcut 중복을 제거하기 위해 포인트컷을 미리 선언 후 사용
    @Order 여러 애스팩트가 동일 지점 충돌 시 적용 우선 순위 지정
    Advice 적용 시점 제어 @Before 메서드 실행 전 부가 기능 적용
    @After 메서드 실행 후 부가 기능 적용
    @AfterReturning 메서드가 성공적으로 반환됐을 때 부가 기능 적용
    @AfterThrowing 메서드 실행 중 예외 발생 시에만 부가 기능 적용
    @Around 실행 코드를 직접 작성

    2. AspectJ 문법

    기본적으로 @Aspect 사용 시 포인트컷은 AspectJ의 문법을 이용한다 아래는 AspectJ 문법의 핵심을 요약하였으며, 실무에 실제 적용 시 자세히 공부하는 것을 추천한다. 여기서는 개념 정도만 배워가는 것이 좋다.

    // 기본 형태
    // ?: 생략 가능, *: 모두 허용, (..): 개수/타입 상관 없음
    지시자(접근제어자? 반환타입 패키지.클래스?.메서드명(파라미터) 예외?)
    
    // 예시
    execution("public String com.app.service.OrderService.save*(..)")
    
    // 아래와 같이 && (AND), || (OR), ! (NOT) 연산자로 지시자를 조합하여 논리 작성이 가능하다.
    @Around("""
             execution(* com.example.service.SimpleService.method*(..)) &&
             !execution(* com.example.service.SimpleService.method3(..))
             """)

    지시자의 종류

    분류 지시자 설명
    실행 메서드 매칭 execution
    리턴 타입, 패키지, 클래스, 메서드명, 파라미터 등을 조합한 가장 정교한 매칭
    범위 및 타입 매칭 within
    특정 패키지 또는 클래스 내부에 속한 모든 메서드 매칭
    this
    스프링 빈 객체(프록시)가 특정 타입인 경우 매칭
    target
    실제 타겟 객체(원본 구현체)가 특정 타입인 경우 매칭
    파라미터 매칭 args
    메서드 호출 시 전달되는 인자의 런타임 타입 기준 매칭
    어노테이션 매칭 @annotation
    특정 어노테이션이 붙은 메서드를 매칭 (가장 흔히 사용)
    @within
    특정 어노테이션이 붙은 타입(클래스) 내의 모든 메서드 매칭
    @target
    실제 대상 객체의 클래스에 특정 어노테이션이 있는 경우 매칭
    @args
    전달된 인자의 클래스에 특정 어노테이션이 있는 경우 매칭
    스프링 전용 bean
    스프링 빈 이름 또는 아이디 패턴을 기준으로 매칭

    3. @Aspect 방식의 AOP 적용

    • @Aspect 방식의 AOP를 사용하려면 먼저 빌드 도구에 아래 의존성을 추가해야한다.
      // "org.springframework.boot:spring-boot-starter-aop"의 의존성 추가
      // 문법은 빌드 도구에 따라 다름
      implementation("org.springframework.boot:spring-boot-starter-aop")
    • @Aspect는 스프링 컨테이너 기반으로 동작하므로 SimpleService에 @Service(@Component와 동일)를 추가하였다.
      @Service
      public class SimpleService {
          public void method1() { System.out.println("method1 실행"); }
      
          public void method2() { System.out.println("method2 실행"); }
      
          public void method3() { System.out.println("method3 실행"); }
      
          public void method4() { System.out.println("method4 실행"); }
      }
      
    • TimeAspect 클래스를 선언 후 @Aspect를 이용하여 공통 관심사 모듈임을 선언하고, AspectJ의 문법을 이용하여 포인트컷을 지정하였다.
      package com.example.advice;
      
      import lombok.extern.slf4j.Slf4j;
      import org.aspectj.lang.ProceedingJoinPoint;
      import org.aspectj.lang.annotation.Around;
      import org.aspectj.lang.annotation.Aspect;
      import org.springframework.stereotype.Component;
      
      @Slf4j
      @Aspect
      @Component
      public class TimeAspect {
      
          @Around("execution(* com.example.service.SimpleService.method*(..)) &&" +
                  " !execution(* com.example.service.SimpleService.method3(..))")
          public Object invoke(ProceedingJoinPoint joinPoint) throws Throwable {
              long start = System.currentTimeMillis();
              String methodName = joinPoint.getSignature().getName();
              Object result = joinPoint.proceed(); // 타겟 메서드 실행
              long end = System.currentTimeMillis();
              log.info("{} 메서드 처리 시간: {}초", methodName, ((double) (end - start)) / 1000);
              return result;
          }
      
      }
      

      이 코드는 아까 전 SimpleService의 메서드1, 2, 4에만 부가 기능이 적용되도록 AspectJ의 문법을 이용하여 작성하였다.
    • 실행 테스트 코드 작성
      @Slf4j
      @SpringBootTest
      public class AspectTest {
      
          private final SimpleService simpleService;
      
          @Autowired
          public AspectTest(SimpleService simpleService) {
              this.simpleService = simpleService;
          }
      
          @Test
          void aspect_proxy() {
              simpleService.method1();
              simpleService.method2();
              simpleService.method3();
              simpleService.method4();
          }
      }

      @Aspect는 스프링 컨테이너 위에서 동작하므로 @SpringBootTest로 스프링의 설정을 풀로드하여 테스트를 진행하였다. 테스트는 SimpleService의 모든 메서드를 실행하여 부가 기능 적용 여부를 확인하는 방식으로 진행하였다.
    • 결과
      결과를 심플하게 확인하기 위해 출력 메시지의 간소화 설정 후 출력된 결과이다. 확인 시 method3을 제외한 나머지 메서드에 부가 기능(처리 시간 측정)이 적용된 것을 볼 수 있다.

    @Aspect의 문법을 사용하여 AOP를 적용하니 극단적으로 심플한 코드가 작성되었다. Advice 클래스에 @Aspect로 공통 관심사 모듈임을 선언하고, 포인트컷만 지정하니 포인트컷에 해당하는 메서드들에 부가기능이 모두 적용되었다.

    이는 어노테이션 기반 스프링 컨테이너 빈 관리 방식의 장점과 AspectJ의 문법의 장점이 모두 결합된 훌륭한 방식이라고 볼 수 있다.

    실무에서는 AspectJ의 다양한 문법 형식을 사용하여 세밀하게 포인트컷을 지정할 수 있으며 @Around가 아닌 @Before, @After 등으로 어노테이션 한 줄 추가하여 별도의 코드 변경 없이 Advice 적용이 가능하다. 사용자에게 이러한 간단한 사용 편의성을 제공하기 위해 스프링이 안 보이는 곳에서 추상화된 거대한 구조들은 볼 수록 놀랍다고 생각한다.

    3.4. 스프링 AOP Proxy의 한계

    3.4.1 프록시 내부 호출 (Self-Invocation) 문제

    여기까지 봤을 때 프록시 생성 시 런타임 위빙(Weaving) 방식을 사용하는 스프링 AOP의 프록시 패턴에는 많은 장점이 있다. 하지만 여기에는 스프링 AOP 사용 시 반드시 알아야 할 단점이 있다. 바로 '프록시 내부 호출 문제'이다.

    프록시 내부 호출 (Self-Invocation) 문제
    타겟의 내부 메서드 호출 시 부가 기능이 적용되지 않는 문제

    스프링 컨테이너의 동작 과정을 한번 떠올려보자. 스프링 컨테이너는 의존성 주입(DI) 방식으로 객체 생성 시점에 의존 관계 객체를 주입한다. 이 과정은 프록시 객체 주입에도 똑같이 적용된다. 스프링은 객체 생성 시점에 포인트컷 일치 여부를 확인 후 일치한다면 타겟의 프록시 객체를, 일치하지 않는다면 원본 객체를 주입한다.

    그런데 만약 프록시 객체를 사용해야 하는데 원본 객체의 인스턴스를 직접 사용하면 어떻게 될까? 당연히 프록시(부가 기능)이 적용되지 않은 원본 객체를 그대로 사용하게 될 것이다. 아래 코드를 한번 보자.

    @Slf4j
    @SpringBootTest
    public class AspectTest {
    
        private final SimpleService simpleService;
    
        // 스프링 컨테이너에서 주입된 프록시 객체
        @Autowired
        public AspectTest(SimpleService simpleService) {
            this.simpleService = simpleService;
        }
    
        @Test
        void aspect_proxy() {
        	// DI 객체 사용
            simpleService.method1();
            // 직접 인스턴스
            new SimpleService().method1();
        }
    }

    위 코드에서 SimpleService의 method1은 부가 기능이 적용되어야 하는 메서드이다.

    코드를 보면 Autowired로 simpleService 필드에 의존성 주입을 하였으며, 빈 후처리기에 의해 프록시 객체가 주입되는 코드이다. 그리고 테스트 코드를 보면 위는 외부에서 주입된 DI 필드의 메서드를 실행하고, 아래는 내부에서 직접 인스턴스 하여 메서드를 실행하였다. 아래는 실행 결과이다.

    실행 결과를 보면 의존성 주입 필드를 사용한 객체의 메서드 호출 시 부가 기능 적용 코드가 실행됐음을 알 수 있으며, 직접 인스턴스 한 경우 부가기능이 적용되지 않았음을 알 수 있다. 이는 당연한 결과이다.

    그리고 이 현상은 프록시 객체의 타겟이 내부 메서드를 호출할 때도 그대로 발생한다. 아래 코드를 보자

    @Service
    public class SimpleService {
        // 부가 기능이 적용돼야 하는 메서드
        public void method1() { System.out.println("method1 실행"); }
        // 부가 기능이 적용되지 않아야 하는 메서드
        public void method2() {
            System.out.println("method2 실행");
            method1(); // 이 메서드는 부가 기능이 적용되어야 함.
        }
    }

    위 코드를 보면 method1, 2가 있는데 method 2 내부에서 method1을 호출하고 있다. 그리고 method1은 포인트컷 문법에 의해 아래와 같이 부가 기능이 적용되어야 한다.

    // method1에만 부가 기능 적용
    @Around("execution(* com.example.service.SimpleService.method1(..)))")

    아래 코드로 위의 두 메서드를 실행해 보았다.

    @Slf4j
    @SpringBootTest
    public class AspectTest {
    
        private final SimpleService simpleService;
    
        // 스프링 컨테이너에서 주입된 프록시 객체
        @Autowired
        public AspectTest(SimpleService simpleService) {
            this.simpleService = simpleService;
        }
    
        @Test
        void aspect_proxy() {
            simpleService.method1();
            simpleService.method2();
        }
    }

    결과

    결과를 보니 외부(DI 주입된 필드)에서 실행한 method1은 부가 기능이 적용돼 있다. 하지만 method2에서 실행한 내부 메서드 method1은 부가 기능이 적용되지 않았다. 이러한 현상을 스프링의 프록시 내부 호출 (Self-Invocation) 문제라고 부른다.

    문제의 원인은 자바의 고유 문법에 있다. 자바는 내부 메서드를 호출할 때 this로 자신의 인스턴스를 직접 사용하며, 우리는 이러한 this가 생략된 형태의 코드를 작성한다고 볼 수 있다. 자신의 인스턴스를 직접 사용한다면, 당연히 프록시 객체를 사용하지 않게 된다.

    그리고 동일한 코드에 일반적으로 트랜잭션 기능 적용을 위해 자주 사용하는 @Transactional 어노테이션을 적용해 보자.

    @Service
    public class SimpleService {
        @Transactional
        public void method1() { System.out.println("method1 실행"); }
    
        public void method2() {
            System.out.println("method2 실행");
            method1(); // this.method1(); 이 생략된 형태
        }
    }

    @Transactional은 스프링에서 지원하는 트랙잭션 기능을 위한 대표적인 AOP 어노테이션이다. 당연히 스프링 프록시 형태로 동작하며 위 코드에서 method2를 실행하면 method1은 작동하지 않는다.

    내부 호출은 공통 코드로 분리 시 충분히 일어날 수 있는데 AOP가 적용되어야 한다면 위 단점은 치명적이다. 이를 해결하기 위한 대표적인 방법 몇 가지가 있다.

    3.4.2 프록시 내부 호출 (Self-Invocation) 문제 해결 방법

    1. 클래스 분리

    가장 많이 사용하는 방법은 클래스로 분리하는 것이다. 아래처럼 별도의 클래스로 분리 후 외부에서 의존성을 주입받아 사용하는 방법이며, 이 방법은 순환 참조 문제도 피할 수 있고, 책임을 분리시키므로 객체지향적으로 바람직한 방법이라고 할 수 있다.

    @Service
    public class InnerService {
        @Transactional
        public void method1() {
            System.out.println("method1 실행");
        }
    }
    
    @Service
    @RequiredArgsConstructor
    public class OuterService {
        private final InnerService innerService; // 별도 서비스 주입
    
        public void method2() {
            System.out.println("method2 실행");
            // DI 주입된 프록시 객체를 사용, 트랜잭션 기능 활성화
            innerService.method1(); 
        }
    }

    2. 셀프 주입 방법

    아래 방법은 자신의 빈을 직접 주입받는 방법이다. 하지만 순환 참조 문제가 발생할 수 있어 @Lazy(지연 로딩)를 사용하여야 하며, 자기 자신을 주입한다는 것 자체가 좋은 방법이라고는 할 수 없다.

    @Service
    public class SimpleService {
    
        // 자기 자신을 주입받음 (순환 참조 방지를 위해 @Lazy 사용)
        @Autowired
        @Lazy
        private SimpleService self;
    
        @Transactional
        public void method1() {
            System.out.println("method1 실행");
        }
    
        public void method2() {
            System.out.println("method2 실행");
            // method1() 대신 주입받은 self(프록시)를 통해 호출
            self.method1(); 
        }
    }

    3. AopContext 사용

    스프링의 AOP 컨텍스트에서 프록시를 강제로 꺼내오는 방법이다. 하지만 스프링의 느슨한 결합의 장점을 사라지게 하므로 추천하는 방법은 아니다.

    // 설정 파일에 @EnableAspectJAutoProxy(exposeProxy = true) 추가 필요
    public void method2() {
        ((SimpleService) AopContext.currentProxy()).method1();
    }

    4. 정리

    이번에는 스프링 AOP에 대해 한번 정리해 보았다. 기본적으로 원리 위주로 설명하려는 경향이 있어 AOP의 기본 개념부터 설명하다 보니, 하나의 작은 프로젝트가 되어버렸다. 하지만 스프링의 이해에 있어 AOP 동작 원리(프록시)에 대한 이해는 스프링의 활용에 있어 반드시 알아야 할 정보라고 할 수 있다. AOP에 대해 전혀 모르더라도 이 글 하나만 읽으면 스프링 AOP를 실무에 사용하기 위한 배경 지식을 충분히 습득할 수 있다면 좋겠다는 생각에 작성해 보았다.

    요약하자면, 스프링 AOP는 OOP만으로는 해결하기 힘든 공통 관심사(Cross-Cutting Concern)를 스프링의 핵심 가치인 POJO를 유지하며 관리하기 위한 기술이다. 이를 위해 메서드 실행 지점을 기준으로 런타임에 위빙(Weaving)하는 방식을 채택하여 느슨한 결합을 유지한다. 또한, 내부적으로는 프록시 객체를 싱글톤으로 관리함으로써 성능 효율성까지 고려한 영리한 구현 방식이라 할 수 있다.

    실제 사용에 있어서는 @Aspect 문법으로 개발자에게 단순한 형태의 코드로 적용할 수 있도록 지원하고, 개발자는 스프링 AOP의 한계(자기 호출 문제)만 정확히 이해하고 사용한다면. AOP 모듈 관리에 있어 매우 훌륭한 도구가 될 수 있다고 생각한다.

    '백엔드 > Spring' 카테고리의 다른 글

    [Spring] 스프링 IoC/DI  (0) 2025.11.25
    [Spring] 기본 개념  (1) 2025.11.18