Spring UnexpectedRollbackException: catch했는데도 롤백되는 이유
try/catch로 예외를 처리했고 서비스 메서드도 끝났는데, 호출자는 UnexpectedRollbackException을 받는다. 반대로 메서드를 같은 클래스 안으로 옮겼더니 예외는 사라졌지만 실패한 작업의 데이터까지 저장될 수도 있다.
먼저 구분할 것은 두 가지다. 자바 예외를 처리한 위치와 DB 트랜잭션이 끝나는 위치는 같지 않다. 이미 롤백 대상으로 표시된 트랜잭션을 catch가 정상 상태로 되돌려 주는 것도 아니다.
이 글은 2026-09-25에 Java 17.0.20, Spring Framework 6.2.19, H2 2.3.232로 실행한 격리 실험을 바탕으로 한다. 실제 Spring 트랜잭션 인터셉터와 JDBC INSERT를 사용했다. Spring Boot 자동 설정, 운영 PostgreSQL, JPA·MyBatis 통합, 커넥션 풀 부하까지 시험한 결과는 아니다.
먼저 결론: 예외를 없애기 전에 함께 실패해야 할 일을 정한다
한 업무로 묶인 두 저장이 함께 성공해야 한다면, 중간 실패 시 전체 롤백하는 것이 맞다. 이때 UnexpectedRollbackException을 지우려고 안쪽 트랜잭션을 무조건 분리하면 데이터의 원자성이 달라진다.
반대로 업무 실패와 관계없이 시도 기록을 남겨야 한다면, 감사 기록을 독립된 트랜잭션으로 설계할 수 있다. 단, 그 기록은 ‘업무 성공’이 아니라 ‘시도 또는 실패가 있었다’는 의미여야 한다.
두 선택의 기준은 오류 메시지의 유무가 아니라, 실패 후 어떤 행이 남아야 하는가다.
이번 실험은 무엇을 비교했나
실험에는 work_log와 audit_log라는 메모리 DB 테이블만 사용했다. 업무 테이블에는 outer, inner 표시값을 넣고, 감사 테이블에는 attempt를 넣었다. 날짜·예약·개인정보 같은 운영 데이터는 사용하지 않았다.
Outer와 Inner는 ProxyFactory로 만든 실제 Spring 클래스 프록시다. TransactionInterceptor가 애너테이션을 읽고 DataSourceTransactionManager가 JDBC 트랜잭션을 관리한다. 프록시인지 검사한 뒤 예외 종류와 최종 행 수를 확인했다. Spring은 IoC 컨테이너 없이도 ProxyFactory로 AOP 프록시를 만드는 방식을 제공한다. 다만 일반 애플리케이션에서는 컨테이너 통합 구성을 사용한다. Spring ProxyFactory 공식 문서
아래 결과는 4가지 업무 흐름을 포함한 8개 확인 항목에서 얻었다. 8종류의 운영 환경을 시험했다는 의미는 아니다.
| 호출 형태 | 호출자가 받은 결과 | 최종 저장 결과 |
|---|---|---|
| 다른 프록시의 REQUIRED 실패를 바깥에서 catch | UnexpectedRollbackException | 업무 0행 |
| 같은 객체의 REQUIRES_NEW 메서드를 자기 호출하고 catch | 정상 반환 | 업무 2행 |
| 다른 프록시의 REQUIRES_NEW 실패를 바깥에서 catch | 정상 반환 | outer 1행만 |
| 독립 감사 기록 성공 후 바깥 업무가 실패 | 원래 실험용 예외 | 업무 0행·감사 1행 |
표의 정상 반환이 곧 업무 성공은 아니다. 두 번째 행처럼 트랜잭션 분리가 적용되지 않았는데도 예외가 밖으로 나오지 않을 수 있다.
1. REQUIRED: catch 전에 이미 롤백 대상으로 표시된다
첫 번째 흐름의 핵심 코드는 다음과 같다. inner는 같은 객체가 아니라 별도의 트랜잭션 프록시다. 코드는 실험용 메서드 발췌이며 jdbc는 동일한 DataSource의 JdbcTemplate이다.
// Outer 프록시를 통해 호출
@Transactional
public void requiredCaught() {
jdbc.update("insert into work_log values ('outer')");
try {
inner.requiredFailure();
} catch (PlannedFailure expected) {
System.out.println(
TransactionAspectSupport.currentTransactionStatus()
.isRollbackOnly());
}
}
// 별도 Inner 프록시
@Transactional
public void requiredFailure() {
jdbc.update("insert into work_log values ('inner')");
throw new PlannedFailure(); // RuntimeException의 실험용 하위 타입
}
실제 출력에서 catch 블록의 isRollbackOnly()는 true였다. 바깥 호출은 UnexpectedRollbackException으로 끝났고 work_log에는 한 행도 남지 않았다.
여기서는 바깥과 안쪽 REQUIRED가 같은 물리 트랜잭션을 사용한다. 안쪽 메서드에서 나온 런타임 예외를 먼저 본 것은 안쪽 트랜잭션 인터셉터다. 그 뒤 바깥 코드가 예외를 잡더라도, 커밋 가능한 트랜잭션으로 되돌아간 것은 아니다. Spring은 바깥 호출자가 커밋됐다고 잘못 이해하지 않도록 예상치 못한 롤백을 예외로 알린다. Spring 전파 공식 문서
순서를 짧게 쓰면 다음과 같다.
- 바깥 프록시가 트랜잭션을 시작한다.
- 안쪽 프록시의 REQUIRED가 합류한다.
- 안쪽 메서드가 실험용 런타임 예외를 던진다.
- 안쪽 인터셉터 처리 후 바깥 catch에 도달한다.
- 바깥 메서드는 정상 반환하지만, 프록시의 완료 단계에서는 커밋되지 않는다.
따라서 서비스 메서드 안 마지막 로그만 보고 저장 성공을 응답하면 안 된다. 해당 트랜잭션을 마무리하는 프록시 호출까지 성공했는지 확인해야 한다.
2. 자기 호출: 오류가 사라져도 해결된 것이 아니다
두 번째 실험은 안쪽 실패 메서드를 Outer 안에 두고 직접 호출했다. 그 메서드에는 REQUIRES_NEW까지 붙였다.
@Transactional
public void selfCaught() {
jdbc.update("insert into work_log values ('outer')");
try {
selfFailure();
} catch (PlannedFailure expected) {
// 실험상 예외를 잡고 정상 반환
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void selfFailure() {
jdbc.update("insert into work_log values ('inner')");
throw new PlannedFailure();
}
실제로는 outer와 inner 두 행이 모두 저장됐다. 같은 객체 안에서 호출했기 때문에 안쪽 메서드의 트랜잭션 인터셉터를 통과하지 않았고, REQUIRES_NEW 선언이 이 호출에 적용되지 않았다. 바깥 프록시로 나갈 때도 예외가 없었다.
이는 Spring 기본 프록시 모드의 자기 호출 특성과 맞는다. AspectJ 방식은 적용 구조가 다르므로 같은 결론을 그대로 옮기지 않는다. Spring @Transactional 공식 문서
중요한 제한이 있다. 실험은 INSERT 성공 후 자바에서 의도적으로 던진 PlannedFailure를 사용했다. DB 제약 위반이나 Hibernate 예외 뒤에도 같은 방식으로 계속 실행해도 된다는 뜻이 아니다. DB·영속성 계층이 이미 실패 상태가 됐을 수 있다. ‘catch했더니 저장됐다’를 안전한 복구 전략으로 삼으면 안 된다.
3. REQUIRES_NEW: 독립적으로 저장해도 되는 작업에만 쓴다
세 번째 실험에서는 실패 메서드를 다시 별도의 Inner 프록시에 두고 REQUIRES_NEW로 호출했다. 바깥 메서드는 실험용 예외만 잡았다.
결과는 outer 한 행만 저장되고 inner는 저장되지 않는 것이었다. 동일한 INSERT와 예외라도 경계가 달라지면 최종 데이터가 달라진다.
네 번째 실험은 반대 방향의 위험을 확인했다. 독립 감사 메서드가 audit_log에 attempt를 저장한 뒤, 바깥 메서드가 실패하도록 했다. 업무 데이터는 전부 롤백됐지만 감사 행은 남았다.
이 구조는 ‘실패 시도 기록’을 남기는 목적에는 맞을 수 있다. 하지만 감사 행이 주문 완료·결제 성공·좌석 확정으로 해석된다면 잘못된 기록이다. 업무 트랜잭션이 아직 커밋되지 않았다는 사실을 모델에 반영해야 한다.
또한 바깥 트랜잭션이 잡은 연결은 유지되고 안쪽 트랜잭션에는 별도 연결이 필요할 수 있다. 동시 요청이 많으면 풀 고갈이나 대기 문제가 생길 수 있으므로 독립 트랜잭션을 단순 오류 제거 옵션으로 사용하지 않는다. 이번 실험은 풀을 사용하지 않았고 이 부하 위험을 측정하지 않았다. Spring REQUIRES_NEW 주의사항
읽기·쓰기 DB 분리까지 구성했다면 기존 라우팅 가이드와 함께 확인하자. 트랜잭션을 나누는 것과 올바른 DB를 선택하는 것은 서로 연결되지만 동일한 검증 항목은 아니다.
4. 원인 확인은 마지막 예외보다 첫 실패부터
실제 서비스에서는 다음 순서로 범위를 좁히는 편이 좋다.
- 가장 먼저 발생한 예외와
Caused by를 찾는다. 마지막UnexpectedRollbackException만 저장하면 최초 실패가 묻힐 수 있다. - 실패한 메서드가 다른 Spring 빈의 프록시를 통해 호출됐는지 확인한다.
new로 만든 객체와 같은 객체 내부 호출도 구분한다. - 바깥·안쪽의 propagation, rollback 규칙과 사용 중인 트랜잭션 매니저를 함께 확인한다.
- 실제 남은 행을 확인한다. 테스트 프레임워크가 테스트 전체를 감싼 트랜잭션과 애플리케이션의 경계를 혼동하지 않는다.
- 감사·알림·재시도 기록이 원래 업무와 함께 롤백돼야 하는지 결정한다.
Spring의 기본 롤백 규칙과 프로젝트의 규칙은 다를 수 있다. 이번 실험의 PlannedFailure는 RuntimeException이며 별도 rollback 규칙은 주지 않았다. 프로젝트가 rollbackFor, noRollbackFor, 전역 롤백 기본값을 설정했다면 그 조건으로 다시 시험해야 한다. Spring 롤백 규칙
JPA를 쓴다면 예외가 저장 메서드 호출 시점이 아니라 flush·commit에서 드러날 수 있다는 점도 검사 대상이다. 이번 JdbcTemplate 결과만으로 JPA/MyBatis 혼합 서비스까지 검증했다고 판단하지 않는다. 배치 저장과 SQL 실행 확인은 JPA saveAll·MyBatis 비교 글에서 이어서 볼 수 있다.
운영 로그에는 요청 본문, SQL 바인딩 값, 개인정보, 비밀번호를 무조건 덤프하지 않는다. 요청 식별자와 예외 타입, 실패 단계 등 최소 정보부터 남긴다.
5. 해결 방향은 세 가지로 나눈다
업무 전체가 함께 성공해야 한다
실패를 호출자에게 전달하고 전체 롤백을 유지한다. 예외를 처리하더라도 성공 응답으로 바꾸지 않는다. 같은 실패 상태에서 SQL을 계속 밀어 넣는 대신 새 요청·새 트랜잭션으로 재시도 가능한지 검토한다. 결제나 예약처럼 외부 부작용이 있으면 중복 처리 방지도 필요하다.
별도로 남아야 하는 시도·실패 기록이다
독립된 감사 서비스와 트랜잭션을 검토한다. 안쪽 실패를 바깥에서 처리할지, 감사 실패도 전체 실패로 볼지 명시한다. 감사 호출이 성공한 뒤 바깥 업무가 실패하는 테스트를 꼭 넣는다. 이것이 이번 네 번째 실험의 목적이다.
순차적인 두 작업의 경계를 코드로 드러내고 싶다
TransactionTemplate 같은 명시적 경계도 선택지다. 단, 바깥 트랜잭션이 있는데 기본 전파를 그대로 쓰면 자동으로 독립 트랜잭션이 된다고 생각하면 안 된다. 완료 후 다음 작업을 시작하는 구조인지, 기존 트랜잭션에 합류하는 구조인지 먼저 정한다. 이 대안 코드는 이번 실험에서 실행하지 않았다. Spring 프로그램 방식 트랜잭션
noRollbackFor나 트랜잭션 매니저 옵션을 전역으로 바꿔 오류만 숨기는 방식은 첫 조치로 삼지 않는다. 이미 실패한 DB 작업을 복구하는 만능 옵션이 아니며, 다른 서비스의 커밋 조건까지 바꿀 수 있다.
다시 확인할 최소 테스트
정상 경로만 테스트하면 자기 호출 사례를 놓치기 쉽다. 적어도 아래 네 결과를 서비스 요구사항으로 적어 두자.
- 핵심 업무 실패 시 관련 행이 모두 사라지는가?
- 내부 메서드를 다른 빈으로 옮겨도 커밋 결과가 의도대로인가?
- 독립 감사 기록 실패가 본 업무에 미치는 영향은 무엇인가?
- 감사 성공 후 본 업무가 실패하면 어떤 기록이 남는가?
테스트 결과는 ‘예외 없음’으로 끝내지 않고, 예외 타입과 실제 저장 결과를 함께 확인한다. 예약 흐름이라면 Redis 락 TTL과 DB 중복 방지에서 설명한 DB 최종 제약과도 연결된다. 락 획득 성공과 DB 커밋 성공을 같은 사건으로 취급하면 안 된다.
정리
UnexpectedRollbackException을 볼 때 가장 먼저 물을 것은 “왜 catch가 안 먹지?”가 아니라 “누가 먼저 실패를 보고, 어느 트랜잭션이 완료되려 했나?”다.
이번 실험에서는 catch한 REQUIRED 실패가 전체 롤백으로, 자기 호출이 오히려 두 행 커밋으로, 별도 프록시의 REQUIRES_NEW가 독립된 결과로 이어졌다. 애너테이션을 바꾸기 전에 실패 후 남아야 할 데이터를 정하고, 그 경계를 실제 INSERT와 최종 조회로 확인하자.