Last updated on

[Spring] 스프링 트랜잭션 관리의 기본 개념과 원리

백엔드 글 목록

스프링 프레임워크는 개발자가 복잡한 트랜잭션 처리를 더 쉽게 다룰 수 있도록 강력한 트랜잭션 관리 기능을 제공한다.

이 글에서는 스프링의 트랜잭션 관리가 어떤 원리로 동작하는지, 그리고 기본적으로 어떤 개념을 바탕으로 구성되어 있는지 정리한다. 트랜잭션의 ACID 특성과 함께 스프링이 이를 어떻게 추상화하고 처리하는지 이해하는 것이 목적이다.

스프링 트랜잭션의 핵심 기술은 트랜잭션 추상화, 프록시 활용, 선언적 트랜잭션 관리이다.

스프링 트랜잭션 관리

트랜잭션은 데이터의 일관성과 무결성을 보장하기 위해 여러 작업을 하나의 논리적 단위로 묶어 원자적으로 처리하는 기법이자 개념적 단위이다.

트랜잭션은 논리적 성공을 나타내는 commit 연산과, 실패 후 원상태로 되돌리는 rollback 연산을 이용하여 ACID를 충족한다.

Spring Framework는 JDBC, JPA, Hibernate, JTA 등 다양한 데이터 접근 기술에 대해 일관된 트랜잭션 관리 추상화를 제공한다.

기존 트랜잭션 관리 방법의 단점

스프링 트랜잭션 추상화가 왜 도입되었는지 이해하려면 기존 Java EE 기반 트랜잭션 관리 방식의 한계를 먼저 볼 필요가 있다.

스프링 트랜잭션 관리 이전에 사용되던 Java EE 기반 트랜잭션 관리 기법은 크게 두 가지이다.

  • 글로벌 트랜잭션: 여러 종류의 자원, 예를 들어 RDB와 Message Queue 등에서의 작업을 하나의 트랜잭션으로 묶는 것
  • 로컬 트랜잭션: 하나의 트랜잭션 자원, 보통은 하나의 데이터베이스에 대해서만 트랜잭션을 관리하는 것

기존 Java EE에서 제공하는 트랜잭션 관리 기법은 위 두 가지 방법으로 제공되었다. 그러나 글로벌 트랜잭션 모델의 경우 애플리케이션 서버 환경에서만 사용 가능한 JTA(Java Transaction API)와 JNDI(Java Naming and Directory Interface)를 같이 사용해야 했기 때문에 환경 의존성으로 인해 코드의 재사용이 어려웠다.

로컬 트랜잭션의 경우 여러 리소스에서 작동하기 어렵다는 한계가 존재했다.

아래는 로컬 트랜잭션과 글로벌 트랜잭션 관리법의 예시이다. 각각 모두 개별적인 관리가 필요하며 재사용이 어렵다는 점을 보면 된다.

Connection conn = null;

