@Transactional이 같은 클래스 내부 호출에서 동작하지 않는 이유 — AOP와 프록시

@Service
public class OrderService {
    @Transactional
    public void placeOrder(Order order) { ... }   // 트랜잭션 적용됨

    public void bulkOrder(List<Order> orders) {
        for (Order order : orders) {
            placeOrder(order);   // 내부 호출 — @Transactional이 동작 안 함!
        }
    }
}

placeOrder()@Transactional이 붙어 있지만, 같은 클래스 내부에서 호출하면 트랜잭션이 적용되지 않는다. 원인은 Spring AOP의 프록시 기반 작동 방식이다. @Transactional은 프록시 객체를 통한 외부 호출에서만 끼어들 수 있다.

이 글은 AOP(관점 지향 프로그래밍)의 핵심 개념, JDK 동적 프록시 vs CGLIB, pointcut/advice, 그리고 자기 호출(self-invocation)의 함정을 풀어간다. (Spring Framework Reference - AOP)

AOP란 — 횡단 관심사(cross-cutting concern)의 분리

로깅, 트랜잭션, 보안, 캐싱 — 이런 기능은 여러 클래스에 걸쳐 필요하다(횡단 관심사). 각 클래스에 복사-붙여넣기하는 대신, AOP는 이것을 관점(aspect)으로 분리하여 한 곳에서 정의하고 자동으로 끼워넣는다.

flowchart LR
    subgraph without["AOP 없음"]
        S1["OrderService<br/>+ 로깅<br/>+ 트랜잭션"] --> S2["UserService<br/>+ 로깅<br/>+ 트랜잭션"] --> S3["ProductService<br/>+ 로깅<br/>+ 트랜잭션"]
    end
    subgraph with["AOP 있음"]
        B["비즈니스 로직만"] --- A1["LoggingAspect"]
        B --- A2["TransactionAspect"]
        B --- A3["SecurityAspect"]
    end

Spring AOP의 핵심 개념

용어 의미
Aspect 횡단 관심사의 모듈화 @Aspect 클래스
Join point advice가 끼어들 수 있는 실행 지점 메서드 호출 (Spring은 메서드 실행만 지원)
Pointcut 어떤 join point에 advice를 적용할지 정의 execution(* com.example..*.*(..))
Advice join point에서 수행할 동작 @Before, @After, @Around
Weaving aspect를 대상 객체에 적용 런타임 프록시 (Spring) 또는 컴파일 타임 (AspectJ)
Target advice가 적용되는 객체 OrderService

Advice 종류

// Spring 7 / Java 25 — advice 종류
@Aspect
@Component
public class LoggingAspect {

    @Before("execution(* com.example.service.*.*(..))")
    public void logBefore(JoinPoint jp) {
        System.out.println("[시작] " + jp.getSignature().getName());
    }

    @AfterReturning(pointcut = "execution(* com.example.service.*.*(..))",
                    returning = "result")
    public void logAfter(JoinPoint jp, Object result) {
        System.out.println("[완료] " + jp.getSignature().getName() + " → " + result);
    }

    @AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
                   throwing = "ex")
    public void logException(JoinPoint jp, Exception ex) {
        System.out.println("[예외] " + jp.getSignature().getName() + " → " + ex.getMessage());
    }

    @Around("execution(* com.example.service.*.*(..))")
    public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();   // 대상 메서드 실행
            return result;
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            System.out.println("[소요] " + pjp.getSignature().getName() + ": " + elapsed + "ms");
        }
    }
}

@Around가 가장 강력하다 — 메서드 실행 전후를 모두 제어하고, 반환값을 변경하거나 예외를 삼킬 수도 있다. 하지만 가장 복잡하므로, @Before/@After로 충분한 경우에는 그것을 우선한다.

프록시 메커니즘 — JDK 동적 프록시 vs CGLIB

Spring AOP는 프록시 객체를 생성하여 advice를 끼워넣는다. 두 가지 프록시 방식이 있다:

방식 조건 특징
JDK 동적 프록시 타겟이 인터페이스 구현 java.lang.reflect.Proxy, 인터페이스 필요
CGLIB 타겟이 클래스 (인터페이스 없어도 됨) 서브클래스 생성, final 클래스/메서드 불가
flowchart LR
    C["client"] -->|"호출"| P["프록시<br/>(AOP 적용 지점)"]
    P -->|"advice 실행 후"| T["target 객체<br/>(실제 bean)"]

