Profile image
Jinyoung
Dev

TIL-01: 스프링 프레임워크에서 트랜잭션을 처리하는 방법 - 2

TIL-01: 스프링 프레임워크에서 트랜잭션을 처리하는 방법 - 2
0 views
26 min read

이번 글에서는 지난 글에 이어서 Spring Framework에서 트랜잭션을 어떻게 처리하는지 좀 더 구체적으로 알아보도록 하겠습니다.

Part 4: Spring Framework Transaction

1. Declarative Transaction Management

선언적 vs 프로그래밍 방식

스프링은 두 가지 트랜잭션 관리 방식을 제공합니다:

방식설명코드 스타일
프로그래밍 방식코드로 직접 트랜잭션 제어transactionManager.begin(), transactionManager.commit()
선언적 방식어노테이션으로 트랜잭션 선언@Transactional

프로그래밍 방식의 문제점

// ❌ 프로그래밍 방식 - 코드가 복잡함
@Service
public class OrderService {
  private final TransactionTemplate transactionTemplate;

  public void createOrder(Order order) {
    transactionTemplate.execute(status -> {
      try {
        // 비즈니스 로직
        orderRepository.save(order);
        paymentRepository.save(payment);
        return null;
      } catch (Exception e) {
        status.setRollbackOnly();
        throw e;
      }
    });
  }
}
  • 문제점:
    • 🔴 트랜잭션 코드와 비즈니스 로직이 섞임
    • 🔴 모든 메서드마다 반복 코드
    • 🔴 테스트하기 어려움
    • 🔴 가독성 저하

선언적 방식의 장점 🌟

// ✅ 선언적 방식 - 깔끔!
@Service
public class OrderService {
  @Transactional
  public void createOrder(Order order) {
    // 비즈니스 로직만!!
  }
}
  • 장점:
    • ✅ 비즈니스 로직과 트랜잭션 분리
    • ✅ 코드 중복 제거
    • ✅ 테스트 용이
    • ✅ 가독성 향상
    • ✅ 유지보수 쉬움

선언적 트랜잭션의 핵심 구성 요소

Declarative Transaction Management
    │
    │--- 1. @Transactional (선언)
    │    "이 메서드는 트랜잭션이 필요해!"
    │
    │--- 2. AOP Proxy (실행)
    │    프록시가 트랜잭션 로직을 대신 실행
    │
    │--- 3. Transaction Manager (관리)
    │    실제 트랜잭션을 시작/커밋/롤백
    │
    │--- 4. Transaction Synchronization Manager (동기화)
        트랜잭션 정보를 ThreadLocal에 저장
  • 이 4개의 핵심 구성 요소는 매우 중요합니다.
    • @Transactional
    • AOP Proxy
    • Transaction Manager
    • Transaction Synchronization Manager

AOP Proxy는 우리가 이미 살펴봤고 나머지 3개의 개념에 대해 이 글에서 자세히 살펴봅시다.

2. @Transaction 동작 원리

@Transactional 이란? 🎯

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
  // 트랜잭션 속성들...
}
  • 한 문장 정의: @Transactional이 메서드를 트랜잭션 안에서 실행하라고 스프링에게 알려주는 Marker 어노테이션입니다.
  • Github spring-framework repository에서 직접 Transactional.java 코드를 살펴보고 한 번 (코멘트까지 포함하여) 읽어보시면 더 좋습니다.

@Transactional의 주요 속성들

@Service
public class OrderService {
  @Transactional(
    propagation     = Propagation.REQUIRED,   // 전파 속성
    isolation       = Isolation.DEFAULT,      // 격리 수준
    timeout         = 30,                     // 타임아웃 (초)
    readOnly        = false,                  // 읽기 전용 여부
    rollbackFor     = Exception.class,        // 롤백 대상 예외
    noRollbackFor   = RuntimeException.class  // 롤백하지 않을 예외
  )
  public void createOrder(Order order) {
    orderRepository.save(order);
  }
}

1️⃣ Propagation (전파 속성) - 가장 중요! ⭐️

문제 상황:

@Transactional
public void methodA() {
  // 트랜잭션 A 시작

  methodB(); // methodB도 @Transactional인데?

  // 트랜잭션을 새로 만들까? 기존 것을 사용할까?
}

