[스프링] 트랜잭션 처리에도 위아래가 있지 (상)

FrogRat·2021년 6월 12일
1

스프링 썸네일

배경

사내 전용 레거시 프레임워크 제거 작업시, 데이터 엑세스 로직 관련 플러그인을 개선 및 재개발하는 작업을 진행했었다. 그와 관련한 스프링에서의 트랜잭션 처리 흐름에 대해 정리해보았다.

문제

레거시 트랜잭션 매니저 내부적으로 스프링 트랜잭션 매니저를 사용할 수 있게 수정하는 작업 중 서로 다른 2개의 DB 를 트랜잭션 처리하는 부분에 문제가 발생했다. 소스 코드 상에 트랜잭션 설정만 보자면 글로벌 트랜잭션 처리를 하는 것인가 생각할 수 있다.

public class TransactionExample {
    public void execute() {
        LegacyTransactionManager tx = new LegacyTransactionManager();

        try {
            // serviceA_DB, serviceB_DB 2개의 DB 에 대한 트랜잭션 처리
            tx.start("serviceA_DB", "serviceB_DB");

            // 트랜잭션 처리가 필요한 비지니스 로직 수행

            tx.commit();
        } catch(Exception e) {
            tx.rollback();
        } finally {
            tx.end();
        }
    }
}

하지만 내부를 까보면 글로벌 트랜잭션과는 거리가 멀다. LegacyTransactionManager 클래스 내부에서는 2개의 DB 에 대해 각각 로컬 트랜잭션을 열어주고 커밋/롤백하는 작업을 수행해준다. 이때 트랜잭션 처리할 DB 순서에 관계없이 각 DB 에 대한 트랜잭션 커밋/롤백을 정상적으로 수행한다.
상세하게 설명하자면 내부적으로 2개의 DB 중 하나의 DB가 커밋된 후 남은 다른 DB에 대해 커밋 작업중에 에러가 발생할 경우, 이전에 이미 커밋된 DB에 대해서는 트랜잭션 롤백처리가 전혀 안된다. (Simple version: 글로벌 트랜잭션을 지원하지 않는다.)
글로벌 트랜잭션 지원과 관련된 논의는 다음 과제로 넘기고 일단 현재와 동일 스펙으로 동작하도록 해주는게 첫번째 과제였다. 사실 LegacyTransactionManager 내부적으로 스프링 트랜잭션 매니저를 사용하도록 변경해도 별다른 문제 없겠지 생각했었다.

트랜잭션 동기화 문제

하나의 DB 에 대한 트랜잭션 처리에는 문제가 없었지만 2개 이상의 DB 에 대한 트랜잭션 처리시 문제가 발생했는데 테스트를 돌리면 10번 중에 6 ~ 7번은 아래와 같은 에러가 발생하는 것이었다.

java.lang.IllegalStateException: 
	Cannot deactivate transaction synchronization - not active
    
at org.springframework.transaction.support.TransactionSynchronizationManager.
	clearSynchronization(TransactionSynchronizationManager.java:327)
at org.springframework.transaction.support.TransactionSynchronizationManager.
	clear(TransactionSynchronizationManager.java:462)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.
    cleanupAfterCompletion(AbstractPlatformTransactionManager.java:1008)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.
	processCommit(AbstractPlatformTransactionManager.java:804)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.
	commit(AbstractPlatformTransactionManager.java:723)

에러 로그를 그대로 해석해보자면 트랜잭션 동기화에 대한 비활성화가 실패했는데 이유가 활성화된 트랜잭션 동기화 부분이 없다는 내용이다. 스프링 프레임워크 내부를 낱낱이 들여다보는건 너무 복잡하기도 하고, 누군가 벌써 해결한 사례가 있지 않을까 해서 이런 저런 키워드로 구글링을 하다 보니 Stackoverflow 에 아래와 같은 글을 발견하게 되었다.

(대충 번역)

 > Q. 스프링 트랜잭션을 시작/커밋 하는데 특정 순서가 있는건가요?
 > A. 맞습니다. 스택(LIFO) 형태로 먼저 시작한 트랜잭션이 나중에 종료처리 되어야합니다.

