[Spring] TransactionSynchronizationManager

TransactionSynchronizationManager란

트랜잭션은 여러 작업들을 원자적으로 처리할 수 있게 도와주는 장치이다. RDB에서 트랜잭션은 여러 dml들 중에 하나라도 실패하면 롤백을, 성공하면 커밋하여 데이터를 영속화해주고, 원자적으로 관리할 수 있게 해준다.

스프링에서 @Transactional도 마찬가지이다. 스프링 애플리케이션에서 데이터베이스에 접근하여 커넥션을 맺고, 여러 요청들을 하나의 트랜잭션으로 묶어 원자성을 보장해주는 장치이다.

스프링 애플리케이션에선 하나의 스레드에서 DB에 접근하여 영속화하거나 조회하는 작업을 주로 한다. 보통 스레드 당 트랜잭션 하나를 열어서 작업을 수행하는데, 이 때 DB에 접근하여 커넥션을 획득하고 트랜잭션 자원을 관리해주는 클래스가 TransactionSynchronizationManager이다.

TransactionSynchronizationManager의 주요 구성 요소와 컴포넌트 다이어그램

필드타입역할
resourcesMap<Object, Object>DataSource를 키로, ConnectionHolder를 값으로 묶는다. 같은 스레드에서 같은 DataSource를 조회하면 항상 같은 값이 나온다
synchronizationsSet커밋과 롤백 시점에 호출할 콜백 목록이다
currentTransactionNameString트랜잭션이 시작된 메서드 이름이다
currentTransactionReadOnlyBoolean읽기 전용 트랜잭션 여부다
currentTransactionIsolationLevelInteger이 트랜잭션에 적용된 격리 수준이다. 기본 격리 수준과 같으면 비워 둔다
actualTransactionActiveBoolean물리 트랜잭션이 실제로 진행 중인지를 나타낸다
자원 바인딩 메서드로직
bindResource(key, value)key를 벗겨 resources 맵에 value를 넣는다. 맵이 없으면 새로 만든다. 기존 값이 있으면 IllegalStateException
unbindResource(key)맵에서 key를 지우고 그 값을 반환한다. 맵이 비면 ThreadLocal도 지운다. 값이 없으면 IllegalStateException
unbindResourceIfPossible(key)unbindResource와 같은 제거 로직을 타되, 값이 없으면 null을 반환한다
getResource(key)맵에서 key로 조회해 반환한다. void로 표시된 ResourceHolder면 맵에서 지우고 null을 반환한다
hasResource(key)getResource와 같은 조회를 한 뒤 값이 null인지만 boolean으로 반환한다
getResourceMap()resources 맵 전체를 수정 불가 래퍼로 감싸 반환한다. 맵이 없으면 빈 Map을 반환한다
콜백 등록 메서드로직
initSynchronization()synchronizations에 빈 LinkedHashSet을 넣어 동기화를 연다. 이미 활성이면 IllegalStateException
registerSynchronization(s)Set에 콜백을 추가한다. Set이 없으면 IllegalStateException
getSynchronizations()Set을 List로 복사해 Ordered 기준으로 정렬한 뒤 수정 불가로 반환한다. Set이 없으면 IllegalStateException
clearSynchronization()synchronizations ThreadLocal을 지운다. 비활성이면 IllegalStateException
isSynchronizationActive()synchronizations.get()이 null인지만 확인해 boolean으로 반환한다

📂 참고 소스: org.springframework.transaction.support.TransactionSynchronizationManager

// org.springframework.transaction.support.TransactionSynchronizationManager (핵심 발췌)
public abstract class TransactionSynchronizationManager {
 
    // ① 리소스 저장소. 키는 보통 DataSource, 값은 ConnectionHolder
    private static final ThreadLocal<Map<Object, Object>> resources =
        new NamedThreadLocal<>("Transactional resources");
 
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
        new NamedThreadLocal<>("Transaction synchronizations");
    // ... currentTransactionName / ReadOnly / IsolationLevel / actualTransactionActive 도 같은 방식
 
    // ② 같은 키로 두 번 바인딩하면 예외. REQUIRES_NEW가 기존 자원을 먼저
    //    suspend 하고서야 새 트랜잭션을 여는 이유가 여기에 있다.
    public static void bindResource(Object key, Object value) {
        Map<Object, Object> map = resources.get();
        if (map == null) {
            map = new HashMap<>();
            resources.set(map);
        }
        Object oldValue = map.put(key, value);
        if (oldValue != null) {
            throw new IllegalStateException(
                "Already value [" + oldValue + "] for key [" + key + "] bound to thread");
        }
    }
 
