이끌든지 따르든지 비키든지

Framework/Spring

[Spring] @Transactinal 파헤치기

SeongHo5 2024. 2. 4. 23:07

들어가기 앞서 - 트랜잭션(Transaction)이란?

트랜잭션(Transaction)은 데이터베이스의 상태를 변화시키는 하나의 작업 단위를 의미합니다. 하나의 트랜잭션에 여러 작업(연산)이 포함될 수 있으며, 이 작업들은 모두 성공적으로 완료되거나, 하나라도 실패할 경우 전체가 취소(롤백)되어야 합니다(All or Nothing이라고도 합니다. 한국어로는 모 아니면 도?). 이러한 특성 때문에 트랜잭션은 데이터의 일관성과 무결성을 유지하는 데 핵심적인 역할을 합니다.

 

 

'@Transactinal' 어노테이션


 

// try-catch를 통해 트랜잭션을 수동으로 관리하는 코드
public class Example {

    public static void main(String[] args) {
        Connection conn = null;
        try {
            // 데이터베이스 연결
            conn = DriverManager.getConnection("jdbc:mydatabase", "root", "1234");
            // 트랜잭션 시작
            conn.setAutoCommit(false);
            // 데이터베이스 업데이트 실행
            PreparedStatement updateStmt = conn.prepareStatement("UPDATE your_table SET your_column = ? WHERE another_column = ?");
            updateStmt.setString(1, "new value");
            updateStmt.setString(2, "specific value");
            updateStmt.executeUpdate();
            conn.commit();
        } catch (SQLException e) {
            if (conn != null) {
                try {
                    // 예외 발생 시 롤백
                    conn.rollback();
                } catch (SQLException ex) {
                    ex.printStackTrace();
                }
            }
            e.printStackTrace();
        } finally {
        // 연결 종료
        }
    }
}

 

 

try-catch 블록을 사용해프로그래밍 방식으로 트랜잭션을 관리하는 코드입니다.

실제 비즈니스 로직(DB 작업)보다 트랜잭션 관리에 상당히 많은 추가 코드가 필요합니다.

 

이 문제를 해결하기 위해 트랜잭션 관리를 횡단 관심사(Cross-Cutting Concern)로 보고, 이를 관점 지향 프로그래밍(AOP)의 개념을 이용해 처리하는데, 스프링에서는  '@Transactional'를 사용해 이를 구현합니다.

 

 

@Service
@RequiredArgsConstructor
public class MyService {

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public void updateYourTable(String newValue, String specificValue) {
        String sql = "UPDATE your_table SET your_column = ? WHERE another_column = ?";
        jdbcTemplate.update(sql, newValue, specificValue);
        // 이 메소드 내의 추가적인 DB 작업도 이 트랜잭션의 일부가 됩니다.
        
        // 모든 작업이 성공적으로 완료되면 스프링은 자동으로 트랜잭션을 커밋합니다.
        // 예외가 발생하면 스프링은 자동으로 롤백합니다.
    }
}

 

 

@Transactional 어노테이션을 사용하면, 위에서 본 프로그래밍 방식의 트랜잭션 관리 코드를 훨씬 간결하고 선언적으로 처리할 수 있습니다.

스프링은 @Transactional이 붙은 메서드의 실행을 트랜잭션 경계로 취급해, 해당 메서드 내의 모든 DB 작업을 하나의 트랜잭션으로 관리합니다.

이 어노테이션을 통해 개발자는 복잡한 트랜잭션 관리 코드를 작성하지 않고도, 비즈니스 로직에만 집중할 수 있게 됩니다.

 

@Transactional을 클래스 레벨에 적용해 클래스 내의 모든 메서드의 모든 데이터베이스 연산을 하나의 트랜잭션으로 관리하도록 지시할 수도 있습니다.

 


 

※ 주의할 점

정말 마법 같이 간편하게 트랜잭션을 관리해 주는 듯 하지만, @Transactional의 구현은 스프링의 프록시 기반 AOP에 의존하고 있기 때문에, 동작 방식으로 인해 몇 가지 주의해야 할 부분이 있습니다.

 

💡프록시(Proxy)란?