"스프링은 원래 이렇군!" 하고 나름의 해결책을 강구하여 큰 산은 하나 넘었다. 하지만 도대체 스프링 트랜잭션 매니저 내부에서 어떤 것이 저런식으로 동작하게 만드는지 궁금해서 나중에 스프링 트랜잭션 관련 내부 로직을 한번 파헤쳐보게 되었다.

살짝 내부 둘러보기

스프링에서는 트랜잭션 처리를 추상화된 PlatformTransactionManager 인터페이스 기반으로 구현해주고 있고, 해당 인터페이스를 구현하는 추상 클래스인 AbstractPlatformTransactionManager 는 스프링 표준 트랜잭션 작업 흐름을 담고 있다. 레거시 프레임워크 제거 작업 일부 내용도 결국에는 LegacyTransactionManager 에서 PlatformTransactionManager 인터페이스를 구현한 하위 타입 빈을 스프링 컨테이너에서 가져와서 사용한다.

package org.springframework.transaction;

public interface PlatformTransactionManager {
    // 트랜잭션 시작
    TransactionStatus getTransaction(TransactionDefinition var1) throws TransactionException;

    void commit(TransactionStatus var1) throws TransactionException;

    void rollback(TransactionStatus var1) throws TransactionException;
}

트랜잭션은 인터페이스 상에 getTransaction 메서드를 호출하면서 시작된다. 해당 메서드는 TransactionStatus 타입을 객체를 반환하는데, 시작한 트랜잭션의 현재 상태를 반환한다. 그 중 DefaultTransactionStatus 클래스는 TransactionStatus 인터페이스를 구현한 하위 클래스로 AbstractPlatformTransactionManager 에서는 DefaultTransactionStatus 타입 클래스를 반환한다. 내부를 살펴보면 아래와 같은 인스턴스 변수들이 있다.

public class DefaultTransactionStatus extends AbstractTransactionStatus {
    @Nullable
    private final Object transaction;

    private final boolean newTransaction;

    private final boolean newSynchronization; // (1)

    private final boolean readOnly;

    private final boolean debug;

    @Nullable
    private final Object suspendedResources; // (2)
 }

여기서 눈여겨 봐야할 변수는 대략 2가지 이다.

번호인스턴스 변수명설명
(1)newSynchronization해당 트랜잭션을 통해 트랜잭션 동기화가 새롭게 시작되었는지 여부
(2)suspendedResources해당 트랜잭션으로 인해 지연 처리된 트랜잭션 정보

newSynchronization

해당 트랜잭션을 통해 새로운 트랜잭션 동기화 작업이 시작되었는지 여부를 알려주는 변수다. 해당 트랜잭션으로 인해 트랜잭션 동기화가 최초로 활성화 된거라면 true 아니면 false 를 반환한다. 해당 값은 트랜잭션 동기화 처리시 참조하는 값으로 true 일 경우 트랜잭션 시작시 동기화 관련 기능들을 초기화 해주고, 트랜잭션 종료시에는 동기화 관련 상태값을 제거하는 작업, 즉 동기화 종료 처리를 한다.

suspendedResources

트랜잭션 전파 설정(propagation level)에 따라 중첩된 트랜잭션 실행시 먼저 실행된 트랜잭션에 대한 지연처리를 위해 관련 리소스(트랜잭션 객체, 트랜잭션 동기화 정보 등)을 저장해두는 변수다.

2개의 트랜잭션이 각 트랜잭션 별로 DefaultTransactionStatus 객체를 생성하면서 트랜잭션이 시작되고, 트랜잭션 종료 처리도 결국은 위 객체에 담긴 상태값을 가지고 진행하게 된다. 트랜잭션 시작/종료의 처리 과정을 쫓아가다 보면 이 문제의 원인을 파악할 수 있다. 그 과정을 먼저 좀 더 이해하기 쉬운 스토리로 이해하고 프레임워크 내부 소스를 살펴보려고 한다.

트랜잭션들의 여정

트랜잭션(1)(2)가 있다. 이들은 각각의 트랜잭션 여정을 시작 전에 스프링 트랜잭션 매니저를 거쳐서 간다. 내부적으로 스프링 트랜잭션 매니저는 트랜잭션 동기화 매니저와 함께 일한다.

