@Transactional 심화: propagation, isolation, rollback rules
들어가며
@Transactional은 스프링에서 가장 자주 쓰이는
애너테이션이다. 한 줄이면 커밋/롤백이 알아서 붙고, DB 커넥션이 열렸다
닫힌다 — 편하다. 그런데 편한 만큼 내부 동작을 안 보고 쓰다가 한
번씩 크게 밟는 애너테이션이기도 하다. 커밋이 안 되어야 할
상황에서 커밋되고, 롤백되지 말아야 할 상황에서
UnexpectedRollbackException이 튀고,
isolation = SERIALIZABLE을 박았는데 실제 DB에는 아무 변화가
없는 일이 반복해서 일어난다.
1편에서
@Transactional을 AOP 프록시의 대표 예시로 썼다. 그땐
"프록시가 메서드를 감싸서 커밋/롤백을 걸어준다"까지만 이야기했다. 이번
8편은 그 프록시 안쪽, 정확히는 프록시가 호출하는
PlatformTransactionManager SPI 한 층과, 그 위에서 돌아가는
TransactionInterceptor, 그리고 7종의
Propagation이 만드는 "경계의 언어"를 본다. 실무에서 터지는
@Transactional 버그는 거의 전부 이 경계를 **값(속성)**으로
착각할 때 나온다.
throws Exception을 던졌는데 왜 커밋되지? — 기본 롤백 규칙은 unchecked만이다- 내부에서 롤백했는데 바깥에서
UnexpectedRollbackException이 튄다 —REQUIRED의 rollback-only 전파 REQUIRES_NEW를 남발하면 커넥션 풀이 말라버린다 — 한 요청이 커넥션 2개를 잡는다isolation = SERIALIZABLE을 박았는데 조용히 무시된다 — 참여(participating) 트랜잭션이라서this.innerMethod()호출은@Transactional을 통째로 우회한다 — 1편·7편에서 본 그 self-invocation
이 글은 Transaction 추상 → 인터셉터 동작 → Propagation → Isolation → Rollback rules → readOnly → Event phase → Template → 실무 순으로, Spring Framework 6.x / Spring Boot 3.x / Java 17+ 기준으로 정리한다.
목차
- 1) Transaction 추상: PlatformTransactionManager SPI
- 2) @Transactional 동작 흐름: TransactionInterceptor
- 3) Propagation 7종: 경계 설계의 언어
- 4) Isolation과 DB 기본값
- 5) Rollback rules: 기본값의 비직관성
- 6) readOnly=true의 실제 효과
- 7) @TransactionalEventListener: 트랜잭션 경계와 이벤트
- 8) TransactionTemplate: 프로그래매틱 경계
- 9) 실무에서 이렇게 읽고 쓴다
- 10) 한 줄 정리
1) Transaction 추상: PlatformTransactionManager SPI
스프링의 트랜잭션은 처음부터 JDBC도 JPA도 JTA도 Kafka도 같은
얼굴로 쓸 수 있게 설계되어 있다. 그 얼굴이 바로
PlatformTransactionManager라는 SPI(Service Provider
Interface)다. 인터페이스 자체는 허무할 정도로 작다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}세 개짜리 메서드다. getTransaction으로 "트랜잭션 경계를
시작하거나 기존 경계에 참여"하고, 정상 종료면 commit,
예외면 rollback. 이 추상 한 층 덕분에
@Transactional이 데이터 액세스 기술에
독립적이게 된다. 자세한 건 Spring
Reference — Transaction Strategies의 "The
PlatformTransactionManager" 절에 한 문장으로 정리되어
있다.
"Spring's transaction strategy is defined by the
PlatformTransactionManagerinterface."
구현체 목록을 보면 이 추상의 힘이 체감된다.
| 구현체 | 대상 | 언제 쓰나 |
|---|---|---|
DataSourceTransactionManager |
JDBC DataSource 커넥션 |
MyBatis·JdbcTemplate·순수 JDBC |
JpaTransactionManager |
JPA EntityManager + JDBC |
Spring Data JPA, Hibernate |
JtaTransactionManager |
JTA (글로벌/XA) | 멀티 리소스 2PC, WAS 환경 |
KafkaTransactionManager |
Kafka producer transaction | exactly-once publish |
R2dbcTransactionManager |
R2DBC 커넥션 | 리액티브 스택 |
재밌는 건, 필요하면 직접 구현할 수도 있다는 것이다. 스켈레톤은 이 정도다.
public class MyCustomTxManager extends AbstractPlatformTransactionManager {
@Override
protected Object doGetTransaction() {
// 현재 스레드/컨텍스트에 바인딩된 "트랜잭션 객체"를 꺼내거나 만든다
return new MyTxObject();
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
// 실제로 트랜잭션 시작 — 커넥션 autoCommit(false) 등
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
// commit
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
// rollback
}
}대부분은 AbstractPlatformTransactionManager를 상속하고
4~6개 훅만 채운다. 그러면 @Transactional이 이 SPI를 통해
그대로 작동한다. @Transactional은 DB를 아는 게
아니라 이 인터페이스를 안다 — 이 구분이 뒤의
Propagation/Isolation 이야기를 전부 떠받친다.
2) @Transactional 동작 흐름: TransactionInterceptor
AOP 부분은 1편
§5와 7편에서 다뤘으니
여기서는 트랜잭션 관점에서만 한 번 더 지나간다.
@Transactional이 달린 빈이 컨테이너에 올라갈 때,
BeanPostProcessor가 원본 빈을 프록시로
교체한다. 외부에서 orderService.place(order)를
호출하면 실제로 호출되는 건 프록시고, 프록시 안쪽에
TransactionInterceptor가 들어 있다.
이 인터셉터의 invoke 메서드 흐름은 단순한 6단계다.
- 프록시 메서드 호출 진입
TransactionAttributeSource가@Transactional메타데이터를 읽어TransactionAttribute를 만든다 (propagation, isolation, rollbackFor 등)createTransactionIfNecessary—PlatformTransactionManager.getTransaction()호출. 기존 트랜잭션이 없으면 시작, 있으면 참여- 타겟 메서드 실행
- 예외가 나면
completeTransactionAfterThrowing→rollbackOn(throwable)이 true면 rollback, 아니면 commit - 정상 종료면
commitTransactionAfterReturning→ commit
여기서 5단계의 rollbackOn 기본값이
나중에 §5에서 다룰 함정의 핵심이다.
DefaultTransactionAttribute.rollbackOn은
"RuntimeException 또는 Error일 때 true"만
반환한다. 즉 throws IOException으로 던지면 기본적으로는
rollback이 아니라 commit이 된다. 뒤에서 재현해
보자.
2-5) AOP 복기: self-invocation이 또 문제인 이유
여기서 한 번 더 짚어야 하는 게 있다. @Transactional은
프록시에 붙은 것이지 타겟 클래스의 메서드 그 자체에
붙은 게 아니다. 따라서 같은 클래스 안에서 this.inner()
형태로 호출하면 프록시를 거치지 않고 바로 타겟 메서드로
들어간다. TransactionInterceptor가 낄 자리가
없다.
@Service
public class OrderService {
@Transactional
public void outer(Order o) {
// 여기는 프록시 경유 → 트랜잭션 O
validate(o);
this.inner(o); // <-- 피하기: this. 는 프록시 우회
}
@Transactional(propagation = REQUIRES_NEW)
public void inner(Order o) {
// 이 어노테이션은 아무 의미가 없다. REQUIRES_NEW 안 걸린다.
auditRepository.save(new Audit(o));
}
}outer가 이미 REQUIRED로 걸려 있으니,
this.inner()를 호출하면 같은 트랜잭션에 계속
머문다. REQUIRES_NEW가 "별도 커넥션으로 독립
커밋되겠지"라고 기대했다면 그 기대는 조용히 깨진다. Kotlin에서는
클래스·메서드가 기본 final이라 CGLIB 프록시 자체가 안
만들어질 수 있다 — open 키워드 또는
kotlin-spring 컴파일러 플러그인이 필요하다.
해법은 세 가지 중 하나다.
// 선호 1: 자기 자신을 프록시로 주입받는다 (Spring 4.3+, 생성자 주입)
@Service
public class OrderService {
private final OrderService self;
public OrderService(@Lazy OrderService self) { this.self = self; }
@Transactional
public void outer(Order o) {
self.inner(o); // 프록시 경유 → REQUIRES_NEW 작동
}
}
// 선호 2: 책임을 다른 빈으로 분리 (가장 깔끔)
@Service
public class OrderService {
private final AuditService auditService;
@Transactional
public void outer(Order o) {
auditService.audit(o); // 다른 빈 = 다른 프록시 = 정상 경유
}
}
// 허용: AopContext.currentProxy() (exposeProxy = true 설정 필요, 테스트 비친화적)이 함정은 @Transactional을 AOP로 구현한 필연적
부작용이다. AOP 깊이가 필요하면 7편을 같이 보면 된다. 8편에서는
"트랜잭션 경계가 프록시 경유일 때만 생긴다"만 기억하면 충분하다.
3) Propagation 7종: 경계 설계의 언어
@Transactional(propagation = ...)의 7가지 값은 단순한
스위치가 아니라 "호출자와 피호출자의 경계를 어떻게 합칠지"를
정하는 언어다. 스펙은 Spring
Reference — Transaction Propagation에 있다.
| Propagation | 기존 트랜잭션 있을 때 | 없을 때 | 전형적 용도 |
|---|---|---|---|
REQUIRED (기본) |
참여(join) | 새로 시작 | 대부분의 서비스 메서드 |
REQUIRES_NEW |
일시중단 후 새 트랜잭션 | 새로 시작 | 독립 커밋 필요 (감사 로그, 알림) |
NESTED |
savepoint로 중첩 | 새로 시작 | 부분 롤백 (JDBC 전용) |
SUPPORTS |
참여 | 트랜잭션 없이 실행 | 읽기 전용 유틸 |
NOT_SUPPORTED |
일시중단, 트랜잭션 없이 실행 | 트랜잭션 없이 실행 | 트랜잭션 밖에서 돌려야 하는 작업 |
MANDATORY |
참여 | 예외 | 반드시 외부 경계 요구 |
NEVER |
예외 | 트랜잭션 없이 실행 | 트랜잭션 금지 강제 |
7개를 다 외울 필요는 없다. 실무에서 정말 많이 쓰는 건
REQUIRED, REQUIRES_NEW, NESTED 이
셋이다. 각각을 자세히 보자.
3-1) REQUIRED: 참여의 기본값, silently ignored 속성들
REQUIRED는 "경계가 있으면 끼어들고, 없으면 새로 연다"가
전부다. 거의 모든 @Transactional의 기본값이다. 문제는
참여(participating) 상황에서 발생한다. 외부 트랜잭션에
끼어들면, 내부 @Transactional에 적힌
isolation, timeout, readOnly 같은
속성은 조용히 무시된다. Spring Reference가 직접
경고한다.
"
PROPAGATION_REQUIREDuses the characteristics of the outer scope. ATransactionExceptionis not thrown. ... local isolation level, timeout value, and read-only flags of such inner scopes are silently ignored."
— Transaction Propagation — REQUIRED
"조용히 무시된다"가 핵심이다. 에러도 로그도 없다. 그래서
isolation = SERIALIZABLE을 inner 메서드에 박으면
박은 사람만 안 무시된다고 믿는다. §4에서 한 번 더
꺼낸다.
3-2) REQUIRES_NEW: 독립 커밋과 커넥션 압박
REQUIRES_NEW는 "기존 트랜잭션이 있어도
일시중단(suspend)하고 새 커넥션으로 새 트랜잭션을
연다". 독립 커밋이 필요할 때 — 예를 들어 메인 작업이 실패해도 감사
로그는 남겨야 할 때 — 쓴다.
@Service
public class OrderService {
@Transactional // REQUIRED
public void place(Order o) {
inventoryService.reserve(o);
auditService.audit(o); // REQUIRES_NEW
paymentService.charge(o); // 여기서 실패해도 audit는 이미 커밋됨
}
}
@Service
public class AuditService {
@Transactional(propagation = REQUIRES_NEW)
public void audit(Order o) { auditRepo.save(new Audit(o)); }
}여기서 실무가 자주 밟는 함정이 있다. 한 요청이 동시에 커넥션
2개를 쥔다. 외부 트랜잭션의 커넥션을 놓지 않은 채로 내부
트랜잭션이 새 커넥션을 요구하기 때문이다. HikariCP
maximum-pool-size = 10인데 동시 요청 6개가
REQUIRES_NEW를 쓰면 바로 12개의 커넥션이
필요해지고, 풀은 고갈된다. 데드락 비슷한 대기 현상이 나고,
타임아웃이 터진다.
경험칙은 명확하다. REQUIRES_NEW는 동시성이 풀
크기의 절반을 넘지 않도록 경로를 제한해서 써야 한다. 남발하면
재앙이다.
3-3) NESTED: savepoint 중첩, 단 JPA에서는 안 된다
NESTED는 외부 트랜잭션 안에서 JDBC
savepoint를 찍어 "부분 롤백"을 만든다. 내부 실패 시
savepoint로만 롤백하고 외부는 그대로 진행할 수 있다. 이게 가능한 건
DataSourceTransactionManager뿐이다.
JpaTransactionManager는 NESTED를 지원하지
않는다 — NestedTransactionNotSupportedException을 던진다.
Hibernate의 1차 캐시 구조와 savepoint가 맞물리지 않아서다. JPA
프로젝트라면 NESTED 대신 REQUIRES_NEW 또는
도메인 수준의 보상 트랜잭션을 쓰는 쪽이 안전하다.
3-4) 세 Propagation의 흐름 비교
세 가지를 한 장에 놓고 보자.
REQUIRED는 커넥션 하나, REQUIRES_NEW는
커넥션 두 개, NESTED는 커넥션 하나 + savepoint. 이 그림이
머리에 박혀 있어야 풀 고갈 계산이 가능하다.
3-5) 함정 재현: UnexpectedRollbackException
REQUIRED의 참여 구조에서 가장 유명한 버그는 이거다.
외부는 try/catch로 내부 예외를 삼켰는데, 커밋 시점에
UnexpectedRollbackException이 튀는 현상.
@Transactional // REQUIRED
public void outer() {
try {
self.inner(); // 프록시 경유
} catch (DataIntegrityViolationException ignored) {
// "난 이거 삼켰으니까 커밋되겠지"
}
// 여기서 UnexpectedRollbackException 터짐
}
@Transactional // REQUIRED
public void inner() {
repo.save(bad); // DataIntegrityViolation → rollback-only 마킹
}이유는 단순하다. inner는 외부 트랜잭션에
참여 중이었고, 예외가 나면서
TransactionStatus에
rollbackOnly = true 플래그를 찍었다. 이
플래그는 한번 찍히면 지울 수 없다. outer가 예외를 삼키고
정상 종료하면, 커밋 시점에 트랜잭션 매니저가 "어? rollbackOnly인데
commit하려고 하네" → UnexpectedRollbackException을
던진다.
"when a participating transaction is marked rollback-only, the entire transaction is marked rollback-only globally. When the outer caller eventually commits, it receives an
UnexpectedRollbackException." — Spring Reference
해법은 둘 중 하나다. 삼켜도 되는 실패라면
inner를 REQUIRES_NEW로 분리해서 독립
커밋/롤백을 시키거나, 삼키지 말고 외부에서도 롤백시키거나. "참여 중인
트랜잭션의 예외는 외부가 마음대로 삼킬 수 없다"를 규칙으로 외워 두면
편하다.
4) Isolation과 DB 기본값
@Transactional(isolation = ...)은 4단계
표준(READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE) +
DEFAULT를 지원한다. DEFAULT는 DB 기본값을
그대로 쓴다. 이 "DB 기본값"이 생각보다 중요하다.
| DBMS | 기본 Isolation |
|---|---|
| MySQL (InnoDB) | REPEATABLE_READ |
| PostgreSQL | READ_COMMITTED |
| Oracle | READ_COMMITTED |
| SQL Server | READ_COMMITTED |
| H2 | READ_COMMITTED |
MySQL만 REPEATABLE_READ인 점이 눈에 띈다. 팀이
PostgreSQL에서 개발하다가 MySQL로 배포하면 같은 코드가 다르게
동작할 수 있다 — 특히 gap lock과 phantom read 관련 시나리오.
@Transactional에 isolation을 명시하지 않으면 이 차이가
그대로 따라간다.
그리고 §3-1에서 예고한 함정. 참여 트랜잭션의 isolation은 silently ignored다. 재현해 보자.
@Transactional // 기본: REQUIRED, isolation=DEFAULT (READ_COMMITTED)
public void outer() {
self.inner();
}
@Transactional(isolation = Isolation.SERIALIZABLE) // 이 속성은 무시된다
public void inner() {
// READ_COMMITTED로 동작한다
}inner에 SERIALIZABLE을 박았지만
outer의 경계에 참여하기 때문에 아무 효력도 없다. 심지어
에러도 안 난다. Spring 4.1부터는 "선언이 일치하지
않으면 예외를 내도록" 강제하는
validateExistingTransaction = true 옵션이 있지만 기본값은
false다. 현실에서는 isolation은 트랜잭션 경계가 새로 생기는
지점(진입점/REQUIRES_NEW/NEVER→REQUIRED
전환 지점)에서만 의미 있다가 맞는 표현이다.
5) Rollback rules: 기본값의 비직관성
여기가 @Transactional에서 가장 많은 사람을 한 번씩
속이는 지점이다. Spring Reference의 해당 문장을 그대로 옮겨 본다.
"In its default configuration, the Spring Framework's transaction infrastructure code marks a transaction for rollback only in the case of runtime, unchecked exceptions. That is, when the thrown exception is an instance or subclass of
RuntimeException. (Errorinstances also, by default, result in a rollback.) Checked exceptions that are thrown from a transactional method do not result in rollback in the default configuration."
— Spring Reference — Rolling Back a Declarative Transaction
정리하면 기본 롤백 대상은 RuntimeException과
Error뿐이다. IOException,
SQLException, 커스텀
BusinessException extends Exception 전부 롤백되지
않고 커밋된다. 재현 코드를 보자.
@Service
public class PaymentService {
@Transactional
public void charge(Order o) throws BusinessException {
paymentRepo.save(new Payment(o, PENDING));
if (o.amount() > limit) {
// checked exception — 기본값에서는 rollback 안 됨
throw new BusinessException("over limit");
}
paymentRepo.updateStatus(o.id(), SUCCESS);
}
}
public class BusinessException extends Exception {
public BusinessException(String m) { super(m); }
}charge를 over-limit으로 호출하면
BusinessException이 던져지고, 눈으로는 "롤백되겠지"라고
읽힌다. 실제로는 TransactionInterceptor의
rollbackOn이 false를 반환하고,
Payment(PENDING) 레코드가 DB에 그대로
커밋된다. 운영 데이터에 유령 PENDING이 쌓이는 전형적
경로다.
해법은 네 가지다.
// 선호 1: rollbackFor로 명시
@Transactional(rollbackFor = Exception.class)
public void charge(Order o) throws BusinessException { ... }
// 선호 2: 팀 컨벤션용 메타 애너테이션
@Target(METHOD)
@Retention(RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface TxRollback {
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
boolean readOnly() default false;
}
// 이후
@TxRollback
public void charge(Order o) throws BusinessException { ... }
// 선호 3: checked → unchecked 래핑 원칙
// 서비스 레이어에서는 checked를 던지지 말고 unchecked로 감싼다
throw new PaymentFailedException("over limit", cause); // extends RuntimeException
// 허용: 메서드별 rollbackFor는 잊어버리기 쉽다. 컨벤션으로 강제하는 쪽이 안전팀 컨벤션으로는 **"모든 서비스 메서드는 @TxRollback 또는
@Transactional(rollbackFor = Exception.class)"**을 박아
두는 쪽을 추천한다. 한 명이 @Transactional만 쓰는 순간 그
지점이 잠재 버그다.
6) readOnly=true의 실제 효과
@Transactional(readOnly = true)도 오해가 많다. Spring
Reference는 이 속성의 한계를 명시적으로 적어 두었다.
"This just serves as a hint for the actual transaction subsystem; it does not necessarily cause failure of write access attempts."
— Spring Reference — Transaction Attribute — readOnly
즉 readOnly = true는 체크가 아니라
힌트다. 읽기 전용 트랜잭션 안에서도 INSERT,
UPDATE가 그대로 실행된다. 재현 한 번.
@Transactional(readOnly = true)
public Order findAndTouch(Long id) {
Order o = orderRepo.findById(id).orElseThrow();
// readOnly인데도 이 쿼리는 실행되고 커밋된다 (DB가 직접 막지 않으면)
orderRepo.updateLastAccessed(id, Instant.now());
return o;
}그럼 readOnly = true는 뭐 하러 쓰냐 — 세 가지
구체적 효과가 있다.
효과 1 — Hibernate FlushMode.MANUAL.
JPA(Hibernate) 환경에서 readOnly = true는 세션의 flush
mode를 MANUAL로 바꿔, dirty checking과 자동 flush를
스킵시킨다. 엔티티를 조회한 뒤 필드를 바꿔도 commit 시점에
UPDATE가 나가지 않는다. 읽기 전용 경로의 성능이 확연히 올라간다.
효과 2 — JDBC Connection.setReadOnly(true)
힌트. 커넥션에 read-only 힌트가 전달되고, 일부 드라이버/DB는
이를 바탕으로 최적화한다 (PostgreSQL의 경우 실제로 쓰기 거부까지
간다).
효과 3 — 라우팅 DataSource 키. 실무에서 가장
실용적인 용도다. AbstractRoutingDataSource로
Master/Replica를 나눠 쓸 때, 현재 트랜잭션의 readOnly
플래그를 라우팅 키로 쓴다.
public class ReplicaRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
boolean readOnly = TransactionSynchronizationManager
.isCurrentTransactionReadOnly();
return readOnly ? "replica" : "master";
}
}
@Configuration
public class DataSourceConfig {
@Bean
public DataSource routingDataSource(
@Qualifier("master") DataSource master,
@Qualifier("replica") DataSource replica) {
var ds = new ReplicaRoutingDataSource();
ds.setTargetDataSources(Map.of("master", master, "replica", replica));
ds.setDefaultTargetDataSource(master);
return ds;
}
@Bean
public DataSource dataSource(@Qualifier("routingDataSource") DataSource rds) {
// LazyConnectionDataSourceProxy 필수 — 트랜잭션 시작 "전에" 커넥션을 잡지 않도록
return new LazyConnectionDataSourceProxy(rds);
}
}여기 숨은 함정이 하나 더 있다.
LazyConnectionDataSourceProxy로 감싸지 않으면, 트랜잭션
시작 전에 커넥션을 미리 잡아 버려서 readOnly 플래그가
결정되기 전에 라우팅이 끝난다 — 모든 트랜잭션이 master로
가는 허무한 버그다. 라우팅 DataSource는 반드시 lazy로
감싼다.
7) @TransactionalEventListener: 트랜잭션 경계와 이벤트
@TransactionalEventListener는 **"이벤트를 트랜잭션
경계와 동기화"**시키는 스프링의 트랜잭션-이벤트 바인딩 장치다. 8편에서는
경계와의 관계만 본다. 실제 사용 패턴·재시도·outbox 같은
심화는 10편에서 다룬다.
네 가지 phase가 있다.
| Phase | 발화 시점 | 전형적 용도 |
|---|---|---|
BEFORE_COMMIT |
커밋 직전 | 커밋 직전 검증, 보조 flush |
AFTER_COMMIT (기본값) |
커밋 성공 직후 | 도메인 이벤트 발행, 알림, 캐시 무효화 |
AFTER_ROLLBACK |
롤백 직후 | 보상 로직, 실패 로그 |
AFTER_COMPLETION |
커밋/롤백 무관 완료 후 | 리소스 정리 |
핵심은 한 문장으로 요약된다. Spring Reference — TransactionalEventListener에 이렇게 적혀 있다.
"If no transaction is running, the listener is not invoked at all, since we cannot honor the required semantics."
트랜잭션이 없으면 리스너는 호출조차 되지 않는다 —
fallback 동작을 원하면 fallbackExecution = true를 명시해야
한다. 기본값은 조용히 스킵이다.
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
@Transactional
public void place(Order o) {
orderRepo.save(o);
publisher.publishEvent(new OrderPlaced(o.id()));
// 여기서 커밋된 뒤에야 listener가 돌아간다
}
}
@Component
public class OrderPlacedListener {
@TransactionalEventListener // 기본 AFTER_COMMIT
public void onPlaced(OrderPlaced e) {
// 이 시점엔 DB에 Order가 확실히 커밋되어 있다
notificationService.send(e.orderId());
}
}시점 관계를 시퀀스로 보면 이렇게 된다.
Publisher.publishEvent를 호출한 시점이 아니라
커밋이 끝난 시점에 listener가 실행된다는 게 핵심이다.
덕분에 "DB에는 커밋됐는데 알림만 날아가지 않은" 케이스가 생기지 않는다 —
반대로 "DB는 커밋됐는데 알림이 실패" 케이스는 여전히 가능하므로 outbox
같은 패턴이 필요하다. 상세 패턴과 재시도는 10편에서
다룬다. 8편에서는 "트랜잭션이 없으면 listener도 없다",
"AFTER_COMMIT이 기본값"만 짚고 넘어간다.
8) TransactionTemplate: 프로그래매틱 경계
@Transactional은 **선언적(declarative)**이다. 편하지만
동적이지 않다. 반복문 안에서 1000건 단위로 청크 커밋을 하고 싶다거나,
테스트에서 경계를 손으로 열고 닫고 싶다거나, 런타임에 propagation을
결정하고 싶을 때는 TransactionTemplate(프로그래매틱)을
쓴다.
@Service
public class BulkImportService {
private final TransactionTemplate tx;
private final ItemRepository repo;
public BulkImportService(PlatformTransactionManager ptm, ItemRepository repo) {
this.tx = new TransactionTemplate(ptm);
this.tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
this.repo = repo;
}
public void importAll(List<Item> items) {
int chunk = 1000;
for (int i = 0; i < items.size(); i += chunk) {
List<Item> slice = items.subList(i, Math.min(i + chunk, items.size()));
tx.execute(status -> {
try {
repo.saveAll(slice);
return null;
} catch (SkippableException e) {
status.setRollbackOnly(); // 이 청크만 롤백
return null;
}
});
}
}
}핵심 API는 두 개다. execute(TransactionCallback)으로
경계를 열고, 안에서 status.setRollbackOnly()를 호출하면
예외 없이 조건부 롤백이 가능하다.
@Transactional에서는 이게 어색하다 (예외를 던져야
하니까).
언제 TransactionTemplate을 쓰나:
- 배치의 chunk 커밋 — 100만 건을 한 트랜잭션에 묶으면 undo log가 터진다
- 테스트에서 경계를 손으로 열고 Assertion을 하거나,
@Transactional어노테이션의 rollback 동작을 우회해야 할 때 - 동적 propagation — 런타임 설정에 따라
REQUIRED/REQUIRES_NEW를 바꿔야 할 때 - self-invocation 무풍지대 — 이건 진짜 실용적이다.
TransactionTemplate은 AOP를 안 쓰기 때문에 §2-5에서 본 self-invocation 문제가 원천적으로 없다. 복잡한 트랜잭션 조합이 필요하면 오히려 프로그래매틱이 안전한 경우가 있다
반대로, 대부분의 평범한 서비스 메서드에서
TransactionTemplate으로 전환할 이유는 없다. 선언적이 읽기에
훨씬 명확하다.
9) 실무에서 이렇게 읽고 쓴다
- 팀 컨벤션: 모든 서비스 메서드는
@Transactional(rollbackFor = Exception.class)또는@TxRollback. 기본@Transactional만 쓰는 코드는 리뷰에서 지적 대상으로 둔다. checked exception이 나중에 섞였을 때 조용히 커밋되는 사고를 막는 가장 싼 방법이다. REQUIRES_NEW는 동시성이 커넥션 풀 크기의 절반을 넘지 않는 경로에서만 쓴다. 감사 로그, 알림 큐잉처럼 호출 빈도가 낮은 곳이 전형. API 핫패스에REQUIRES_NEW를 넣으면 풀 고갈이 시간문제다.- 참여 트랜잭션에는
isolation/timeout/readOnly를 박지 않는다. 박으면 "조용히 무시되는 속성"이 코드에 남아 다음 사람을 속인다. 박아야 한다면REQUIRES_NEW와 세트다. - 도메인 이벤트는
AFTER_COMMIT+ listener 쪽은REQUIRES_NEW. 이벤트 처리 실패가 원 트랜잭션에 역류하지 않게 분리. (10편에서 이 패턴을 본격적으로 다룬다.) - JPA에서는
NESTED를 안 쓴다.NestedTransactionNotSupportedException이 튀든가, 기대와 다르게 동작한다. 부분 롤백이 필요하면REQUIRES_NEW또는 도메인 수준 보상. - Master/Replica 라우팅은
@Transactional(readOnly = true)+AbstractRoutingDataSource+LazyConnectionDataSourceProxy3종 세트. 셋 중 하나 빠지면 작동 안 한다. - Kotlin 프로젝트는
open또는kotlin-spring플러그인 필수. 그렇지 않으면 프록시 자체가 안 만들어지고@Transactional이 완전히 먹통이다. - 디버깅 순서: 커밋/롤백이 이상하면 먼저 프록시 경유
여부(
this.호출인지), 그다음 예외 타입(checked인지), 그다음 propagation, 그다음 isolation. 이 순서대로 보면 대부분의@Transactional버그가 잡힌다.
10) 한 줄 정리
@Transactional은 애너테이션 한 줄이지만, 그 뒤에
PlatformTransactionManager SPI와
TransactionInterceptor 한 메서드, 그리고 7종 Propagation이
만드는 경계 설계 언어가 있다. 실무 버그는 거의 전부 이 경계를
"값(속성)"으로 착각할 때 — checked exception 기본 커밋, 참여 트랜잭션의
silently ignored 속성, REQUIRES_NEW의 풀 압박,
self-invocation에서 터진다. 다음에 @Transactional을
볼 때는 "이 경계가 누구의 경계인가"부터 묻자.
태그: Spring Framework, @Transactional, Transaction Management, Propagation, Isolation, REQUIRES_NEW, Rollback Rules, TransactionalEventListener, Spring Boot 3
'CS > Spring' 카테고리의 다른 글
| ApplicationEvent와 @Async: 컨테이너 안의 비동기 (0) | 2026.04.13 |
|---|---|
| Environment와 설정 조립: PropertySource / @Value / @ConfigurationProperties (0) | 2026.04.13 |
| Spring AOP 심화: AspectJ와의 경계, Pointcut 표현식, Advice 순서 (0) | 2026.04.12 |
| 빈 초기화 순서: @DependsOn·@Lazy·@Order로 기동을 통제하기 (0) | 2026.04.12 |
| 조건부 빈 등록: @Conditional과 Auto-configuration의 뿌리 (1) | 2026.04.12 |