Skip to main content

Command Palette

Search for a command to run...

Spring @Transactional 동작 원리 — 프록시, 트랜잭션 매니저, 전파와 롤백의 진실

Updated
12 min read

메서드 위에 @Transactional 한 줄을 붙이면 Spring은 그 호출을 가로채 트랜잭션을 시작하고, 예외가 나면 롤백하고, 정상 종료되면 커밋합니다. 이 글은 그 한 줄 뒤에서 일어나는 일을 코드 흐름과 함께 풀어냅니다. 프록시가 어떻게 메서드를 가로채는지, TransactionInterceptor가 어떤 순서로 매니저를 호출하는지, 전파 속성과 롤백 규칙이 어디서 결정되는지를 같은 흐름 안에서 정리해 보고, 마지막에는 실전에서 자주 부딪히는 다섯 가지 함정을 다룹니다. Spring Framework 6.x를 기준으로 작성했고, 7.0 시점에도 핵심 흐름은 동일합니다.

글 전 한 가지 사실 정리

@Transactional선언적 트랜잭션을 위한 어노테이션입니다. "선언적"이라는 말은 트랜잭션을 시작하고 끝내는 코드를 직접 쓰지 않고, 어노테이션만으로 동작 시점·범위·격리 수준·롤백 정책을 선언한다는 뜻입니다. 그 선언을 실제 동작으로 바꾸는 주체는 다음 다섯 가지입니다.

  • BeanFactoryTransactionAttributeSourceAdvisor — 어노테이션이 붙은 메서드를 advice 대상으로 잡아 주는 advisor
  • TransactionInterceptor — 메서드 호출을 감싸는 around-advice (= 트랜잭션 advice 본체)
  • TransactionAspectSupportTransactionInterceptor의 부모. 트랜잭션 시작·커밋·롤백 흐름의 알고리즘을 담음
  • PlatformTransactionManager — 실제 자원에 대해 begin/commit/rollback을 수행하는 추상 (예: DataSourceTransactionManager, JpaTransactionManager)
  • TransactionSynchronizationManagerThreadLocal로 자원(Connection 등)과 동기화 콜백을 스레드에 묶어 주는 중앙 저장소

이 다섯이 어떻게 협력하는지를 따라가면, @Transactional 한 줄이 가진 의미가 또렷하게 보입니다.

1. AOP 프록시가 메서드를 가로챈다

Spring은 컨테이너 부팅 시점에 @EnableTransactionManagement(또는 Spring Boot의 TransactionAutoConfiguration)을 통해 두 개의 핵심 빈을 등록합니다.

  • BeanFactoryTransactionAttributeSourceAdvisor — pointcut + advice 묶음
  • AnnotationTransactionAttributeSource — 메서드/클래스에서 @Transactional을 읽어 TransactionAttribute로 변환하는 메타데이터 파서

advisor가 등록되면, Bean이 생성될 때 AbstractAutoProxyCreator가 후보 advisor를 평가하고, 매칭되는 advisor가 있는 빈은 프록시로 감싸서 컨테이너에 등록합니다. 인터페이스가 있으면 JDK Dynamic Proxy, 그렇지 않으면 CGLIB 서브클래스 프록시가 만들어집니다. (이 부분은 별도 글 Spring AOP 프록시 메커니즘에서 자세히 다뤘습니다.)

flowchart LR
    Caller[Caller] --> Proxy[Proxy]
    Proxy --> Interceptor[TransactionInterceptor]
    Interceptor --> Target[Target Bean]
    Interceptor -.->|begin/commit/rollback| TxManager[PlatformTransactionManager]
    TxManager -.->|bind/unbind| TSM[TransactionSynchronizationManager]

호출자가 보는 빈은 실제 타깃이 아니라 프록시이고, 프록시가 메서드 호출을 인터셉트해서 advice 체인을 돌립니다. TransactionInterceptor는 그 체인의 한 단계로, 타깃 메서드 호출 전후에 트랜잭션을 열고 닫는 책임을 가집니다.

