0+ 스프링/0+스프링 DB

[스프링 DB] 트랜잭션 전파(propagation)

힘들면힘을내는쿼카 2023. 7. 29. 23:25
728x90
반응형

[스프링 DB] 트랜잭션 전파(propagation)

 

트랜잭션 전파에 대해서 알기 전에,
먼저 트랜잭션에 대해서 간단하게 설명 하겠습니다.^^

 

 

트랜잭션? 🤷‍♀️

트랜잭션(Transaction)을 번역하면 “거래” 라는 의미 입니다.

데이터베이스에서 트랜잭션은 하나의 “거래”를 안전하게 처리하도록 보장해주는 것을 의미합니다.

다시 말하면 작업의 완전성을 보장해주는 것 입니다. 👍

 

 


참고

트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에 발생합니다.
트랜잭션의 시작과 종료는 Connection객체를 통해 이뤄지기 때문입니다.


2023.07.06 - [0+ 스프링/0+스프링 DB] - [스프링 DB] 트랜잭션의 이해 

 

[스프링 DB] 트랜잭션의 이해

[스프링 DB] 트랜잭션의 이해 개발을 하면 자연스럽게 데이터베이스와 연동하여 데이터를 저장, 수정, 삭제, 조회 합니다. 데이터를 저장할 때 파일에 저장해도 되는데, 데이터 베이스에 저장하

howisitgo1ng.tistory.com

 

 

트랜잭션이 2개 있을때? 🤔

그런데 트랜잭션 2개 있을때는 어떻게 동작할까요?
당연히 트랜잭션 2개가 각각 작업의 완전성을 보장해 줍니다.

 

트랜잭션이 각각 수행되면서 사용되는 DB 커넥션도 각각 다르기 때문입니다.

 

트랜잭션1, 트랜잭션2 커밋

@Test
void commit1_commit2() {
    log.info("트랜잭션1 시작"); 
    TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션1 커밋"); 
    txManager.commit(tx1); 

    log.info("트랜잭션2 시작"); 
    TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션2 커밋"); 
    txManager.commit(tx2);
}

 

  

트랜잭션1, 트랜잭션2가 각각 커밋되는 것을 확인할 수 있습니다.

 

트랜잭션1 커밋, 트랜잭션2 롤백

@Test
void commit1_rollback() {
    log.info("트랜잭션1 시작"); 
    TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션1 커밋"); 
    txManager.commit(tx1); 

    log.info("트랜잭션2 시작"); 
    TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션2 롤백"); 
    txManager.rollback(tx2);
}

 

 

이 경우에는 트랜잭션1은 커밋되고 트랜잭션2는 롤백되는 것을 확인할 수 있습니다.
트랜잭션이 각각 수행되면서 사용되는 DB 커넥션이 각각 다르기 때문입니다.^^

 

그런데 위와 같이 트랜잭션을 각각 사용하는 것이 아니라,
트랜잭션이 이미 진행중인데, 추가로 트랜잭션을 수행하면 어떻게 될까요? 🤔

 

 

트랜잭션 전파(propagation)

트랜잭션이 이미 진행중인데, 추가로 트랜잭션을 수행하면…? 🤔

 

기존 트랜잭션과 별도의 트랜잭션을 진행하면 될까? 🤨
아니면 기존 트랜잭션을 이어 받아서 트랜잭션을 수행하면 될까? 🧐

이런 경우 어떻게 동작할지 결정하는 것을 우리는 트랜잭션 전파라고 합니다.

스프링은 트랜잭션 전파에 대한 다양한 옵션을 제공합니다.
우선은 기본 옵션인 REQUIRE에 대해서 먼저 알아봅시다.^^

 

물리 트랜잭션, 논리 트랜잭션

트랜잭션 전파 옵션인 REQUIRED를 사용하면,
스프링에서는 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 트랜잭션을 만들어 줍니다.

 

그리고 이를 논리 트랜잭션물리 트랜잭션이라는 개념으로 나누었습니다.
논리 트랜잭션 개념은 트랜잭션이 진행되는 중에 내부에 추가로 트랜잭션을 사용하는 경우에 사용 합니다.
(정확히는 REQUIRED 전파 옵션을 사용하는 경우에 사용)

 

 

  • 논리 트랜잭션들은 1개의 물리 트랜잭션으로 묶임
  • 물리 트랜잭션은 실제 DB에 적용되는 트랜잭션을 의미
    • 실제 DB 커넥션을 통해서 트랜잭션을 시작(setAutoCommit(false))하고, 실제 커넥션을 통해서 커밋 or 롤백 하는 단위

 

그런데 왜? 🤔
스프링은 물리, 논리 트랜잭션 개념을 사용하는 것 일까요?

 