Spring Boot 2.0+부터는 CGLIB가 기본이다 (spring.aop.proxy-target-class=true). 인터페이스가 있든 없든 CGLIB 서브클래스 프록시를 생성한다. (Spring Boot Reference - AOP)

CGLIB의 함정 — final 클래스와 private 메서드

// Spring 7 / Java 25 — CGLIB 프록시가 불가능한 경우
@Service
public final class FinalService {   // final 클래스 → CGLIB 서브클래스 생성 불가
    @Transactional
    public void process() { ... }   // AOP 적용 안 됨!
}

@Service
public class MyService {
    @Transactional
    private void secretProcess() { ... }   // private 메서드 → 프록시에서 override 불가
    // AOP 적용 안 됨!
}

CGLIB은 서브클래스를 생성하여 메서드를 override하는 방식으로 작동한다. 따라서 final 클래스는 서브클래싱이 불가하고, private 메서드는 override할 수 없다 — AOP가 적용되지 않는다. (Spring Framework Reference - AOP Proxying)

왜 자기 호출(self-invocation)이 안 되는가 — 내부 메커니즘: Spring AOP는 bean을 프록시 객체로 감싼다. 외부에서 orderService.placeOrder()를 호출하면 → 프록시 객체가 받고 → AOP 어드바이스(예: 트랜잭션 시작)를 실행한 후 → 실제 OrderService의 메서드를 호출한다. 하지만 OrderService 내부에서 this.placeOrder()를 호출하면 → this는 프록시가 아니라 원본 객체다. 원본 객체에는 AOP 어드바이스가 없으므로 → @Transactional이 무시된다.

비유: 프록시는 경호원을 데운 VIP와 같다. 외부에서 VIP에게 접근하려면 경호원(프록시)을 거쳐야 한다 — 경호원이 신원 확인(인증), 기록(로깅), 차단(권한)을 수행한다. 하지만 VIP가 자기 수행원에게 직접 지시를 내리면(this 호출) — 경호원은 그 지시를 보지 못한다. 경호원은 "외부에서 오는 호출"만 감시한다.

자기 호출(self-invocation)의 함정 — 이 글의 출발점

// Spring 7 / Java 25 — self-invocation 문제
@Service
public class OrderService {
    @Transactional
    public void placeOrder(Order order) {
        repo.save(order);
    }

    public void bulkOrder(List<Order> orders) {
        for (Order order : orders) {
            this.placeOrder(order);   // this는 프록시가 아닌 원본 객체!
            // → @Transactional AOP가 끼어들지 못함
        }
    }
}

bulkOrderplaceOrder를 호출할 때 this프록시가 아니라 원본 객체를 가리킨다. 프록시를 통하지 않은 호출은 AOP advice가 적용되지 않는다.

해결 방법

// Spring 7 / Java 25 — 해결 1: 자기 주입 (self-injection)
@Service
public class OrderService {
    private final OrderService self;   // 자기 자신의 프록시를 주입
    private final OrderRepository repo;

    public OrderService(OrderService self, OrderRepository repo) {
        this.self = self;
        this.repo = repo;
    }

    public void bulkOrder(List<Order> orders) {
        for (Order order : orders) {
            self.placeOrder(order);   // self는 프록시 → @Transactional 적용됨
        }
    }
}
// Spring 7 / Java 25 — 해결 2: 별도 클래스로 분리
@Service
public class OrderBulkService {   // 분리된 클래스
    private final OrderService orderService;

    public void bulkOrder(List<Order> orders) {
        for (Order order : orders) {
            orderService.placeOrder(order);   // 다른 bean 호출 → 프록시 통과
        }
    }
}

해결 2(별도 클래스 분리)가 더 권장된다 — 자기 주입은 순환 의존성처럼 보여 혼란을 준다. 구조적으로 트랜잭션 경계를 분리하는 것이 더 명확하다.

실습 — AOP 적용 확인

// Spring 7 / Java 25 — AopDemo.java
// 프록시를 통한 호출 vs 직접 호출의 차이 시뮬레이션

public class AopDemo {
    interface Greeting { String hello(String name); }

    // JDK 동적 프록시로 advice 끼워넣기
    static Greeting createProxy(Greeting target) {
        return (java.lang.reflect.Proxy.newProxyInstance(
            Greeting.class.getClassLoader(),
            new Class[]{ Greeting.class },
            (proxy, method, args) -> {
                System.out.println("[AOP] 메서드 호출 전: " + method.getName());
                Object result = method.invoke(target, args);
                System.out.println("[AOP] 메서드 호출 후: " + result);
                return result;
            }
        ));
    }

