본문 바로가기
TWIL

Spring @Transactional - 프록시 기반 동작방식과 예외상황재현 테스트

by swims 2025. 4. 13.
@Transactional noRollback 옵션을 줬는데 왜 롤백이 되죠..예외를 catch 했는데 왜 롤백이 되는거죠…..일하면서 겪었던 문제들을 언젠가는 정리해야지 하고 안했었는데.. 최근 또 의도대로 동작하지 않는 코드때문에 아까운 시간을 날려버려서.. 이제는 한번 정리해보기로 했다.

 

@Transactional 의 프록시 기반 작동방식을 간단하게 정리하고 실무에서 겪었던 예외상황을 간단하게 코드로 재현해보았다.

 

스프링 @Transactional 

스프링의 @Transactinal은 TransactionInterceptor 이라는 AOP Advice를 통해 다음 순서로 작동한다.

* Advice : 프록시가 호출하는 부가기능이다.

	1.클라이언트가 프록시 객체의 메서드 호출
	2.프록시가 TransactionInterceptor.invoke() 실행
	3.트랜잭션 처리 전 준비
	4.invocation.proceed() → 실제 서비스 메서드 실행
	   - 메서드 정상 종료 → 트랜잭션 커밋
	   - 예외 발생 → 트랜잭션 롤백

 

TransactionInterceptor.invoke() 전체 구조를 간단하게 보면 다음과 같다.

Object invoke(...) {
   시작 전 → 트랜잭션 시작
   try {
       Object result = method.invoke(); // 실제 서비스 메서드
       트랜잭션 커밋
       return result;
   } catch (Throwable ex) {
       트랜잭션 롤백
       throw ex;
   }
}

 

* TransactionInterceptor.invoke() 메서드의 동작을 좀더 자세하게 들여다보면 트랜잭션의 시작과 커밋/롤백이 AOP Advice인 TransactionInterceptor 내부에서 처리된다는 것을 확인할 수 있다.

1. 클라이언트가 프록시 객체 메서드 호출 
	- callerService.call()

2. 프록시 객체가 TransactionInterceptor.invoke() 을 실행한다.

3. invokeWithinTransaction 메서드 내부로 진입 (이후 invokeWininTransaction 내부로직)
	- TransactionAttribute txAttr = tas != null ? tas.getTransactionAttribute(method, targetClass) : null;
	- 트랜잭션 어트리뷰트를 가져와서

4. 트랜잭션 매니저 설정
  - TransactionManager tm = this.determineTransactionManager(txAttr, targetClass);

5. 트랜잭션 생성 
  - TransactionInfo txInfo = this.createTransactionIfNecessary(ptm, txAttr, joinpointIdentification); 

6. 실제 비즈니스 메서드 실행 -> 예외발생하면 롤백 여부 판단후 처리
		try {
		     // 실제 서비스로직 실행 
         retVal = invocation.proceedWithInvocation();
    } catch (Throwable ex) {
         // 예외 발생하면 예외처리
           this.completeTransactionAfterThrowing(txInfo, ex);
           throw ex;
    } finally {
           this.cleanupTransactionInfo(txInfo); // ThreadLocal 초기화 (트랜잭션 종료후 정리)
	  }  
7. 예외발생하지 않고, 정상종료되면 커밋
   this.commitTransactionAfterReturning(txInfo);

 

 

즉, 트랜잭션을 시작하고 커밋·롤백을 결정하는 핵심 로직은 프록시가 호출하는 TransactionInterceptor 내부에 있다는 점을 기억하자. 이걸 염두에 두고 아래 예외상황 예시를 보자.

 

예상과 다르게 발생했던 사례

내 예상과 다르게 발생했던 상황들을 간단하게 재현해봤다. 

  • 전파속성 : default인 REQUIRED
    • 현재 트랜잭션이 존재하면 그걸 사용하고, 없으면 새로 만든다.
    • rollbackOnly 플래그가 상위 트랜잭션에 전달되게 하기위해 REQUIRED 속성으로 설정
  • 로그 레벨 : org.springframework.transaction=TRACE
    • TransactionInterceptor 내부 로그 확인하기 위해

 

1. noRollbackFor가 적용되지 않고 롤백 됨