2. TransactionInterceptor.invoke의 흐름

TransactionInterceptor는 AOP Alliance의 MethodInterceptor를 구현합니다. invoke(MethodInvocation)는 부모 클래스 TransactionAspectSupportinvokeWithinTransaction(...)에 위임하고, 실제 알고리즘은 거기에 있습니다. 핵심 흐름을 의사코드로 옮기면 다음과 같습니다.

public Object invokeWithinTransaction(Method method, Class<?> targetClass,
        InvocationCallback invocation) throws Throwable {
    TransactionAttribute txAttr = ...; // AnnotationTransactionAttributeSource 조회
    PlatformTransactionManager tm = determineTransactionManager(txAttr);

    // (1) 필요하면 트랜잭션을 시작하고, ThreadLocal에 TransactionInfo 바인딩
    TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

    Object retVal;
    try {
        retVal = invocation.proceedWithInvocation(); // (2) 타깃 메서드 호출
    } catch (Throwable ex) {
        // (3) 예외 발생: 롤백 규칙에 따라 롤백 또는 커밋
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
    } finally {
        cleanupTransactionInfo(txInfo); // ThreadLocal 정리
    }

    // (4) 정상 종료: 커밋
    commitTransactionAfterReturning(txInfo);
    return retVal;
}

네 단계가 깔끔하게 분리되어 있습니다.

  1. createTransactionIfNecessarytm.getTransaction(txAttr)TransactionStatus를 받아오고, TransactionInfo로 감싸 ThreadLocal에 푸시합니다. TransactionAttributenull이면(즉, 어노테이션이 없으면) 트랜잭션을 만들지 않고 바로 (2)로 넘어갑니다.
  2. proceedWithInvocation — advice 체인의 다음 인터셉터를 거쳐 결국 타깃 메서드를 실제로 호출합니다.
  3. completeTransactionAfterThrowing — 예외가 던져졌을 때만 호출됩니다. txAttr.rollbackOn(ex)truetm.rollback(status), falsetm.commit(status)를 호출합니다. 즉 예외가 났다고 무조건 롤백하지 않습니다.
  4. commitTransactionAfterReturning — 정상 종료 시 tm.commit(status)를 호출합니다. 이 시점에 rollbackOnly 플래그가 켜져 있으면 UnexpectedRollbackException이 발생합니다.

위 흐름이 @Transactional이 일관되게 동작하는 모든 메서드 호출의 골격입니다. 이 골격을 이해하면, 뒤에서 다룰 모든 함정의 원인이 자연스럽게 보입니다.

3. PlatformTransactionManager의 세 가지 동작

PlatformTransactionManager는 다음 세 메서드만을 가지는 단순한 인터페이스입니다.

public interface PlatformTransactionManager extends TransactionManager {
    TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException;
    void commit(TransactionStatus status) throws TransactionException;
    void rollback(TransactionStatus status) throws TransactionException;
}

대표 구현체는 두 가지입니다.

  • DataSourceTransactionManagerjavax.sql.DataSource 위에서 JDBC Connection을 얻어 setAutoCommit(false)로 트랜잭션을 시작
  • JpaTransactionManagerEntityManagerFactory 위에서 EntityManagerEntityTransaction을 함께 관리. 내부적으로 같은 DataSource의 Connection을 통해 JDBC 트랜잭션도 얻음

이 둘 모두 추상 클래스 AbstractPlatformTransactionManager를 상속합니다. 알고리즘의 큰 틀은 추상 클래스에 있고, 자원별 세부 동작(doBegin, doCommit, doRollback, doSuspend, doResume)만 서브클래스에서 구현합니다. 전형적인 템플릿 메서드 패턴입니다.

AbstractPlatformTransactionManager.getTransaction의 흐름을 정리하면 다음과 같습니다.