@Transactional
public void methodB() {
  // ???
}

전파 속성 종류:

속성의미동작
REQUIRED(기본값)트랜잭션 필요기존 있으면 참여, 없으면 새로 생성
REQUIRES_NEW항상 새 트랜잭션기존 있어도 중단하고 새로 생성
SUPPORTS트랜잭션 지원있으면 참여, 없어도 실행
MANDATORY트랜잭션 필수기존 있으면 참여, 없으면 예외
NOT_SUPPORTED트랜잭션 불필요있으면 중단, 트랜잭션 없이 실행
NEVER트랜잭션 금지있으면 예외
NESTED중첩 트랜잭션기존 트랜잭션 내 중첩

가장 많이 쓰이는 REQUIRED 예시:

@Service
public class OrderService {
  @Autowired
  private PaymentService paymentService;

  @Transactional // REQUIRED (기본값)
  public void createOrder(Order order) {
    // 트랜잭션 A 시작
    orderRepository.save(order);

    // paymentService.pay()도 @Transactional(REQUIRED)
    // 트랜잭션 A에 참여! (새로 만들지 않음)
    paymentService.pay(order.getId());

    // 둘 다 트랜잭션 A 안에서 실행됨
    // 하나라도 실패하면 둘 다 롤백!
  }
}

@Service
public class PaymentService {
  @Transactional // REQUIRED
  public void pay(Long orderId) {
    paymentRepository.save(payment);
  }
}
  • PaymentService.pay 메서드 실행 과정에서 오류가 발생하게 되면 pay 메서드 뿐만 아니라 createOrder 메서드 실행 과정에서 수행된 데이터 처리 작업 역시 전부 롤백 됩니다.
    • 모두 하나의 트랜잭션으로 처리되고 있었기 때문입니다.

REQUIRES_NEW 예시:

@Service
public class OrderService {
  @Autowired
  private LogService logService;

  @Transactional // REQUIRED
  public void createOrder(Order order) {
    // 트랜잭션 A
    orderRepository.save(order);

    // REQUIRES_NEW -> 트랜잭션 B 새로 생성
    logService.log("주문 생성");

    // 주문이 실패해서 롤백되어도 로그는 별도 트랜잭션이라 커밋됨!
    throw new RuntimeException("주문 실패");
  }
}

@Service
public class LogService {
  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void log(String message) {
    // 트랜잭션 B (독립적)
    logRepository.save(log);
    // 여기는 항상 커밋됨!
  }
}
  • LogService.log 메서드 실행이 끝나고 그 이후에 createOrder 메서드에서 오류가 발생해도 log 메서드 실행 과정에서 수행된 데이터 처리 작업은 롤백되지 않습니다.
    • 서로 다른 독립적인 트랜잭션으로 처리되었기 때문입니다.
    • 이렇게 의도적으로 분리된 트랜잭션을 만들어서 원래 작업의 성공 여부에 관계없이 무조건 데이터 처리 작업을 저장하게끔 하도록 할 수 있습니다.

2️⃣ Isolation (격리 수준)

문제: 동시성 이슈

Transaction A          Transaction B
   │                       │
   ├─ 계좌 조회 (10,000원)    │
   │                       ├─ 계좌 조회 (10,000원)
   ├─ 5,000원 출금           │
   ├─ 커밋 (5,000원)         │
   │                       ├─ 3,000원 출금 
   │                       └─ 커밋 (???)

최종 잔액: 2,000원? 7,000원? 🤔
  • 하나의 계좌에서 5,000원 출금과 3,000원 출금 작업이 동시에 발생하는 경우입니다.
  • Transaction A와 Transaction B가 각각 거의 동시에 원본 금액(10,000원)을 읽고 그 이후에 개별적으로 차감 작업을 하고 있기 때문에 두 Transaction 처리가 모두 끝났을 때, 최종 금액은 7,000원이 됩니다.
  • 이는 정상적인 금액인 2,000원과 차이가 나는 것으로서 동시성 이슈가 발생했다고 합니다.

