@Transactional은 Spring에서 가장 많이 쓰이는 애노테이션 중 하나지만,
동작 원리를 모르면 "분명히 붙였는데 트랜잭션이 안 걸린다"는 버그를 만납니다.
AOP 프록시가 어떻게 동작하고, self-invocation이 왜 문제인지 이해해야 이런 버그를 피할 수 있습니다.
AOP(Aspect-Oriented Programming)는 여러 클래스에 반복되는 횡단 관심사(Cross-Cutting Concern)를 비즈니스 로직과 분리하는 기법입니다. 트랜잭션 처리, 로깅, 보안 검사, 성능 측정이 대표적입니다.
@Aspect 애노테이션 사용.execution(* com.example.service.*.*(..))Spring AOP는 프록시 패턴으로 구현됩니다. Bean을 직접 주입하는 게 아니라 Bean을 감싼 프록시 객체를 주입합니다. 클라이언트는 프록시를 통해 메서드를 호출하고, 프록시가 Before/After 로직을 끼워 넣은 뒤 실제 Bean에 위임합니다.
Spring AOP는 두 가지 프록시 방식을 사용합니다.
JDK Dynamic Proxy: 인터페이스 기반. 대상 Bean이 인터페이스를 구현하면 사용.
java.lang.reflect.Proxy로 인터페이스 구현체인 프록시 생성.
CGLIB (Code Generation Library): 클래스 기반. 인터페이스 없이도 동작.
대상 클래스를 상속하는 서브클래스(프록시)를 런타임에 바이트코드로 생성.
Spring Boot 2+는 기본적으로 CGLIB을 사용합니다
(spring.aop.proxy-target-class=true가 기본값).
따라서 인터페이스가 없어도 AOP가 동작합니다.
단, CGLIB은 final 클래스·메서드를 상속할 수 없으므로 이 경우 AOP 적용 불가입니다.
@Service
public class OrderService {
@Transactional
public void placeOrder(OrderRequest req) {
// ... 주문 처리
sendNotification(req); // 내부 호출!
}
@Transactional(propagation = REQUIRES_NEW)
public void sendNotification(OrderRequest req) {
// 여기서 예외가 나도 placeOrder 롤백 안됨
// REQUIRES_NEW도 의미없음!
// 이유: this.sendNotification() 호출 →
// 프록시를 거치지 않음 →
// Spring AOP 동작 안 함
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
// 자기 자신이 아닌 별도 Bean 주입
private final NotificationService notifService;
@Transactional
public void placeOrder(OrderRequest req) {
// ... 주문 처리
// 외부 Bean 호출 → 프록시 통과 → AOP 동작
notifService.sendNotification(req);
}
}
@Service
public class NotificationService {
@Transactional(propagation = REQUIRES_NEW)
public void sendNotification(OrderRequest req) {
// 이제 독립 트랜잭션으로 동작
}
}
| 옵션 | 동작 | 주요 사용처 |
|---|---|---|
REQUIRED 기본값 |
트랜잭션이 있으면 참여, 없으면 새로 생성 | 일반적인 서비스 메서드 |
REQUIRES_NEW |
항상 새 트랜잭션 시작. 기존 트랜잭션은 일시 중단 | 알림 발송, 감사 로그 — 메인 롤백에 영향받으면 안 되는 작업 |
NESTED |
savepoint를 만들고 중첩 트랜잭션 시작. 내부 롤백 시 savepoint까지만 롤백 | 일부 실패를 부분 롤백으로 처리할 때 (JDBC savepoint 지원 필요) |
SUPPORTS |
트랜잭션 있으면 참여, 없으면 트랜잭션 없이 실행 | 읽기 전용 조회 (있어도 되고 없어도 되는 경우) |
NOT_SUPPORTED |
트랜잭션 없이 실행. 기존 트랜잭션 일시 중단 | 트랜잭션 범위 밖에서 실행해야 하는 대용량 배치 |
@Transactional(readOnly = true)
public List<Order> getOrders(Long userId) {
return orderRepo.findByUserId(userId);
}
// 효과 1: JPA 영속성 컨텍스트에서
// dirty checking(변경 감지) 비활성화
// → 스냅샷 저장 안 함 → 메모리 절약
// 효과 2: DB 드라이버/Replication 설정에 따라
// 읽기 전용 replica로 라우팅 가능
// 효과 3: 트랜잭션 내 쓰기 시도 시
// 일부 DB는 예외 발생 (의도 명시)
@Transactional // 기본: RuntimeException만 롤백
public void process() throws IOException {
// IOException은 Checked Exception
// → 기본적으로 롤백 안 됨!
// → DB에 데이터 저장됐는데 파일 처리 실패 가능
fileService.write(data); // throws IOException
}
// 해결
@Transactional(rollbackFor = Exception.class)
public void process() throws IOException {
// 모든 예외(Checked 포함)에서 롤백
fileService.write(data);
}
두 가지 주요 케이스가 있습니다.
첫째, self-invocation입니다.
같은 클래스 내 다른 메서드를 this.method()로 호출하면 프록시를 거치지 않아 AOP가 동작하지 않습니다.
둘째, private 메서드입니다.
CGLIB 프록시는 상속 기반이므로 private 메서드는 오버라이드할 수 없어 AOP 적용이 불가합니다.
추가로 final 클래스나 메서드도 CGLIB이 상속할 수 없어 적용되지 않습니다.
REQUIRES_NEW는 기존 트랜잭션을 일시 중단하고 완전히 독립된 새 트랜잭션을 시작합니다.
내부 트랜잭션이 커밋되면 외부 트랜잭션 결과와 무관하게 DB에 반영됩니다.
NESTED는 DB savepoint를 이용해 중첩 트랜잭션을 만듭니다.
내부에서 예외가 나면 savepoint까지만 롤백하고 외부 트랜잭션은 계속할 수 있지만,
외부 트랜잭션이 롤백되면 내부도 함께 롤백됩니다.
알림처럼 메인 롤백에 영향받으면 안 되는 작업엔 REQUIRES_NEW, 부분 실패 허용엔 NESTED를 씁니다.
Spring의 @Transactional 기본 동작은 RuntimeException(Unchecked)과
Error에서만 롤백합니다.
이는 EJB 관례를 따른 설계로, Checked Exception은 "처리 가능한 예외"로 간주해 롤백하지 않습니다.
IOException, SQLException 등 Checked Exception에서도 롤백이 필요하면
@Transactional(rollbackFor = Exception.class)를 명시해야 합니다.
반대로 특정 RuntimeException에서 롤백을 막으려면 noRollbackFor를 사용합니다.
Spring AOP는 프록시 패턴으로 동작하므로 self-invocation·private·final 메서드에는 적용되지 않으며, @Transactional은 기본적으로 RuntimeException에서만 롤백하고 Checked Exception은 rollbackFor로 명시해야 한다.