    // ③ 같은 스레드, 같은 키로 조회하면 바인딩된 값을 그대로 돌려준다
    public static Object getResource(Object key) {
        Map<Object, Object> map = resources.get();
        return (map != null ? map.get(key) : null);
    }
}

bindResource에서 볼 수 있듯이 한 스레드는 같은 DataSource에 대해 딱 하나의 ConnectionHolder만 가질 수 있다. REQUIRES_NEW로 새 트랜잭션을 열 때 기존 트랜잭션을 먼저 suspend(자원을 잠시 unbind)해야 하는 이유도 이것과 같다.

TransactionSynchronizationManager의 ThreadLocal

TransactionSynchronizationManager의 여섯 필드는 전부 private static final ThreadLocal<...>로 선언된다. ThreadLocal 로 필드들이 관리되기 때문에 스레드마다 별도의 값을 가질 수 있다. 때문에 static 형태로 전역적으로 관리되더라도, ThreadLocal 내부 ThreadLocalMap에선 각 스레드마다의 값을 관리해주기 때문에 스레드마다 독립적으로 트랜잭션 자원을 관리할 수 있다.

주문 저장 스레드와 배치 정산 스레드가 같은 순간 각자의 트랜잭션을 돌리고 있어도, 두 스레드의 TransactionSynchronizationManager는 서로의 상태를 전혀 모른다.

TransactionSynchronizationManager의 트랜잭션 동기화 원리

트랜잭션 동기화에선 자원 바인딩이 존재한다. bindResource()로 resources에 지금 쓰는 커넥션을 걸어 두고, 트랜잭션이 끝나면 unbindResource()로 자원을 정리한다. 또한 콜백 등록도 존재한다. registerSynchronization()으로 synchronizations에 커밋, 롤백 시점에 실행할 콜백을 등록해 둔다.

서블릿 컨테이너는 요청 하나에 스레드 하나를 배정한다. 톰캣 커넥터는 요청이 들어오면 워커 스레드 하나를 배정하고 그 요청이 끝날 때까지 동작한다. 컨트롤러에서 서비스, 리포지토리로 이어지는 호출 사슬 전체가 정확히 하나의 스레드 위에서 실행되므로, 앞서 본 ThreadLocal 전제가 그대로 성립한다.

트랜잭션 경계는 @Transactional 로 정한다. (혹은 TransactionTemplate를 가져와서 트랜잭션 범위를 정할 수도 있다.)

  1. TransactionInterceptor가 호출을 가로채고
  2. AbstractPlatformTransactionManager.getTransaction()을 부르고, 그 안에서 doBegin()이 Connection을 얻어 bindResource()로 자원을 저자한다.
  3. 이후 실행되는 dml들은 Connection을 다시 찾아 쓰고, 메서드가 정상 종료하면 commitTransactionAfterReturning()이 물리 커밋과 unbindResource()를 함께 처리한다.
sequenceDiagram
    autonumber
    participant Client
    participant Interceptor as TransactionInterceptor
    participant TxMgr as AbstractPlatformTransactionManager
    participant RepoA as OrderRepository
    participant RepoB as PaymentRepository

    Client->>Interceptor: placeOrder() 호출
    Interceptor->>TxMgr: getTransaction(txAttr)
    TxMgr->>TxMgr: doBegin(), bindResource(dataSource, holder)
    TxMgr-->>Interceptor: TransactionStatus
    Interceptor->>RepoA: proceedWithInvocation()
    RepoA->>TxMgr: DataSourceUtils.getResource(dataSource)
    TxMgr-->>RepoA: 같은 Connection
    RepoA->>RepoB: 같은 스레드에서 호출
    RepoB->>TxMgr: DataSourceUtils.getResource(dataSource)
    TxMgr-->>RepoB: 같은 Connection
    RepoB-->>Interceptor: 반환
    Interceptor->>TxMgr: commit(status)
    TxMgr->>TxMgr: doCommit(), unbindResource(dataSource)
    TxMgr-->>Client: 반환
// as-is: 원시 DataSource를 그대로 써서 트랜잭션 경계를 모른다
@Repository
public class LegacyAuditRepository {
    private final DataSource dataSource;
 
    public void write(AuditLog log) throws SQLException {
        try (Connection con = dataSource.getConnection()) { // 매번 풀에서 새로 받는다
            // ... insert. 바깥 @Transactional 메서드와 다른 커넥션, 다른 트랜잭션
        }
    }
}
 
// to-be: DataSourceUtils로 스레드에 묶인 커넥션을 먼저 찾는다
@Repository
public class AuditRepository {
    private final DataSource dataSource;
 
