빌드노트
안쪽 예외를 잡았는데 커밋에서 UnexpectedRollbackException이 나는 이유
서로 다른 빈의 REQUIRED 호출이 같은 트랜잭션에 참여하면, 안쪽 예외를 바깥에서 잡아도 rollback-only 상태는 남는다. Spring 6.2.19·H2에서 정상 호출의 3행 커밋과 예외를 잡은 호출의 최종 0행을 비교하고, 업무상 원자성에 맞는 경계를 판단한다.
바깥의 @Transactional 메서드에서 안쪽 서비스 예외를 잡았는데, 메서드를 빠져나갈 때 UnexpectedRollbackException이 나고 INSERT도 남지 않을 수 있다. 두 서비스가 같은 REQUIRED 트랜잭션에 참여했다면 catch에 도착하기 전에 공유 트랜잭션이 이미 rollback-only로 표시됐는지 먼저 확인한다. 예외를 잡아 실행을 이어가는 것과 트랜잭션을 커밋할 수 있는 것은 별개다.
이 글은 같은 스레드에서 서로 다른 두 Spring 빈의 프록시를 통과하고, 같은 트랜잭션 매니저와 DataSource를 사용하는 경우를 다룬다. REQUIRED는 기존 트랜잭션이 있으면 참여한다. 따라서 메서드마다 논리적인 트랜잭션 범위가 있어도 실제 DB에서 커밋·롤백할 물리 트랜잭션은 하나일 수 있다. Spring의 REQUIRED 설명 (새 탭에서 열림)
안쪽 호출 뒤의 rollback-only를 확인한다
자기 코드에서는 예외를 잡는 바깥 서비스와 예외를 던지는 안쪽 서비스부터 본다. 안쪽 메서드의 예외가 그 빈의 트랜잭션 프록시 밖으로 나오는지, 바깥에서 주입받은 빈을 호출하는지 확인한다. 기본 프록시 모드는 이런 외부 호출을 가로챈다. 트랜잭션 프록시 적용 조건 (새 탭에서 열림)
아래는 별도 재현에 사용한 두 서비스의 핵심 흐름이다. OuterService에는 InnerService inner와 JdbcTemplate jdbc가 생성자로 주입되고, InnerService에도 같은 DataSource를 사용하는 jdbc가 주입돼 있다. 두 메서드는 각각 별도 빈에 있으며 생성자와 기록용 코드는 생략했다. tx_demo에는 문자열 기본 키 label 하나가 있다. 자신의 바깥 메서드에 적용할 진단은 안쪽 호출을 catch한 뒤 rollback-only를 출력하는 부분이다.
// OuterService의 메서드
@Transactional(propagation = Propagation.REQUIRED)
public String save(boolean fail) {
jdbc.update("insert into tx_demo(label) values (?)", "outer-before");
try {
inner.save(fail);
} catch (IllegalStateException ex) {
System.out.println("outer.catch=" + ex.getClass().getSimpleName());
}
System.out.println("outer.afterInner rollbackOnly=" +
TransactionAspectSupport.currentTransactionStatus().isRollbackOnly());
jdbc.update("insert into tx_demo(label) values (?)", "outer-after");
return "ok";
}
// InnerService의 메서드
@Transactional(propagation = Propagation.REQUIRED)
public void save(boolean fail) {
jdbc.update("insert into tx_demo(label) values (?)", "inner");
if (fail) {
throw new IllegalStateException("inner failed");
}
}
어노테이션은 org.springframework.transaction.annotation의 Transactional, Propagation이고, 상태 조회는 org.springframework.transaction.interceptor.TransactionAspectSupport다. 상태 조회를 추가한다면 Spring이 트랜잭션을 적용한 바깥 메서드 안에서 실행한다. 해당 AOP 호출 문맥이 없는 곳에서 currentTransactionStatus()를 호출하면 NoTransactionException이 날 수 있다. 상태 조회 API (새 탭에서 열림)
기본 롤백 규칙에서 IllegalStateException은 RuntimeException이므로 롤백 대상이다. 안쪽 프록시는 예외를 바깥 메서드의 catch로 전달하기 전에 자기 트랜잭션 범위의 실패를 처리한다. 기본 롤백 규칙 (새 탭에서 열림)
본문은 끝났지만 호출자는 정상 반환을 받지 못했다
Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.19, H2 2.3.232에서 위 흐름을 별도 Spring 컨텍스트로 실행했다. 트랜잭션 매니저는 DataSourceTransactionManager의 기본 완료 동작을 그대로 호출하면서 시작·커밋·롤백·rollback-only 표시만 기록했다. 각 비교는 새 H2 메모리 DB에서 시작했고, 프록시 호출이 끝난 뒤 트랜잭션 밖의 새 JDBC 연결로 행 수를 조회했다.
먼저 save(false)로 안쪽 메서드도 정상 반환하게 했다. 바깥의 두 INSERT와 안쪽의 한 INSERT, 총 3행이 남았다. 다음은 기록 중 완료 판단에 필요한 줄을 발췌한 것이다. outer.body는 바깥 메서드의 return 직전 로그이고, caller.returned는 그 프록시 호출을 마친 호출자의 로그다.
CASE normal
outer.afterInner rollbackOnly=false
outer.beforeReturn rowsInTransaction=3
outer.body=return ok
manager.jdbcCommit=completed
caller.returned=ok
caller.after active=false freshConnection=true autoCommit=true rows=3
이어서 같은 흐름에 save(true)를 입력했다. 안쪽에서 INSERT 후 예외를 던지고 바깥에서 잡도록 바꾼 결과다.
CASE caught-inner-runtime
inner.throw=IllegalStateException
manager.markRollbackOnly=completed
outer.catch=IllegalStateException
outer.afterInner rollbackOnly=true
outer.beforeReturn rowsInTransaction=3
outer.body=return ok
manager.jdbcRollback=completed
caller.exception=org.springframework.transaction.UnexpectedRollbackException
caller.after active=false freshConnection=true autoCommit=true rows=0
catch 뒤에도 바깥 INSERT가 실행돼 트랜잭션 안에서는 3행을 읽었다. 하지만 종료 뒤에는 0행이었다. return 직전 로그와 트랜잭션 안의 조회 결과는 커밋 성공 증거가 되지 않았다. 실패 조건에서는 JDBC 커밋 호출이 한 번도 없었고, 물리 롤백이 한 번 기록됐다. DB가 INSERT를 거부한 실험도 아니다. 예외는 INSERT가 성공한 뒤 Java 코드에서 직접 던졌다.
두 비교 모두 바깥·안쪽 빈은 실제 CGLIB 프록시였고 TransactionInterceptor가 붙어 있었다. 양쪽 메서드 안에서 트랜잭션 활성 값은 true였다. 바깥 범위는 isNewTransaction=true, 안쪽 범위는 false였으며, DataSourceUtils로 얻은 JDBC Connection은 같은 객체였다. 시작도 각 비교에서 한 번이었다. 이 관찰은 두 프록시가 같은 물리 트랜잭션에 참여했다는 근거다. JdbcTemplate이 스레드에 연결된 JDBC 연결을 사용하는 방식은 DataSourceTransactionManager API (새 탭에서 열림)에 설명돼 있다.
기본 관리자는 참여한 범위의 롤백 요청을 공유 상태에 반영한다. 바깥 메서드는 예외를 잡고 정상 반환 경로로 갔지만, 프록시가 마지막 완료 처리를 할 때는 커밋할 수 없는 상태였다. 그래서 롤백한 뒤 호출자에게 UnexpectedRollbackException을 전달했다. 이 관리자의 globalRollbackOnParticipationFailure=true, failEarlyOnGlobalRollbackOnly=false도 실제 출력으로 확인했다. 참여 실패와 예외 발생 시점의 기본값 (새 탭에서 열림)
자기 코드에서도 catch 뒤 값이 true라면 마지막 예외만 보지 말고 그보다 먼저 안쪽 경계를 빠져나온 예외를 찾는다. false라면 이 재현과 조건이 다르다. 적용된 롤백 규칙, 실제 트랜잭션 매니저·DataSource, 상태 조회 이후의 다른 호출을 확인한다. 프록시 여부나 활성 값 하나만으로 같은 트랜잭션을 공유한다고 단정할 수는 없다.
세 쓰기를 함께 취소해야 하는지 먼저 정한다
outer-before, inner, outer-after가 하나의 업무를 이루며 일부만 남으면 안 된다면 전체 롤백이 맞다. 이 경우에는 catch에서 원래 IllegalStateException을 throw ex로 다시 던져 호출자에게 실패를 전달한다. 업무 예외로 바꿔야 한다면 바깥 트랜잭션 경계의 롤백 대상이어야 한다. 기본 규칙의 RuntimeException 계열을 쓰거나, 체크 업무 예외 BusinessChecked에는 바깥 메서드에 @Transactional(rollbackFor = BusinessChecked.class)를 지정한다. 프로젝트의 전역 롤백 규칙과 noRollbackFor도 함께 확인한다. 예외별 롤백 규칙 (새 탭에서 열림)
같은 Spring 6.2.19·H2 환경에서 기본 설정의 DataSourceTransactionManager로 추가 비교했다. 이미 rollback-only가 된 뒤 체크 업무 예외로 감싸 던지기만 하면, 바깥 프록시가 커밋 경로로 들어가 호출자는 업무 예외 대신 UnexpectedRollbackException을 받았다. 원래 예외를 다시 던진 경우, 런타임 업무 예외로 바꾼 경우, 체크 업무 예외에 바깥 rollbackFor를 지정한 경우에는 각각 의도한 예외가 전달됐다. 네 경우 모두 최종 행 수는 0이었다. 예외 변환은 커밋 가능 상태를 복원하지 않는다. 사용자에게 보여 줄 실패 응답은 트랜잭션 경계 밖에서 만든다.
반대로 안쪽 실패가 있어도 바깥 결과를 저장해야 한다면, 무엇이 남아도 되는지부터 정해야 한다. 예상 가능한 업무 거절을 쓰기 전에 판정해 결과값으로 반환할 수 있는지도 검토한다. 이미 일부 데이터를 바꾼 뒤 발생한 예외를 값으로 바꾸기만 하면, 그 변경까지 커밋하는 다른 문제가 생길 수 있다. noRollbackFor 같은 예외 규칙 변경 역시 해당 실패 뒤의 데이터를 커밋해도 된다는 업무 계약을 먼저 요구한다.
독립 커밋이 정말 필요한 작업에는 별도 빈의 REQUIRES_NEW가 선택지가 된다. 바깥 트랜잭션을 보류하고 새 물리 트랜잭션을 쓰므로, 안쪽 성공 뒤 바깥이 실패해도 안쪽 결과는 남을 수 있다. 이 독립성이 원하는 의미인지 먼저 판단한다. REQUIRES_NEW의 완료 경계 (새 탭에서 열림)
또한 바깥 연결을 보유한 동안 안쪽이 추가 연결을 얻어야 한다. 동시 호출과 풀 크기에 따라 연결 고갈이나 대기가 생길 수 있다는 것이 같은 공식 문서의 설명이다. 여기서는 REQUIRES_NEW 전환이나 연결 풀 부하를 실행하지 않았다. 직접 확인한 범위는 단일 스레드·단일 DataSource의 REQUIRED 두 범위, 기본 JDBC 트랜잭션 매니저와 H2의 완료 결과다. JPA·JTA·다른 DB의 실패 후 상태나 커스텀 관리자의 동작까지 이 결과로 보장하지 않는다.