프록시(Proxy)는 원래 객체를 대신하여 대리로 작동하는 객체입니다.
스프링에서는 AOP를 통해 @Transactional 어노테이션이 붙은 메서드를 호출할 때, 실제 대상 객체 대신 프록시 객체를 생성하여 사용합니다. 이 프록시 객체를 통해 트랜잭션의 시작, 커밋, 롤백 등을 자동으로 처리합니다.

 

  • public 키워드만 함께할 수 있어요:
    • 스프링 AOP의 프록시 생성은 실제 메서드 · 클래스를 상속 또는 오버라이드하는 방식입니다. private / protected / final 키워드가 선언된 메서드나 클래스는 접근이 불가능하기 때문에 프록시를 생성할 수 없고, AOP를 적용할 수 없습니다.

 

  • 자기 호출에 주의하세요:
    • 같은 클래스 내의 메서드에서 다른 @Transactional 메서드를 호출하는 경우, 스프링이 해당 호출을 가로채 프록시 객체에게 전달하는 작업이 불가능해져 트랜잭션 관리가 되지 않을 수 있습니다.
    • 이 경우, 별도의 Bean으로 분리해 호출하도록 구성하는 것이 좋을 수 있습니다.

 

public class TransactionalService {

    @Transactional
    public void methodA() {
        // 비즈니스 로직
    }

    public void commonMethod() {
        methodA();  // 같은 클래스 내에서 호출할 경우 AOP 적용 X

    }
}

 

 

@Transactional 의 옵션과 속성


 

transactionManager 또는 value 

 

여러 트랜잭션 매니저(JDBC, Hibernate 등)가 존재하는 경우, 어떤 트랜잭션 매니저를 사용할지 지정할 수 있습니다.

value는 transactionManager의 별칭(Alias)으로, 동일한 역할을 수행합니다.

 

하지만, 여러 트랜잭션 매니저를 사용하면 애플리케이션의 구성과 관리가 복잡해지고, 각각의 트랜잭션 매니저가 동일한 DataSource에 대해 독립적으로 연산을 수행하므로, 성능 저하가 발생할 수 있으니 주의해서 사용해야 합니다.

 

■ propagation

 

트랜잭션의 전파 범위를 결정합니다. 'propagation = Propagation.[OPTION]'을 통해 설정할 수 있습니다. 옵션 종류와 설명은 아래와 같습니다.

  • REQUIRED: 진행 중인 트랜잭션이 존재하면 그 트랜잭션에 참여하고, 없으면 새 트랜잭션을 시작한다. (default)
  • REQUIRES_NEW: 진행 중이던 트랜잭션을 보류(일시 중지)하고,  항상 새 트랜잭션을 시작한다.
  • SUPPORTS: 진행 중인 트랜잭션이 존재한다면, 그 트랜잭션에 참여한다. 없으면 트랜잭션 없이 진행한다.
  • NOT_SUPPORTED: 진행 중인 트랜잭션이 있다면 보류하고, 항상 트랜잭션 없이 진행합니다. 
  • MANDATORY: 반드시 트랜잭션에 참여해야 한다. 진행 중인 트랜잭션이 없다면 예외를 던진다.
  • NEVER: 반드시 트랜잭션 없이 진행한다. 진행중인 트랜잭션이 있다면 예외를 던진다. 
  • NESTED: 진행 중인 트랜잭션이 있다면, 중첩되는 (자식) 트랜잭션을 만든다. 없으면 새 트랜잭션을 시작한다.

전파 범위에 대해 코드 예시를 통해 알아보겠습니다.

 

// FooService.java
@Service
@RequiredArgsConstructor
public class FooService {

    private FooRepository fooRepository;
    private BarService barService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void doFoo() {
        // 비즈니스 로직 수행
    }
    
}

// BarService.java
@Service
@RequiredArgsConstructor
public class BarService {
	