@Transactional 어노테이션의 isolation 속성은 이 동시성 이슈를 처리하기 위한 여러가지 격리 수준을 지정할 수 있게 합니다. 이 글에서는 isolation 이라는 개념에 대해 깊게 다루지 않습니다. isolation은 DB에서 아주 중요한 개념으로서 다른 글에서 따로 자세하게 다뤄볼 예정입니다. 일단 지금은 개발자가 선언적으로 이 isolation의 수준을 지정할 수 있다는 것 정도만 알고 넘어가도록 합시다.


격리 수준:

수준설명발생 가능한 문제
DEFAULTDB 기본값 사용DB마다 다름
READ_UNCOMMITTED커밋 안 된 것도 읽기Dirty Read
READ_COMMITTED커밋된 것만 읽기Non-Repetable Read
REPEATABLE_READ같은 데이터 반복 조회Phantom Read
SERIALIZABLE완전 격리성능 저하

3️⃣ readOnly

읽기 전용 최적화:

@Transactional(readOnly = true)
public List<Order> findOrders() {
  // 읽기만 함
  return orderRepository.findAll();
}
  • 효과
    • ✅ JPA: flush를 하지 않아 성능 향상
    • ✅ JDBC: DB가 읽기 전용으로 최적화
    • ✅ 실수로 데이터 변경 방지

4️⃣ rollbackFor / noRollbackFor

기본 롤백 규칙:

  • ✅ RuntimeException (Unchecked): 롤백
  • ❌ Exception (Checked): 커밋
// 기본 동작
@Transactional
public void method1() {
    // RuntimeException → 롤백 ✅
    throw new RuntimeException("롤백됨");
}

@Transactional
public void method2() throws Exception {
    // Checked Exception → 커밋 ❌
    throw new Exception("커밋됨?!");
}

// 커스텀 설정
@Transactional(rollbackFor = Exception.class)
public void method3() throws Exception {
    // 이제 Checked Exception도 롤백! ✅
    throw new Exception("롤백됨");
}
  • 메서드 실행 과정에서 오류가 발생하면 모든 데이터 처리 작업을 롤백 한다는 것이 트랜잭션의 기본 개념입니다. 여기서 '오류'가 무엇인지 세밀하게 지정할 수 있도록 지원하는 것이 rollbackFor, noRollbackFor 속성입니다.

3. Transaction Manager 역할

Transaction Manager란?

Transaction Manager는 트랜잭션을 실제로 시작, 커밋, 롤백하는 핵심 컴포넌트입니다.

PlatformTransactionManager 인터페이스 🔑

스프링의 모든 Transaction Manager는 이 인터페이스를 구현합니다. Github에서 살펴보기

Spring 6.x부터는 TransactionManager 인터페이스가 상위에 추가되었고, PlatformTransactionManager는 이를 확장하도록 변경되었습니다.

// 간소화 된 코드를 통해 이해해봅시다!
public interface PlatformTransactionManager {
  // 트랜잭션 시작
  TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException;

  // 트랜잭션 커밋
  void commit(TransactionStatus status) throws TransactionException;
  
  // 트랜잭션 롤백
  void rollback(TransactionStatus status) throws TransactionException;
}
  • 핵심 메서드 3개:
    1. getTransaction(): 트랜잭션 시작 또는 기존 트랜잭션 참여
    2. commit(): 트랜잭션 커밋
    3. rollback(): 트랜잭션 롤백

Transaction Manager 구현체 📊

PlatformTransactionManager (인터페이스)
    │
    ├─── DataSourceTransactionManager
    │    └─ JDBC, MyBatis 사용 시
    │
    ├─── JpaTransactionManager
    │    └─ JPA, Hibernate 사용 시
    │
    ├─── HibernateTransactionManager
    │    └─ Hibernate만 사용 시
    │
    ├─── JtaTransactionManager
    │    └─ 분산 트랜잭션 (JTA) 사용 시
    │
    └─── WebLogicJtaTransactionManager
        └─ WebLogic 서버에서 JTA 사용 시

🌟 DataSourceTransactionManager

@Configuration
public class AppConfig {
  @Bean
  public DataSource dataSource() {
    // DB Connection Pool 설정
    return new HikariDataSource();
  }

