이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 열일곱 번째 글입니다. 16편까지로 1편 지도의 Phase 1(컨테이너)·Phase 2(AOP)·Phase 3(기반 기술)을 모두 지났습니다. 이번 글부터 Phase 4(트랜잭션과 데이터 접근) 로 들어갑니다. 그 첫 단위 U15가 이 글이고, 10편과 11편에서 본 프록시가 실제로 어디에 쓰이는지를 보는 글이기도 합니다.
서비스 메서드에 @Transactional을 붙이면 메서드가 끝날 때 커밋되고 예외가 나면 롤백된다고 배웁니다. 그런데 메서드 안에는 커밋도 롤백도 없습니다. 답하려는 질문은 네 가지입니다.
- 트랜잭션을 "열고 닫는" 일은 소스에서 어떤 인터페이스로 추상화되어 있고,
@Transactional의 속성은 어디에 대응되는가 @EnableTransactionManagement한 줄이 컨테이너에 무엇을 등록해서@Transactional메서드가 프록시로 감싸이고, 그 프록시가 호출될 때 누가 트랜잭션을 열고 닫는가- 트랜잭션 안에서 또 트랜잭션 메서드를 부르면(전파) 무슨 일이 일어나고, 커넥션 하나가 메서드 여러 개에 걸쳐 같은 것으로 유지되는 장치는 무엇인가
- 체크 예외를 던지면 왜 롤백되지 않고, 안쪽 메서드의 예외를 바깥이 잡아서 삼켰는데도 왜
UnexpectedRollbackException이 나는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-tx(transaction, transaction.support, transaction.interceptor, transaction.annotation), spring-jdbc(jdbc.datasource) |
| 주로 읽는 클래스 | PlatformTransactionManager, TransactionDefinition, TransactionStatus, AbstractPlatformTransactionManager, TransactionSynchronizationManager, TransactionInterceptor, TransactionAspectSupport, AnnotationTransactionAttributeSource, DataSourceTransactionManager, DataSourceUtils |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
원래 공부 노트는 줄 단위로 훨씬 깊이 들어가지만, 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.
먼저 알아 둘 말
트랜잭션(transaction)은 "여러 번의 데이터베이스 작업을 한 덩어리로 묶어, 전부 반영하거나(커밋) 전부 되돌리는(롤백) 단위"입니다. 계좌 이체에서 출금은 됐는데 입금이 안 되는 일을 막는 장치입니다. JDBC에서는 Connection 객체 하나가 트랜잭션 하나를 담고, con.commit()·con.rollback()으로 끝냅니다.
자동 커밋(auto-commit)은 JDBC 커넥션의 기본 상태로, SQL 한 문장마다 바로 커밋되는 모드입니다. 여러 문장을 한 트랜잭션으로 묶으려면 con.setAutoCommit(false)로 끄고 나중에 직접 커밋해야 합니다. 이 글에서 Spring이 하는 일의 절반은 이 켜고 끄기입니다.
**DataSource**는 커넥션을 내주는 공장입니다. dataSource.getConnection()을 부르면 새 커넥션(또는 풀에서 꺼낸 커넥션)을 받습니다. 이 글의 예제는 풀이 없는 가장 단순한 DriverManagerDataSource를 씁니다.
전파(propagation)는 "이미 트랜잭션이 열려 있는데 또 트랜잭션 메서드가 불렸을 때 어떻게 할 것인가"에 대한 규칙입니다. 기존 것에 합류할 수도, 기존 것을 잠시 밀어 두고 새로 열 수도 있습니다. 소스에는 일곱 가지가 있고 1절의 표에 정리합니다.
프록시·어드바이스·어드바이저는 10편과 11편에서 본 말입니다. 프록시는 원본 객체를 감싸 메서드 호출 앞뒤에 끼어드는 가짜 객체, 어드바이스는 그 끼어드는 코드, 어드바이저는 "어디에(포인트컷) 무엇을(어드바이스)"의 묶음입니다. 이 글의 TransactionInterceptor가 바로 어드바이스입니다.
**ThreadLocal**은 "스레드마다 따로 값을 두는 변수"입니다. 같은 static 필드라도 스레드 A가 넣은 값은 스레드 A만 보고 스레드 B는 못 봅니다. Spring 트랜잭션이 "메서드 인자로 커넥션을 넘기지 않아도 같은 커넥션을 쓰게" 하는 비밀이 이것입니다(5절).
1. 세 인터페이스 — 열고, 어떻게 열지, 지금 어떤 상태인지
spring-tx 모듈의 org.springframework.transaction 패키지 맨 위에는 메서드가 하나도 없는 마커 인터페이스 TransactionManager가 있습니다(TransactionManager 28행). 그 아래로 전통적인(동기) 쪽의 PlatformTransactionManager와 리액티브 쪽의 ReactiveTransactionManager(36행)가 갈라집니다. 리액티브 쪽은 19편으로 미루고, 이 글은 PlatformTransactionManager만 봅니다.
PlatformTransactionManager — 열고 닫는 세 메서드
public interface PlatformTransactionManager extends TransactionManager {
// ...
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
// ...
void commit(TransactionStatus status) throws TransactionException;
// ...
void rollback(TransactionStatus status) throws TransactionException;
}
spring-tx/src/main/java/org/springframework/transaction/PlatformTransactionManager.java 47–120행
메서드가 셋뿐입니다. getTransaction에 "어떻게 열지"(TransactionDefinition)를 주면 "지금 어떤 상태인지"(TransactionStatus)를 돌려주고, 그 상태를 commit 또는 rollback에 넘겨 닫습니다. 인터페이스의 설명 주석은 애플리케이션이 이것을 직접 부르기보다 TransactionTemplate이나 AOP를 통한 선언적 트랜잭션으로 쓰게 되어 있다고 적습니다(22–25행). 이 글의 주인공 @Transactional이 그 "AOP를 통한" 쪽입니다.
구현 클래스는 데이터 접근 기술마다 하나씩입니다. JDBC용 DataSourceTransactionManager(spring-jdbc 122행)와 그 서브클래스 JdbcTransactionManager(54행), JTA용 JtaTransactionManager(116행), 그리고 19편에서 볼 JPA용이 있습니다. 전부 AbstractPlatformTransactionManager(89–90행)를 상속하는데, 그 클래스가 4절과 6절의 주인공입니다.
세 인터페이스가 맞물리는 모양과 구현 클래스의 계보입니다. 기술별 구현은 전부 AbstractPlatformTransactionManager를 거칩니다. 회색 칸이 정의·상태, 노란 칸이 이 글이 읽는 두 층입니다.
TransactionDefinition — 어떻게 열지
TransactionDefinition은 상수와 default 메서드로 이루어진 인터페이스입니다. 전파 7가지(52–132행), 격리 수준 5가지(140–184행, 값은 java.sql.Connection의 상수와 같다고 주석에 적혀 있습니다), 타임아웃 기본값 -1(191행)이 상수이고, 기본값을 돌려주는 메서드 다섯 개가 있습니다.
| 메서드 | 기본값 | 위치 |
|---|---|---|
getPropagationBehavior() |
PROPAGATION_REQUIRED |
203–205행 |
getIsolationLevel() |
ISOLATION_DEFAULT(-1, DB 기본값을 따름) |
225–227행 |
getTimeout() |
TIMEOUT_DEFAULT(-1) |
240–242행 |
isReadOnly() |
false |
261–263행 |
getName() |
null |
275행 |
@Transactional 어노테이션의 속성은 이 가운데 앞의 넷에 하나씩 대응됩니다(다섯째인 이름은 어노테이션 속성이 아니고, 3절에서 인터셉터가 채웁니다). propagation의 기본값 Propagation.REQUIRED(Transactional 178행), isolation(192행), timeout(203행), readOnly(229행)가 그것이고, Propagation 열거형의 각 값은 위 상수를 그대로 들고 있습니다(Propagation 37–97행). 어노테이션에 있고 여기에는 없는 것 가운데 이 글에서 중요한 것이 rollbackFor·noRollbackFor인데, 3절에서 봅니다.
전파 7가지는 4절에서 코드로 확인하겠지만, 뜻을 미리 표로 적어 둡니다.
| 전파 | 트랜잭션이 없을 때 | 이미 있을 때 |
|---|---|---|
REQUIRED(기본) |
새로 연다 | 합류한다 |
SUPPORTS |
없이 간다 | 합류한다 |
MANDATORY |
예외 | 합류한다 |
REQUIRES_NEW |
새로 연다 | 기존 것을 밀어 두고(suspend) 새로 연다 |
NOT_SUPPORTED |
없이 간다 | 기존 것을 밀어 두고 없이 간다 |
NEVER |
없이 간다 | 예외 |
NESTED |
새로 연다 | 기존 것 안에 세이브포인트를 만든다 |
TransactionStatus — 지금 어떤 상태인지
TransactionStatus는 TransactionExecution, SavepointManager, Flushable을 합친 인터페이스입니다(40행). 이 글에서 쓰는 것은 TransactionExecution 쪽의 네 개입니다. "내가 새로 연 트랜잭션인가"를 말하는 isNewTransaction()(73행), 예외 없이 롤백을 예약하는 setRollbackOnly()(106행), isRollbackOnly()(116행), isCompleted()(125행)입니다. 실제 구현은 DefaultTransactionStatus(52행)이고, isNewTransaction()이 true냐 false냐가 6절에서 커밋·롤백의 운명을 가릅니다.
2. @EnableTransactionManagement 한 줄이 등록하는 것
11편의 @EnableAspectJAutoProxy와 모양이 같습니다. @EnableTransactionManagement를 열면 @Import(TransactionManagementConfigurationSelector.class) 하나가 붙어 있고(EnableTransactionManagement 159–163행), 속성은 proxyTargetClass(182행), mode(기본 PROXY, 194행), order(201행), 그리고 6.2에 들어온 rollbackOn(222행)입니다. 9편에서 본 대로 @Import에 ImportSelector를 주면 그 클래스가 "무엇을 설정 클래스로 더 읽을지"를 이름으로 돌려줍니다.
@Override
protected String[] selectImports(AdviceMode adviceMode) {
return switch (adviceMode) {
case PROXY -> new String[] {AutoProxyRegistrar.class.getName(),
ProxyTransactionManagementConfiguration.class.getName()};
case ASPECTJ -> new String[] {determineTransactionAspectClass()};
};
}
파란 상자가 등록되는 것, 초록 상자가 결과입니다. 어드바이저는 포인트컷과 인터셉터를 묶은 것입니다.
PROXY 모드에서 돌아오는 이름은 둘입니다.
AutoProxyRegistrar— 11편에서 본AopConfigUtils.registerAutoProxyCreatorIfNecessary를 불러 자동 프록시 생성기InfrastructureAdvisorAutoProxyCreator를 등록합니다(AutoProxyRegistrar72–73행). 11편 1절에서 본 대로 자동 프록시 생성기는 컨테이너에 하나뿐이라,@EnableAspectJAutoProxy도 함께 있으면 더 능력 많은 생성기로 승격됩니다. 어느 쪽이든 "모든 빈의 초기화 후 훅에서 어드바이저가 맞으면 감싼다"는 동작은 같습니다.ProxyTransactionManagementConfiguration— 어드바이저와 그 부품을 등록하는@Configuration클래스입니다.
@Bean(name = TransactionManagementConfigUtils.TRANSACTION_ADVISOR_BEAN_NAME)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public BeanFactoryTransactionAttributeSourceAdvisor transactionAdvisor(
TransactionAttributeSource transactionAttributeSource, TransactionInterceptor transactionInterceptor) {
BeanFactoryTransactionAttributeSourceAdvisor advisor = new BeanFactoryTransactionAttributeSourceAdvisor();
advisor.setTransactionAttributeSource(transactionAttributeSource);
advisor.setAdvice(transactionInterceptor);
if (this.enableTx != null) {
advisor.setOrder(this.enableTx.<Integer>getNumber("order"));
}
return advisor;
}
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TransactionInterceptor transactionInterceptor(TransactionAttributeSource transactionAttributeSource) {
TransactionInterceptor interceptor = new TransactionInterceptor();
interceptor.setTransactionAttributeSource(transactionAttributeSource);
if (this.txManager != null) {
interceptor.setTransactionManager(this.txManager);
}
return interceptor;
}
이 클래스에는 @Bean이 둘뿐입니다. 세 번째 부품인 transactionAttributeSource는 부모 클래스 AbstractTransactionManagementConfiguration에 있습니다. 부모에는 @Bean이 하나 더 있는데, 글 끝 "더 깊이 들어가면"에 이름만 적어 둔 @TransactionalEventListener를 위한 TransactionalEventListenerFactory이고(94–98행) 이 글에서는 다루지 않습니다.
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TransactionAttributeSource transactionAttributeSource() {
// Accept protected @Transactional methods on CGLIB proxies, as of 6.0
AnnotationTransactionAttributeSource tas = new AnnotationTransactionAttributeSource(false);
// Apply default rollback rule, as of 6.2
if (this.enableTx != null && this.enableTx.getEnum("rollbackOn") == RollbackOn.ALL_EXCEPTIONS) {
tas.addDefaultRollbackRule(RollbackRuleAttribute.ROLLBACK_ON_ALL_EXCEPTIONS);
}
return tas;
}
세 빈의 역할을 정리하면 이렇습니다.
| 빈 | 클래스 | 역할 |
|---|---|---|
transactionAttributeSource |
AnnotationTransactionAttributeSource |
메서드·클래스에서 @Transactional을 읽어 TransactionAttribute로 바꾼다 |
transactionInterceptor |
TransactionInterceptor |
어드바이스. 호출 앞에서 열고 뒤에서 닫는다(3절) |
transactionAdvisor |
BeanFactoryTransactionAttributeSourceAdvisor |
포인트컷 + 위 인터셉터. 자동 프록시 생성기가 이 어드바이저를 보고 감쌀지 정한다 |
어드바이저의 포인트컷은 TransactionAttributeSourcePointcut이고(BeanFactoryTransactionAttributeSourceAdvisor 34–36행), 그 matches는 "어트리뷰트 소스가 이 메서드에 대해 트랜잭션 속성을 가지고 있는가"로 끝납니다(54–57행). 즉 @Transactional을 읽는 일과 "감쌀지 정하는" 일이 같은 객체에서 나옵니다. 클래스 필터는 TransactionManager 구현체 등 기반 클래스는 애초에 빼고, 나머지는 isCandidateClass로 "이 클래스에 어노테이션이 있을 가능성"만 빠르게 봅니다(83–90행).
@Transactional 을 읽는 쪽
AnnotationTransactionAttributeSource는 파서 목록을 들고 있고, Spring의 @Transactional은 SpringTransactionAnnotationParser가 맡습니다(86–98행; jakarta.transaction.Transactional이 클래스패스에 있으면 JTA 파서도 더해집니다, 89–91행).
protected TransactionAttribute parseTransactionAnnotation(AnnotationAttributes attributes) {
RuleBasedTransactionAttribute rbta = new RuleBasedTransactionAttribute();
Propagation propagation = attributes.getEnum("propagation");
rbta.setPropagationBehavior(propagation.value());
Isolation isolation = attributes.getEnum("isolation");
rbta.setIsolationLevel(isolation.value());
rbta.setTimeout(attributes.getNumber("timeout").intValue());
// ...
rbta.setReadOnly(attributes.getBoolean("readOnly"));
rbta.setQualifier(attributes.getString("value"));
rbta.setLabels(Set.of(attributes.getStringArray("label")));
List<RollbackRuleAttribute> rollbackRules = new ArrayList<>();
for (Class<?> rbRule : attributes.getClassArray("rollbackFor")) {
rollbackRules.add(new RollbackRuleAttribute(rbRule));
}
// ...
for (Class<?> rbRule : attributes.getClassArray("noRollbackFor")) {
rollbackRules.add(new NoRollbackRuleAttribute(rbRule));
}
// ...
rbta.setRollbackRules(rollbackRules);
return rbta;
}
어노테이션 속성은 13편의 AnnotatedElementUtils.findMergedAnnotationAttributes로 읽습니다(56–57행). 결과 RuleBasedTransactionAttribute는 DefaultTransactionAttribute를, 그것은 DefaultTransactionDefinition을 상속하므로(39행, 40행) 1절의 TransactionDefinition이기도 합니다. 거기에 더해진 것이 rollbackFor·noRollbackFor를 담은 롤백 규칙 목록이고, 이것이 TransactionAttribute 인터페이스가 TransactionDefinition에 더하는 rollbackOn(Throwable) 메서드의 재료입니다(TransactionAttribute 37행, 60행).
어느 메서드의 어노테이션을 읽을지는 부모 AbstractFallbackTransactionAttributeSource가 정합니다. 결과는 메서드+클래스를 열쇠로 캐시되고(116–140행), 처음 한 번은 computeTransactionAttribute가 찾습니다.
protected @Nullable TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
// Don't allow non-public methods, as configured.
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null;
}
// ...
Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);
// First try is the method in the target class.
TransactionAttribute txAttr = findTransactionAttribute(specificMethod);
if (txAttr != null) {
return txAttr;
}
// Second try is the transaction attribute on the target class.
txAttr = findTransactionAttribute(specificMethod.getDeclaringClass());
if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
return txAttr;
}
// ...
return null;
}
대상 클래스의 메서드 → 대상 클래스 자체 → (인터페이스 메서드로 불렸다면) 원래 메서드 → 그 선언 클래스 순서입니다(187–198행). 클래스에 붙인 @Transactional이 메서드의 기본값이 되는 이유가 두 번째 시도입니다.
헷갈리기 쉬운 곳: public 이 아닌 메서드
protected·패키지 접근 메서드의@Transactional은 이 버전에서는 읽힙니다.allowPublicMethodsOnly()는publicMethodsOnly필드를 돌려주고 기본값은true지만(75행, 215–217행), 위에서 본 대로@EnableTransactionManagement는new AnnotationTransactionAttributeSource(false)로 만듭니다. 주석에 "6.0부터 CGLIB 프록시의 protected@Transactional메서드를 받아들인다"고 적혀 있습니다.private메서드는 그래도 안 됩니다. 어트리뷰트를 읽느냐의 문제가 아니라 프록시의 문제입니다. 10편에서 본 대로 CGLIB 프록시는 서브클래스라서private과final메서드를 덮어쓸 수 없고, 애초에private메서드는 같은 클래스 안에서만, 곧this로만 불립니다. 그 경우가 다음 절의 자기 호출입니다.Transactional의 설명 주석도 메서드 가시성 제약은 참조 문서를 보라고만 적어 두었습니다(37–39행).- 클래스에 붙인 어노테이션은 그 클래스와 서브클래스의 메서드에만 기본값이 됩니다. 부모 클래스에서 물려받은 메서드에는 적용되지 않는다고 주석에 적혀 있습니다(33–36행).
3. 호출 시점 — TransactionInterceptor 가 열고 닫습니다
이제 제목의 질문에 답할 수 있습니다. 프록시의 메서드가 불리면 11편 5절의 어드바이스 체인을 따라 TransactionInterceptor.invoke가 불립니다. TransactionInterceptor는 MethodInterceptor이고(58–59행), invoke는 실제 일을 부모 TransactionAspectSupport에 넘깁니다.
@Override
public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
// ...
Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
// Adapt to TransactionAspectSupport's invokeWithinTransaction...
return invokeWithinTransaction(invocation.getMethod(), targetClass, new InvocationCallback() {
@Override
public @Nullable Object proceedWithInvocation() throws Throwable {
return invocation.proceed();
}
// ...
노란 상자가 ThreadLocal 에 묶는 자리, 빨간 상자가 예외 뒤의 두 갈래입니다. 체크 예외는 오른쪽 갈래, 곧 커밋으로 갑니다.
invokeWithinTransaction의 뼈대입니다. 앞부분의 리액티브 분기(341–358행, 19편)와 Future·Vavr 반환값 처리(382–406행)는 줄였습니다.
protected @Nullable Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
// If the transaction attribute is null, the method is non-transactional.
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
final TransactionManager tm = determineTransactionManager(txAttr, targetClass);
// ...
PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, invocation, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
// ...
commitTransactionAfterReturning(txInfo);
return retVal;
}
// ...
- 어노테이션을 읽습니다. 2절의
getTransactionAttribute입니다. 캐시 덕분에 두 번째 호출부터는 맵 조회입니다. - 트랜잭션 매니저를 고릅니다.
determineTransactionManager는 어노테이션의value/transactionManager(qualifier)가 있으면 그 이름의 빈을, 없으면 클래스 수준@Qualifier를, 그것도 없으면transactionManagerBeanName설정을, 인터셉터에 직접 넣어 둔 매니저(2절 코드의txManager)도 없으면 마지막으로 컨테이너에서TransactionManager타입의 빈 하나를 찾아 캐시합니다(489–534행, 타입으로 찾는 줄은 527행). 예제의H2Config가PlatformTransactionManager빈을 하나만 두는 이유입니다. - 엽니다.
createTransactionIfNecessary가tm.getTransaction(txAttr)을 부릅니다(628행). 그 직전에 어노테이션에 이름이 없으면 "클래스 이름.메서드 이름"을 트랜잭션 이름으로 삼습니다(616–623행). 아래 예제 출력에서getCurrentTransactionName()이ex.u15.…MemberService.report인 이유입니다. - 원본 메서드를 실행합니다.
proceedWithInvocation()은 위invoke의 콜백, 곧invocation.proceed()입니다. - 닫습니다. 정상 반환이면
commitTransactionAfterReturning이tm.commit(status)를(682–689행), 예외면completeTransactionAfterThrowing이 아래처럼 둘 중 하나를 부릅니다.
if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
invocation.onRollback(ex, txInfo.getTransactionStatus());
try {
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
}
// ...
}
else {
// We don't roll back on this exception.
// Will still roll back if TransactionStatus.isRollbackOnly() is true.
try {
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
// ...
}
그러니 "트랜잭션은 누가 열고 누가 닫나"의 답은 이렇습니다. 여는 것도 닫는 것도 TransactionInterceptor(정확히는 TransactionAspectSupport)이고, 실제 작업은 PlatformTransactionManager에게 시킵니다. 서비스 메서드 자신은 둘 다 모릅니다.
롤백할지 정하는 규칙: rollbackOn
위 코드의 갈림길 rollbackOn(ex)입니다. 규칙이 하나도 없을 때의 기본은 DefaultTransactionAttribute에 있습니다.
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
RuntimeException과 Error만 롤백하고, 체크 예외는 커밋합니다. 바로 위 주석은 체크 예외를 "비즈니스 예외, 곧 또 하나의 반환값"으로 보기 때문이라고 설명합니다(170–176행). rollbackFor·noRollbackFor를 적으면 RuleBasedTransactionAttribute가 먼저 규칙을 봅니다.
@Override
public boolean rollbackOn(Throwable ex) {
RollbackRuleAttribute winner = null;
int deepest = Integer.MAX_VALUE;
if (this.rollbackRules != null) {
for (RollbackRuleAttribute rule : this.rollbackRules) {
int depth = rule.getDepth(ex);
if (depth >= 0 && depth < deepest) {
deepest = depth;
winner = rule;
}
}
}
// User superclass behavior (rollback on unchecked) if no rule matches.
if (winner == null) {
return super.rollbackOn(ex);
}
return !(winner instanceof NoRollbackRuleAttribute);
}
getDepth는 던져진 예외의 클래스에서 부모 쪽으로 올라가며 규칙의 예외 타입과 같아지는 단계 수를 돌려주고, Throwable까지 가도 없으면 -1입니다(165–186행). 예외 계층에서 가장 가까운 규칙이 이기고, 그것이 noRollbackFor 쪽이면 커밋, 아니면 롤백입니다. 어느 규칙에도 안 걸리면 기본 규칙으로 돌아갑니다.
예외 하나가 커밋이 될지 롤백이 될지 정해지는 길입니다. 아래 칸의 행 수는 예제 ①의 실제 출력입니다.
예제 ①: 예외의 종류에 따라 남는 행 수
H2 인메모리 DB에 member 테이블을 만들고, @Transactional 메서드 안에서 두 행을 넣은 뒤 예외를 던집니다. 세 메서드는 던지는 예외와 어노테이션 속성만 다릅니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Transactional
public void saveTwoThenRuntimeException() {
jdbc.update("INSERT INTO member (name) VALUES (?)", "a");
jdbc.update("INSERT INTO member (name) VALUES (?)", "b");
throw new IllegalStateException("런타임 예외");
}
@Transactional
public void saveTwoThenCheckedException() throws Exception {
jdbc.update("INSERT INTO member (name) VALUES (?)", "a");
jdbc.update("INSERT INTO member (name) VALUES (?)", "b");
throw new Exception("체크 예외");
}
@Transactional(rollbackFor = Exception.class)
public void saveTwoThenCheckedExceptionWithRollbackFor() throws Exception {
jdbc.update("INSERT INTO member (name) VALUES (?)", "a");
jdbc.update("INSERT INTO member (name) VALUES (?)", "b");
throw new Exception("체크 예외 (rollbackFor = Exception.class)");
}
이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 다음 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다).
== 프록시
빈의 클래스 = ex.u15.RollbackRuleExample$MemberService$$SpringCGLIB$$0
CGLIB 프록시인가 = true
== 트랜잭션 메서드 밖
[밖] isActualTransactionActive = false
[밖] getCurrentTransactionName = null
== 트랜잭션 메서드 안 (report)
[안] isActualTransactionActive = true
[안] getCurrentTransactionName = ex.u15.RollbackRuleExample$MemberService.report
[안] isSynchronizationActive = true
[안] currentTransactionStatus().isNewTransaction() = true
== RuntimeException 을 던지면
예외: IllegalStateException - 런타임 예외
남은 행 수 = 0
== 체크 예외(Exception)를 던지면
예외: Exception - 체크 예외
남은 행 수 = 2
== 체크 예외 + rollbackFor = Exception.class
예외: Exception - 체크 예외 (rollbackFor = Exception.class)
남은 행 수 = 0
빈은 인터페이스가 없으니 10편대로 CGLIB 프록시이고, 메서드 안에서만 트랜잭션이 "있다"고 나옵니다(isActualTransactionActive는 5절). 마지막 세 묶음이 핵심입니다. IllegalStateException은 롤백되어 0행, Exception은 커밋되어 2행이 남고, rollbackFor = Exception.class를 붙이면 다시 0행입니다.
헷갈리기 쉬운 곳
- 체크 예외는 기본으로 롤백되지 않습니다. 위 출력 그대로입니다. 메서드마다
rollbackFor를 붙이는 대신@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)로 한 번에 바꿀 수 있고, 그러면 2절에서 본 대로ROLLBACK_ON_ALL_EXCEPTIONS규칙이 모든 어트리뷰트에 더해집니다(AnnotationTransactionAttributeSource201–203행).EnableTransactionManagement의 주석은 특히 체크 예외가 강제되지 않는 Kotlin 에서는 바꾸기를 권합니다(211–215행). - 예외를 메서드 안에서 잡아 버리면 인터셉터는 모릅니다. 인터셉터가 보는 것은 메서드 밖으로 나온 예외뿐입니다. 잡고도 롤백하고 싶으면
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()를 부릅니다(163–169행). 위 코드의 주석 "Will still roll back if TransactionStatus.isRollbackOnly() is true"가 그 경우입니다. - 자기 호출에는 트랜잭션이 걸리지 않습니다. 10편 6절과 11편 6절에서 본 그 규칙입니다. 어드바이스는 프록시에만 있고
this.inner()는 프록시를 거치지 않습니다.EnableTransactionManagement의 주석도 "같은 클래스 안의 로컬 호출은 가로채지 못하며, 그런 메서드의@Transactional은 무시된다"고 적습니다(187–192행).
예제 ②: this.inner() 와 this.innerRequiresNew()
트랜잭션 없는 outer()가 @Transactional inner()를 this로 부르고, @Transactional outerTx()가 @Transactional(REQUIRES_NEW) innerRequiresNew()를 this로 부릅니다. 같은 메서드를 바깥에서 직접 부른 결과와 나란히 놓았습니다.
== 바깥에서 outer() 호출 → 안에서 this.inner()
[outer] this 의 클래스 = NoticeService
[inner] isActualTransactionActive = false
== 바깥에서 inner() 직접 호출
[inner] isActualTransactionActive = true
== 바깥에서 outerTx() 호출 → 안에서 this.innerRequiresNew()
[outerTx] 트랜잭션 이름 = ex.u15.SelfInvocationExample$NoticeService.outerTx
[innerRequiresNew] 트랜잭션 이름 = ex.u15.SelfInvocationExample$NoticeService.outerTx
== 바깥에서 innerRequiresNew() 직접 호출
[innerRequiresNew] 트랜잭션 이름 = ex.u15.SelfInvocationExample$NoticeService.innerRequiresNew
this의 클래스가 프록시가 아닌 NoticeService로 찍히고, 안에서 부른 inner()에는 트랜잭션이 없습니다. 두 번째 쌍이 더 미묘합니다. this.innerRequiresNew()는 예외 없이 잘 돌지만 새 트랜잭션이 아니라 바깥 outerTx의 트랜잭션 안에서 돕니다. REQUIRES_NEW가 "안 먹는" 가장 흔한 원인입니다.
4. getTransaction — 템플릿 메서드와 전파
인터셉터가 부른 tm.getTransaction(txAttr)로 들어갑니다. AbstractPlatformTransactionManager는 전파·동기화·커밋 흐름을 전부 구현해 두고, 자원(커넥션)을 실제로 다루는 부분만 doXxx 메서드로 비워 둔 템플릿입니다. 서브클래스가 반드시 채워야 하는 것은 doGetTransaction(1099행), doBegin(1159행), doCommit(1253행), doRollback(1264행) 넷이고, isExistingTransaction(기본 false, 1116–1118행)·doSuspend·doResume·doSetRollbackOnly·doCleanupAfterCompletion은 선택입니다.
@Override
public final TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException {
// Use defaults if no transaction definition given.
TransactionDefinition def = (definition != null ? definition : TransactionDefinition.withDefaults());
Object transaction = doGetTransaction();
boolean debugEnabled = logger.isDebugEnabled();
if (isExistingTransaction(transaction)) {
// Existing transaction found -> check propagation behavior to find out how to behave.
return handleExistingTransaction(def, transaction, debugEnabled);
}
// ...
// No existing transaction found -> check propagation behavior to find out how to proceed.
if (def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_MANDATORY) {
throw new IllegalTransactionStateException(
"No existing transaction found for transaction marked with propagation 'mandatory'");
}
else if (def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRED ||
def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW ||
def.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) {
SuspendedResourcesHolder suspendedResources = suspend(null);
// ...
try {
return startTransaction(def, transaction, false, debugEnabled, suspendedResources);
}
// ...
}
else {
// Create "empty" transaction: no actual transaction, but potentially synchronization.
// ...
boolean newSynchronization = (getTransactionSynchronization() == SYNCHRONIZATION_ALWAYS);
return prepareTransactionStatus(def, null, true, newSynchronization, debugEnabled, null);
}
}
왼쪽이 트랜잭션이 없을 때, 오른쪽이 있을 때입니다. 초록 상자만 isNewTransaction 이 true 입니다.
흐름은 셋으로 나뉩니다.
doGetTransaction()— 서브클래스가 "지금 이 스레드의 트랜잭션 상태"를 담은 객체를 만듭니다.DataSourceTransactionManager는 5절에서 볼TransactionSynchronizationManager.getResource(dataSource)로 스레드에 묶인ConnectionHolder를 찾아 담습니다(247–254행).isExistingTransaction()— 그 홀더가 있고isTransactionActive()이면 "이미 트랜잭션 안"입니다(257–260행). 그러면handleExistingTransaction으로 갑니다.- 없을 때의 전파 분기 —
MANDATORY는 예외,REQUIRED·REQUIRES_NEW·NESTED는startTransaction, 나머지 셋은 실제 트랜잭션 없이 상태 객체만 만듭니다.
startTransaction은 상태 객체를 newTransaction = true로 만들고 doBegin을 부른 뒤 prepareSynchronization으로 스레드 로컬에 트랜잭션 이름·읽기 전용 여부·"실제 트랜잭션 활성" 표시를 적습니다(524–541행, 575–585행). 예제 ①에서 isActualTransactionActive와 getCurrentTransactionName이 메서드 안에서만 값을 가졌던 것이 이 자리에서 적힌 값입니다.
이미 트랜잭션이 있을 때: handleExistingTransaction
handleExistingTransaction(426–519행)은 전파 속성을 위에서부터 차례로 봅니다.
| 전파 | 하는 일 | 위치 |
|---|---|---|
NEVER |
IllegalTransactionStateException |
430–433행 |
NOT_SUPPORTED |
suspend(transaction) 뒤 트랜잭션 없는 상태 객체 |
435–443행 |
REQUIRES_NEW |
suspend(transaction) 뒤 startTransaction — 밀어 둔 자원은 상태 객체에 실어 둔다 |
445–458행 |
NESTED |
nestedTransactionAllowed가 아니면 예외, 맞으면 세이브포인트(createAndHoldSavepoint) |
460–492행 |
REQUIRED·SUPPORTS·MANDATORY |
기존 트랜잭션에 참여. newTransaction = false인 상태 객체 |
494–518행 |
suspend는 동기화 목록을 떼어 내고, doSuspend로 자원을 떼어 내고, 스레드 로컬의 이름·읽기 전용·활성 표시를 비운 뒤 그것들을 SuspendedResourcesHolder 하나에 담아 돌려줍니다(614–648행). DataSourceTransactionManager.doSuspend는 스레드에서 ConnectionHolder를 unbindResource로 떼어 내는 것이고(324–328행), 그래서 이어지는 doBegin이 새 커넥션을 엽니다. 안쪽 트랜잭션이 끝나면 cleanupAfterCompletion이 담아 둔 것을 resume으로 되돌립니다(1063–1069행).
헷갈리기 쉬운 곳
NESTED는 매니저가 허용해야 합니다.nestedTransactionAllowed의 기본값은false지만(134행)DataSourceTransactionManager는 생성자에서true로 켭니다(137–138행). JDBC 세이브포인트가 있기 때문입니다.SUPPORTS·NOT_SUPPORTED·NEVER로 "트랜잭션 없이" 돌 때도 상태 객체는 만들어집니다. 위 코드의 "empty" transaction 입니다. 동기화(5절)는 켤 수 있지만isActualTransactionActive()는false입니다. 이때 격리 수준을 지정하면 무시된다는 경고 로그가 남습니다(414–417행).- 참여할 때 격리 수준·읽기 전용은 기본으로 검사하지 않습니다.
validateExistingTransaction이true일 때만 안쪽 정의와 바깥 트랜잭션을 비교해 예외를 던집니다(136행, 499–516행). 기본에서는 안쪽의isolation이 조용히 무시됩니다.
5. 스레드에 묶이는 커넥션 — doBegin 과 TransactionSynchronizationManager
startTransaction이 부른 doBegin에서 JDBC 커넥션이 실제로 열립니다.
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
Connection con = null;
try {
if (!txObject.hasConnectionHolder() ||
txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
Connection newCon = obtainDataSource().getConnection();
// ...
txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
}
txObject.getConnectionHolder().setSynchronizedWithTransaction(true);
con = txObject.getConnectionHolder().getConnection();
Integer previousIsolationLevel = DataSourceUtils.prepareConnectionForTransaction(con,
definition.getIsolationLevel(),
(definition.isReadOnly() && !isDefaultReadOnly()));
// ...
if (con.getAutoCommit()) {
txObject.setMustRestoreAutoCommit(true);
// ...
con.setAutoCommit(false);
}
prepareTransactionalConnection(con, definition);
txObject.getConnectionHolder().setTransactionActive(true);
// ...
// Bind the connection holder to the thread.
if (txObject.isNewConnectionHolder()) {
TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder());
}
}
// ...
}
하는 일은 넷입니다. DataSource에서 커넥션을 받아 ConnectionHolder에 담고, 격리 수준·읽기 전용을 커넥션에 적용하고, 자동 커밋을 끄고, 홀더를 스레드에 묶습니다. 마지막 줄의 bindResource가 이 절의 핵심입니다.
노란 상자가 ThreadLocal 맵입니다. 왼쪽이 넣는 쪽, 가운데가 꺼내 쓰는 쪽, 오른쪽이 치우는 쪽입니다.
TransactionSynchronizationManager는 static 메서드만 있는 클래스이고, 상태는 전부 ThreadLocal입니다.
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
private static final ThreadLocal<String> currentTransactionName =
new NamedThreadLocal<>("Current transaction name");
private static final ThreadLocal<Boolean> currentTransactionReadOnly =
new NamedThreadLocal<>("Current transaction read-only status");
private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
new NamedThreadLocal<>("Current transaction isolation level");
private static final ThreadLocal<Boolean> actualTransactionActive =
new NamedThreadLocal<>("Actual transaction active");
| 필드 | 담는 것 | 넣는 곳 |
|---|---|---|
resources |
자원 공장 → 자원 홀더 맵. JDBC에서는 DataSource → ConnectionHolder |
bindResource(170–177행) |
synchronizations |
커밋·완료 시점에 불릴 콜백 집합. 있으면 "동기화 활성" | initSynchronization(316–321행) |
currentTransactionName 등 셋 |
이름, 읽기 전용, 격리 수준 | 4절의 prepareSynchronization |
actualTransactionActive |
실제 트랜잭션이 있는가. isActualTransactionActive()는 null이 아닌지만 본다(499–501행) |
같은 곳 |
bindResource는 맵에 넣되 같은 열쇠가 이미 있으면 IllegalStateException을 던지고(173–176행), getResource는 맵에서 꺼냅니다(133–136행). ConnectionHolder는 커넥션 하나와 "JDBC 트랜잭션이 활성인가" 표시를 가진 작은 객체이고(43–55행), 부모 ResourceHolderSupport가 6절에서 중요해지는 rollbackOnly 표시와 참조 횟수를 가집니다(41행, 45행).
꺼내 쓰는 쪽: DataSourceUtils.getConnection
묶어 두기만 하면 소용이 없습니다. 서비스 메서드 안의 JdbcTemplate(18편의 주제입니다)이 같은 커넥션을 찾아 써야 트랜잭션 안에 들어갑니다. 그 길이 DataSourceUtils.getConnection입니다(79–89행).
public static Connection doGetConnection(DataSource dataSource) throws SQLException {
Assert.notNull(dataSource, "No DataSource specified");
ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);
if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) {
conHolder.requested();
// ...
return conHolder.getConnection();
}
// Else we either got no holder or an empty thread-bound holder here.
logger.debug("Fetching JDBC Connection from DataSource");
Connection con = fetchConnection(dataSource);
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// ...
TransactionSynchronizationManager.bindResource(dataSource, holderToUse);
// ...
}
return con;
}
spring-jdbc/src/main/java/org/springframework/jdbc/datasource/DataSourceUtils.java 103–147행
스레드에 묶인 홀더가 있으면 그 커넥션을 돌려주고 참조 횟수만 올립니다. 짝이 되는 releaseConnection은 묶인 커넥션이면 닫지 않고 참조 횟수만 내립니다(407–420행, "It's the transactional Connection: Don't close it."). 묶인 것이 없으면 새로 받되, 동기화가 켜져 있으면(트랜잭션 없이 SUPPORTS로 도는 경우 등) 그 자리에서 묶어 두어 메서드가 끝날 때까지 재사용합니다.
18편에서 볼 JdbcTemplate은 커넥션이 필요한 execute 메서드마다 콜백을 부르기 전에 DataSourceUtils.getConnection(obtainDataSource())를 부르고 끝나면 releaseConnection을 부릅니다(첫 번째 execute에서는 JdbcTemplate 362행과 finally의 377행). 그래서 @Transactional 메서드 안에서 jdbcTemplate.update()를 몇 번 부르든, 서로 다른 빈에서 부르든, 인자로 커넥션을 넘긴 적이 없는데도 전부 doBegin이 연 그 커넥션을 씁니다. 6절의 예제 출력에서 바깥 메서드와 안쪽 메서드의 커넥션이 같은 conn7로 찍히는 것이 그 증거입니다.
트랜잭션이 끝나면 doCleanupAfterCompletion이 거꾸로 합니다. 스레드에서 홀더를 떼고(unbindResource), 자동 커밋을 되돌리고, 커넥션을 반납합니다(376–405행).
헷갈리기 쉬운 곳
- 다른 스레드는 트랜잭션을 모릅니다. 모든 것이
ThreadLocal이므로,@Transactional메서드 안에서 새 스레드나CompletableFuture로 넘긴 작업은 묶인 커넥션을 찾지 못해 자기 커넥션을 새로 받습니다. 바깥을 롤백해도 그쪽은 되돌아가지 않습니다. dataSource.getConnection()을 직접 부르면 트랜잭션 밖입니다. 맵을 보는 쪽은DataSourceUtils입니다.DataSource에서 직접 받은 커넥션은 묶인 것과 다른 커넥션이고 자동 커밋 상태입니다.readOnly는 힌트입니다.PlatformTransactionManager의 주석은 읽기 전용 플래그를 "최적화를 위한 힌트일 뿐이며 지원하지 않으면 무시해야 한다"고 적습니다(57–59행). JDBC에서는con.setReadOnly(true)를 부르되 실패해도 타임아웃이 아니면 삼킵니다(DataSourceUtils232–236행). 쓰기를 막아 주는지는 드라이버와 DB에 달려 있습니다.
6. commit 과 rollback — 그리고 UnexpectedRollbackException
인터셉터가 마지막에 부르는 commit입니다.
@Override
public final void commit(TransactionStatus status) throws TransactionException {
if (status.isCompleted()) {
throw new IllegalTransactionStateException(
"Transaction is already completed - do not call commit or rollback more than once per transaction");
}
DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
if (defStatus.isLocalRollbackOnly()) {
// ...
processRollback(defStatus, false);
return;
}
if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
// ...
processRollback(defStatus, true);
return;
}
processCommit(defStatus);
}
commit이라고 불렀는데 롤백으로 가는 길이 둘 있습니다. 하나는 로컬 롤백 전용 — 누군가 이 TransactionStatus에 setRollbackOnly()를 부른 경우(AbstractTransactionStatus 62–67행, 86–88행)입니다. 다른 하나는 글로벌 롤백 전용 — 트랜잭션 객체 자체에 롤백 표시가 붙은 경우로, DefaultTransactionStatus.isGlobalRollbackOnly는 트랜잭션 객체가 SmartTransactionObject이면 그것의 isRollbackOnly()를 묻습니다(175–178행). JDBC의 트랜잭션 객체는 그것을 ConnectionHolder.isRollbackOnly()로 답합니다(JdbcTransactionObjectSupport 48행, DataSourceTransactionManager 512–515행). shouldCommitOnGlobalRollbackOnly()의 기본값은 false입니다(1227–1229행). 이 표시가 누가, 언제 붙이는지가 이 절의 수수께끼입니다.
정상 경로 processCommit(766–848행)은 순서가 중요합니다. triggerBeforeCommit → triggerBeforeCompletion(774–775행) → 세이브포인트가 있으면 해제, 새 트랜잭션이면 doCommit, 참여 중이면 아무것도 안 함(778–798행) → triggerAfterCommit → triggerAfterCompletion(835–838행) → finally에서 cleanupAfterCompletion(846행). trigger…는 5절 맵의 synchronizations에 등록된 콜백을 부르는 것이고(TransactionSynchronization 133–186행), doCommit은 JDBC에서 con.commit() 한 줄입니다(336–348행). cleanupAfterCompletion은 상태를 완료로 표시하고, 새 동기화였으면 스레드 로컬을 비우고(TransactionSynchronizationManager.clear), 새 트랜잭션이었으면 5절 끝의 doCleanupAfterCompletion을 부르고, 밀어 둔 자원이 있으면 resume합니다(1055–1070행).
rollback: 새 트랜잭션이냐, 참여 중이냐
rollback은 완료 검사 뒤 곧장 processRollback(status, false)입니다(858–866행). 그 안의 갈림길입니다.
if (status.hasSavepoint()) {
// ...
status.rollbackToHeldSavepoint();
}
else if (status.isNewTransaction()) {
// ...
doRollback(status);
}
else {
// Participating in larger transaction
if (status.hasTransaction()) {
if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()) {
// ...
doSetRollbackOnly(status);
}
// ...
}
// ...
}
// ...
// Raise UnexpectedRollbackException if we had a global rollback-only marker
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction rolled back because it has been marked as rollback-only");
}
- 내가 연 트랜잭션(
isNewTransaction)이면doRollback, 곧con.rollback()입니다(351–363행). - 참여 중이면 커넥션을 되돌릴 권한이 없습니다. 대신
globalRollbackOnParticipationFailure(기본true, 138행)에 따라doSetRollbackOnly로 트랜잭션 객체에 롤백 표시를 남깁니다. JDBC에서는ConnectionHolder.setRollbackOnly()입니다(366–373행,ResourceHolderSupport67–69행).
이제 수수께끼가 풀립니다. 그 ConnectionHolder는 바깥 메서드와 안쪽 메서드가 같이 쓰는 홀더입니다. 안쪽이 표시를 남기고 돌아간 뒤 바깥이 commit을 부르면, commit의 두 번째 검사 isGlobalRollbackOnly()가 true가 되어 processRollback(status, true)로 갑니다. 바깥은 새 트랜잭션이니 con.rollback()이 실행되고, unexpected = true였으므로 마지막에 UnexpectedRollbackException 이 던져집니다.
노란 상자가 두 메서드가 공유하는 홀더에 남는 표시, 빨간 상자가 그 표시 때문에 바깥이 받는 예외입니다.
예제 ③: 안쪽 예외를 바깥이 삼켰을 때
바깥 @Transactional 메서드가 한 행을 넣고 다른 빈의 안쪽 메서드를 부릅니다. 안쪽은 한 행을 넣고 IllegalStateException을 던지며, 바깥은 그것을 catch로 잡고 정상 반환합니다. 안쪽의 전파만 REQUIRES_NEW와 REQUIRED로 바꿔 두 번 돌립니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Transactional
public void outerWithRequired() {
printConnection(dataSource, "바깥 REQUIRED");
jdbc.update("INSERT INTO member (name) VALUES (?)", "outer");
try {
inner.insertThenFailRequired(); // 안쪽은 @Transactional(propagation = REQUIRED)
}
catch (IllegalStateException ex) {
System.out.println(" [바깥] 안쪽 예외를 잡았다: " + ex.getMessage());
}
}
printConnection은 DataSourceUtils.getConnection으로 꺼낸 커넥션과 getCurrentTransactionName()을 찍습니다.
== 1. REQUIRED 안에서 REQUIRES_NEW 가 실패
[바깥 REQUIRED] 트랜잭션 이름 = ex.u15.PropagationExample$OuterService.outerWithRequiresNew
[바깥 REQUIRED] 커넥션 = conn3: url=jdbc:h2:mem:u15 user=SA
[안쪽 REQUIRES_NEW] 트랜잭션 이름 = ex.u15.PropagationExample$InnerService.insertThenFailRequiresNew
[안쪽 REQUIRES_NEW] 커넥션 = conn4: url=jdbc:h2:mem:u15 user=SA
[바깥] 안쪽 예외를 잡았다: 안쪽에서 실패
바깥 메서드가 정상 반환
남은 행 = [outer]
== 2. REQUIRED 안에서 REQUIRED 가 실패
[바깥 REQUIRED] 트랜잭션 이름 = ex.u15.PropagationExample$OuterService.outerWithRequired
[바깥 REQUIRED] 커넥션 = conn7: url=jdbc:h2:mem:u15 user=SA
[안쪽 REQUIRED] 트랜잭션 이름 = ex.u15.PropagationExample$OuterService.outerWithRequired
[안쪽 REQUIRED] 커넥션 = conn7: url=jdbc:h2:mem:u15 user=SA
[바깥] 안쪽 예외를 잡았다: 안쪽에서 실패
바깥 메서드가 던진 예외: UnexpectedRollbackException - Transaction rolled back because it has been marked as rollback-only
남은 행 = []
1번은 4절의 REQUIRES_NEW 경로입니다. 안쪽은 다른 커넥션(conn4)과 다른 트랜잭션 이름을 받았고, 안쪽만 롤백되어 outer 행이 남았습니다. 2번은 참여 경로입니다. 안쪽의 트랜잭션 이름이 바깥 메서드의 이름이고 커넥션도 conn7로 같습니다. 바깥은 예외를 잡아 정상 반환했는데도 커밋 대신 UnexpectedRollbackException을 받았고, 두 행 모두 사라졌습니다. 예외 메시지가 위 processRollback의 문자열 그대로입니다.
헷갈리기 쉬운 곳
- 안쪽 예외를 잡는다고 바깥이 살지 않습니다. 위 2번입니다. 롤백 표시는 안쪽 메서드가 빠져나오는 순간 인터셉터가 남기므로, 바깥 메서드가 그 뒤에 무엇을 하든 커밋은 불가능합니다.
setGlobalRollbackOnParticipationFailure의 주석은 이 상황의 권장 해법으로 세이브포인트를 쓰는PROPAGATION_NESTED를 들고, 그것이DataSourceTransactionManager에서는 되지만JtaTransactionManager에서는 안 된다고 적습니다(278–283행). UnexpectedRollbackException은 가장 바깥에서만 납니다. 참여 중인 상태에서는failEarlyOnGlobalRollbackOnly(기본false, 140행)가 아니면unexpectedRollback을false로 되돌립니다(916–919행). 세 겹이면 가운데는 조용히 지나가고 맨 바깥의commit에서 터집니다.commit이 예외를 던진 뒤rollback을 부르면 안 됩니다.commit은 실패해도 트랜잭션을 완료 상태로 만들고 정리까지 끝내므로, 그 뒤의rollback은IllegalTransactionStateException이 됩니다.PlatformTransactionManager의 주석이 굵은 글씨로 경고하는 내용입니다(106–109행). 3절의 인터셉터가rollback또는commit중 하나만 부르는 이유이기도 합니다.
더 깊이 들어가면
이 글은 큰 줄기를 위해 많은 것을 뺐습니다. 공부 노트에는 있지만 여기서는 이름만 적어 둡니다. 어노테이션 없이 람다로 트랜잭션을 감싸는 TransactionTemplate(65행). getTransaction·commit·rollback 대신 콜백으로 트랜잭션을 받는 매니저를 위한 invokeWithinTransaction의 두 번째 갈래 CallbackPreferringPlatformTransactionManager(44행, TransactionAspectSupport 412행부터). 프록시 대신 AspectJ 위빙을 쓰는 mode = ASPECTJ와 그때 읽는 설정 클래스 이름(TransactionManagementConfigurationSelector 51행, TransactionManagementConfigUtils 49행). 커밋 뒤에만 이벤트 리스너를 부르는 @TransactionalEventListener(70행, 기본 단계 AFTER_COMMIT은 78행; 12편의 이벤트와 이 글의 동기화 콜백이 만나는 자리). 시작·커밋·롤백 전후를 듣는 6.1의 TransactionExecutionListener(38행). NESTED가 쓰는 세이브포인트의 생성·되돌리기·해제(AbstractTransactionStatus 142–170행). 타임아웃이 ConnectionHolder의 마감 시각이 되는 길(AbstractPlatformTransactionManager 596–601행, DataSourceTransactionManager 303–306행). 커밋이 실패했을 때의 롤백 처리 doRollbackOnCommitException(953–976행). 그리고 ReactiveTransactionManager와 invokeWithinTransaction의 리액티브 갈래(341–358행)는 19편에서 봅니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
PlatformTransactionManager |
getTransaction·commit·rollback 세 메서드. 기술별 구현이 AbstractPlatformTransactionManager를 상속한다 |
TransactionDefinition |
어떻게 열지. 전파 7가지·격리·타임아웃·읽기 전용. 기본은 REQUIRED·DB 기본·-1·false |
TransactionStatus |
지금 어떤 상태인지. isNewTransaction()이 커밋·롤백의 운명을 가른다 |
@EnableTransactionManagement |
자동 프록시 생성기 + 어드바이저(포인트컷·인터셉터·어트리뷰트 소스)를 등록한다 |
TransactionInterceptor |
호출 앞에서 getTransaction, 뒤에서 commit 또는 rollback. 트랜잭션을 여는 것도 닫는 것도 이 어드바이스 |
rollbackOn |
기본은 RuntimeException·Error만 롤백. rollbackFor·noRollbackFor는 예외 계층에서 가장 가까운 규칙이 이긴다 |
| 자기 호출 | this.inner()는 프록시를 거치지 않아 @Transactional이 무시된다. REQUIRES_NEW도 마찬가지 |
getTransaction |
doGetTransaction → isExistingTransaction → 전파에 따라 startTransaction(doBegin)·suspend·세이브포인트·참여 |
TransactionSynchronizationManager |
ThreadLocal 맵에 DataSource → ConnectionHolder를 묶어 같은 스레드의 모든 JDBC 호출이 같은 커넥션을 쓰게 한다 |
DataSourceUtils.getConnection |
묶인 커넥션이 있으면 그것을 주고, releaseConnection은 닫지 않는다. JdbcTemplate이 쓰는 길 |
| 롤백 표시 | 참여 중인 안쪽이 실패하면 공유 홀더에 rollbackOnly를 남기고, 바깥의 commit이 그것을 보고 롤백한 뒤 UnexpectedRollbackException을 던진다 |
다음에 볼 것
1편의 지도에서 Phase 4(트랜잭션과 데이터 접근)의 첫 단위를 마쳤습니다. 이 글에서 5절은 "JdbcTemplate이 스레드에 묶인 커넥션을 DataSourceUtils로 꺼내 쓴다"고만 하고 넘어갔습니다. 다음 글 18편: JdbcTemplate — 템플릿-콜백 패턴과 예외 변환에서는 그 JdbcTemplate이 커넥션을 받은 뒤 Statement를 만들고, 결과를 RowMapper로 바꾸고, SQLException을 Spring의 DataAccessException 계층으로 번역하는 과정을 봅니다. 이 글의 ConnectionHolder가 거기서 다시 나옵니다.
트랜잭션 매니저가 마지막에 부르는 커밋이 데이터베이스 서버 안에서 무엇이 되는지는 MariaDB 소스 파헤치기 12편에서, 이름만 같은 Kafka의 트랜잭션은 Kafka 소스 파헤치기 12편에서 봅니다.