
SOFTMOA TECHNOLOGY
Spring @Transactional에서 IOException이 롤백되지 않을 때 해결 방법
소프트모아가 정리한 기술 기록입니다.
Contents
Spring에서 IOException이 발생했는데 DB에 행이 남는다면 롤백 규칙을 확인해야 합니다. 기본 @Transactional은 checked exception인 IOException을 자동 롤백하지 않습니다. 저장도 취소하려면 rollbackFor에 타입을 지정합니다.[1] 소프트모아의 인메모리 DB 예제로 비교합니다.
오류 메시지와 저장 결과는 따로 확인합니다
조건은 Spring Framework 6.2.12, 동기식 JDBC, 전역 롤백 규칙 미변경, Spring 빈의 public 메서드 외부 호출입니다. 기본 규칙은 RuntimeException과 Error에 롤백합니다. throws IOException 선언은 롤백 설정이 아닙니다.[1]
- 먼저 실제 예외 타입과 메서드의 rollbackFor·noRollbackFor 설정을 확인합니다.
- 메서드 안에서 예외를 잡고 정상 반환했다면 바깥으로 전달된 실패가 있는지 확인합니다.[3]
- new로 만든 객체나 같은 객체 내부 호출이라면 기본 프록시 방식의 트랜잭션 적용부터 확인합니다.[2]
두 번 실패시켜 남은 ID를 비교합니다
운영 DB와 분리한 빈 디렉터리에서 실행합니다. Gradle이 설치된 환경에 다음 build.gradle을 저장합니다. 최신 버전이 아닌 아래 고정 버전의 재현 예제입니다.
plugins { id 'application' }
repositories { mavenCentral() }
dependencies {
implementation 'org.springframework:spring-context:6.2.12'
implementation 'org.springframework:spring-jdbc:6.2.12'
runtimeOnly 'com.h2database:h2:2.3.232'
}
application { mainClass = 'RollbackDemo' }
src/main/java/RollbackDemo.java를 만듭니다. original은 ID 1, corrected는 ID 2를 넣고 같은 IOException으로 실패합니다. 각각 별도 트랜잭션입니다.
import java.io.IOException;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.*;
@Configuration
@EnableTransactionManagement
public class RollbackDemo {
@Bean DriverManagerDataSource ds() {
return new DriverManagerDataSource("jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1", "sa", "");
}
@Bean JdbcTemplate jdbc() { return new JdbcTemplate(ds()); }
@Bean DataSourceTransactionManager tx() {
return new DataSourceTransactionManager(ds());
}
@Bean Writer writer() { return new Writer(jdbc()); }
public static class Writer {
private final JdbcTemplate jdbc;
public Writer(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Transactional
public void original() throws IOException {
jdbc.update("insert into demo values (1)");
throw new IOException("export failed");
}
@Transactional(rollbackFor = IOException.class)
public void corrected() throws IOException {
jdbc.update("insert into demo values (2)");
throw new IOException("export failed");
}
}
public static void main(String[] args) {
try (var ctx = new AnnotationConfigApplicationContext(RollbackDemo.class)) {
var jdbc = ctx.getBean(JdbcTemplate.class);
var writer = ctx.getBean(Writer.class);
jdbc.execute("create table demo (id int primary key)");
try { writer.original(); } catch (IOException e) {
System.out.println("original: " + e.getMessage());
}
System.out.println(jdbc.queryForList("select id from demo order by id", Integer.class));
try { writer.corrected(); } catch (IOException e) {
System.out.println("corrected: " + e.getMessage());
}
System.out.println(jdbc.queryForList("select id from demo order by id", Integer.class));
}
}
}
다음 명령과 애플리케이션 출력은 Java 25.0.4.1로 확인했습니다.
gradle -q run
original: export failed
[1]
corrected: export failed
[1]
첫 번째 [1]은 예외가 전달돼도 ID 1이 커밋됐다는 뜻입니다. 두 번째도 [1]인 것은 ID 2만 롤백됐기 때문입니다. 설정 변경이 앞서 커밋된 ID 1을 지우지는 않습니다. main의 catch는 프록시 호출 이후에 있으므로 메서드 내부에서 예외를 숨기는 경우와 다릅니다.
업무상 취소해야 하는 실패만 지정합니다
예제는 IOException을 저장 취소 사유로 가정했습니다. 실제 업무의 정책을 정한 뒤 IOException.class처럼 타입을 지정합니다. 타입 규칙은 해당 클래스와 하위 타입에 적용됩니다.[1] 무조건 Exception.class를 붙이기보다 기존 예외 정책과 함께 검토합니다.
Spring 6.2부터 rollbackOn=ALL_EXCEPTIONS로 전역 기본값을 바꿀 수도 있으므로, 운영 설정이 다르면 결과도 달라집니다.[2] DB 롤백은 외부 파일이나 원격 API 요청 취소를 보장하지 않습니다. 이 예제는 단일 H2 DB의 비교이며 운영 DB·다중 트랜잭션 매니저·비동기 실행은 검증하지 않았습니다.
확인 문서
- [1] Transactional API (6.2.12)
- [2] 선언적 트랜잭션 어노테이션
- [3] 롤백 규칙
소프트모아는 해당 시스템을 구축합니다. 문의하기
