본문 바로가기
빌드노트 검색

빌드노트

예외가 났는데 INSERT가 남았다면: 체크 예외의 롤백 규칙

트랜잭션이 활성화돼 있어도 기본 체크 예외는 커밋될 수 있다. Spring 6.2.19와 H2에서 예외별 종료 후 행 수를 비교하고, rollbackFor·ALL_EXCEPTIONS·예외를 잡아 정상 반환하는 경우를 구분한다.

@Transactional 서비스가 예외를 던졌는데 INSERT한 행이 남았다. 메서드 안에서 트랜잭션 활성 상태도 true였다면, 다음으로 볼 것은 트랜잭션이 어떤 예외를 롤백 대상으로 삼는가다. Spring의 기본 규칙에서는 체크 예외가 경계를 빠져나가도 커밋할 수 있다. 예외 발생과 롤백은 같은 뜻이 아니다.

그 실패가 앞선 저장까지 취소해야 하는 상황이라면 rollbackFor에 해당 예외 타입을 지정할 수 있다. 다만 프로젝트가 이미 전역 롤백 규칙을 바꿨는지, 예외를 서비스 안에서 잡고 정상 반환했는지를 함께 확인해야 한다.

트랜잭션이 있는데도 한 행이 남았다

운영 장애 기록이 아니라 별도 실험으로 확인했다. Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.19, H2 2.3.232에서 DataSourceTransactionManager와 Spring JDBC를 사용했다. 외부 DB나 기존 앱 설정은 읽지 않았다.

저장 서비스는 Spring 빈으로 등록하고, 다른 빈에 주입해 호출했다. 클래스 기반 프록시가 만들어진 것을 확인했으며 호출자에는 바깥 트랜잭션을 두지 않았다. 각 비교에서 테이블을 비운 뒤 한 행을 넣고, 정상 반환·런타임 예외·체크 예외·체크 예외에 대한 명시적 롤백을 비교했다.

체크 예외는 RuntimeException 계열이 아닌 Exception의 하위 클래스로 만들었다. Java의 예외 분류는 언어 명세의 예외 종류 (새 탭에서 열림)에서 확인할 수 있다. 실험 메서드에서 상태 기록을 빼고 쓰기와 예외만 남기면 다음과 같다. jdbc는 트랜잭션 매니저와 같은 DataSource로 만든 JdbcTemplate이다. 애너테이션은 org.springframework.transaction.annotation.Transactional을 사용했다.

public static class BusinessFailure extends Exception {
    public BusinessFailure() { super("planned checked failure"); }
}

@Transactional
public void checkedFailure(String label) throws BusinessFailure {
    jdbc.update("INSERT INTO tx_probe(label) VALUES (?)", label);
    throw new BusinessFailure();
}

비교용 테이블은 CREATE TABLE tx_probe(label VARCHAR(120) NOT NULL)로 만들었다. 런타임 예외 조건에서는 같은 INSERT 뒤에 IllegalStateException을 던졌다. 명시적 롤백 조건에서는 같은 체크 예외를 던지되 애너테이션에 rollbackFor = BusinessFailure.class를 지정했다.

아래는 실제 출력에서 비교에 필요한 행을 발췌한 것이다. DEFAULT는 @EnableTransactionManagement의 rollbackOn을 생략하고 별도의 기본 롤백 규칙도 추가하지 않은 구성이다.

AFTER case=DEFAULT/runtimeFailure rows=0 newSession=true session=9 active=false
INSIDE case=DEFAULT/checkedFailure active=true autoCommit=false rows=1 session=11
AFTER case=DEFAULT/checkedFailure rows=1 newSession=true session=12 active=false
AFTER case=DEFAULT/checkedRollback rows=0 newSession=true session=15 active=false

