> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# Spring UnexpectedRollbackException: catch했는데도 롤백되는 이유
- URL: https://mlog.me/spring-unexpected-rollback-exception-catch-rollback-only/
- Published: 2026-09-26T07:46:24.000Z
- Updated: 2026-09-26T07:46:24.000Z
- Description: Spring에서 예외를 catch해도 UnexpectedRollbackException이 나는 이유를 실제 JDBC 실험으로 확인합니다. REQUIRED·자기 호출·REQUIRES_NEW의 커밋 결과와 안전한 수정 기준을 비교합니다.
- Author: mLog
- Tags: 개발, Spring Boot, Java, 데이터베이스, 트랜잭션

`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 공식 문서](https://docs.spring.io/spring-framework/reference/core/aop-api/prog.html?ref=mlog.me)

아래 결과는 **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이다.

```java
// 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 전파 공식 문서](https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/tx-propagation.html?ref=mlog.me)

순서를 짧게 쓰면 다음과 같다.

1. 바깥 프록시가 트랜잭션을 시작한다.
2. 안쪽 프록시의 REQUIRED가 합류한다.
3. 안쪽 메서드가 실험용 런타임 예외를 던진다.
4. 안쪽 인터셉터 처리 후 바깥 catch에 도달한다.
5. 바깥 메서드는 정상 반환하지만, 프록시의 완료 단계에서는 커밋되지 않는다.

따라서 서비스 메서드 안 마지막 로그만 보고 저장 성공을 응답하면 안 된다. 해당 트랜잭션을 마무리하는 프록시 호출까지 성공했는지 확인해야 한다.

## 2\. 자기 호출: 오류가 사라져도 해결된 것이 아니다

두 번째 실험은 안쪽 실패 메서드를 `Outer` 안에 두고 직접 호출했다. 그 메서드에는 `REQUIRES_NEW`까지 붙였다.