  @Bean
  public PlatformTransactionManager transactionManager() {
    return  new DataSourceTransactionManager(dataSource());
  }
}

동작 방식:

// 내부 동작 (간소화)
public class DataSourceTransactionManager implements PlatformTransactionManager {
  private DataSource dataSource;

  @Override
  public TransactionStatus getTransaction(TransactionDefinition def) {
    // 1. DataSource에서 Connection 획득
    Connection conn = dataSource.getConnection();

    // 2. 자동 커밋 크기
    conn.setAutoCommit(false);

    // 3. 격리 수준 설정
    conn.setTransactionIsolation(def.getIsolationLevel().value());

    // 4. Connection을 ThreadLocal에 저장
    TransactionSynchronizationManager.bindResource(dataSource, conn);

    return new DefaultTransactionStatus(conn, ...);
  }

  @Override
  public void commit(TransactionStatus status) {
    Connection conn = status.getConnection();
    conn.commit(); // JDBC commit
    conn.close();
  }

  @Override
  public void rollback(TransactionStatus status) {
    Connection conn = status.getConnection();
    conn.rollback(); // JDBC rollback
    conn.close();
  }
}

🌟 JpaTransactionManager

JPA, Hibernate와 함께 사용:

@Configuration
@EnableJpaRepositories
public class JpaConfig {
  @Bean
  public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
    // JPA EntityManager 설정
    return new LocalContainerEntityManagerFactoryBean();
  }

  @Bean
  public PlatformTransactionManager transactionManager() {
    return new JpaTransactionManager(
      entityManagerFactory().getObject()
    );
  }
}

동작 방식:

// 내부 동작 (간소화)
public class JpaTransactionManager implements PlatformTransactionManager {
  private EntityManagerFactory emf;

  @Override
  public TransactionStatus getTransaction(TransactionDefinition def) {
    // 1. EntityManager 생성
    EntityManager em = emf.createEntityManager();

    // 2. JPA 트랜잭션 시작
    em.getTransaction().begin();

    // 3. EntityManager를 ThreadLocal에 저장
    TransactionSynchronizationManager.bindResource(
      emf, em
    );

    return new DefaultTransactionStatus(em, ...);
  }

  @Override
  public void commit(Transaction status) {
    EntityManager em = status.getEntityManager();
    em.flush();       // 변경사항을 DB에 반영
    em.getTransaction().commit(); // JPA commit
    em.close();
  }

  @Override
  public void rollback(TransactionStatus status) {
    EntityManager em = status.getEntityManager();
    em.getTransaction().rollback(); // JPA rollback
    em.close();
  }
}

📊 DataSource vs JPA Transaction Manager 비교

항목DataSourceTransactionManagerJpaTransactionManager
사용 기술JDBC, MyBatisJPA, Hibernate
관리 대상ConnectionEntityManager
획득dataSource.getConnection()emf.createEntityManager()
시작conn.setAutoCommit(false)em.getTransaction().begin()
커밋conn.commit()em.getTransaction().commit()
롤백conn.rollback()em.getTransaction().rollback()
종료conn.close()em.close()
  • JpaTransactionManager는 DataSource도 함께 지원 합니다. DataSource를 사용하는 JDBC 코드도 같은 트랜잭션에 포함시킬 수 있습니다.

🔃 Transaction Manager의 핵심 역할

Transaction Manager의 3대 책임:

1. 트랜잭션 리소스 관리
   ├─ Connection 또는 EntityManager 획득
   └─ ThreadLocal에 저장 (스레드 안전)

2. 트랜잭션 경계 설정
   ├─ 시작: begin / setAutoCommit(false)
   ├─ 종료: commit / rollback
   └─ 전파 속성 적용 (REQUIRED, REQUIRES_NEW 등)

3. 예외 변환
   └─ DB 예외를 스프링 예외로 변환

4. Transaction Synchronization Manager

ThreadLocal 기반 트랜잭션 동기화:

// 간소화된 코드
public abstract class TransactionSynchronizationManager {

  // ThreadLocal로 트랜잭션 리소스 저장
  private static final ThreadLocal<Map<Object, Object>> resources = 
    new NamedThreadLocal<>("Transactional resources");