flowchart TD
    Start[getTransaction definition] --> Existing{isExistingTransaction?}
    Existing -- yes --> Handle[handleExistingTransaction]
    Existing -- no --> CheckProp{propagation == MANDATORY?}
    CheckProp -- yes --> ThrowMandatory[Throw IllegalTransactionStateException]
    CheckProp -- no --> CheckRequired{REQUIRED / REQUIRES_NEW / NESTED?}
    CheckRequired -- yes --> StartTx[startTransaction -> doBegin -> bind ThreadLocal]
    CheckRequired -- no --> Empty[Return empty TransactionStatus]

핵심은 두 갈래입니다. 기존 트랜잭션이 있으면 handleExistingTransaction이 전파 속성에 따라 분기하고, 없으면 전파 속성이 새 트랜잭션을 요구하는지(REQUIRED/REQUIRES_NEW/NESTED) 검사합니다.

commit/rollback은 더 단순합니다.

  • commit(status)status.isRollbackOnly()이면 processRollback으로 우회. 그렇지 않으면 doCommit 호출
  • rollback(status) — 무조건 processRollbackdoRollback. REQUIRES_NEW로 일시중단된 외부 트랜잭션이 있으면 resume으로 복원

여기서 **UnexpectedRollbackException**이 끼어드는 자리가 보입니다. commit이 호출됐는데 rollbackOnly가 켜져 있으면 Spring은 약속을 지킬 수 없으므로(이미 롤백 예약 상태이므로) 커밋 대신 롤백하고, 호출자에게 이 예외를 던집니다. "분명히 트랜잭션이 정상 종료된 것 같은데 갑자기 RuntimeException이 튀어나오는" 증상의 정체가 바로 이것입니다.

4. TransactionSynchronizationManager — 트랜잭션의 ThreadLocal 거점

TransactionSynchronizationManager는 정적 메서드만 가지는 ThreadLocal 기반 중앙 저장소입니다. 이 클래스가 관리하는 것은 다음 네 가지입니다.

항목 의미
Resources (Map<Object, Object>) DataSource → Connection 같은 자원 매핑
Synchronizations (List<TransactionSynchronization>) 트랜잭션 라이프사이클 콜백
트랜잭션 메타데이터 이름, 읽기전용 여부, 격리 수준, 활성 여부
actualTransactionActive 진짜 트랜잭션이 시작됐는지 여부

DataSourceTransactionManager.doBegin은 새 Connection을 얻어 bindResource(dataSource, connectionHolder)로 ThreadLocal에 묶습니다. 이후 같은 스레드에서 어떤 코드가 DataSourceUtils.getConnection(dataSource) 또는 DataSource.getConnection()(스프링이 감싼 형태)을 호출하면, 새 커넥션을 얻는 대신 ThreadLocal에 묶인 Connection을 반환합니다. 이것이 동일 트랜잭션 안에서 여러 DAO 호출이 같은 커넥션을 공유하는 메커니즘입니다.

flowchart LR
    Method1[Repository A.save] --> Utils1[DataSourceUtils.getConnection]
    Method2[Repository B.update] --> Utils2[DataSourceUtils.getConnection]
    Utils1 --> TSM[TransactionSynchronizationManager]
    Utils2 --> TSM
    TSM --> Conn[(Same Connection bound to thread)]

콜백 등록도 같은 자리에서 일어납니다.

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override public void afterCommit() { /* 커밋 직후만 동작 */ }
        @Override public void afterCompletion(int status) { /* 커밋/롤백 모두 */ }
    });

대표적인 활용은 "커밋이 확정된 뒤에만 외부 시스템에 메시지를 발행"하는 경우입니다. Spring Framework 4.2부터는 @TransactionalEventListener가 같은 메커니즘을 내부에서 활용해 AFTER_COMMIT(기본값), AFTER_ROLLBACK, AFTER_COMPLETION, BEFORE_COMMIT 단계 이벤트를 제공합니다.

ThreadLocal을 쓴다는 사실은 두 가지 결과를 낳습니다. 첫째, 같은 스레드 안의 호출은 자연스럽게 같은 트랜잭션 컨텍스트를 공유합니다. 둘째, 스레드를 벗어나는 순간(예: @Async, CompletableFuture.supplyAsync 등) 트랜잭션 컨텍스트가 사라집니다. 이 점은 함정 절에서 다시 다룹니다.