    private BarRepository barRepository;
    private FooService fooService;
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void doBar() {
    	// 비즈니스 로직 수행
    }
}

 

  • doFoo() 메서드가 doBar() 메서드를 호출하면 

        → doFoo() 메서드 호출

           → doFoo()는 트랜잭션 전파 범위가 REQUIRED이고, 진행 중인 트랜잭션 없으므로 새 트랜잭션(A) 시작

             → doBar() 메서드 호출

               → dooBar()는 REQUIRES_NEW이므로, 진행 중인 트랜잭션을 보류하고 새 트랜잭션(B) 시작

                    (트랜잭션 A는 트랜잭션 B 완료까지 블록)

 

  • doBar() 메서드가 doFoo() 메서드를 호출하면 

         → doBar() 메서드 호출

           → doBar()는 전파 범위가 REQUIRES_NEW이고, 진행중인 트랜잭션 없으므로 새 트랜잭션(A) 시작

             → doFoo() 메서드 호출

               → dooBar()는 REQUIRED이고, 진행중인 트랜잭션 있으므로, 해당 트랜잭션(A)에 참여

 

 

readOnly

 

트랜잭션이 쓰기 작업(INSERT, UPDATE)을 수행하지 않는, 읽기(SELECT) 전용인지 여부를 지정합니다.

이 옵션을 true로 지정하면, 트랜잭션 매니저가 성능 최적화를 진행할 수 있습니다.

 

다만, 이 옵션은 트랜잭션 매니저에게 힌트를 주는 것일 뿐으로, 어떤 트랜잭션 매니저를 사용하냐에 따라 읽기 전용 메서드 내에서 쓰기 작업이 반드시 실패하거나 예외를 던지지는 않습니다.

(테스트해 보니, Hibernate는 readOnly 메서드 내에서 쓰기 작업을 시도하면, JpaSystemException를 던지는 것 같습니다.)

 

 

 isolation

 

트랜잭션의 격리 수준을 설정합니다.  'isolation = Isolation.[OPTION]'을 통해 설정할 수 있습니다.

격리 수준은 트랜잭션이 다른 트랜잭션으로부터 독립적으로 실행되는 정도(다른 트랜잭션의 변경 사항을 어느 정도 보거나 영향을 받을 수 있는지)를 결정합니다.

 

  • DEFAULT: 데이터 저장소의 기본 격리 수준을 사용합니다. 
  • READ_UNCOMMITTED: 가장 낮은 격리 수준으로, 다른 트랜잭션에 의해 수정되었지만 아직 커밋되지 않은 데이터를 읽을 수 있습니다(더티 리드).
  • READ_COMMITTED: 커밋하여 확정된 변경 사항만 다른 트랜잭션이 읽을 수 있도록 합니다. 더티 리드는 방지하지만, 비반복 가능 리드와 팬텀 리드는 발생할 수 있습니다.
  • REPEATABLE_READ: 트랜잭션이 시작할 때 읽은 데이터에 대한 "스냅샷"을 생성하고, 이를 쿼리에 활용합니다. 더티 리드, 비반복 가능 리드는 방지하지만, 팬텀 리드는 발생할 수 있습니다.
  • SERIALIZABLE: 가장 높은 격리 수준으로, 한 트랜잭션이 조회한 데이터를 잠금해 모든 접근(삽입, 수정, 삭제)를 차단해 더티 리드, 비반복 가능 리드, 팬텀 리드를 모두 방지합니다.

 

💡용어 설명

더티 리드(Dirth Read): 한 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 변경 사항을 읽는 경우

비반복 가능 리드(Non-repeatable Read): 한 트랜잭션 내에서 같은 데이터를 두 번 조회했을 때, 두 조회 사이에 다른 트랜잭션이 데이터를 변경·커밋해 조회의 결과가 다른 경우

팬텀 리드(Phantom Read): 한 트랜잭션 내에서 특정 조건의 데이터를 두 번 조회했을 때, 다른 트랜잭션이 같은 조건의 데이터를 INSERT 또는 DELETE해, 첫 조회에서 보이지 않던 팬텀(유령) 데이터가 생기거나 사라지는 경우

 

 

 

 

rollbackFor(rollbackForClassName) / noRollbackFor (noRollbackForClassName)

 

특정 예외(클래스)가 발생했을 때, 해당 트랜잭션을 롤백할지, 롤백하지 않을지 설정할 수 있습니다.

 

 


 

readOnly 옵션을 통해 어떻게 성능 상 이점을 얻을까? (Hibernate 기준)

