@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가 끼어들지 못함
}
}
}
bulkOrder가 placeOrder를 호출할 때 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로 충분한 경우에는 간단한 것을 우선한다.
생각해 볼 문제
@Transactional이 붙은public메서드를 같은 클래스의private메서드에서 호출하면 어떻게 되는가?- JDK 동적 프록시 vs CGLIB의 성능 차이는 유의미한가? 어느 것이 더 빠른가?
- AspectJ(컴파일 타임 weaving)가 Spring AOP(런타임 프록시)보다 나은 점은 무엇인가?
@Pointcut("execution(* com.example..*(..))")에서..와*의 차이는?- Spring 7에서 CGLIB과 ByteBuddy 중 어느 것을 사용하는가? 변경 사유는?
참고
- Spring Framework 7 - AOP - 접근 2026-07-10
- Spring Framework 7 - AOP Proxying - 접근 2026-07-10
- Spring Framework 7 - @AspectJ Support - 접근 2026-07-10
- Spring Framework 7 - AOP Examples - 접근 2026-07-10
'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 |
