Tiny Star

IT

[Spring] @Transactional 이 일어나지 않는 경우

하얀지 2025. 7. 23. 14:53

 

"트랜잭션이 일어나지 않는 경우"에 대해서 설명해달라는 질문을 받았다.

트랜잭션 메서드 안에 트랜잭션 메서드가 있는 경우에는 작동하지 않는다는 건 알고 있었는데, 알고 있는 것조차 대답을 못할정도로 충분히 숙지되어있지 않다는 걸 느껴 다시금 정리해보려고 한다.

 


 

1. 자기 자신을 호출하는 경우 (self-invocation)

@Service
public class MyService {
    @Transactional
    public void methodA() {
        this.methodB(); // 프록시를 거치지 않아 @Transactional 무시됨
    }

    @Transactional
    public void methodB() {
        userRepository.save(new User("Key"));
        throw new RuntimeException("에러 발생");
    }
}

동일 클래스 내부에서 `this.methodB()`처럼 호출하면 프록시를 거치지 않기 때문에 `methodB()`에 붙은 `@Transactional`은 동작하지 않는다. 하지만 `methodA()` 자체에 트랜잭션이 적용되어 있다면, `methodB()`도 같은 트랜잭션 내에서 실행되므로 롤백은 함께 일어난다.

정리하면, `this.methodB()`처럼 자기 자신을 호출하면 Spring의 AOP 트랜잭션 프록시가 적용되지 않기 때문에, methodB()에 붙은 @Transactional의 속성은 반영되지 않는다. 하지만 이 호출이 이미 트랜잭션 안에서 수행되면, 그 트랜잭션의 범위 안에 포함되어 롤백은 함께 된다.

 

 

2. private 메서드에 @Transactional을 붙인 경우

@Transactional
private void someMethod() {
    // 트랜잭션 적용 안 됨
}

Spring AOP는 기본적으로 public 메서드만 프록시를 통해 트랜잭션 적용이 가능하다.

`@Transactional`은 Spring AOP 기반으로 동작하는데, Spring AOP는 프록시 객체를 통해 메서드를 가로채서 트랜잭션 시작/커밋/롤백을 처리한다. 그런데 `private` 메서드는 자바 언어 레벨에서 외부 클래스는 물론, 프록시 객체에서도 접근이 불가능하므로, `private` 메서드에 `@Transactional`을 붙여도 프록시가 호출할 수 없어서 트랜잭션이 적용되지 않는다.

 

 

3. 예외가 잡히는 경우 (예외 전파 실패)

@Transactional
public void process() {
    try {
        // 예외 발생
        throw new RuntimeException("error");
    } catch (Exception e) {
        // rollback 되지 않음
    }
}

`@Transactional`은 기본적으로 런타임 예외가 발생해야 rollback 되기 때문에, 예외가 `try-catch`로 잡히면 rollback 되지 않는다.

`try-catch`로 예외를 잡아버리면, Spring은 “예외가 발생하지 않았다”고 판단해 트랜잭션을 정상 종료시키기 때문에, catch 블록 안에서 rollback 처리를 명시적으로 하지 않으면 커밋된다.

 

그러면 "`@Transactional`을 사용할 땐 `try-catch`를 쓰면 안되는건가?"라는 생각이 들 수도 있다.

@Transactional
public void registerUser() {
    try {
        //  실패해도 괜찮은 외부 API 호출
        externalNotificationService.sendWelcomeMessage();
    } catch (Exception e) {
        // 실패해도 DB는 롤백되지 않아야 하므로 try-catch로 감쌈
        log.warn("알림 전송 실패", e);
    }
    // 아래는 반드시 성공해야 하므로 예외 발생 시 트랜잭션 롤백
    userRepository.save(new User("haeun"));  // 예외 나면 롤백됨
}

이렇게 예외 여부가 트랜잭션에 영향을 주지 않는 로직의 경우는 `try-catch`를 사용하고 그 외에는 사용하지 않으면 된다.

 

@Transactional
public void process() {
    try {
        saveUser();
    } catch (Exception e) {
        log.error("에러 발생", e);
        // 수동 롤백 지시
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

`try-catch` 부분에도 롤백이 필요하다면 위와 같이 `setRollbackOnly()`를 명시하면 된다.

`@Transactional에서` `try-catch`는 필요한 부분에서만 사용하도록 하자.

 

 

4. 프록시 기반이 아닌 경우 (예: 내부 호출, 직접 인스턴스 생성 등)

new 키워드로 직접 객체를 생성하거나 AOP 프록시를 거치지 않으면 트랜잭션 적용 안 된다.

위에서 한 번 언급했다시피, `@Transactional`은 Spring AOP 기반으로 동작하는데, Spring AOP는 프록시 객체를 통해 메서드를 가로채서 트랜잭션 시작/커밋/롤백을 처리하기 때문에 AOP 프록시를 반드시 거쳐야한다.

프록시를 거치는 방법

@Service
public class InnerService {
    @Transactional
    public void innerMethod() {
        System.out.println("inner 시작");
    }
}

 

1. 다른 빈에서 호출

@Component
public class Caller {
    private final InnerService innerService;

    public Caller(InnerService innerService) {
        this.innerService = innerService;
    }

    public void doSomething() {
        innerService.innerMethod(); // 프록시 타고 호출
    }
}

 

2. 강제로 프록시에서 다시 호출

import org.springframework.aop.framework.AopContext;

@Transactional
public void outerMethod() {
    ((MemberService) AopContext.currentProxy()).innerMethod(); // 프록시를 직접 호출
}

AopContext.currentProxy()를 쓸 때 반드시 exposeProxy = true가 켜져 있어야 한다. 이 옵션 없이는 IllegalStateException: Cannot find current proxy 오류 발생

 

 

5. 메서드가 final인 경우

CGLIB 프록시는 final 메서드를 오버라이드할 수 없기 때문에 AOP 적용이 되지 않는다.

 

CGLIB 프록시란?

클래스를 상속받아서 새로운 클래스를 런타임에 동적으로 생성해 AOP 기능을 수행하는 프록시 방식

// 클래스
public class MyService { 
    public void doSomething() { ... }
}
// CGLIB가 감산 클래스
public class MyService$$EnhancerBySpringCGLIB extends MyService {
    @Override
    public void doSomething() {
        try {
            // AOP before advice: 트랜잭션 시작
            TransactionManager.begin();

            // 원래 로직 실행
            super.doSomething();

            // AOP after advice: 트랜잭션 커밋
            TransactionManager.commit();

        } catch (Exception e) {
            // AOP after-throwing advice: 트랜잭션 롤백
            TransactionManager.rollback();
            throw e;
        }
    }
}

`final`이면 CGLIB가 `@Override` 할 수 없다.

 

 

6. Checked Exception일 경우

@Transactional
public void save() throws IOException {
    throw new IOException("실패"); // 롤백 안 됨
}

`@Transactional`은 RuntimeException은 자동으로 롤백하지만, Checked Exception은 롤백하지 않는다.

 

@Transactional(rollbackFor = IOException.class)
public void save() throws IOException {
    throw new IOException("파일 오류 발생"); // 이젠 롤백됨
}

Checked 예외로 롤백하고 싶다면, 이렇게 예외를 명시해주면 된다.

 

 

top