롤백 규칙
밖으로 빠져나온 예외에 적용되는 기본 규칙과 rollbackFor·noRollbackFor의 실제 커밋 결과를 검증합니다.
이 절의 일반 동기식 메서드와 기본 설정에서는 밖으로 빠져나온 RuntimeException과 Error에 롤백, 밖으로 빠져나온 검사 예외에 커밋 시도를 적용합니다. 원 설정은 전역 기본 롤백 정책을 바꾸지 않습니다. 이미 rollback-only로 표시됐거나 커밋 단계 자체가 실패한 경우까지 성공을 보장하는 문장은 아닙니다.
이 기본값은 Java 예외 분류의 관습이지 업무 불변식 자체가 아닙니다. 어떤 데이터가 함께 남아야 하는지 먼저 정한 뒤 rollbackFor 또는 noRollbackFor로 경계의 정책을 좁혀야 합니다.
밖으로 나온 예외와 가장 구체적인 규칙이 완료 결정을 만든다
밖으로 나온 예외의 명시 규칙과 기본 규칙을 판정해 commit 시도 또는 rollback으로 갈라지는 순서가 있다.
EXCEPTION ESCAPE · STRONGEST RULE · COMPLETION
밖으로 나온 예외에 가장 구체적인 규칙을 적용한다
이 교재의 동기식 호출은 Spring 기본 롤백 설정을 사용합니다. 정상 반환과 예외 전달을 먼저 구분하고 예외에는 명시 규칙을 우선합니다.
- 정상 반환인가, 예외가 빠져나왔는가
내부에서 잡아 정상 값을 반환한 예외는 인터셉터에 도달하지 않습니다. 정상 반환은 커밋을 시도합니다.
- 예외와 일치하는 명시 규칙을 확인
rollbackFor / noRollbackFor 가운데 실제 타입에 가장 가까운 규칙이 이깁니다.
- 일치 규칙이 없을 때 기본값 적용
RuntimeException·Error는 롤백, 검사 예외는 커밋 시도입니다. 예외 전달 여부와 완료 결정은 별개입니다.
명시 규칙 · rollbackFor
검사 예외도 함께 롤백
예: 보고서 생성 실패와 현재 변경을 함께 무효화해야 할 때.
명시 규칙 · noRollbackFor
런타임 예외여도 커밋 시도
예: 핵심 상태가 유효한 알림 실패. 구체 규칙이 넓은 Exception 규칙보다 우선합니다.
기본값 · RuntimeException / Error
경계 밖으로 나오면 롤백
대상 메서드 안에서 처리한 예외에는 이 기본 판단을 적용하지 않습니다.
기본값 · 검사 예외 / 정상 반환
커밋 시도
이미 rollback-only인 참여 경계나 커밋 단계 오류까지 성공을 보장하는 뜻은 아닙니다.
규칙 입력과 최종 DB 행을 함께 검증합니다. 완료된 커밋 뒤 외부 전달 실패는 별도 전달 구조로 처리합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원래 일반 동기식 메서드와 기본 설정의 판단 순서다. 밖으로 예외가 없으면 commit을 시도하고, 있으면 가장 구체적인 명시 타입 규칙을 선택한다. rollbackFor면 rollback, noRollbackFor면 commit 시도다. 일치 규칙이 없으면 RuntimeException/Error에 rollback, checked에 commit을 시도한다. 이미 rollback-only인 공유 경계나 commit 실패까지 성공을 보장하지 않는다. 두 배치의 7노드와 6분기 의미가 같다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 밖으로 나온 예외와 가장 구체적인 규칙이 완료 결정을 만든다 |
| 6 | 원래 일반 동기식 메서드와 기본 설정의 판단 순서다. 밖으로 예외가 없으면 commit을 시도하고, 있으면 가장 구체적인 명시 타입 규칙을 선택한다. rollbackFor면 rollback, noRollbackFor면 commit 시도다. 일치 규칙이 없으면 RuntimeException/Error에 rollback, checked에 commit을 시도한다. 이미 rollback-only인 공유 경계나 commit 실패까지 성공을 보장하지 않는다. 두 배치의 7노드와 6분기 의미가 같다. |
| 10 | 가장 구체적인 타입 규칙 |
| 15 | 아니요 |
| 34 | 예: 검사 예외로 표현한 보고서 생성 실패와 현재 변경을 함께 무효화해야 할 때. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 밖으로 나온 결과 일반 동기 메서드 밖으로 예외가 나왔는지 확인합니다. | 예외 없음: commit 시도 · 예외 있음: 규칙 탐색 | 정상·예외 결과가 완료 판단 입력이 됩니다. |
| 가장 구체적 명시 규칙 일치하는 명시 타입 규칙을 선택합니다. | rollbackFor: rollback · noRollbackFor: commit 시도 | 구체적인 규칙이 기본 분류에 우선합니다. |
| 미일치 기본 규칙 명시 규칙이 없으면 RuntimeException·Error는 rollback이고 checked는 commit 시도입니다. | 기본: runtime rollback·checked commit 시도 · 완료 성공: 보장 아님 | 예외 타입에 따라 완료 시도가 갈립니다. |
밖으로 나온 예외와 가장 구체적인 규칙이 완료 결정을 만든다
밖으로 나온 예외의 명시 규칙과 기본 규칙을 판정해 commit 시도 또는 rollback으로 갈라지는 순서가 있다.
EXCEPTION ESCAPE · STRONGEST RULE · COMPLETION
밖으로 나온 예외에 가장 구체적인 규칙을 적용한다
이 교재의 동기식 호출은 Spring 기본 롤백 설정을 사용합니다. 정상 반환과 예외 전달을 먼저 구분하고 예외에는 명시 규칙을 우선합니다.
- 정상 반환인가, 예외가 빠져나왔는가
내부에서 잡아 정상 값을 반환한 예외는 인터셉터에 도달하지 않습니다. 정상 반환은 커밋을 시도합니다.
- 예외와 일치하는 명시 규칙을 확인
rollbackFor / noRollbackFor 가운데 실제 타입에 가장 가까운 규칙이 이깁니다.
- 일치 규칙이 없을 때 기본값 적용
RuntimeException·Error는 롤백, 검사 예외는 커밋 시도입니다. 예외 전달 여부와 완료 결정은 별개입니다.
명시 규칙 · rollbackFor
검사 예외도 함께 롤백
예: 보고서 생성 실패와 현재 변경을 함께 무효화해야 할 때.
명시 규칙 · noRollbackFor
런타임 예외여도 커밋 시도
예: 핵심 상태가 유효한 알림 실패. 구체 규칙이 넓은 Exception 규칙보다 우선합니다.
기본값 · RuntimeException / Error
경계 밖으로 나오면 롤백
대상 메서드 안에서 처리한 예외에는 이 기본 판단을 적용하지 않습니다.
기본값 · 검사 예외 / 정상 반환
커밋 시도
이미 rollback-only인 참여 경계나 커밋 단계 오류까지 성공을 보장하는 뜻은 아닙니다.
규칙 입력과 최종 DB 행을 함께 검증합니다. 완료된 커밋 뒤 외부 전달 실패는 별도 전달 구조로 처리합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원래 일반 동기식 메서드와 기본 설정의 판단 순서다. 밖으로 예외가 없으면 commit을 시도하고, 있으면 가장 구체적인 명시 타입 규칙을 선택한다. rollbackFor면 rollback, noRollbackFor면 commit 시도다. 일치 규칙이 없으면 RuntimeException/Error에 rollback, checked에 commit을 시도한다. 이미 rollback-only인 공유 경계나 commit 실패까지 성공을 보장하지 않는다. 두 배치의 7노드와 6분기 의미가 같다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 밖으로 나온 예외와 가장 구체적인 규칙이 완료 결정을 만든다 |
| 6 | 원래 일반 동기식 메서드와 기본 설정의 판단 순서다. 밖으로 예외가 없으면 commit을 시도하고, 있으면 가장 구체적인 명시 타입 규칙을 선택한다. rollbackFor면 rollback, noRollbackFor면 commit 시도다. 일치 규칙이 없으면 RuntimeException/Error에 rollback, checked에 commit을 시도한다. 이미 rollback-only인 공유 경계나 commit 실패까지 성공을 보장하지 않는다. 두 배치의 7노드와 6분기 의미가 같다. |
| 10 | 가장 구체적인 타입 규칙 |
| 15 | 아니요 |
| 34 | 예: 검사 예외로 표현한 보고서 생성 실패와 현재 변경을 함께 무효화해야 할 때. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 밖으로 나온 결과 일반 동기 메서드 밖으로 예외가 나왔는지 확인합니다. | 예외 없음: commit 시도 · 예외 있음: 규칙 탐색 | 정상·예외 결과가 완료 판단 입력이 됩니다. |
| 가장 구체적 명시 규칙 일치하는 명시 타입 규칙을 선택합니다. | rollbackFor: rollback · noRollbackFor: commit 시도 | 구체적인 규칙이 기본 분류에 우선합니다. |
| 미일치 기본 규칙 명시 규칙이 없으면 RuntimeException·Error는 rollback이고 checked는 commit 시도입니다. | 기본: runtime rollback·checked commit 시도 · 완료 성공: 보장 아님 | 예외 타입에 따라 완료 시도가 갈립니다. |
인터셉터가 관찰한 종료가 판정 입력이다
트랜잭션 인터셉터는 대상 호출이 끝날 때 다음 두 입력을 봅니다.
- 정상 반환이면 커밋을 시도합니다.
- 예외가 빠져나오면 가장 구체적으로 일치하는 롤백 규칙을 적용합니다.
따라서 대상 메서드 안에서 예외를 잡고 정상 값을 반환하면 그 예외는 기본 롤백 판정에 도달하지 않습니다. 반대로 호출자에서 예외를 HTTP 응답으로 바꾸는 것은 프록시가 먼저 예외를 관찰하고 완료한 뒤이므로 경계 의미를 흐리지 않습니다.
기본 규칙은 다음과 같습니다.
| 밖으로 빠져나온 값 | 기본 완료 |
|---|---|
RuntimeException 하위 타입 | 롤백 |
Error 하위 타입 | 롤백 |
| 검사 예외 | 커밋 |
| 정상 반환 | 커밋 시도 |
예외 이름에 Business가 들어가는지, 메시지에 특정 단어가 있는지는 기본 판정 입력이 아닙니다.
상태 요구를 타입 규칙으로 명시한다
다음 서비스는 같은 삽입 뒤 서로 다른 실패를 밖으로 전달합니다. 일곱 메서드의 차이는 예외가 실제로 빠져나오는 방식과 애노테이션 규칙뿐입니다.
package board.tx.proxy.rollback;
import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
public class RollbackPolicyService {
private final JdbcTemplate jdbc;
public RollbackPolicyService(DataSource dataSource) {
this.jdbc = new JdbcTemplate(dataSource);
}
@Transactional
public void defaultChecked(long id) throws ReportExportFailure {
insert(id, "default-checked");
throw new ReportExportFailure();
}
@Transactional
public void defaultRuntime(long id) {
insert(id, "default-runtime");
throw new NotificationFailure();
}
@Transactional
public void defaultError(long id) {
insert(id, "default-error");
throw new AssertionError("broken invariant");
}
@Transactional(rollbackFor = ReportExportFailure.class)
public void rollbackChecked(long id) throws ReportExportFailure {
insert(id, "rollback-checked");
throw new ReportExportFailure();
}
@Transactional(noRollbackFor = NotificationFailure.class)
public void commitRuntime(long id) {
insert(id, "commit-runtime");
throw new NotificationFailure();
}
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = NotificationFailure.class
)
public void specificNoRollbackWins(long id) {
insert(id, "specific-no-rollback");
throw new NotificationFailure();
}
@Transactional
public boolean catchRuntimeAndReturn(long id) {
insert(id, "caught-runtime");
try {
throw new NotificationFailure();
} catch (NotificationFailure handled) {
return false;
}
}
private void insert(long id, String scenario) {
jdbc.update(
"insert into rollback_note(id, scenario) values (?, ?)",
id,
scenario
);
}
public static final class ReportExportFailure extends Exception {
public ReportExportFailure() {
super("report export failed");
}
}
public static final class NotificationFailure extends RuntimeException {
public NotificationFailure() {
super("notification failed");
}
}
}rollbackFor는 검사 예외가 발생해도 현재 데이터베이스 변경을 롤백해야 한다는 뜻입니다. noRollbackFor는 런타임 예외가 빠져나와도 현재 데이터베이스 상태가 유효하므로 커밋하겠다는 강한 정책입니다. specificNoRollbackWins()에서는 Exception 규칙과 NotificationFailure 규칙이 모두 일치하지만, 말단 타입에 더 가까운 noRollbackFor가 선택됩니다. 외부 시스템과 원자성이 생긴다는 뜻은 아닙니다.
예외와 행 결과를 함께 테스트한다
다음 테스트는 예외가 호출자에게 전달되는지와 최종 행 수를 모두 고정합니다. 메서드가 실패했다는 주장만으로는 커밋과 롤백을 구분할 수 없습니다.
package board.tx.proxy.rollback;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;
class RollbackPolicyTest {
private AnnotationConfigApplicationContext context;
private JdbcTemplate jdbc;
private RollbackPolicyService service;
@BeforeEach
void openContext() {
context = new AnnotationConfigApplicationContext(RollbackConfig.class);
var dataSource = context.getBean(DataSource.class);
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("create table if not exists rollback_note(id bigint primary key, scenario varchar(80) not null)");
jdbc.update("delete from rollback_note");
service = context.getBean(RollbackPolicyService.class);
}
@AfterEach
void closeContext() {
context.close();
}
@Test
void framework_default_is_runtime_and_error_rollback_checked_commit() {
var rule = new DefaultTransactionAttribute();
assertFalse(rule.rollbackOn(new RollbackPolicyService.ReportExportFailure()));
assertTrue(rule.rollbackOn(new RollbackPolicyService.NotificationFailure()));
assertTrue(rule.rollbackOn(new AssertionError("broken invariant")));
}
@Test
void escaping_checked_exception_commits_by_default() {
assertThrows(
RollbackPolicyService.ReportExportFailure.class,
() -> service.defaultChecked(11L)
);
assertEquals(1, rows());
}
@Test
void escaping_runtime_exception_rolls_back_by_default() {
assertThrows(
RollbackPolicyService.NotificationFailure.class,
() -> service.defaultRuntime(12L)
);
assertEquals(0, rows());
}
@Test
void escaping_error_rolls_back_through_the_transaction_proxy() {
assertTrue(AopUtils.isAopProxy(service));
assertThrows(
AssertionError.class,
() -> service.defaultError(16L)
);
assertEquals(0, rows());
}
@Test
void rollback_for_rolls_back_the_named_checked_exception() {
assertThrows(
RollbackPolicyService.ReportExportFailure.class,
() -> service.rollbackChecked(13L)
);
assertEquals(0, rows());
}
@Test
void no_rollback_for_commits_the_named_runtime_exception() {
assertThrows(
RollbackPolicyService.NotificationFailure.class,
() -> service.commitRuntime(14L)
);
assertEquals(1, rows());
}
@Test
void specific_no_rollback_rule_beats_the_matching_broad_rollback_rule() {
assertThrows(
RollbackPolicyService.NotificationFailure.class,
() -> service.specificNoRollbackWins(17L)
);
assertEquals(1, rows());
}
@Test
void caught_exception_becomes_normal_return_to_the_interceptor() {
assertFalse(service.catchRuntimeAndReturn(15L));
assertEquals(1, rows());
}
private int rows() {
return jdbc.queryForObject(
"select count(*) from rollback_note",
Integer.class
);
}
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(proxyTargetClass = true)
static class RollbackConfig {
@Bean
DataSource dataSource() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:b92_proxy_rollback;DB_CLOSE_DELAY=-1");
source.setUser("sa");
source.setPassword("");
return source;
}
@Bean
PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
RollbackPolicyService rollbackPolicyService(DataSource dataSource) {
return new RollbackPolicyService(dataSource);
}
}
}검사 예외 테스트에서도 예외는 호출자에게 그대로 전달됩니다. 차이는 전달 여부가 아니라 트랜잭션 완료 결정입니다.
같은 catch가 다른 최종 결과를 만드는 조건 같은 JDBC 매니저를 쓰는 두 호출을 비교하세요. A는 하나의 대상 메서드 안에서 행을 넣고 일반 도우미의 RuntimeException을 직접 잡은 뒤 false를 정상 반환합니다. 도우미는 프록시 경계가 아니며 다른 실패나 rollback-only 표시는 없습니다. B는 외부 REQUIRED가 행을 넣고 별도 빈의 REQUIRED 프록시를 호출합니다. 내부 런타임 실패로 공유 트랜잭션이 rollback-only가 됐지만 외부 대상은 예외를 잡고 false를 반환합니다. 최종 행과 호출자가 받는 결과가 각각 어떻게 달라지나요?
대상 내부 처리와 공유 rollback-only의 풀이
A에서는 프록시가 정상 반환을 관찰하므로 커밋을 시도합니다. 다른 완료 실패가 없다는 조건에서 삽입 행이 남고 false가 반환됩니다. B에서는 내부 프록시가 이미 공유 물리 트랜잭션을 rollback-only로 표시했습니다. 외부 대상의 catch는 그 표시를 지우지 않으므로 외부 완료에서 전체 롤백과 UnexpectedRollbackException이 발생하며 false를 정상 결과로 전달하지 못합니다.
외부의 noRollbackFor 규칙도 이미 표시된 공유 트랜잭션을 정상 상태로 되돌리는 장치가 아닙니다. 예외가 어디서 처리됐는지와 어느 프록시가 먼저 관찰했는지를 함께 기록해야 합니다. 이는 명시한 호출 조건의 추적이며 새 Spring·DB 실행 결과는 아닙니다.
규칙은 좁고 구체적으로 둔다
rollbackFor = Exception.class처럼 넓은 규칙은 프로그래밍 결함과 모든 검사 타입을 한 정책으로 묶습니다. rollbackForClassName 같은 문자열 규칙은 타입이 클래스 경로에 없을 때만 필요하며 오타와 이름 충돌을 컴파일러가 잡지 못합니다.
컴파일 가능한 구체 타입을 우선하고, 같은 메서드에 여러 규칙이 겹치면 각 말단 예외 타입의 행 결과를 테스트합니다. 예외 상속 구조를 바꾸면서 트랜잭션 정책이 몰래 변하지 않게 정책표와 테스트를 함께 수정해야 합니다.
| 상황 | 예외가 경계 밖으로 나가는가 | 의도한 DB 결과 | 표현 |
|---|---|---|---|
| 낙관적 충돌 | 예 | 롤백 | 런타임 기본값 |
| 검사 예외로 표현한 보고서 생성 실패 | 예 | 함께 롤백 | 구체 rollbackFor |
| 핵심 상태가 유효한 알림 실패 | 예 | 커밋 | 구체 noRollbackFor |
| 내부에서 복구해 정상 반환 | 아니요 | 커밋 시도 | 복구된 상태를 별도 검증 |
커밋 뒤 실패는 롤백 규칙으로 해결하지 않는다
지연 flush나 제약 조건 때문에 대상 코드가 정상 반환한 뒤 커밋 단계에서 실패할 수 있습니다. 대상 메서드 내부의 try 블록은 프록시가 수행하는 커밋 실패를 감싸지 못하므로 호출자는 프록시 호출 전체의 실패를 처리해야 합니다.
이미 커밋된 뒤 실행되는 콜백 실패는 데이터베이스를 되돌리지 못합니다. 외부 전달이 핵심 저장과 독립적이라면 로컬 데이터와 전달할 사실을 같은 트랜잭션에 기록하고 비동기 경계에서 발행하는 구조를 사용합니다. 커밋 뒤 드러난 프로그래밍 결함은 롤백 규칙으로 숨기지 말고 incident로 기록한 뒤 필요한 보상을 별도 작업으로 실행합니다. 그 구조의 원자성·재시도·멱등성은 뒤의 전파와 아웃박스 절에서 다룹니다.
점검 질문
- 롤백 규칙이 보는 예외가 실제로 프록시 밖으로 빠져나오는가?
- 예외 전달과 최종 행 결과를 함께 검증했는가?
rollbackFor와noRollbackFor가 구체 업무 상태를 설명하는가?- 외부 시스템 실패를 로컬 애노테이션 한 줄로 원자화한다고 오해하지 않았는가?
- 커밋 이후 실패를 롤백 규칙이 아니라 별도 전달 구조로 소유했는가?