@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이 붙은 메서드만 어드바이스의 적용 대상으로 본다.
'TWIL' 카테고리의 다른 글
| [3기] 잇츠 스터디 (IT’s Study Crew) : TWIL 스터디 회고 (0) | 2025.04.27 |
|---|---|
| MariaDB의 READ COMMITTED vs REPEATABLE READ 격리 수준과 부정합 문제들 (0) | 2025.03.30 |
| JPA 더티체킹 사용시 주의할점 : REPEATABLE READ 격리 수준에서 발생한 동시성문제 해결하기 (1) | 2025.03.22 |
| Spring Cloud Stream으로 Kafka 메시지 처리하기 - 1 (0) | 2025.03.17 |
| API 성능 최적화하기 - 1 (2) | 2025.03.16 |