하위메서드에서 예외가 발생한경우 상위메서드에서 하위메서드에서 발생한 예외를 noRollbackFor 옵션을 통해 롤백 되지 않게 설정했는데 롤백되었다. 간단하게 예시를 설명하면

  • CallerService.call(message) 에서 
    • logRepository.save(new LogEntry(message)); 로그를 저장하고
    • targetService.doSomething() 호출
    • 해당 메서드(call)의 @Transactional 어노테이션에서에서 RuntimeException에 대해 noRollbackFor 설정했다.

 

2. 예외를 catch 했는데도 롤백 됨

하위 트랜잭션에서 예외가 발생하고 상위 메서드에서 예외를 catch 했지만 커밋 시도할때 예외가 발생해서 롤백되었다.

  • callAndCatchException(message) 에서
    • logRepository.save(new LogEntry(message)); 로그를 저장하고
    • targetService.doSomething()를 호출하고 doSomething()에서 발생한 예외를 catch하고 로그를 남긴다

 

* TargetService.doSomething() 내부에서는 무조건 RuntimeException을 던진다.

* 상위메서드, 하위메서드 둘 다 메서드에 @Transactional 어노테이션이 붙어있다.

 

예외상황을 구현한 코드는 다음과 같다.

@Slf4j
@Service
@RequiredArgsConstructor
public class CallerServiceImpl implements CallerService {

    private final TargetService targetService;
    private final LogRepository logRepository;

    @Override
    @Transactional(noRollbackFor = RuntimeException.class)
    public void call(String message) {
        log.info("call() - isNewTransaction: {}", TransactionAspectSupport.currentTransactionStatus().isNewTransaction());
        log.info("targetService is proxy: {}", targetService.getClass()); // TargetServiceImpl$$SpringCGLIB$$0 proxy
        logRepository.save(new LogEntry(message));
        targetService.doSomething();
    }

    @Override
    @Transactional
    public void callAndCatchException(String message) {
        log.info("[call] >>>>> TransactionInterceptor START (Advice - before method)");
        log.info("[call] isActualTransactionActive: {}", TransactionSynchronizationManager.isActualTransactionActive());
        log.info("[call] isNewTransaction: {}", TransactionAspectSupport.currentTransactionStatus().isNewTransaction());
        log.info("[call] isRollbackOnly (before): {}", TransactionAspectSupport.currentTransactionStatus().isRollbackOnly());
        logRepository.save(new LogEntry(message));
        try {
            log.info("[call] targetService is proxy: {}", targetService.getClass()); // TargetServiceImpl$$SpringCGLIB$$0 proxy
            targetService.doSomething();
        } catch (RuntimeException e) {
            log.info("[call] caught exception: {}", e.getClass().getSimpleName());
        }
        log.info("[call] isRollbackOnly (after): {}", TransactionAspectSupport.currentTransactionStatus().isRollbackOnly());
        log.info("[call] <<<<< TransactionInterceptor END (Advice - after method)");
    }
}

@Slf4j
@Service
@RequiredArgsConstructor
public class TargetServiceImpl implements TargetService {

    @Override
    @Transactional
    public void doSomething() {
        log.info("{} : doSomething() - isNewTransaction: {}", this.getClass(), TransactionAspectSupport.currentTransactionStatus().isNewTransaction());
        log.info("{} : doSomething() - isActualTransactionActive: {}", this.getClass(), TransactionSynchronizationManager.isActualTransactionActive());
        log.info("TargetService.doSomething() - getCurrentTransactionName: {}", TransactionSynchronizationManager.getCurrentTransactionName());
        log.info("TargetServiceImpl doSomething throw RuntimeException");
        throw new RuntimeException();
    }
}

 

예외상황 테스트

@Slf4j
@SpringBootTest
class RollbackTest {

    @Autowired
    CallerService callerService;

    @Autowired
    LogRepository logRepository;

    @BeforeEach
    void setUp() {
        logRepository.deleteAll();
    }