1번 트랜잭션의 여행 시작

트랜잭션(1)의 여정이 먼저 시작되었다. 트랜잭션 매니저에게 자신의 트랜잭션 상태 정보를 달아두고 여정을 시작한다.

2번 트랜잭션의 여행 시작

트랜잭션(1)이 출발하고 바로 트랜잭션(2)가 여정을 시작한다. 트랜잭션(1)의 여정이 미처 끝나기 전이라 스프링 트랜잭션 매니저는 아직 트랜잭션(1)의 상태 정보를 들고 있다. 스프링 트랜잭션 매니저는 새로운 여정을 시작하려는 트랜잭션(2)에게 트랜잭션(1)의 상태 정보를 들려주고, 트랜잭션(2)의 상태 정보를 달아둔다. 그렇게 트랜잭션(2)트랜잭션(1)의 상태 정보를 주렁주렁 들고 여정에 나선다.

별탈없는 여정의 끝

2번 트랜잭션의 컴백

트랜잭션(2)가 먼저 돌아왔다. 스프링 트랜잭션 매니저는 트랜잭션(2)의 귀환을 기다리고 있는 상태다. 트랜잭션(2)가 돌아와 자신의 여정을 마무리하는 작업을 진행한다. 스프링 트랜잭션 매니저가 들고 있던 트랜잭션(2)의 상태를 깔끔하게 초기화 되고, 트랜잭션(2)는 자신이 들고 떠난 트랜잭션(1)의 상태 정보를 트랜잭션 매니저에게 넘긴다. 그렇게 스프링 트랜잭션 매니저는 트랜잭션(1)의 귀환을 기다린다.

1번 트랜잭션의 컴백

그 후 트랜잭션(1)이 돌아왔다. 스프링 트랜잭션 매니저는 트랜잭션(1)의 귀환을 맞아 트랜잭션(1)의 상태를 초기화하고, 트랜잭션(1)은 별도로 가지고 간 다른 트랜잭션의 정보가 없기 때문에 매니저는 더 이상 귀환할 트랜잭션이 없다고 판단한다. 그렇게 트랜잭션(1)의 여정까지 마무리되며 모든 트랜잭션이 종료된다.

탈이 나는 여정의 끝

이제 펼쳐질 케이스는 여정의 마무리 순서가 뒤바뀔 경우에 발생할 탈이 나는 여정의 케이스이다.

2번 트랜잭션을 기다렸건만 1번이 먼저 왔다.

먼저 왔어야할 놈이 안오고, 트랜잭션(1)이 왔다. 하지만 스프링 트랜잭션 매니저는 먼저 도착한 트랜잭션이 (1)번인지 (2)번인지 별다른 확인을 하지 않는다. 트랜잭션 매니저는 대롱대롱 달고 있던 트랜잭션(2)의 상태 정보를 초기화 하고, 트랜잭션(1)은 별도로 다른 트랜잭션의 상태를 들고 있지 않았기 때문에 스프링 트랜잭션 매니저는 이제 더이상 귀환해야할 트랜잭션 매니저가 없다고 판단한다.

2번 트랜잭션이 왔는데...

그런데 이와중에 트랜잭션(2)가 왔다. 트랜잭션(2)는 앞서 일어난 사건을 모른채 스프링 트랜잭션 매니저에게 귀환 신고를 한다. 하지만 매니저는 아래와 같은 메시지를 던지면서 귀환을 거부한다.

Cannot deactivate transaction synchronization - not active

매니저의 입장에서는 지금 트랜잭션 동기화가 이미 비활성화 되어 있기 때문에 또 비활성화 할 수 없다. 트랜잭션(2)는 귀환처리가 되지 않으니 트랜잭션의 여정이 제대로 종료되지 못한다.

다음 편에서는...

진짜 소스를 까서 살펴보자.

참조

링크

profile
병 안에 쥐개구리

1개의 댓글

comment-user-thumbnail
2021년 11월 16일

덕분에 야근 안하게 됨
감사합니다. ㅋ

답글 달기