CS/Spring

@Transactional 심화: propagation, isolation, rollback rules

dding-shark 2026. 4. 13. 10:22
728x90

@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

스프링의 트랜잭션은 처음부터 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 PlatformTransactionManager interface."

구현체 목록을 보면 이 추상의 힘이 체감된다.

구현체 대상 언제 쓰나
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편 §57편에서 다뤘으니 여기서는 트랜잭션 관점에서만 한 번 더 지나간다. @Transactional이 달린 빈이 컨테이너에 올라갈 때, BeanPostProcessor가 원본 빈을 프록시로 교체한다. 외부에서 orderService.place(order)를 호출하면 실제로 호출되는 건 프록시고, 프록시 안쪽에 TransactionInterceptor가 들어 있다.

이 인터셉터의 invoke 메서드 흐름은 단순한 6단계다.

  1. 프록시 메서드 호출 진입
  2. TransactionAttributeSource@Transactional 메타데이터를 읽어 TransactionAttribute를 만든다 (propagation, isolation, rollbackFor 등)
  3. createTransactionIfNecessaryPlatformTransactionManager.getTransaction() 호출. 기존 트랜잭션이 없으면 시작, 있으면 참여
  4. 타겟 메서드 실행
  5. 예외가 나면 completeTransactionAfterThrowingrollbackOn(throwable)이 true면 rollback, 아니면 commit
  6. 정상 종료면 commitTransactionAfterReturning → commit

08_spring-transactional-deep-dive-01

여기서 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_REQUIRED uses the characteristics of the outer scope. A TransactionException is 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이다. JpaTransactionManagerNESTED를 지원하지 않는다 — NestedTransactionNotSupportedException을 던진다. Hibernate의 1차 캐시 구조와 savepoint가 맞물리지 않아서다. JPA 프로젝트라면 NESTED 대신 REQUIRES_NEW 또는 도메인 수준의 보상 트랜잭션을 쓰는 쪽이 안전하다.

3-4) 세 Propagation의 흐름 비교

세 가지를 한 장에 놓고 보자.

08_spring-transactional-deep-dive-02

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는 외부 트랜잭션에 참여 중이었고, 예외가 나면서 TransactionStatusrollbackOnly = 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

해법은 둘 중 하나다. 삼켜도 되는 실패라면 innerREQUIRES_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로 동작한다
}

innerSERIALIZABLE을 박았지만 outer의 경계에 참여하기 때문에 아무 효력도 없다. 심지어 에러도 안 난다. Spring 4.1부터는 "선언이 일치하지 않으면 예외를 내도록" 강제하는 validateExistingTransaction = true 옵션이 있지만 기본값은 false다. 현실에서는 isolation은 트랜잭션 경계가 새로 생기는 지점(진입점/REQUIRES_NEW/NEVERREQUIRED 전환 지점)에서만 의미 있다가 맞는 표현이다.


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. (Error instances 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

정리하면 기본 롤백 대상은 RuntimeExceptionError이다. 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이 던져지고, 눈으로는 "롤백되겠지"라고 읽힌다. 실제로는 TransactionInterceptorrollbackOn이 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());
    }
}

시점 관계를 시퀀스로 보면 이렇게 된다.

08_spring-transactional-deep-dive-03

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 + LazyConnectionDataSourceProxy 3종 세트. 셋 중 하나 빠지면 작동 안 한다.
  • 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

728x90