  // 현재 스레드의 connection 저장
  public static void bindResource(Object key, Object value) {
    Map<Object, Object> map = resources.get();
    if (map == null) {
      map = new HashMap<>();
      resource.set(map);
    }
    map.put(key, value);
  }

  // 현재 스레드의 connection 가져오기
  public static Object getResource(Object key) {
    Map<Object, Object> map = resources.get();
    return map != null ? map.get(key) : null;
  }
}

왜 ThreadLocal을 사용할까?

@Service
public class OrderService {
  @Autowired
  private OrderRepository orderRepository;
  @Autowired
  private PaymentRepository paymentRepository;
  
  @Transactional
  public void createOrder(Order order) {
    // 1. Transaction Manager가 Connection 획득
    // 2. ThreadLocal에 저장
    
    orderRepository.save(order);
    // → Repository가 ThreadLocal에서 같은 Connection 꺼내 씀
    
    paymentRepository.save(payment);
    // → 이것도 ThreadLocal에서 같은 Connection 꺼내 씀
    
    // 같은 Connection = 같은 트랜잭션!
  }
}
  • 효과:
    • ✅ 같은 스레드 내 모든 Repository가 같은 Connection 사용
    • ✅ Connection을 파라미터로 전달할 필요 없음

지금까지 Transaction을 구성하는 각 요소들에 대해 알아봤고 이제부터는 전체 Flow를 살펴보면서 큰 그림을 그려보도록 하겠습니다.

Transaction 시작/커밋/롤백 전체 Flow

🎯 예제 시나리오:

@RestController
@RequiredArgsConstructor
public class OrderController {
  private final OrderService orderService; // proxy 주입됨

  @PostMapping("/orders")
  public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
    orderService.createOrder(request.toOrder());
    return ResponseEntity.ok().build();
  }
}

@Service
@RequiredArgsConstructor
public class OrderService {
  private final OrderRepository orderRepository;
  private final PaymentService paymentService; // proxy 주입됨

  @Transactional
  public void createOrder(Order order) {
    // 1. 주문 저장
    orderRepository.save(order);

    // 2. 결제 처리 (다른 @Transactional 메서드 호출)
    paymentService.processPayment(order.getId());

    // 3. 재고 감소
    order.decreaseStock();
  }
}

@Service
@RequiredArgsConstructor
public class PaymentService {
  private final PaymentRepository paymentRepository;

  @Transactional(propagation = Propagation.REQUIRED)
  public void processPayment(Long orderId) {
    Payment payment = new Payment(orderId);
    paymentRepository.save(payment);
  }
}

전체 호출 흐름

[1] HTTP 요청
     ⬇️
[2] OrderController.createOrder()
     ⬇️
[3] OrderService(프록시).createOrder() ⬅️ 프록시 진입!
     ⬇️
[4] TransactionInterceptor.invoke()
     ⬇️
[5] TransactionAspectSupport.invokeWithinTransaction()
     ⬇️
[6] TransactionManager.getTransaction() ⬅️ 트랜잭션 시작
     ⬇️
[7] OrderService(원본).createOrder() ⬅️ 실제 메서드 실행
     ⬇️
[8] orderRepository.save()
     ⬇️
[9] PaymentService(프록시).processPayment() ⬅️ 중첩 호출
     ⬇️
[10] 기존 트랜잭션 참여 (REQUIRED)
     ⬇️
[11] paymentRepository.save()
     ⬇️
[12] 메서드 완료
     ⬇️
[13] TransactionManager.commit() ⬅️ 트랜잭션 커밋

단계 별 상세 분석

HTTP 요청부터 트랜잭션이 커밋되는 전 과정을 이제 하나씩 상세하게 살펴봅시다. 예제 코드를 기준으로 설명할 것입니다. 이 예제 코드는 실제 Spring Framework 코드를 간소화 한 버전으로서 이해를 돕기 위해 작성되었습니다.

Phase 1: 프록시 진입

// [3] 프록시 메서드 호출
orderService.createOrder(order);