try {
    conn = dataSource.getConnection();
    conn.setAutoCommit(false); // 트랜잭션 시작

    PreparedStatement ps1 = conn.prepareStatement("INSERT INTO orders (...) VALUES (...)");
    ps1.executeUpdate();

    PreparedStatement ps2 = conn.prepareStatement("UPDATE stock SET ... WHERE ...");
    ps2.executeUpdate();

    conn.commit(); // 성공 시 커밋
} catch (SQLException e) {
    if (conn != null) {
        try {
            conn.rollback(); // 실패 시 롤백
        } catch (SQLException ex) {
            ex.printStackTrace();
        }
    }
} finally {
    if (conn != null) {
        try {
            conn.close(); // 자원 해제
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

이 방식은 하나의 DB에만 트랜잭션 제어가 가능하며, try-catch-finally 구조로 직접 관리해야 한다.

import javax.naming.InitialContext;
import javax.transaction.UserTransaction;

public class OrderService {

    public void placeOrder() throws Exception {
        InitialContext ctx = new InitialContext();
        UserTransaction tx = (UserTransaction) ctx.lookup("java:comp/UserTransaction");

        try {
            tx.begin();

            // DB 작업 (예: JPA, JDBC 등)
            // MQ 작업 (예: JMS 메시지 전송)

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

여러 리소스 간 트랜잭션 처리를 위해 UserTransaction을 직접 제어해야 하며, 서버 환경에 종속된다.

스프링 트랜잭션의 이점

기존 트랜잭션 관리 기술의 한계를 해결하기 위해 스프링은 크게 세 가지 관점에서 트랜잭션 관리 기술을 제공한다.

  1. 트랜잭션 추상화(Spring Framework transaction abstraction)
  2. 트랜잭션과 리소스 동기화(Synchronizing resources with transactions)
  3. AOP, Proxy를 이용한 선언적 트랜잭션 관리(Declarative transaction management)

1. 트랜잭션 추상화

스프링 트랜잭션 추상화의 핵심은 Transaction Strategy라고 부르는 개념이다. Transaction StrategyPlatformTransactionManager 인터페이스 타입으로 추상화되어 있다.

트랜잭션 전략(Transaction Strategy)은 스프링이 다양한 트랜잭션 처리 방식(JDBC, JTA 등)에 대해 일관된 프로그래밍 모델을 제공하기 위해 내부적으로 사용하는 추상화 계층이다. 실제 트랜잭션 처리 로직은 구현체에 위임하는 방식이다.

이를 통해 트랜잭션에 필요한 핵심 연산인 시작, 커밋, 롤백을 인터페이스로 추상화한다. 따라서 애플리케이션 코드, 주로 Service 계층에서는 데이터 처리 기술에 직접 종속되지 않으면서 일관적으로 트랜잭션을 처리할 수 있다.

// org.springframework.transaction.PlatformTransactionManager
public interface PlatformTransactionManager {

    TransactionStatus getTransaction(
            TransactionDefinition definition) throws TransactionException;

    void commit(TransactionStatus status) throws TransactionException;

    void rollback(TransactionStatus status) throws TransactionException;
}

// 애플리케이션 코드에서는 인터페이스로 받을 수 있다.
// 다만 추후에 설명할 @Transactional을 이용한 선언적 트랜잭션 관리를 주로 사용한다.
public class ServiceLayer {

    @Autowired
    PlatformTransactionManager txManager;

    public void someMethod() {
        // transaction 관리 로직
    }
}

스프링 프레임워크는 개발자의 생산성과 유연성을 높이기 위해, 복구 불가능한 예외 상황에 대해서는 체크 예외 대신 런타임 예외(RuntimeException)를 사용하는 설계 철학을 따른다.

트랜잭션 처리 과정에서 발생하는 대부분의 오류는 복구가 어려운 인프라 문제(DB 연결 오류, 커밋/롤백 실패 등)로 간주된다. 따라서 TransactionExceptionRuntimeException을 상속하여 선언적 트랜잭션 처리와 서비스 계층 코드가 불필요한 예외 처리를 강제받지 않도록 설계되어 있다.

getTransaction은 트랜잭션을 시작하거나, 기존 트랜잭션에 참여하게 해주는 메서드이다. getTransaction 메서드의 파라미터에 정의되어 있는 TransactionDefinition 인터페이스는 해당 트랜잭션의 격리 수준, 전파 수준, 타임아웃, readOnly 같은 정보를 포함한다.

해당 정보를 파라미터로 넘겨줌으로써 getTransaction() 호출 시 “트랜잭션을 어떤 규칙으로 시작할 것인가?”를 설정하는 데 사용된다.

2. 트랜잭션과 리소스 동기화

Spring은 애플리케이션 코드가 JDBC, Hibernate 등의 리소스 생성, 재사용, 정리를 올바르게 수행하는 방법을 제공한다.

트랜잭션 동기화는 고수준 트랜잭션 동기화 접근법과 저수준 트랜잭션 동기화 접근법을 제공한다.

고수준 트랜잭션 동기화는 Persistence API 등에 의존하는 방식이며, 저수준 동기화는 개발자가 직접 트랜잭션 동기화를 관리할 수 있도록 스프링이 통합된 인터페이스를 제공하는 방식이다.

DataSourceUtils, EntityManagerFactoryUtils, SessionFactoryUtils 등은 스프링에서 제공하는 더 낮은 수준의 클래스이다. 애플리케이션 코드가 JDBC, JPA, Hibernate 등의 네이티브 API 리소스를 직접 다루길 원할 때 위 클래스들을 통해 다음과 같은 이점을 얻을 수 있다.

  • 스프링이 관리하는 올바른 리소스 인스턴스를 얻을 수 있다.
  • 트랜잭션이 선택적으로 동기화된다.
  • 발생한 예외가 스프링의 일관된 API 형태로 변환된다.
// JDBC code
Connection conn = DataSourceUtils.getConnection(dataSource);

스프링은 내부적으로 Connection 객체를 보관하며, getConnection() 호출 시 다음과 같이 동작한다.

  • 이미 트랜잭션과 연결된 커넥션이 있다면 그 커넥션을 반환한다.
  • 없다면 새 커넥션이 생성되며, 필요 시 트랜잭션에 동기화되고 같은 트랜잭션 내에서 재사용 가능하도록 생성된다.

또한 발생한 SQLException은 스프링의 CannotGetJdbcConnectionException으로 감싸진다. 이 예외는 Spring의 언체크 예외인 DataAccessException 계층에 속한다.

이 방식은 단순한 SQLException보다 더 많은 정보를 제공하고, DB나 기술(JPA, Hibernate 등)이 달라져도 코드 이식성을 유지할 수 있게 해준다.

다만 직접적으로 위와 같은 헬퍼 클래스를 이용하여 트랜잭션을 관리하는 것은 드물기 때문에, 일반적으로는 추상화된 트랜잭션 매니저를 이용하여 관리한다.

3. 선언적 트랜잭션 관리

아래는 트랜잭션 과정에서 매번 트랜잭션을 시작하고, 커밋하고, 롤백하는 보일러플레이트 코드와 이를 개선한 선언적 트랜잭션 관리의 예시이다.

public void someLogic(SomeDTO dto) {
    // 프로그래밍 트랜잭션 관리
    TransactionStatus status = txManager.getTransaction(
        new DefaultTransactionDefinition()
    );

    try {
        // some logic

        this.transactionManager.commit(status);
    } catch (Exception e) {
        this.transactionManager.rollback(status);
        throw e;
    }
}

// 선언적 트랜잭션 관리
@Transactional
public void someLogic(SomeDTO dto) {
    // some logic, 예외 발생 시 자동 롤백
}

스프링은 AOP와 Proxy를 이용하여 데이터 접근 코드에서 공통적으로 나타나는 보일러플레이트를 제거할 수 있도록, 어노테이션을 이용한 선언적 트랜잭션 관리 기능을 제공한다.

@Transactional 어노테이션을 이용하면 메서드 혹은 클래스 레벨에서 AOP를 통해 트랜잭션 관리를 사용할 수 있다.

AOP 기반 트랜잭션 관리

AOP 기반 트랜잭션 관리

의존관계를 맺고 있는 호출자가 Proxy를 호출하고, 이후 트랜잭션 관리 기능을 수행하는 Advisor가 호출된다. 그 다음 개발자가 구현한 Target의 메서드가 호출되고, 예외가 발생했다면 롤백을 수행한다.

@Transactional 어노테이션은 AOP가 public 메서드만 가로챌 수 있기 때문에, public 클래스와 public 메서드에만 적용된다.

3-1. 트랜잭션 세부 설정

선언적 혹은 프로그래밍 방식으로 트랜잭션 관리를 이용할 때 트랜잭션과 관련된 세부 사항을 전달할 수 있다.

이는 TransactionDefinition 인터페이스를 구현한 구현체에 의해 정의되며, 아래 네 가지 속성을 통해 트랜잭션의 세부 사항을 정의한다.

  • 격리 수준(isolation)
  • 트랜잭션 전파(propagation)
  • 제한 시간(timeout)
  • 읽기 전용 여부(readOnly)

TransactionDefinition에 정의된 기본 세부 사항 외에도, @Transactional 어노테이션에는 rollbackFor, noRollbackFor 등의 옵션을 전달할 수 있다. 특정 예외에 대해 롤백할지, 롤백하지 않을지를 설정하는 옵션이다.

@Transactional(
    propagation = Propagation.REQUIRES_NEW,
    isolation = Isolation.READ_COMMITTED,
    timeout = 3,
    readOnly = true
)
public void someLogic() {}

격리 수준

격리 수준은 DBMS의 개념상 사용할 수 있는 DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE 등을 어노테이션 선언 시 전달할 수 있다.

트랜잭션 격리 수준에 대한 내용은 별도 글로 정리해두었다.

트랜잭션 전파

트랜잭션 전파는 하나의 트랜잭션 안에서 다른 메서드를 호출할 때, 그 메서드가 기존 트랜잭션을 어떻게 처리할지를 정의하는 것이다.

이를 이해하려면 스프링에서 관리하는 물리적 트랜잭션과 논리적 트랜잭션의 개념을 알아야 한다.

물리적 트랜잭션은 데이터베이스 커넥션 레벨에서 실제로 시작되고 커밋 또는 롤백되는 트랜잭션이다. Connection.setAutoCommit(false)부터 시작해서 commit() 또는 rollback()으로 종료된다. JDBC, JPA 등에서 직접적으로 DB 트랜잭션을 제어하는 것이며, 하나의 물리적 트랜잭션 내에서 여러 논리적 트랜잭션이 존재할 수 있다.

논리적 트랜잭션은 애플리케이션 코드 수준에서 메서드 단위로 관리되는 트랜잭션의 논리적 범위이다. 실제로는 물리적 트랜잭션과 동일한 DB 연결을 공유할 수도 있고, 별도일 수도 있다. 내부적으로 하나의 물리적 트랜잭션을 공유할 수도 있고, 전파 속성에 따라 새로운 물리적 트랜잭션을 만들 수도 있다. 각 논리 트랜잭션은 독립적으로 rollback-only 상태를 설정할 수 있다.

Required

PROPAGATION_REQUIRED

PROPAGATION_REQUIRED

PROPAGATION_REQUIRED 전파 속성은 하나의 논리적 트랜잭션 범위를 생성한다. 외부 트랜잭션이 존재할 경우 기존 트랜잭션에 참여하며, 존재하지 않을 경우 새로운 트랜잭션을 생성한다.

PROPAGATION_REQUIRED에서 각 논리적 트랜잭션은 실제로 동일한 물리적 트랜잭션을 공유하지만, 각 메서드마다 rollback-only 상태를 설정할 수 있다. 각각의 @Transactional이 선언된 메서드들은 자신만의 논리적 트랜잭션 범위를 가지며, rollback-only 마커를 내부적으로 기록한다.

만약 내부 트랜잭션이 rollback-only 상태로 마킹되면 외부 트랜잭션도 커밋할 수 없다. 내부 트랜잭션이 rollback-only로 설정된 상태에서 외부 트랜잭션이 commit을 시도할 경우, 스프링은 UnexpectedRollbackException을 던져 커밋되지 않았음을 알린다.

이 경우 내부 메서드에서 롤백이 발생하면 전체 트랜잭션이 롤백된다. 또한 외부 트랜잭션의 격리 수준, 타임아웃 등을 그대로 공유한다.

PROPAGATION_REQUIRED는 하나의 물리적 트랜잭션을 사용하기 때문에 최초의 물리적 트랜잭션을 공유하며, 트랜잭션 시작 시점에 생성된 하나의 커넥션을 같이 사용한다.

RequiresNew

PROPAGATION_REQUIRES_NEW

PROPAGATION_REQUIRES_NEW

PROPAGATION_REQUIRES_NEW는 각 트랜잭션 범위에 대해 완전히 독립적인 트랜잭션을 사용한다. 각 메서드마다 사용하는 물리적 트랜잭션이 서로 다르므로, 각 메서드마다 독립적으로 commit하거나 rollback을 수행할 수 있다.

PROPAGATION_REQUIRES_NEW는 트랜잭션이 분리되어 있기 때문에 내부에서 새로운 트랜잭션을 시작할 경우 커넥션 풀에서 새 커넥션을 가져와 트랜잭션을 시작한다.

Nested

PROPAGATION_NESTED는 롤백 가능한 여러 savepoint를 가진 단일 물리적 트랜잭션을 사용한다. savepoint 기반 부분 롤백을 통해 내부 트랜잭션 범위가 해당 범위에 대한 롤백을 수행할 수 있고, 외부 트랜잭션은 일부 작업이 롤백되었더라도 계속해서 진행될 수 있다.

일반적으로 위 설정은 JDBC SavePoint에 매핑되기 때문에 JDBC Driver에서만 사용 가능하다.

NotSupported

PROPAGATION_NOT_SUPPORTED의 경우 트랜잭션을 사용하지 않는다.

제한 시간

제한 시간은 트랜잭션을 수행하는 최대 제한 시간을 설정하는 옵션이며, 트랜잭션을 새롭게 생성하는 옵션의 경우에만 적용된다. 해당 제한 시간이 경과할 경우 트랜잭션은 롤백된다.

읽기 전용 여부

읽기 전용 트랜잭션으로 설정할 경우 조회만 가능하며, 구현체별로 읽기 전용이라는 힌트를 제공하므로 일부 성능 최적화 효과를 볼 수 있다.

참고 자료