5. 전파 속성 — 7가지 정의와 의사결정

@Transactional(propagation = ...)로 지정하는 전파 속성은 7가지입니다. 정의는 org.springframework.transaction.annotation.Propagation enum에 들어 있고, 처리 알고리즘은 AbstractPlatformTransactionManager.handleExistingTransaction에 있습니다.

전파 속성 기존 트랜잭션 있음 기존 트랜잭션 없음
REQUIRED (기본) 참여 새로 시작
SUPPORTS 참여 트랜잭션 없이 실행
MANDATORY 참여 IllegalTransactionStateException
REQUIRES_NEW 외부를 일시중단(suspend), 새 트랜잭션 시작 새로 시작
NOT_SUPPORTED 외부를 일시중단, 트랜잭션 없이 실행 트랜잭션 없이 실행
NEVER IllegalTransactionStateException 트랜잭션 없이 실행
NESTED 외부 안에 savepoint 생성 새로 시작 (= REQUIRED와 동일)
flowchart TD
    Call[Method invoked] --> Has{Existing tx?}
    Has -- yes --> Branch1{Propagation}
    Has -- no --> Branch2{Propagation}

    Branch1 -- REQUIRED/SUPPORTS/MANDATORY --> Join[Join existing tx]
    Branch1 -- REQUIRES_NEW/NOT_SUPPORTED --> Suspend[Suspend outer + new context]
    Branch1 -- NESTED --> Savepoint[Create savepoint inside outer]
    Branch1 -- NEVER --> Throw1[Throw IllegalTransactionStateException]

    Branch2 -- REQUIRED/REQUIRES_NEW/NESTED --> Begin[Begin new tx]
    Branch2 -- SUPPORTS/NOT_SUPPORTED/NEVER --> NoTx[Run without tx]
    Branch2 -- MANDATORY --> Throw2[Throw IllegalTransactionStateException]

REQUIRES_NEWNESTED는 자주 헷갈리지만 동작이 완전히 다릅니다.

  • REQUIRES_NEW — 외부 트랜잭션을 정말로 일시중단합니다. doSuspend가 외부의 Connection을 ThreadLocal에서 떼어내 SuspendedResourcesHolder에 보관하고, 새 Connection을 시작합니다. 즉 두 개의 물리적 트랜잭션이 동시에 떠 있게 됩니다. 외부 커넥션은 그동안 살아 있는 채로 보관되므로, 풀 사이즈가 작으면 데드락이 납니다.
  • NESTED — 외부 트랜잭션 위에 JDBC SAVEPOINT를 만듭니다. 물리 트랜잭션은 하나입니다. 내부에서 예외가 나면 savepoint까지만 롤백되고 외부는 살아남습니다. DataSourceTransactionManager는 기본 지원, JpaTransactionManager는 JDBC 커넥션 위에서만 지원합니다. 데이터베이스도 savepoint를 지원해야 합니다(PostgreSQL/Oracle/H2/MySQL InnoDB는 지원, MyISAM은 미지원).

NOT_SUPPORTED는 "이 메서드만 트랜잭션 밖에서 돌리고 싶다"는 의도로 쓰입니다. 외부 트랜잭션은 일시중단되고, 메서드 본문은 ThreadLocal에 자원이 묶이지 않은 상태에서 실행됩니다. 그래서 같은 메서드 안에서 데이터를 읽어도 (격리 수준에 따라) 외부 트랜잭션이 아직 커밋하지 않은 변경은 보이지 않을 수 있습니다.

6. 격리 수준 — 4 + 1

@Transactional(isolation = ...)로 지정하는 격리 수준은 5가지입니다.

격리 수준 의미
DEFAULT 데이터 소스(DB)의 기본값 사용
READ_UNCOMMITTED dirty read / non-repeatable read / phantom read 모두 허용
READ_COMMITTED dirty read만 차단
REPEATABLE_READ dirty + non-repeatable read 차단, phantom read는 가능
SERIALIZABLE 세 가지 모두 차단