// 실제로는 이렇게 동작:
OrderService$$EnhancerBySpringCGLIB$$123412344.createOrder(order) {
  // MethodInvocation 생성
  MethodInvocation invocation = createMethodInvocation(
    "createOrder",
    new Object[]{order}
  );

  // [4] TransactionInterceptor에게 위임
  return interceptor.invoke(invocation);
}

Phase 2: TransactionInterceptor 실행

// [4] TransactionInterceptor.invoke()
public class TransactionInterceptor extends TransactionAspectSupport {
  @Override
  public Object invoke(MethodInvocation invocation) throws Throwable {
    // 1. 대상 클래스 정보
    Class<?> targetClass = invocation.getThis().getClass();

    // 2. 호출할 메서드
    Method method = invocation.getMethod();

    // [5] 트랜잭션 내에서 메서드 실행
    return invokeWithinTransaction(
      method,
      targetClass,
      invocation::proceed
    )
  }
}
  • 실제 클래스 코드는 여기를 참조하세요.

Phase 3: 트랜잭션 속성 파싱

// [5] TransactionAspectSupport.invokeWithinTransaction()
protected Object invokeWithinTransaction(
  Method method,
  Class<?> targetClass,
  InvocationCallback invocation
) throws Throwable {
  // 1. @Transactional 속성 읽기
  TransactionAttribute txAttr = getTransactionAttributeSource()
    .getTransactionAttribute(method, targetClass);

  // txAttr 내용:
  //  - propagation: REQUIRED
  //  - isolation: DEFAULT
  //  - timeout: -1 (무제한)
  //  - readOnly: false
  //  - rollbackFor: [RuntimeException.class]

  // 2. TransactionManager 찾기
  PlatformTransactionManager tm = determineTransactionManager(txAttr);

  // 3. 트랜잭션 내에서 실행
  String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
  return createTransactionIfNecessary(tm, txAttr, joinpointIdentification)
    .execute(status -> {
      return invocation.proceedWithInvocation();
    });
}
  • 실제 구현 코드는 이곳을 참조하세요.

Phase 4: 트랜잭션 시작

// [6] TransactionManager.getTransaction()
public class DataSourceTransactionManager {
  @Override
  public TransactionStatus getTransaction(TransactionDefinition definition) {
    // 1. 기존 트랜잭션이 있는지 확인
    Object transaction = doGetTransaction();

    // 2. 기존 트랜잭션 확인
    if (isExistingTransaction(transaction)) {
      // 전파 속성에 따라 처리
      return handleExistingTransaction(definition, transaction);
    }

    // 3. 새 트랜잭션 시작 필요해
    return startNewTransaction(definition, transaction);
  }

  private TransactionStatus startNewTransaction(
    TransactionDefinition definition,
    Object transaction
  ) {
    // 1. Connection 획득
    Connection conn = dataSource.getConnection();

    // 2. 자동 커밋 끄기
    conn.setAutoCommit(false);

    // 3. 격리 수준 설정
    if (definition.getIsolationLevel() != Isolation.DEFAULT) {
      conn.setTransactionIsolation(
        definition.getIsolationLevel().value()
      );
    }

    // 4. ThreadLocal에 Connection 저장
    TransactionSynchronizationManager.bindResource(
      dataSource,
      new ConnectionHolder(conn)
    );

    // 5. TransactionStatus 반환
    return new DefaultTransactionStatus(
      transaction,
      true,   // newTransaction = true
      conn,
      definition
    );
  }
}

현재 상태:

Thread: http-nio-8080-exec-1
  │
  ├─ ThreadLocal<Map<DataSource, ConnectionHolder>>
  │   └─ HikariDataSource → ConnectionHolder
  │                          └─ Connection (autoCommit=false) ✅
  │
  └─ TransactionStatus
      ├─ transaction: DataSourceTransaction
      ├─ newTransaction: true
      ├─ connection: HikariProxyConnection@123abc
      └─ completed: false

Phase 5: 실제 메서드 실행

// [7] 원본 메서드 실행
orderService.createOrder(order);