우리가 방금까지 고민한 내용인
트랜잭션이 사용중일 때 추가로 트랜잭션이 사용되면 여러가지 복잡한 상황이 발생합니다.
이러한 상황을 고려하여 물리, 논리 트랜잭션 개념을 도입하여 원칙을 만들었습니다.^^

 

물리, 논리 트랜잭션 원칙

  • 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
  • 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.

 

모든 트랜잭션 매니저를 커밋해야 물리 트랜잭션이 커밋!
하나의 트랜잭션 매니저라도 롤백하면 물리 트랜잭션은 롤백!


if(논리_트랜잭션1 && 논리_트랜잭션2 && ... && 논리_트랜잭션N) {
    return commit;
} else {
    return rollback;
}

 

이를 그림으로 표현하면 아래와 같습니다.^^

 

모든 트랜잭션 매니저를 커밋해야 물리 트랜잭션이 커밋

 

 

하나의 트랜잭션 매니저라도 롤백하면 물리 트랜잭션은 롤백

 

 

🌿 스프링어떻게 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 물리 트랜잭션으로 동작하게 하는걸까요? 🤔

 

아래와 같은 코드를 작성해서 확인해봅시다.

 

application.properties

logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG

 

PaymentRepository

@Service
public class PaymentRepository {

    @Transactional
    public void logic() {
        // ... //
    }
}

 

Service

@Service
@RequiredArgsConstructor
public class PayemntService {

    private final PaymentRepository paymentRepository;

    @Transactional
    public void payment() {
        paymentRepository.logic();
    }
}

 

이와 같은 형식의 코드를 작성하고 실행하면
Participating in existing transaction 라는 로그를 확인할 수 있습니다.
이 로그는 내부 트랜잭션이 기존에 존재하는 외부 트랜잭션에 참여한다는 의미 입니다.!

 

즉, 외부 트랜잭션(payment)만 물리 트랜잭션을 시작하고 커밋 합니다.
만약에 내부 트랜잭션이 실제 물리 트랜잭션을 커밋하면 트랜잭션이 끝나버리기 때문에,
트랜잭션을 처음 시작한 외부 트랜잭션까지 이어갈 수 없습니다.

 

스프링은 여러 트랜잭션이 함께 사용되는 경우,
트랜잭션 전파 기본 옵션(REQUIRE)으로 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 합니다.
다시 말하면, 기존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여 합니다.
(참여한다는 의미는 해당 트랜잭션을 그대로 따른다는 의미이고, 동시에 같은 동기화 커넥션을 사용한다는 뜻 입니다.)

 

이것을 통해 트랜잭션 중복 커밋 문제를 해결할 수 있습니다!

 

 

 

 

만약에 1개의 트랜잭션으로 묶지 않는다면 어떤 일이 발생할까?🤔

Payment 비지니스 로직에서 Logic1, Logic2, Logic3가 각각 신규 트랜잭션으로 관리되기 때문에
일부는 커밋되고 일부는 롤백되어 데이터 정합성이 맞지 않을 수 있습니다.

 

반면에 1개의 트랜잭션으로 묶는다면 데이터 정합성을 유지할 수 있습니다.

 

 

참고
논리 트랜잭션이 롤백되면 rollbackOnly를 적용하고,
물리 트랜잭션은 rollbackOnly를 확인하여
rollbackOnly가 존재하면 rollback하고, 없으면 commit을 수행 합니다.^^

 

 

트랜잭션 전파(REQUIRES_NEW)

우리는 하나의 트랜잭션으로 묶어서 데이터 정합성 문제를 해결하였습니다.^^

 

만약에 Logic2에 문제가 자주 발생하여 payment(전체 로직)이 롤백되어
회원이 결제를 하지않고 이탈하는 경우가 많아졌습니다. ㅠㅠ
그래서 상대적으로 덜 중요한 Logic2만에만 트랜잭션을 따로 적용하고 싶으면 어떻게 해야할까요?

 

먼저 아래와 같이 PaymentV2를 이용하여 분리하는 방법이 있을것 같습니다.
하지만, 이러한 문제가 계속 발생하면 계속 각각 만들어야할 것 입니다. 🥲

 

이러한 문제를 해결하기 위해 트랜잭션 전파가 등장했습니다.

 

REQUIRES_NEW
REQUIRES_NEW를 적용하면 항상 신규 트랜잭션을 생성하게 됩니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logic2()

 

REQUIRES_NEW 적용 구조

 

 