Spring은 Connection.setTransactionIsolation(int)로 격리 수준을 설정합니다. 즉 격리 수준의 실제 의미는 드라이버와 DB가 정의합니다. 같은 REPEATABLE_READ라도 PostgreSQL과 MySQL InnoDB의 동작이 미묘하게 다른 이유입니다(예: PostgreSQL은 SI 기반이라 phantom도 사실상 차단, MySQL InnoDB는 갭 락으로 phantom 차단).

DEFAULT를 쓰면 DB 기본값이 적용됩니다. 운영에서는 명시적으로 의도를 적어 두는 편이 안전합니다. 단, 격리 수준을 트랜잭션마다 바꾸면 같은 커넥션 풀의 다른 트랜잭션에 영향이 갈 수 있어, DataSourceTransactionManager는 종료 시 격리 수준을 원복하는 설정(enforceReadOnly, setIsolationLevel...)을 함께 가지고 있습니다.

읽기 전용 힌트인 readOnly = true는 격리 수준과는 별개입니다. 이 플래그는 다음 두 갈래로 작동합니다.

  • JDBC 단의 Connection.setReadOnly(true) — 드라이버에 따라 옵티마이저 힌트로 활용
  • ORM(JPA/Hibernate) 단의 flush 모드 변경 — Hibernate는 dirty checking을 사실상 끔

성능 효과는 드라이버와 ORM 구현에 좌우되므로 측정 없이 단정하지 말고, 실수로 쓰기 메서드에 붙이지 않도록 주의합니다.

7. 롤백 규칙 — 가장 가까운 규칙이 이긴다

기본 규칙은 단 한 줄로 요약됩니다.

명시 규칙이 없으면, RuntimeExceptionError 롤백합니다. 체크 예외는 커밋합니다.

DefaultTransactionAttribute.rollbackOn이 그렇게 정의되어 있고, RuleBasedTransactionAttribute(즉 @Transactional이 만들어 내는 attribute)는 그 위에 명시 규칙을 얹습니다. rollbackFor/rollbackForClassNameRollbackRuleAttribute로, noRollbackFor/noRollbackForClassNameNoRollbackRuleAttribute로 변환되어 리스트에 들어갑니다.

판정 알고리즘은 shallowest match wins입니다.

@Override public boolean rollbackOn(Throwable ex) {
    RollbackRuleAttribute winner = null;
    int deepest = Integer.MAX_VALUE;
    for (RollbackRuleAttribute rule : this.rollbackRules) {
        int depth = rule.getDepth(ex); // 클래스 계층 거리, 미스매치는 -1
        if (depth >= 0 && depth < deepest) {
            deepest = depth;
            winner = rule;
        }
    }
    if (winner == null) return super.rollbackOn(ex); // 기본 규칙
    return !(winner instanceof NoRollbackRuleAttribute);
}

예를 들어 @Transactional(rollbackFor = Exception.class, noRollbackFor = MyBusinessException.class)이고 MyBusinessException extends Exception이 던져지면, MyBusinessException이 더 가까운 규칙이므로 noRollback이 이겨서 커밋됩니다. 반대로 IOException이 던져지면 Exception 규칙만 매칭되어 롤백됩니다.

체크 예외 함정의 정체도 여기서 보입니다. 사람이 직접 catch해서 던지지 않는 한, 체크 예외는 기본 규칙상 커밋 대상입니다. "DAO에서 SQLException을 그대로 던졌는데 데이터가 그대로 들어가 있더라"는 흔한 사고가 이 규칙에서 옵니다. 체크 예외에서도 롤백을 원하면 rollbackFor = SQLException.class처럼 명시해야 합니다.

8. 어노테이션 발견 규칙

AnnotationTransactionAttributeSource@Transactional을 다음 우선순위로 찾습니다.

  1. 구체 메서드의 어노테이션
  2. 구체 클래스의 어노테이션
  3. 인터페이스 메서드의 어노테이션
  4. 인터페이스 자체의 어노테이션