public void createOrder(Order order) {
  orderRepository.save(order);
  // [8] orderRepository.save()
    // Repository 내부에서 Connection 가져오기:
    Connection conn = TransactionSynchronizationManager.getResource(dataSource);
    // 같은 Connection으로 SQL 실행
    PreparedStatement stmt = conn.prepareStatement(
      "INSERT INTO orders (id, amount) VALUES (?, ?)"
    );
    stmt.setLong(1, order.getId());
    stmt.setInt(2, order.getAmount());
    stmt.executeUpdate(); // 아직 커밋은 안됨!

  // [9] PaymentService 호출
  paymentService.processPayment(order.getId());
}

Phase 6: 중첩 트랜잭션 처리 (REQUIRED)

// [9] PaymentService(프록시).processPayment() 호출
// ➡️ TransactionInterceptor 다시 실행

public TransactionStatus getTransaction(TransactionDefinition definition) {
  // 1. 기존 트랜잭션 확인
  ConnectionHolder holder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);

  if (holder != null && holder.isTransactionActive()) {
    // 기존 트랜잭션 있음 ✅

    // 2. REQUIRED 속성 확인
    if (definition.getPropagationBehavior() == PROPAGATION_REQUIRED) {
      // 기존 트랜잭션에 참여
      return new DefaultTransactionStatus(
        transaction,
        false,    // newTransaction = false (새 트랜잭션 아님!)
        holder.getConnection(),
        definition
      )
    }
  }
}

중요 포인트:

OrderService.createOrder() [트랜잭션 A]
          ⬇️
orderRepository.save()  [트랜잭션 A 사용]
          ⬇️
PaymentService.processPayment() [트랜잭션 A에 참여] ⭐️
          ⬇️
paymentRepository.save()  [트랜잭션 A 사용]
          ⬇️
모두 같은 Connection, 같은 트랜잭션!
하나라도 실패하면 전부 롤백!

Phase 7: 메서드 완료 커밋

// [12] createOrder() 메서드 정상 완료
// ➡️ TransactionInterceptor로 돌아옴

protected Object invokewithinTransaction(...) {
  try {
    // 메서드 실행
    Object result = invocation.proceedWithInvocation();

    // [13] 성공 시 커밋
    commitTransactionAfterReturning(txInfo);

    return result;
  } catch (Throwable ex) {
    // 실패 시 롤백
    completeTransactionAfterThrowing(txInfo, ex);
    throw ex;
  }
}

// [13] 커밋 실행
private void commitTransactionAfterReturning(TransactionInfo txInfo) {
  if (txInfo.getTransactionStatus().inNewTransaction()) {
    // 이 메서드가 트랜잭션을 시작했다면 커밋
    txInfo().getTransactionManager().commit(txInfo.getTransactionStatus());
  }
  // 참여한 경우 (REQUIRED) ➡️ 커밋하지 않음
}

// TransactionManager.commit()
public void commit(TransactionStatus status) {
  if (status.isCompleted()) {
    throw new IllegalTransactionStateException("이미 완료된 트랜잭션");
  }

  DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;

  // 롤백 마크가 있는지 확인
  if (defStatus.isLocalRollbackOnly()) {
    rollback(status);
    return;
  }

  // 실제 커밋
  processCommit(defStatus);
}

private void processCommit(DefaultTransactionStatus status) {
  try {
    // 1. Connection.commit() 호출
    Connection conn = status.getConnection();
    conn.commit(); ✅

    // 2. TransactionSynchronization 콜백 호출
    triggerAfterCommit();
  } finally {
    // 3. 리소스 정리
    cleanupAfterCompletion(status);
  }
}

private void cleanupAfterCompletion(TransactionStatus status) {
  // 1. ThreadLocal에서 Connection 제거
  TransactionSynchronizationManager.unbindResource(dataSource);

  // 2. Connection 반환 (➡️ 커넥션 풀)
  Connection conn = status.getConnection();
  DataSourceUtils.releaseConnection(conn, dataSource);
}

최종 상태:

Thread: http-nio-8080-exec-1
    │
    ├─ ThreadLocal<Map> → (비어있음, 정리됨) ✅
    │
    └─ DB 상태
        ├─ orders 테이블: INSERT 커밋됨 ✅
        └─ payments 테이블: INSERT 커밋됨 ✅

