Spring Transactional 롤백 심층 분석
프록시와 롤백 마킹, 전파 옵션, Kotlin 예외 변환이 실제 롤백을 가른다.
Spring 트랜잭션에서 Unchecked Exception은 기본적으로 롤백 마킹의 기준이 된다. 하지만 예외를 잡았다고 해서 항상 안전한 것은 아니며, 실제로는 TransactionInterceptor가 개입했는지, 그리고 해당 예외가 어떤 트랜잭션에 어떤 시점으로 전달됐는지가 핵심이다.
같은 서비스 내부에서 @Transactional 메서드를 직접 호출하면 프록시를 거치지 않기 때문에 트랜잭션이 새로 열리지 않는다. 그래서 REQUIRES_NEW를 붙여도 내부 호출이면 기대한 분리 동작이 발생하지 않으며, 트랜잭션을 분리하려면 별도 서비스로 나누거나 트랜잭션 템플릿을 사용해야 한다.
예외 처리와 롤백의 관계도 단순하지 않다. 다른 서비스의 @Transactional 메서드가 프록시를 통해 호출되어 RuntimeException을 던지면 롤백이 마킹되지만, 상위 메서드에서 예외를 잡아도 이미 마킹된 상태라면 커밋 시점에 결국 롤백된다. 반대로 프록시를 거치지 않은 메서드에서 예외가 났고 상위에서 이를 처리했다면 롤백 마킹 자체가 남지 않아 정상 커밋될 수 있다.
Checked Exception은 기본적으로 롤백 대상이 아니지만, Kotlin에서는 이 예외 처리가 Java와 다르게 동작해 예상치 못한 결과가 생길 수 있다. IOException 같은 Checked Exception이 Java 인터페이스 경계를 지나며 UndeclaredThrowableException으로 바뀌면 이것이 RuntimeException 계열로 취급되어 롤백이 발생할 수 있고, 이를 막으려면 Kotlin 함수에 @Throws(IOException::class)를 명시해 의도한 예외 형태로 전달해야 한다.
@Transactional(propagation = Propagation.REQUIRES_NEW)는 같은 스레드에서 실행되지만 새로운 트랜잭션을 만든다. 따라서 새 트랜잭션 내부의 실패는 그 트랜잭션에만 롤백 마킹이 남고, 상위 트랜잭션이 예외를 처리했다면 부모 트랜잭션은 계속 커밋될 수 있다. 반대로 상위 트랜잭션이 나중에 실패해도 이미 종료된 하위 트랜잭션은 다시 롤백되지 않는다.
실제 사례로는 Kafka 리스너에서 긴 트랜잭션을 유지하지 않도록 작업을 분리하는 방식이 제시된다. 핵심은 다음과 같다.
- 리스너는 실패 시
nack으로 재시도하고, 성공 시에만acknowledge한다. deletePersonalInfo처럼 큰 작업은 상위 트랜잭션에서 관리하되, 내부의 삭제 작업은REQUIRES_NEW로 짧게 끊는다.- 데이터가 너무 많으면 배치 단위 또는 커서 기반으로 더 잘게 나눠 여러 개의 짧은 트랜잭션으로 처리한다.
결국 중요한 것은 예외의 종류, 프록시를 탔는지 여부, 롤백 마킹이 어느 트랜잭션에 남았는지, 그리고 스레드와 전파 옵션의 조합이다. 이 네 가지를 정확히 이해해야 Spring @Transactional을 안정적으로 설계할 수 있다.
이 요약은 원문 이해를 돕기 위한 큐레이션입니다. 저작권은 원저작자에게 있으며, 정확한 내용과 맥락은 원문을 확인하세요.
요약 오류, 출처 표기 문제, 삭제 요청은 문의 · 건의로 알려주세요.