    public static void main(String[] args) {
        Greeting target = name -> "Hello, " + name + "!";
        Greeting proxy = createProxy(target);

        System.out.println("--- 프록시 호출 ---");
        String result = proxy.hello("Java");
        System.out.println("결과: " + result);
    }
}
java AopDemo.java
--- 프록시 호출 ---
[AOP] 메서드 호출 전: hello
[AOP] 메서드 호출 후: Hello, Java!
결과: Hello, Java!

확인할 것: 프록시를 통한 호출에서만 advice가 실행된다. Spring이 이 프록시를 자동으로 생성하여 bean에 적용한다. 원본 객체의 직접 호출(this.method())은 프록시를 우회하므로 AOP가 적용되지 않는다.

Pointcut 표현식 — 자주 쓰는 패턴

// Spring 7 / Java 25 — Pointcut 표현식 참조

// execution: 메서드 시그니처 기반
@Pointcut("execution(public * com.example.service.*.*(..))")
void allPublicServiceMethods() {}

// execution 정밀 제어
@Pointcut("execution(* com.example..*(..))")          // 패키지와 모든 하위 패키지
@Pointcut("execution(* *..Service.*(..))")            // 이름이 Service로 끝나는 클래스
@Pointcut("execution(* find*(..))")                   // find로 시작하는 메서드

// annotation: 특정 어노테이션이 붙은 메서드/클래스
@Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
void transactionalMethods() {}

// within: 특정 타입 내의 모든 메서드
@Pointcut("within(@org.springframework.stereotype.Repository *)")
void allRepositoryMethods() {}

// bean: 특정 bean 이름
@Pointcut("bean(orderService)")
void orderServiceBean() {}

// 결합: AND, OR, NOT
@Pointcut("execution(* com.example..*(..)) && @annotation(Monitorable)")
void monitoredMethods() {}
지시자 의미
execution(...) 메서드 시그니처 매칭
@annotation(...) 특정 어노테이션이 붙은 메서드
within(...) 특정 타입 내의 모든 조인 포인트
bean(name) 특정 bean 이름
target(type) 대상 객체가 특정 타입의 인스턴스
@within(...) 특정 어노테이션이 붙은 타입

execution이 가장 강력하지만 느리다 — 모든 메서드를 검사해야 한다. bean이나 @annotation은 더 빠르고 의도가 명확하다. 가능하면 구체적인 지시자를 우선한다.

요약 — 이 글의 결론

  • AOP는 횡단 관심사를 분리한다. 로깅, 트랜잭션, 보안을 비즈니스 코드에서 분리하여 한 곳에서 관리한다.
  • Spring AOP는 프록시 기반이다. bean을 프록시 객체로 감싸서 advice를 끼워넣는다. CGLIB(기본) 또는 JDK 동적 프록시를 사용한다.
  • final 클래스와 private 메서드에는 AOP가 적용되지 않는다. CGLIB은 서브클래싱으로 작동하므로, 이것들을 override할 수 없다.
  • 자기 호출(self-invocation)은 프록시를 우회한다. this.method()는 원본 객체에서 실행되므로 AOP가 적용되지 않는다. 해결: 자기 주입 또는 별도 클래스 분리.
  • @Around가 가장 강력하지만 복잡하다. @Before/@After로 충분한 경우에는 간단한 것을 우선한다.

생각해 볼 문제

  1. @Transactional이 붙은 public 메서드를 같은 클래스의 private 메서드에서 호출하면 어떻게 되는가?
  2. JDK 동적 프록시 vs CGLIB의 성능 차이는 유의미한가? 어느 것이 더 빠른가?
  3. AspectJ(컴파일 타임 weaving)가 Spring AOP(런타임 프록시)보다 나은 점은 무엇인가?
  4. @Pointcut("execution(* com.example..*(..))")에서 ..*의 차이는?
  5. Spring 7에서 CGLIB과 ByteBuddy 중 어느 것을 사용하는가? 변경 사유는?

참고

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

Spring - 07. spring mvc  (0) 2026.07.12
Spring - 05. bean scope profile  (0) 2026.07.12
Spring - 04. configuration component-scan  (0) 2026.07.12
Spring - 03. dependency-injection  (0) 2026.07.12
Spring - 02. container bean lifecycle  (0) 2026.07.12