    @Test
    @DisplayName("caller 클래스 메서드에서 target 클래스 메서드를 호출했을 때 " +
            "target 클래스에서 발생한 예외에 대해 caller 메서드에서 noRollback 설정한 경우 " +
            "noRollback 설정은 무시되고 롤백됨")
    void noRollbackFor_shouldBeIgnored() {
        String message = "no-rollback-test";

        assertThrows(RuntimeException.class, () -> {
            callerService.call(message);
        });

        // caller proxy : CallerServiceImpl$$SpringCGLIB$$0.caller
        // target proxy : TargetServiceImpl$$SpringCGLIB$$0.doSomething
        // TransactionInterceptor : Application exception overridden by commit exception -> rollbackOnly가 마킹되어서 커밋되지 않고 롤백됨

        boolean exists = logRepository.existsByMessage(message);
        assertFalse(exists); // 롤백됐으므로 exists = false
    }

    @Test
    @DisplayName("target 메서드에서 발생한 예외를 caller 메서드에서 catch 해도 커밋되지 않고 롤백됨")
    void catchException_shouldStillRollback() {
        String message = "catch-rollback-test";

        assertThrows(UnexpectedRollbackException.class, () -> {
            callerService.callAndCatchException(message); // TransactionException 발생 -> 롤백 됨
        });

        boolean exists = logRepository.existsByMessage(message);
        assertFalse(exists); // 롤백됐으므로 exists = false
    }
}

 

callAndCatchException 테스트 로그를 살펴보면

(예외를 catch 했기 때문에 Advice 종료까지 로그로 확인가능하다)

 

