3. Spring AOP와 @Transactional

@Transactional은 Spring에서 가장 많이 쓰이는 애노테이션 중 하나지만, 동작 원리를 모르면 "분명히 붙였는데 트랜잭션이 안 걸린다"는 버그를 만납니다. AOP 프록시가 어떻게 동작하고, self-invocation이 왜 문제인지 이해해야 이런 버그를 피할 수 있습니다.

1. AOP — 횡단 관심사 분리

AOP(Aspect-Oriented Programming)는 여러 클래스에 반복되는 횡단 관심사(Cross-Cutting Concern)를 비즈니스 로직과 분리하는 기법입니다. 트랜잭션 처리, 로깅, 보안 검사, 성능 측정이 대표적입니다.

Aspect
횡단 관심사의 모듈 단위. "어디서, 무엇을 할 것인가"를 담은 클래스. @Aspect 애노테이션 사용.
Advice
실제로 실행될 코드. Before(@Before), After(@After), Around(@Around) 등 실행 시점별로 종류가 다름.
Pointcut
Advice를 적용할 메서드 집합을 지정하는 표현식. execution(* com.example.service.*.*(..))
JoinPoint
Advice가 끼어들 수 있는 실행 지점. Spring AOP는 메서드 실행 시점만 지원.
Weaving
Aspect를 실제 코드에 적용하는 과정. Spring AOP는 런타임 프록시 방식 사용.
Target
Advice가 적용되는 원본 객체. 프록시가 감싸고 있는 실제 Bean.

2. Spring AOP 프록시 동작 원리

Spring AOP는 프록시 패턴으로 구현됩니다. Bean을 직접 주입하는 게 아니라 Bean을 감싼 프록시 객체를 주입합니다. 클라이언트는 프록시를 통해 메서드를 호출하고, 프록시가 Before/After 로직을 끼워 넣은 뒤 실제 Bean에 위임합니다.

@Transactional 메서드 호출 흐름
클라이언트 (Controller 등)
$Proxy: OrderService$$CGLIB
주입된 것은 프록시!
BEGIN TRANSACTION
프록시
실제 OrderService
원본 메서드 실행
예외 없음: COMMIT / 예외 있음: ROLLBACK
결과 반환
JDK Dynamic Proxy vs CGLIB

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 적용 불가입니다.

3. @Transactional 핵심

Self-Invocation 트랩 — 가장 자주 틀리는 부분

❌ Self-Invocation — 트랜잭션 안 걸림
@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 동작 안 함
    }
}
✅ 해결 — 별도 Bean으로 분리
@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) {
        // 이제 독립 트랜잭션으로 동작
    }
}

트랜잭션 전파(Propagation) — 자주 쓰는 옵션

옵션동작주요 사용처
REQUIRED 기본값 트랜잭션이 있으면 참여, 없으면 새로 생성 일반적인 서비스 메서드
REQUIRES_NEW 항상 새 트랜잭션 시작. 기존 트랜잭션은 일시 중단 알림 발송, 감사 로그 — 메인 롤백에 영향받으면 안 되는 작업
NESTED savepoint를 만들고 중첩 트랜잭션 시작. 내부 롤백 시 savepoint까지만 롤백 일부 실패를 부분 롤백으로 처리할 때 (JDBC savepoint 지원 필요)
SUPPORTS 트랜잭션 있으면 참여, 없으면 트랜잭션 없이 실행 읽기 전용 조회 (있어도 되고 없어도 되는 경우)
NOT_SUPPORTED 트랜잭션 없이 실행. 기존 트랜잭션 일시 중단 트랜잭션 범위 밖에서 실행해야 하는 대용량 배치
readOnly = true 의 효과
@Transactional(readOnly = true)
public List<Order> getOrders(Long userId) {
    return orderRepo.findByUserId(userId);
}
// 효과 1: JPA 영속성 컨텍스트에서
//          dirty checking(변경 감지) 비활성화
//          → 스냅샷 저장 안 함 → 메모리 절약
// 효과 2: DB 드라이버/Replication 설정에 따라
//          읽기 전용 replica로 라우팅 가능
// 효과 3: 트랜잭션 내 쓰기 시도 시
//          일부 DB는 예외 발생 (의도 명시)
rollbackFor 주의 — 기본값 함정
@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);
}

4. 면접 대비 핵심 Q&A

Q1. "@Transactional이 붙어있는데 트랜잭션이 적용 안 될 수 있는 경우를 설명해주세요."

두 가지 주요 케이스가 있습니다. 첫째, self-invocation입니다. 같은 클래스 내 다른 메서드를 this.method()로 호출하면 프록시를 거치지 않아 AOP가 동작하지 않습니다. 둘째, private 메서드입니다. CGLIB 프록시는 상속 기반이므로 private 메서드는 오버라이드할 수 없어 AOP 적용이 불가합니다. 추가로 final 클래스나 메서드도 CGLIB이 상속할 수 없어 적용되지 않습니다.

Q2. "REQUIRES_NEW와 NESTED의 차이는?"

REQUIRES_NEW는 기존 트랜잭션을 일시 중단하고 완전히 독립된 새 트랜잭션을 시작합니다. 내부 트랜잭션이 커밋되면 외부 트랜잭션 결과와 무관하게 DB에 반영됩니다. NESTED는 DB savepoint를 이용해 중첩 트랜잭션을 만듭니다. 내부에서 예외가 나면 savepoint까지만 롤백하고 외부 트랜잭션은 계속할 수 있지만, 외부 트랜잭션이 롤백되면 내부도 함께 롤백됩니다. 알림처럼 메인 롤백에 영향받으면 안 되는 작업엔 REQUIRES_NEW, 부분 실패 허용엔 NESTED를 씁니다.

Q3. "Checked Exception에서 트랜잭션이 롤백 안 되는 이유와 해결책은?"

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로 명시해야 한다.