    public void write(AuditLog log) throws SQLException {
        Connection con = DataSourceUtils.getConnection(dataSource); // 있으면 재사용
        try {
            // ... insert. 바깥 트랜잭션과 같은 커넥션, 같은 트랜잭션 경계
        } finally {
            DataSourceUtils.releaseConnection(con, dataSource); // 트랜잭션 중이면 닫지 않는다
        }
    }
}

DataSourceUtils.getConnection(dataSource)는 내부적으로 TransactionSynchronizationManager.getResource(dataSource)를 먼저 조회한다. 값이 있으면 그 ConnectionHolder의 Connection을 그대로 돌려주고, 없으면 DataSource에서 새로 받는다.TransactionAwareDataSourceProxy는 DataSource 자체를 감싸서, 원시 getConnection()을 불러도 프록시 내부에서 같은 조회를 대신 해 준다.

호출 방식트랜잭션 커넥션 재사용호출부가 트랜잭션을 알아야 하는가
dataSource.getConnection() (원시)안 됨, 매번 풀에서 새로 받는다아니오, 대신 재사용도 안 된다
DataSourceUtils.getConnection(dataSource)됨, 스레드에 바인딩된 커넥션을 조회한다예, 호출부가 DataSourceUtils를 직접 써야 한다
TransactionAwareDataSourceProxy로 감싼 DataSource됨, getConnection() 내부에서 같은 조회를 대신한다아니오, 호출부는 원시 API만 알아도 된다

버추얼 스레드에서의 동기화

가상 스레드(VirtualThread)는 BaseVirtualThread를 거쳐 Thread를 그대로 상속한다. Thread가 갖는 threadLocals 필드를 가상 스레드도 사용할 수 있고, ThreadLocal.getMap(Thread t)은 t.threadLocals를 돌려주는 것으로 끝이라 스레드 종류를 구분하지 않는다. 즉, TransactionSynchronizationManager의 여섯 필드는 가상 스레드에서도 플랫폼 스레드와 똑같이 동작한다.

다만 TransactionSynchronizationManager는 스레드 하나당 커넥션 하나를 묶을 뿐이고, 동시에 열 수 있는 커넥션 수는 여전히 한계가 있다. 또한 트랜잭션 안에서 새 가상 스레드를 만들면 트랜잭션 동기화가 되지 않으므로 유의해야 한다.

@Async에서의 동기화

@Async 메서드는 스프링이 관리하는 별도의 스레드 풀에서 실행된다. 그 스레드는 지금까지의 TransactionSynchronizationManager 상태를 전혀 모르는 새 스레드다. resources도, synchronizations도 없는 채로 시작한다. 그래서 @Async 메서드 안에서 리포지토리를 호출하면 바깥 트랜잭션과 같은 커넥션을 절대 재사용할 수 없고, DataSourceUtils.getConnection은 매번 새 Connection을 받아 온다.

TransactionSynchronization의 콜백 메서드

registerSynchronization으로 등록하는 콜백 인터페이스인 TransactionSynchronization은 beforeCommit, beforeCompletion, afterCommit, afterCompletion 네 메서드를 기본 구현(빈 메서드)으로 제공한다.

📂 참고 소스: org.springframework.transaction.support.AbstractPlatformTransactionManager

// org.springframework.transaction.support.AbstractPlatformTransactionManager#processCommit (핵심 발췌, 간소화)
private void processCommit(DefaultTransactionStatus status) {
    // ① 커밋 전 콜백. 여기서 던진 예외는 그대로 전파되어 물리 커밋을 막는다
    triggerBeforeCommit(status);
    // ② 완료 전 콜백. 예외가 나도 로그만 남기고 다음 동기화로 넘어간다
    triggerBeforeCompletion(status);
 
    doCommit(status); // ③ 물리 커밋은 여기서 일어난다
 
    // ④ 커밋 이후 콜백. 원문 주석 그대로 옮기면: 여기서 던진 예외는 호출자에게
    //    전파되지만, 트랜잭션은 이미 커밋된 것으로 간주한다.
    try {
        triggerAfterCommit(status);
    } finally {
        // ⑤ 완료 후 콜백. 이 역시 예외는 로그만 남긴다
        triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
    }
}
  1. beforeCommit을 등록된 모든 동기화에 호출한다. 여기서 예외가 나면 그대로 위로 전파되어 물리 커밋을 막는다.
  2. beforeCompletion을 호출한다. 예외가 나도 로그만 남기고 다음 동기화로 넘어간다.
  3. doCommit으로 물리 커밋을 실행한다.
  4. afterCommit을 호출한다. 예외가 나면 호출자에게 전파되지만, 물리 커밋은 이미 끝난 뒤다.
  5. finally 블록에서 afterCompletion(COMMITTED)을 호출한다. 이 역시 예외는 로그만 남긴다.