트랜잭션을 읽기 전용으로 설정하면, 아래 작업 등을 수행해 성능을 최적화합니다.


1. 플러시(flush) 억제: 읽기 전용 작업에서는 DB에 반영할 영속성 컨텍스트의 변경 내용이 없을 거라 판단, 자동으로 플러시가 발생하지 않도록 설정합니다.

2. 더티 체킹(Dirty Checking) 비활성화: 트랜잭션이 읽기 전용이므로, 엔티티의 변경 사항을 추적하지 않습니다.

 

// HibernateTransactionManager.java
// doBegin 메서드의 일부
if (definition.isReadOnly() && txObject.isNewSession()) {
    /* FlushMode를 MANUAL로 설정해, 
    트랜잭션 종료 때 플러시가 자동으로 발생하지 않도록 설정 */
    session.setHibernateFlushMode(FlushMode.MANUAL);
    // DB 세션을 읽기 전용으로 설정
    session.setDefaultReadOnly(true);
}

 

 

 

트랜잭션의 내부 동작 흐름


 

트랜잭션 처리는 내부적으로 'TransactionInterceptor' 'PlatformTransactionManager' 등 여러 객체가 협력해 구현됩니다. 이 과정 중, 메서드 호출을 트랜잭션으로 관리하는 동작을 코드 예시를 통해 살펴보겠습니다. 

 

public abstract class TransactionAspectSupport implements BeanFactoryAware, InitializingBean {
	@Nullable
	protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
			final InvocationCallback invocation) throws Throwable {
		// Attribute를 가져온다.
		TransactionAttributeSource tas = getTransactionAttributeSource();
		// 트랜잭션 관리자 결정
		final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
		final TransactionManager tm = determineTransactionManager(txAttr);
		PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
		final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
		if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) {
			TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
			Object retVal;
			try {
				// 비즈니스 로직이 있는 메서드 또는 다음 인터셉터를 호출
				retVal = invocation.proceedWithInvocation();
			}
			catch (Throwable ex) {
				// 비즈니스 로직에서 예외가 발생한 경우 롤백
				completeTransactionAfterThrowing(txInfo, ex);
				throw ex;
			}
			finally {
				cleanupTransactionInfo(txInfo);
			}
            // 성공적으로 실행된 경우 커밋
			commitTransactionAfterReturning(txInfo);
			return retVal;
        }
}

 

트랜잭션 시작

 

  • 메소드 호출: @Transactional 메소드를 호출하면, 스프링 AOP는 TransactionInterceptor를 통해 이 메소드 호출을 가로챕니다.
  • 트랜잭션 관리자 결정: TransactionInterceptor 내의 determineTransactionManager 메서드는 @Transactional 어노테이션의 옵션(transactionManager, rollbackFor 등) 또는 @Qualifier을 통해 적절한 PlatformTransactionManager를 결정합니다. 명시된 설정이 없는 경우, 기본 트랜잭션 관리자를 반환합니다.
  • 트랜잭션 시작: createTransactionIfNecessary 메서드는 PlatformTransactionManager와 트랜잭션 속성(TransactionAttribute)을 사용하여 필요한 경우새로운 트랜잭션을 시작하거나 기존 트랜잭션에 참여합니다.


비즈니스 로직 실행

  • 로직 실행: proceedWithInvocation 메서드를 통해 실제 비즈니스 로직이 있는 메소드가 실행됩니다. 

 

트랜잭션 커밋 또는 롤백

  • 예외 처리: 비즈니스 로직 실행 중 예외가 발생하면, completeTransactionAfterThrowing 메서드를 통해 롤백 로직이 수행됩니다.
  • 커밋 처리: 비즈니스 로직의 실행이 성공적으로 마무리되면, commitTransactionAfterReturning 메서드를 통해 PlatformTransactionManager가 트랜잭션을 커밋합니다.


트랜잭션 종료

  • 리소스 반납: 커밋 또는 롤백 후, cleanupTransactionInfo 메서드를 통해 트랜잭션 관련 정보를 정리하고, 사용된 DB 커넥션 풀 등의 리소스를 반납합니다. 이로써 트랜잭션이 완전히 종료됩니다.