지금 우리가 살펴본 시나리오는 orders, payments 테이블에 데이터를 추가하는 2개의 서로 다른 작업이 하나의 공통된 트랜잭션에서 처리되는 것입니다.

모든 작업이 성공적으로 완료되고 마지막 최종 단계에서 commit 하는 것을 볼 수 있었습니다.

Transaction 롤백 시나리오

이번에는 트랜잭션 처리 과정에서 롤백이 발생하는 과정에 대해 살펴봅시다.

🔴 예외 발생 케이스

@Transactional
public void createOrder(Order order) {
  orderRepository.save(order);

  // 예외 발생! 💣
  if (order.getAmount() > 10000) {
    throw new RuntimeException("금액 초과");
  }

  paymentService.processPayment(order.getId());
}

롤백 플로우

protected Object invokeWithinTransaction(...) {
  try {
    Object result = invocation.proceedWithInvocation(); // 예외 발생!
    commitTransactionAfterReturning(txInfo);
    return result;
  } catch (Throwable ex) {
    // [예외 캐치!]
    completeTransactionAfterThrowing(txInfo, ex);
    throw ex;
  }
}

private void completeTransactionAfterThrowing(
  TransactionInfo txiInfo, Throwable ex
) {
  // 1. 롤백해야 하는 예외인지 확인
  if (txInfo.transactionAttribute.rollbackOn(ex)) {
    // RuntimeException -> true (기본값)
    // Exception -> false (기본값)

    // 2. 롤백 실행
    txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
  } else {
    // 롤백하지 않고 커밋
    txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
  }
}

// TransactionManager.rollback()
public void rollback(TransactionStatus status) {
  DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
  processRollback(defStatus);
}

private void processRollback(DefaultTransactionStatus status) {
  try {
    // 1. Connection.rollback() 호출
    Connection conn = status.getConnection();
    conn.rollback(); 🔄

    // 2. TransactionSynchronization 콜백
    triggerAfterCompletion(STATUS_ROLLED_BACK);
  } finally {
    // 3. 리소스 정리
    cleanupAfterCompletion(status);
  }
}

결과:

DB 상태:
├─ orders 테이블: 변경사항 없음 (롤백됨) 🔄
└─ payments 테이블: 변경사항 없음 (실행조차 안됨)

핵심 컴포넌트 관계도

┌─────────────────────────────────────────────┐
│          Client (Controller)                │
└─────────────────┬───────────────────────────┘
                  │ 호출
                  ⬇️
┌─────────────────────────────────────────────┐
│         Proxy (CGLIB 생성)                   │
│  ┌─────────────────────────────────────┐    │
│  │   TransactionInterceptor            │    │
│  │    ├─ @Transactional 속성 읽기        │    │
│  │    └─ TransactionManager 호출        │    │
│  └─────────────────────────────────────┘    │
└─────────────────┬───────────────────────────┘
                  │
                  ⬇️
┌─────────────────────────────────────────────┐
│   PlatformTransactionManager                │
│    ├─ getTransaction() (트랜잭션 시작)         │
│    ├─ commit() (커밋)                        │
│    └─ rollback() (롤백)                      │
└─────────────────┬───────────────────────────┘
                  │
                  ⬇️
┌─────────────────────────────────────────────┐
│  TransactionSynchronizationManager          │
│    └─ ThreadLocal<Connection>               │
└─────────────────┬───────────────────────────┘
                  │
                  ⬇️
┌─────────────────────────────────────────────┐
│   DataSource (Connection Pool)              │
│    └─ Connection (실제 DB 연결)               │
└─────────────────────────────────────────────┘

이번 글에서는 Spring Framework에서 트랜잭션을 구체적으로 어떻게 처리하는지 살펴봤습니다.

Database와 관련해서 트랜잭션은 매우 중요한 개념이면서 동시에 복잡합니다. 그래서 이 개념을 제대로 잘 이해하는 것이 아주 중요합니다. 이번 글을 통해 트랜잭션이 무엇인지 왜 처리해야 하는지 그리고 어떻게 처리되는지 대략적으로 감을 잡으셨기를 바랍니다.

그럼 끝까지 읽어주셔서 감사합니다!

Comments (0)

Checking login status...

No comments yet. Be the first to comment!