콜백호출 시점예외 처리이 시점의 자원 상태
beforeCommit(readOnly)물리 커밋 직전, 커밋이 확정되기 전전파된다, 그리고 실제로 롤백을 유발한다커넥션이 아직 커밋되지 않은 상태
beforeCompletion()커밋 또는 롤백 직전, beforeCommit 이후 항상 호출로그만 남기고 다음 동기화로 넘어간다커넥션이 아직 완료되지 않은 상태
afterCommit()물리 커밋 성공 직후전파된다, 다만 커밋 자체는 이미 끝난 뒤다커넥션이 여전히 열려 있어 조회는 가능하다
afterCompletion(status)커밋 또는 롤백 직후, 자원 반환 직전로그만 남기고 다음 동기화로 넘어간다커넥션이 아직 반환되지 않았다

롤백 경로에서는 beforeCommit과 afterCommit이 호출되지 않는다. beforeCompletion과 afterCompletion(ROLLED_BACK)만 실행된다.

afterCompletion 시점에는 아직 커넥션이 반환되지 않아, 그 안에서 데이터 접근 코드를 실행하면 방금 끝난 트랜잭션의 자원에 그대로 참여한다. 새로 쓰기를 하고 싶다면 REQUIRES_NEW로 별도 트랜잭션을 열어야 한다.

sequenceDiagram
    autonumber
    participant Interceptor as TransactionInterceptor
    participant Mgr as AbstractPlatformTransactionManager
    participant Sync as 등록된 TransactionSynchronization
    participant DB as 물리 커넥션

    alt 정상 종료, commit 호출
        Interceptor->>Mgr: commit(status)
        Mgr->>Sync: beforeCommit(readOnly)
        Mgr->>Sync: beforeCompletion()
        Mgr->>DB: doCommit()
        DB-->>Mgr: 커밋 완료
        Mgr->>Sync: afterCommit()
        Mgr->>Sync: afterCompletion(COMMITTED)
    else 예외 발생, rollback 호출
        Interceptor->>Mgr: rollback(status)
        Mgr->>Sync: beforeCompletion()
        Note right of Sync: beforeCommit, afterCommit은 이 경로에서 호출되지 않는다
        Mgr->>DB: doRollback()
        Mgr->>Sync: afterCompletion(ROLLED_BACK)
    end
    Mgr-->>Interceptor: 반환 또는 afterCommit 예외 전파
// as-is: 호출자가 트랜잭션 안에 있다고 가정한다
public void afterSave(Order order) {
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                notifyReconciled(order);
            }
        });
    // 트랜잭션 없이 호출되면 여기서 IllegalStateException
}
 
// to-be: 활성 상태를 먼저 확인하고, 없으면 즉시 실행한다
public void afterSave(Order order) {
    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    notifyReconciled(order);
                }
            });
    } else {
        notifyReconciled(order); // 트랜잭션이 없으면 커밋을 기다릴 이유도 없다
    }
}

📂 참고 소스: org.springframework.transaction.support.TransactionSynchronizationManager

// org.springframework.transaction.support.TransactionSynchronizationManager (핵심 발췌)
public static boolean isSynchronizationActive() {
    // ① synchronizations 가 세팅돼 있어야 동기화가 활성 상태다
    return (synchronizations.get() != null);
}
 
public static void registerSynchronization(TransactionSynchronization synchronization) {
    Set<TransactionSynchronization> synchs = synchronizations.get();
    if (synchs == null) {
        // ② 활성 트랜잭션이 없는 스레드에서 부르면 그대로 예외가 난다
        throw new IllegalStateException("Transaction synchronization is not active");
    }
    synchs.add(synchronization);
}

@TransactionalEventListener(phase = AFTER_COMMIT)는 이 등록을 대신 해 주는 어노테이션이다. 다만 내부적으로 거는 콜백은 afterCommit()이 아니라 afterCompletion(status)이고, status == STATUS_COMMITTED일 때만 리스너를 실행한다.

수동으로 등록한 afterCommit()은 다르다. 그 자리에서 예외가 나면 호출자에게 그대로 전파되는데, 이미 커밋이 끝난 뒤라 롤백은 불가하다. 트랜잭션이 아예 없는 스레드에서 이벤트를 발행하면, 리스너 등록 자체가 조건 검사를 통과하지 못해 실행되지 않는다.