REQUIRES_NEW 롤백

 

 

  • REQUIRES_NEW를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리 됩니다.
  • REQUIRES_NEW를 사용한 신규 트랜잭션이기 때문에 물리 트랜잭션을 롤백 합니다.
    • 이때 REQUIRES_NEW를 사용한 트랜잭션은 물리 트랜잭션을 롤백했기 때문에 rollbackOnly를 표시하지 않습니다.
    • 논리 트랜잭션이 롤백되었을 때만 rollbackOnly를 표시 합니다.
  • 트랜잭션2 AOP는 전달 받은 예외를 밖으로 던집니다.
  • 예외가 Payment에 전달되고, 예외를 처리합니다.
  • 정상 처리가 되었기 때문에 트랜잭션1 AOP는 커밋을 호출합니다.
    • 커밋을 호출할때 신규 트랜잭션이기 때문에 물리 트랜잭션을 커밋
    • 이때 rollbackOnly를 확인
  • rollbackOnly가 없기 때문에 트랜잭션1을 커밋

 

 

주의
REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용합니다.
이는 성능이 중요한 곳에서는 위에서 언급한 내용을 주의하며 사용해야 합니다.

 

이를 간단하게 구현한 코드를 보면 아래와 같습니다.

 

PaymentLogRepository

@Repository
@RequiredArgsConstructor
public class PaymentLogRepository {

    private final EntityManager em;

      // logic2
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public PaymentLog save(PaymentLog paymentLog) {
        em.persist(paymentLog);

        if(paymentLog.getMessage().contains("비정상카드")) {
            throw new RuntimeException("결제로그 예외 발생");
        }

        return paymentLog;
    }

    public Optional<PaymentLog> findBy(String message) {
        String sql = "select pl from PaymentLog pl where pl.message = :message";

        return em.createQuery(sql, PaymentLog.class)
                .setParameter("message", message)
                .getResultList().stream()
                .findAny();
    }
}

 

PaymentRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class PaymentRepository {

    private final EntityManager em;

    @Transactional
    public Payment save(Payment payment) {
        em.persist(payment);
        return payment;
    }

    public Optional<Payment> findBy(String cardType) {
        String sql = "select p from Payment p where p.cardType = :cardType";

        return em.createQuery(sql, Payment.class)
                .setParameter("cardType", cardType)
                .getResultList().stream()
                .findAny();
    }
}

 

paymentService

@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final PaymentLogRepository paymentLogRepository;

    @Transactional
    public void paymentRequiresNew(String cardType) {
        Payment payment = new Payment();
        payment.setAmount(new BigDecimal(1000));
        payment.setCardType(cardType);
        payment.setPaymentStatus("결제완료");

        log.info("== paymentRepository 호출 시작 ==");
        paymentRepository.save(payment);
        log.info("== paymentRepository 호출 종료 ==");

        // 로직 3, 4, 5 ... 수행 //

        PaymentLog paymentLog = new PaymentLog();
        paymentLog.setMessage(cardType);
        log.info("== paymentLogRepository 호출 시작 ==");
        try {
            paymentLogRepository.save(paymentLog);
        } catch (RuntimeException e) {
            log.error("에러 발생 message={}", e, e.getMessage());
        }
        log.info("== paymentLogRepository 호출 종료 ==");
    }
}

 

TEST

/**
 * paymentService       @Transactional ON
 * paymentRepository    @Transactional ON
 * paymentLogRepository @Transactional ON(REQUIRES_NEW) EX
 */
@Test
@DisplayName("REQUIRES_NEW를 사용하면 신규 트랜잭션이 생성되어 logic2는 롤백되고, 나머지는 커밋된다.")
void rollbackREQUIRES_NEW() {
    // given
    String cardType = "비정상카드";

    // when
    paymentService.paymentRequiresNew(cardType);

      // then
    assertAll(
            () -> assertTrue(paymentRepository.findBy(cardType).isPresent()),
            () -> assertTrue(paymentLogRepository.findBy(cardType).isEmpty())
    );
}

 

 

정리

  • 트랜잭션은 하나의 “거래”를 안전하게 처리하도록 보장해주는 것을 의미
  • 스프링은 물리 트랜잭션, 논리 트랜잭션 개념을 도입하여 트랜잭션을 처리
    • 존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여
    • 여러 논리 트랜잭션을 하나의 물리 트랜잭션으로 묶어서 처리
      • 하나 트랜잭션으로 처리하면 데이터 정합성을 지킬 수 있음
      • 논리 트랜잭션이 모두 커밋되어야 물리 트랜잭션이 커밋
      • 논리 트랜잭션은 하나라도 롤백되면 관련된 모든 물리 트랜잭션은 롤백
      • REQUIRE_NEW를 사용하면 물리 트랜잭션으로 분리할 수 있음
    • 논리 트랜잭션이 롤백되면 rollbackOnly를 명시
    • 물리 트랜잭션은 커밋 전에 rollbackOnly를 확인하여 커밋할지 롤백할지 판단
      • 내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 발생

 

 

참고

 

 

 

728x90
반응형