세 조건 모두 메서드 안에서는 active=true, INSERT에 사용한 연결에서는 autoCommit=false였다. 예외 전에 같은 트랜잭션에서 읽은 행 수도 모두 1이었다. 차이는 서비스 호출이 예외로 끝난 뒤 나타났다. 런타임 예외와 명시적 롤백은 0행, 기본 체크 예외는 1행이었다. 정상 반환 조건에서도 1행이 남았다.

호출자가 예외를 잡은 시점에는 프록시의 트랜잭션 처리가 끝나 있었다. 그 뒤 새 JDBC 연결을 열어 SELECT COUNT(*) FROM tx_probe를 실행했다. H2의 SESSION_ID()로 쓰기 연결과 조회 연결이 다름도 확인했다. 메서드 안에서 읽은 1행을 커밋 증거로 삼지 않은 이유다.

Spring의 롤백 문서 (새 탭에서 열림)는 기본 설정에서 RuntimeException과 Error가 롤백 대상이며, 체크 예외에는 별도의 규칙을 지정할 수 있다고 설명한다. 이번 관찰에서 체크 예외 뒤 남은 행은 트랜잭션이 없어서 자동 커밋된 결과가 아니었다. 활성 트랜잭션이 기본 규칙에 따라 커밋된 결과였다. Error 자체는 이 실험에서 던지지 않았다.

그 실패가 저장 취소를 뜻한다면 타입으로 지정한다

BusinessFailure가 “이번 저장을 완료할 수 없다”는 계약이라면 해당 메서드의 애너테이션을 다음처럼 바꾼다. 메서드의 throws BusinessFailure 선언만으로는 롤백 규칙이 추가되지 않는다.

@Transactional(rollbackFor = BusinessFailure.class)

실험의 checkedRollback은 이 설정으로 같은 체크 예외를 던졌고, 호출이 끝난 뒤 0행이었다. rollbackFor는 지정 타입과 그 하위 타입에 적용된다. 문자열 패턴인 rollbackForClassName과는 구분한다. 공식 롤백 규칙 설명 (새 탭에서 열림)에는 이름 일부가 비슷한 다른 예외까지 패턴에 걸릴 수 있다는 주의도 있다.

모든 체크 예외가 저장 취소를 뜻하는 것은 아니다. 예외를 호출자에게 알리더라도 이미 한 변경은 유지해야 한다는 업무 계약이 있다면 그 조건을 따로 정해야 한다. 반대로 해당 작업의 모든 예외에서 변경을 취소해야 한다면 rollbackFor = Exception.class처럼 넓은 규칙을 선택할 수 있다. 컴파일러가 예외 처리를 요구하는지와, 업무 실패 때 어떤 데이터를 남길지는 별개의 결정이다.

Spring 버전만 보지 말고 전역 규칙도 본다

Spring 6.2부터는 애너테이션 기반 트랜잭션의 기본 롤백 동작을 전역 설정으로 바꿀 수 있다. EnableTransactionManagement와 RollbackOn은 모두 org.springframework.transaction.annotation 패키지에 있다. 기존 트랜잭션 설정 클래스에서 다음 옵션을 지정한다.

@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)

이 구성을 켠 별도 컨텍스트에서 같은 서비스 코드를 다시 실행했다. rollbackFor가 없는 checkedFailure도 이번에는 0행이었다. 반면 @Transactional(noRollbackFor = BusinessFailure.class)를 붙인 checkedNoRollback은 1행이었다. 아래도 실제 출력의 발췌다.

AFTER case=ALL_EXCEPTIONS/checkedFailure rows=0 newSession=true session=12 active=false
AFTER case=ALL_EXCEPTIONS/checkedNoRollback rows=1 newSession=true session=18 active=false

전역 규칙으로 체크 예외의 기본 처리가 바뀌고, 메서드에 명시한 예외별 규칙은 그 기본 처리보다 우선한 것이다. Spring의 애너테이션 설정 문서 (새 탭에서 열림)와 6.2.19의 rollbackOn API (새 탭에서 열림)가 이 관계를 설명한다. 이 API의 기본값은 RUNTIME_EXCEPTIONS다. 6.2 이상이라는 이유만으로 모든 체크 예외가 자동 롤백된다고 판단하면 안 된다.