기본 모드(useInheritedClass = false)에서는 인터페이스보다 구현 클래스를 우선합니다. Spring 공식 가이드는 구현 클래스(또는 메서드)에만 @Transactional을 붙이는 것을 권장합니다. 인터페이스에 붙이면 JDK 동적 프록시일 때만 advice가 적용되고, CGLIB 프록시(인터페이스 미사용 빈)에서는 어노테이션이 보이지 않을 수 있기 때문입니다.

또한 클래스에 붙이면 그 클래스의 모든 public 메서드가 트랜잭션 대상이 됩니다. proxy 모드(기본값)에서는 protected/private/package-private 메서드는 advice 대상이 아닙니다. AspectJ 모드(@EnableTransactionManagement(mode = AdviceMode.ASPECTJ))일 때만 가시성에 무관하게 위빙됩니다.

9. 운영 함정 다섯 가지

여기까지 내부 흐름을 따라온 독자라면, 아래 함정들이 왜 일어나는지가 자연스럽게 보일 겁니다.

함정 1. 같은 클래스 안 self-invocation

@Service
class OrderService {
    public void placeOrder(...) { this.charge(...); }   // 트랜잭션 없음

    @Transactional
    public void charge(...) { ... }
}

placeOrderthis.charge(...)를 호출하면 프록시를 거치지 않고 타깃 객체로 직행합니다. advice 체인이 발동하지 않으므로 @Transactional은 무시됩니다. 해결 방법은 셋입니다.

  • 메서드를 다른 빈으로 분리 (가장 깨끗)
  • ApplicationContext에서 자기 자신을 다시 주입받아 호출 (AopContext.currentProxy()도 가능하지만 @EnableAspectJAutoProxy(exposeProxy = true) 필요)
  • AspectJ 모드로 전환

함정 2. 체크 예외 침묵

SQLException, IOException처럼 체크 예외를 그대로 위로 던지면 기본 규칙상 커밋됩니다. 6절의 알고리즘 그대로입니다. 체크 예외에서도 롤백이 필요하면 rollbackFor로 명시하거나, 도메인에서는 RuntimeException을 상속한 예외로 일관되게 변환하는 편이 안전합니다.

함정 3. REQUIRES_NEW와 커넥션 풀 데드락

REQUIRES_NEW는 외부 트랜잭션의 커넥션을 풀에 반납하지 않고 그대로 들고 있습니다. 같은 스레드가 새 커넥션을 빌리려는 순간, 풀이 비어 있으면 자기 자신을 기다리는 상태가 됩니다.

flowchart LR
    Outer[Outer tx holds conn1] --> Inner[Inner REQUIRES_NEW asks conn2]
    Inner --> Pool[(Connection pool)]
    Pool -.->|exhausted| Wait[Wait until timeout]
    Wait -.->|timeout| Fail[Acquire failed]

해결책은 풀 크기를 충분히 크게 잡거나, 정말로 필요할 때만 REQUIRES_NEW를 쓰는 것입니다. "감사 로그", "외부 메시지 발행" 같은 진짜 독립적 단위에만 쓰고, 일반적인 경계 분리는 NESTED 또는 메서드 분리로 푸는 편이 안전합니다.

함정 4. @Async / 새 스레드 / Reactor 컨텍스트 전이

트랜잭션 컨텍스트는 ThreadLocal에 들어 있습니다. @Async 메서드, 직접 만든 ExecutorService, CompletableFuture.supplyAsync(공유 ForkJoinPool)는 다른 스레드에서 실행되므로 컨텍스트가 따라가지 않습니다.

  • 비동기 메서드 자체에 @Transactional을 붙이면 그 스레드에서 새 트랜잭션이 열립니다.
  • 비동기 안에서 외부 트랜잭션의 커넥션을 공유하고 싶다면 잘못된 설계입니다. 자원 공유를 끊고 메시지나 이벤트로 옮기는 편이 맞습니다.