```java
@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 공식 문서](https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html?ref=mlog.me)

중요한 제한이 있다. 실험은 INSERT 성공 후 자바에서 의도적으로 던진 `PlannedFailure`를 사용했다. DB 제약 위반이나 Hibernate 예외 뒤에도 같은 방식으로 계속 실행해도 된다는 뜻이 아니다. DB·영속성 계층이 이미 실패 상태가 됐을 수 있다. ‘catch했더니 저장됐다’를 안전한 복구 전략으로 삼으면 안 된다.

## 3\. REQUIRES\_NEW: 독립적으로 저장해도 되는 작업에만 쓴다

세 번째 실험에서는 실패 메서드를 다시 별도의 `Inner` 프록시에 두고 `REQUIRES_NEW`로 호출했다. 바깥 메서드는 실험용 예외만 잡았다.

결과는 `outer` 한 행만 저장되고 `inner`는 저장되지 않는 것이었다. 동일한 INSERT와 예외라도 경계가 달라지면 최종 데이터가 달라진다.

네 번째 실험은 반대 방향의 위험을 확인했다. 독립 감사 메서드가 `audit_log`에 `attempt`를 저장한 뒤, 바깥 메서드가 실패하도록 했다. 업무 데이터는 전부 롤백됐지만 감사 행은 남았다.

이 구조는 ‘실패 시도 기록’을 남기는 목적에는 맞을 수 있다. 하지만 감사 행이 주문 완료·결제 성공·좌석 확정으로 해석된다면 잘못된 기록이다. 업무 트랜잭션이 아직 커밋되지 않았다는 사실을 모델에 반영해야 한다.

또한 바깥 트랜잭션이 잡은 연결은 유지되고 안쪽 트랜잭션에는 별도 연결이 필요할 수 있다. 동시 요청이 많으면 풀 고갈이나 대기 문제가 생길 수 있으므로 독립 트랜잭션을 단순 오류 제거 옵션으로 사용하지 않는다. 이번 실험은 풀을 사용하지 않았고 이 부하 위험을 측정하지 않았다. [Spring REQUIRES\_NEW 주의사항](https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html?ref=mlog.me)

읽기·쓰기 DB 분리까지 구성했다면 [기존 라우팅 가이드](https://mlog.me/spring-boot-postgresql-read-write-splitting/)와 함께 확인하자. 트랜잭션을 나누는 것과 올바른 DB를 선택하는 것은 서로 연결되지만 동일한 검증 항목은 아니다.

## 4\. 원인 확인은 마지막 예외보다 첫 실패부터

실제 서비스에서는 다음 순서로 범위를 좁히는 편이 좋다.

- 가장 먼저 발생한 예외와 `Caused by`를 찾는다. 마지막 `UnexpectedRollbackException`만 저장하면 최초 실패가 묻힐 수 있다.
- 실패한 메서드가 다른 Spring 빈의 프록시를 통해 호출됐는지 확인한다. `new`로 만든 객체와 같은 객체 내부 호출도 구분한다.
- 바깥·안쪽의 propagation, rollback 규칙과 사용 중인 트랜잭션 매니저를 함께 확인한다.
- 실제 남은 행을 확인한다. 테스트 프레임워크가 테스트 전체를 감싼 트랜잭션과 애플리케이션의 경계를 혼동하지 않는다.
- 감사·알림·재시도 기록이 원래 업무와 함께 롤백돼야 하는지 결정한다.

Spring의 기본 롤백 규칙과 프로젝트의 규칙은 다를 수 있다. 이번 실험의 `PlannedFailure`는 RuntimeException이며 별도 rollback 규칙은 주지 않았다. 프로젝트가 `rollbackFor`, `noRollbackFor`, 전역 롤백 기본값을 설정했다면 그 조건으로 다시 시험해야 한다. [Spring 롤백 규칙](https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/rolling-back.html?ref=mlog.me)

JPA를 쓴다면 예외가 저장 메서드 호출 시점이 아니라 flush·commit에서 드러날 수 있다는 점도 검사 대상이다. 이번 JdbcTemplate 결과만으로 JPA/MyBatis 혼합 서비스까지 검증했다고 판단하지 않는다. 배치 저장과 SQL 실행 확인은 [JPA saveAll·MyBatis 비교 글](https://mlog.me/jpa-saveall-postgresql-batch-insert-mybatis-performance/)에서 이어서 볼 수 있다.

운영 로그에는 요청 본문, SQL 바인딩 값, 개인정보, 비밀번호를 무조건 덤프하지 않는다. 요청 식별자와 예외 타입, 실패 단계 등 최소 정보부터 남긴다.

## 5\. 해결 방향은 세 가지로 나눈다

### 업무 전체가 함께 성공해야 한다

실패를 호출자에게 전달하고 전체 롤백을 유지한다. 예외를 처리하더라도 성공 응답으로 바꾸지 않는다. 같은 실패 상태에서 SQL을 계속 밀어 넣는 대신 새 요청·새 트랜잭션으로 재시도 가능한지 검토한다. 결제나 예약처럼 외부 부작용이 있으면 중복 처리 방지도 필요하다.

### 별도로 남아야 하는 시도·실패 기록이다

독립된 감사 서비스와 트랜잭션을 검토한다. 안쪽 실패를 바깥에서 처리할지, 감사 실패도 전체 실패로 볼지 명시한다. 감사 호출이 성공한 뒤 바깥 업무가 실패하는 테스트를 꼭 넣는다. 이것이 이번 네 번째 실험의 목적이다.

### 순차적인 두 작업의 경계를 코드로 드러내고 싶다

`TransactionTemplate` 같은 명시적 경계도 선택지다. 단, 바깥 트랜잭션이 있는데 기본 전파를 그대로 쓰면 자동으로 독립 트랜잭션이 된다고 생각하면 안 된다. 완료 후 다음 작업을 시작하는 구조인지, 기존 트랜잭션에 합류하는 구조인지 먼저 정한다. 이 대안 코드는 이번 실험에서 실행하지 않았다. [Spring 프로그램 방식 트랜잭션](https://docs.spring.io/spring-framework/reference/data-access/transaction/programmatic.html?ref=mlog.me)

`noRollbackFor`나 트랜잭션 매니저 옵션을 전역으로 바꿔 오류만 숨기는 방식은 첫 조치로 삼지 않는다. 이미 실패한 DB 작업을 복구하는 만능 옵션이 아니며, 다른 서비스의 커밋 조건까지 바꿀 수 있다.

## 다시 확인할 최소 테스트

정상 경로만 테스트하면 자기 호출 사례를 놓치기 쉽다. 적어도 아래 네 결과를 서비스 요구사항으로 적어 두자.

1. 핵심 업무 실패 시 관련 행이 모두 사라지는가?
2. 내부 메서드를 다른 빈으로 옮겨도 커밋 결과가 의도대로인가?
3. 독립 감사 기록 실패가 본 업무에 미치는 영향은 무엇인가?
4. 감사 성공 후 본 업무가 실패하면 어떤 기록이 남는가?

테스트 결과는 ‘예외 없음’으로 끝내지 않고, 예외 타입과 실제 저장 결과를 함께 확인한다. 예약 흐름이라면 [Redis 락 TTL과 DB 중복 방지](https://mlog.me/redis-lock-ttl-expiry-safe-release-seat-reservation/)에서 설명한 DB 최종 제약과도 연결된다. 락 획득 성공과 DB 커밋 성공을 같은 사건으로 취급하면 안 된다.

## 정리

`UnexpectedRollbackException`을 볼 때 가장 먼저 물을 것은 “왜 catch가 안 먹지?”가 아니라 “누가 먼저 실패를 보고, 어느 트랜잭션이 완료되려 했나?”다.

이번 실험에서는 catch한 REQUIRED 실패가 전체 롤백으로, 자기 호출이 오히려 두 행 커밋으로, 별도 프록시의 REQUIRES\_NEW가 독립된 결과로 이어졌다. 애너테이션을 바꾸기 전에 실패 후 남아야 할 데이터를 정하고, 그 경계를 실제 INSERT와 최종 조회로 확인하자.

[Spring 트랜잭션 실험 전체 코드Java 소스·실행 스크립트·README·8개 검증 결과. Java 17 및 의존성 다운로드가 필요합니다.spring-rollback-repro-2026-09-25.zip4 KBdownload-circle](https://mlog.me/content/files/2026/09/spring-rollback-repro-2026-09-25.zip "Download")