자기 프로젝트에서는 적용 중인 @EnableTransactionManagement의 rollbackOn, 메서드·클래스 또는 합성 애너테이션의 rollbackFor·noRollbackFor를 확인한다. 커스텀 AnnotationTransactionAttributeSource를 등록했다면 addDefaultRollbackRule 같은 기본 규칙 추가도 살핀다. 실험은 이 세 가지 중 전역 옵션과 메서드별 규칙을 비교한 것이며, 독자의 실제 프로젝트 설정은 확인하지 않았다.

한 메서드의 실패 계약을 고치려는 상황에서 전역 옵션부터 바꾸면 다른 서비스의 체크 예외 처리도 달라질 수 있다. 해당 예외만 취소 대상인지, 프로젝트 전체가 모든 예외에서 취소한다는 정책인지에 따라 적용 범위를 고른다.

false를 반환한 경우는 예외가 빠져나온 경우와 다르다

같은 실험에서 INSERT 뒤 직접 던진 IllegalStateException을 서비스 메서드 안에서 잡고 false를 반환하게 했다. 기본 설정과 ALL_EXCEPTIONS 모두 호출자에게 전달된 예외는 없었으며, 종료 후 1행이 남았다. false라는 일반 반환값이 롤백을 요청하지는 않았다.

여기서는 JDBC 쓰기가 성공한 뒤 Java 코드에서 만든 예외를 같은 메서드 안에서 잡았다. 이미 다른 프록시의 내부 트랜잭션 경계에서 예외가 나서 공유 트랜잭션에 rollback-only 표시가 붙은 경우에는, 바깥에서 잡았다고 커밋할 수 있는 것이 아니다. 이는 Spring의 REQUIRED 전파 설명 (새 탭에서 열림)에 있는 별도 경계다. 이번에는 그 중첩 호출을 실행하지 않았다.

따라서 실패 응답으로 바꾸려면 예외가 트랜잭션 서비스의 경계를 빠져나온 뒤 호출자에서 변환하는 방식을 검토한다. 서비스가 실패값을 정상 반환해야 하는 계약이라면 명시적으로 롤백을 표시하는 설계가 필요한지도 정해야 한다. rollbackFor를 붙이는 것만으로 메서드 안에서 삼킨 예외까지 감지해 주지는 않는다.

자신의 코드에서 확인할 때는 쓰기 직전에 다음 두 줄을 넣을 수 있다. org.springframework.transaction.support.TransactionSynchronizationManager를 import한다. 이 API (새 탭에서 열림)는 현재 스레드의 실제 트랜잭션 활성 여부를 알려 준다.

System.out.printf("active=%s%n",
        TransactionSynchronizationManager.isActualTransactionActive());

테스트 전용 DB와 합성 입력을 사용하고, 트랜잭션이 없는 호출자에서 주입받은 서비스 빈을 호출한다. 예외가 경계를 나온 뒤 새 연결이나 새 트랜잭션에서 해당 데이터가 남았는지 읽는다. 테스트 자체에 트랜잭션이 붙어 있으면 서비스가 거기에 참여하므로 서비스 호출 직후가 최종 완료 시점이 아닐 수 있다.

active=false라면 빈 등록·프록시 호출·트랜잭션 설정부터 확인한다. active=true라면 경계 밖으로 나온 실제 예외 타입, 적용된 롤백 규칙, 그 쓰기를 맡은 트랜잭션 매니저 순으로 좁힌다. 이번 글의 비교는 단일 스레드·단일 H2 DB·Spring JDBC에 한정된다. JPA의 flush, 여러 DB, 비동기·리액티브 트랜잭션, Spring 7.x 실행 결과까지 검증한 것은 아니다.

← 전체 글 보기