리액티브 환경에서는 ReactiveTransactionManager + TransactionalOperator가 같은 책임을 합니다. 다만 컨텍스트 전이 메커니즘이 ThreadLocal이 아닌 Reactor Context이므로, 위 패턴이 그대로 통하지 않습니다. (이 글의 범위를 벗어납니다.)

함정 5. UnexpectedRollbackException

이미 본 흐름의 자연스러운 귀결입니다. 외부 트랜잭션 안에서 호출된 내부 메서드가 예외를 던졌고, 그 예외를 외부 메서드에서 try/catch로 삼켰다고 합시다. 이 경우 트랜잭션은 이미 rollbackOnly = true로 마킹되었습니다. 외부 메서드는 정상 종료되어 커밋을 시도하지만, Spring은 약속을 지킬 수 없으므로 UnexpectedRollbackException을 던집니다.

해결은 두 가지입니다.

  • 내부 호출이 정말 독립적이어야 한다면 REQUIRES_NEW로 격리
  • 내부 예외를 정말로 무시하고 싶다면 try/catch로 처리하되, 그 메서드는 별도 빈으로 분리해 트랜잭션 경계 자체를 다르게 가져가기

UnexpectedRollbackException은 버그가 아니라 데이터 일관성을 위한 마지막 안전장치입니다. 메시지를 보고 즉시 위 두 갈래 중 어느 쪽을 적용할지 결정하면 됩니다.

10. 한 페이지 요약

flowchart TD
    A[@Transactional method call] --> B[AOP Proxy]
    B --> C[TransactionInterceptor.invoke]
    C --> D[invokeWithinTransaction]
    D --> E[createTransactionIfNecessary]
    E --> F[PlatformTransactionManager.getTransaction]
    F --> G{Existing tx?}
    G -- yes --> H[handleExistingTransaction by propagation]
    G -- no --> I[doBegin + bind to TransactionSynchronizationManager]
    D --> J[proceedWithInvocation]
    J --> K{Throwable?}
    K -- yes --> L[completeTransactionAfterThrowing -> rollbackOn?]
    K -- no --> M[commitTransactionAfterReturning]
    L --> N[doRollback or doCommit]
    M --> O{rollbackOnly?}
    O -- yes --> P[UnexpectedRollbackException]
    O -- no --> Q[doCommit]
  • 프록시가 메서드 호출을 가로챈다
  • TransactionInterceptorinvokeWithinTransaction을 따른다
  • PlatformTransactionManager가 자원에 begin/commit/rollback을 수행한다
  • TransactionSynchronizationManager가 ThreadLocal로 자원·동기화·메타데이터를 묶는다
  • 전파 속성은 기존 트랜잭션과 만났을 때의 동작을 결정한다
  • 격리 수준은 드라이버에 위임된다
  • 롤백 규칙은 가장 가까운 규칙이 이기고, 명시 규칙이 없으면 RuntimeException/Error만 롤백한다

@Transactional 한 줄을 쓸 때마다 위 흐름이 한 번씩 돈다고 생각하면, 어느 함정이 어느 단계에서 생기는지가 명확해집니다. 그 명확함 위에서 읽기 전용 힌트, 전파 속성, 롤백 규칙을 의도에 맞게 조합하는 것이 선언적 트랜잭션의 진짜 사용법입니다.

참고자료

More from this blog

LangChain, LangGraph, LangSmith — 첫 AI 에이전트를 만들기 전에 알아야 하는 세 도구의 역할

처음으로 AI 에이전트를 만들어 보려는 백엔드 개발자를 대상으로 합니다. 이름이 비슷한 세 도구 — LangChain, LangGraph, LangSmith — 가 각각 어떤 문제를 풀려고 등장했는지, 어떤 관계로 묶여 있는지, 그리고 어디서부터 손을 대야 하는지를 한 흐름으로 정리합니다. 함께 자주 등장하는 단어(Tool, ReAct, Function C

May 14, 202613 min read

끄적끄적 테크 블로그

47 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