2025-04-08T21:27:54.847+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.CallerServiceImpl          : [call] >>>>> TransactionInterceptor START (Advice - before method)
2025-04-08T21:27:54.848+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.CallerServiceImpl          : [call] isActualTransactionActive: true
2025-04-08T21:27:54.848+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.CallerServiceImpl          : [call] isNewTransaction: true
2025-04-08T21:27:54.848+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.CallerServiceImpl          : [call] isRollbackOnly (before): false
2025-04-08T21:27:54.861+09:00 DEBUG 69412 --- [spring-boot-test] [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        log_entry
        (message) 
    values
        (?)
Hibernate: 
    insert 
    into
        log_entry
        (message) 
    values
        (?)
2025-04-08T21:27:54.878+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.CallerServiceImpl          : [call] targetService is proxy: class com.example.springboottest.service.TargetServiceImpl$$SpringCGLIB$$0
2025-04-08T21:27:54.878+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.TargetServiceImpl          : class com.example.springboottest.service.TargetServiceImpl : doSomething() - isNewTransaction: false
2025-04-08T21:27:54.878+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.TargetServiceImpl          : class com.example.springboottest.service.TargetServiceImpl : doSomething() - isActualTransactionActive: true
2025-04-08T21:27:54.879+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.TargetServiceImpl          : TargetServiceImpl doSomething throw RuntimeException
2025-04-08T21:27:54.879+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.CallerServiceImpl          : [call] caught exception: RuntimeException
2025-04-08T21:27:54.879+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.CallerServiceImpl          : [call] isRollbackOnly (after): true
2025-04-08T21:27:54.879+09:00  INFO 69412 --- [spring-boot-test] [    Test worker] c.e.s.service.CallerServiceImpl          : [call] <<<<< TransactionInterceptor END (Advice - after method)
  • TargetServiceImpl.doSomething -> throw RuntimeException 
  • caught exception: RuntimeException 예외를 CallerServiceImpl
  • isRollbackOnly (after): true → isRollbackOnly true로 찍혀서 롤백되는 걸 볼 수 있다.

 

의도한대로 롤백되지 않게 하려면?

TargetService.doSomething 메서드에서 @Transactional 어노테이션을 제거하면 된다.

    @Override
    public void doSomething(String message) {
        log.info("TargetService.doSomething({}) - isNewTransaction: {}", message, TransactionAspectSupport.currentTransactionStatus().isNewTransaction());
        log.info("TargetService.doSomething({}) - isActualTransactionActive: {}", message, TransactionSynchronizationManager.isActualTransactionActive());
        log.info("TargetService.doSomething({}) - getCurrentTransactionName: {}", message, TransactionSynchronizationManager.getCurrentTransactionName());

        throw new RuntimeException(message);
    }

 

어노테이션을 제거하고 의도하는대로 동작하는지 다시 테스트해보면

 

@Slf4j
@SpringBootTest
class NoRollbackTest {

    @Autowired
    CallerService callerService;

    @Autowired
    LogRepository logRepository;

    @BeforeEach
    void setUp() {
        logRepository.deleteAll();
    }

    @Test
    @DisplayName("호출하는 메서드에만 @Transactional이 있는 경우 호출 되는 메서드는 advice 실행 안됨 -> noRollback 설정 적용됨")
    void noRollbackFor_shouldBeCommitted() {
        String message = "no-rollback-test";

        assertThrows(RuntimeException.class, () -> {
            callerService.callNoRollback(message);
        });

        boolean exists = logRepository.existsByMessage(message);
        assertTrue(exists); // 예외가 발생해도 RuntimeException 예외에 대해 noRollback 설정이 적용됐으므로 커밋되었다.
    }

    @Test
    @DisplayName("@Transactional 이 호출하는 메서드에만 있는 경우 target 메서드에서 발생한 예외를 caller 메서드에서 catch 하면 커밋됨")
    void catchException_shouldBeCommitted() {
        String message = "catch-rollback-test";

				assertDoesNotThrow(() -> {callerService.callCatchException(message);});

        boolean exists = logRepository.existsByMessage(message);
				assertTrue(exists);
    }
}

 

의도한대로 커밋됐다.

 

 

@Transactional을 제거했더니 의도대로 동작한 이유가 뭘까? 어노테이션 제거 전후 실행로그를 비교해보면서 찾아보자

 

문제코드와 해결한 뒤 코드의 실행로그 비교

 

* Getting trasaction for [fully-qualified-method-name] : Advice가 실행되는 프록시 객체가 호출한 메서드 기준으로 찍히는 로그

  • TransactionInterceptor 내부에서 남기는 것으로 Advice가 실행되는 시점에 어떤 메서드에 트랜잭션을 적용하고 있는 지를 알려준다.

1. 문제코드 로그

TRACE TransactionInterceptor : Getting transaction for [CallerServiceImpl.call]
TRACE TransactionInterceptor : Getting transaction for [TargetServiceImpl.doSomething]
... 생략
TRACE TransactionInterceptor : Completing transaction for [TargetServiceImpl.doSomething] after exception
TRACE TransactionInterceptor : Completing transaction for [CallerServiceImpl.call] after exception

 

2. 해결 후 코드 로그

c.e.s.service.CallerServiceImpl          : targetService : class com.example.springboottest.service.TargetServiceImpl$$SpringCGLIB$$0
TRACE TransactionInterceptor : Getting transaction for [CallerServiceImpl.callNoRollback]
TRACE TransactionInterceptor : Getting transaction for [SimpleJpaRepository.save]
... 생략
(no entry for TargetServiceImpl.doSomething(String))

 

  •  1번 로그와는 다르게 TransactionInterceptor에서 찍는 `Getting transaction for [TargetServiceImpl.doSomething]` 가 없다. 즉, 이 경우 프록시 객체는 생성되어 있어도, TransactionInterceptor.invoke()는 실행되지 않았다. (AOP Advice가 실행되지 않음)
  • targetService.doSomething() 메서드는 트랜잭션에 참여만 할 뿐 트랜잭션 상태(setRollbackOnly())등을 직접 건드릴 수 있는 로직이 없다. 왜냐면 트랜잭션 상태를 관리하는 로직은 TransactionInterceptor.invoke 에 있으니까! 그러므로, 해당 메서드 내에서 RuntimeException을 던지더라도 트랜잭션을 관리하는 쪽 (CallerService.callAndCatchException 메서드) 에서 예외를 catch해서 무시하면 커밋이 가능한 것이다.

그런데 TargetServiceImpl$$SpringCGLIB$$0 프록시 객체는 생성이 됐는데, 왜 TransactionInterceptor.invoke 는 실행되지 않았을까?

  • @Transactional이 없으면 Pointcut에 매칭되지 않는다.
  • TransactionInterceptor는 Spring AOP에서 @Transactional이 붙은 메서드만 pointcut으로 감지한다.
    • doSomething 메서드에는 @Transactional이 없으므로, 해당 객체가 프록시로 감싸져 있더라도 Advice는 실행되지 않는다.
    • 즉, 해당 메서드가 트랜잭션 어드바이스의 적용 대상이 아닌 것이다.

 

핵심요약

  • Spring AOP는 클래스 단위로 프록시 객체를 생성하고, 메서드 단위로 어드바이스 실행 여부를 판단한다.
    • Spring AOP에서 @Transactional이 붙은 메서드만 어드바이스의 적용 대상으로 본다.