본문 바로가기

Back-End/Spring

스프링 트랜잭션의 이해(feat. 전파)

트랜잭션이란?

하나의 작업 단위를 논리적으로 묶어주는 역할을 해준다!
이를 통해서 데이터 베이스의 ACID 속성을 지킬 수 있도록 한다!

  1. 원자성 (Atomicity)
    예: 송금 시, 돈이 A 계좌에서 빠졌지만 B 계좌로 들어가지 않으면 롤백.
    작업이 모두 성공하거나, 하나라도 실패하면 전체를 롤백.
  2. 일관성 (Consistency)
    작업 전후의 데이터 상태가 항상 유효하도록 보장.
  3. 격리성 (Isolation)
    트랜잭션 간 서로 간섭하지 않도록 격리 수준을 설정할 수 있다.
  4. 지속성 (Durability)
    트랜잭션이 성공하면 결과가 영구적으로 반영된다.

또 다른 구체적인 예시

상품을 구매 했을 때
1. 재고 감소
2. 결제 처리
3. 주문 기록 생성
결국 트랜잭션은 1, 2, 3 과 같은 각각의 작업을 하나의 작업 단위로 묶어주는 역할을 한다!!

@Service
@RequiredArgsConstructor
public class OrderService {

    private final StockService stockService;
    private final PaymentService paymentService;
    private final OrderRecordService orderRecordService;
    private final TransactionLogService transactionLogService;

    @Transactional
    public void processOrder(Long productId, int quantity, BigDecimal amount) {
            // 1. 재고 감소
            stockService.decreaseStock(productId, quantity);

            // 2. 결제 처리
            paymentService.processPayment(productId, amount);

            // 3. 주문 기록 생성
            orderRecordService.createOrderRecord(productId, quantity);
    }
}

트랜잭션은 언제 동작할까?

트랜잭션은 여러 작업을 묶어서 하나의 작업으로 처리하는 것이니 예외가 발생했을 때 이를 핸들링 하는 것이다.
중간에 에러가 나면 기존에 작업 했던 것을 없던 것으로 처음으로 돌려야 한다!

왜 롤백을 해야할까?
> 우리가 예상하지 못한 에러가 발생했고 이를 처리하지 못했으니 없던 것으로 만들어야 하기 때문이다.

예외 처리 시 동작

우리의 서비스 Transactional 내부에 예외가 발생했다고 롤백하고 아 몰라~ 하면 끝나는 걸까? 이러면 안된다 ㅠㅠ

만약 우리 서비스의 상품을 구매했을 때 결제 처리 과정 중

  1. 재고 감소
  2. 결제 처리 => "잔액이 부족하다"라는 예외가 발생!
  3. 주문 기록 생성
try - catch 문 !!! 이럴 때 사용하는 것이다.
물론 모든 부분에 try-catch로 감싸면 코드가 더러워지겠지만... 자주 에러가 발생하는 부분에서는
로그든 어떠한 추가 작업이든 처리해주는 것이 좋다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final StockService stockService;
    private final PaymentService paymentService;
    private final OrderRecordService orderRecordService;
    private final TransactionLogService transactionLogService;

    @Transactional
    public void processOrder(Long productId, int quantity, BigDecimal amount) {
	       try {
            // 1. 재고 감소
            stockService.decreaseStock(productId, quantity);

            // 2. 결제 처리
            paymentService.processPayment(productId, amount); -> 돈 없어요.

            // 3. 주문 기록 생성
            orderRecordService.createOrderRecord(productId, quantity);
           } catch ( Exception e ) {
		   
             log.info("결제 도중 에러 발생");
             혹은 어떤 에러가 발생했는지 디비에 저장한다.(로그 생성)
             throw e;  -------> 예외를 던져야지만 롤백이 된다!!!!
           }
    }
}

중요) 마지막에 throw e를 꼭 던져야지 롤백이 된다! 
=> 그 이유는 Spring에서 트랜잭션 관리는 기본적으로 
RuntimeException 또는 Error가 발생해야 트랜잭션을 롤백 한다. 만약 catch 블록에서 예외를 처리한 후 예외를 다시 던지지 않으면 Spring은 해당 메서드가 정상적으로 종료되었다고 간주하고 트랜잭션을 커밋할 수 있다!!


만약 트랜잭션 안에 있는 메서드가 또 트랜잭션이 있으면 어떻게 될까?

  1. 재고 관리
  2. 금액 차감
    1. 마일리지
    2. 쿠폰
    3. 현금
  3. 주문기록

위 처럼 마일리지, 쿠폰, 현금에도 트랜잭션이 붙어있다면? 우리는 전파라는 속성을 써야한다..!

그럼 전파는 언제 쓰는데?

@Service
@RequiredArgsConstructor
public class OrderService {

    private final StockService stockService;
    private final PaymentService paymentService;
    private final OrderRecordService orderRecordService;
    private final TransactionLogService transactionLogService;

    @Transactional
    public void processOrder(Long productId, int quantity, BigDecimal amount) {
				    // 1. 재고 감소
            stockService.decreaseStock(productId, quantity);

            // 2. 결제 처리
            paymentService.processPayment(productId, amount);

            // 3. 주문 기록 생성
            orderRecordService.createOrderRecord(productId, quantity);

            // 4. 성공 여부 기록
            transactionLogService.logTransactionResult(productId, true, "Order successful");
    }
}

4번을 보면 중요 로직은 솔직히 1, 2, 3번이다 근데 4번 성공 여부 기록도 같은 트랜잭션으로 돌리는게 맞을까? 이럴 때는 그냥 NEW를 쓰자..!

트랜잭션 전파 속성 요약

1. REQUIRED(기본값) => 거의 99%는 이거 씀

  • 기존 트랜잭션이 있으면 해당 트랜잭션에 참여, 없으면 새 트랜잭션을 생성한다.

2. REQUIRES_NEW => 진짜 가끔가다 씀. 

  • 항상 새로운 트랜잭션을 생성하며 기존 트랜잭션은 일시 중단된다.

3. SUPPORTS

  • 기존 트랜재션이 있으면 해당 트랜재션에 참여한다 없으면 트랜잭션 없이 실행된다

4. NOT_SuPPORTED

  • 트랜잭션 없이 실행하며 기존 트랜잭션은 일시 중단된다.

5. MANDATORY

  • 반드시 기존 트랜잭션이 있어야 한다. 트랜잭션이 없다면 예외가 발생.

6. NEVER 

  • 트랜잭션이 활성화된 상태에서 호출되면 예외를 던진다.
  • 트랜잭션이 없는 경우에만 실행

7. NESTED

  • 기존 트랜잭션 내부에 중첩된 트랜잭션을 생성한다.
  • 중첩 트랜잭션은 독립적으로 롤백 가능하지만 부모 트랜잭션의 경계 내에 속한다.

코드로 보는 트랜잭션 전파 동작 예제 

InnerService 클래스

InnerService는 여러 전파 속성을 테스트하기 위한 메서드들을 제공

@Transactional(propagation = Propagation.REQUIRED)
public void innerRequired() { logTransaction("innerRequired"); }

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerRequiresNew() { logTransaction("innerRequiresNew"); }

OuterService 클래스

OuterService는 InnerService를 호출하며, 상튀 트랜잭션에서 하위 메서드 호출 시 전파 속성이 어떻게 동작하는지 테스트

@Transactional(propagation = Propagation.REQUIRED)
public void outerRequired() {
    logTransaction("outerRequired");
    innerService.innerRequired();
    innerService.innerRequiresNew();
}

실행 시 예상 로그

1. REQUIRED
outerRequired가 트랜잭션을 생성하며, innerRequired는 동일한 트랜잭션에 참여

[outerRequired] 트랜잭션 활성화됨. 이름: outerRequiredTransaction
[innerRequired] 트랜잭션 활성화됨. 이름: outerRequiredTransaction

2. REQUIRES_NEW
새로운 트랜잭션을 생성하며 기존 트랜잭션은 일시 중단

[innerRequiresNew] 트랜잭션 활성화됨. 이름: innerRequiresNewTransaction

Try-Catch 전략

  1. outerService가 Propagation.REQUIRED 이고 innerService도 Propagation.REQUIRED인 경우
    > outerService에서 try-catch 전략을 쓰자! 어차피 같은 트랜잭션이라 inner에서 예외가 터져도 outer에서 잡으면 되니깐!
  2. OuterService가 Propagation.REQUIRED이고 InnerService가 Propagation.REQUIRES_NEW인 경우
    > 두 트랜재션은 서로 독립적으로 동작한다.
    우리는 outerService가 롤백이 되는걸 원하지 않고 inner만 롤백되길 원하는거이기 때문에 InnerService에서 예외를 던지고 outer에서 try-catch로 잡으면 된다!

'Back-End > Spring' 카테고리의 다른 글

SpringSecurity + JWT를 활용한 로그인  (0) 2025.01.23
Spring Security + OAuth2.0 + Jwt 활용 소셜 로그인 기능 만들기  (0) 2025.01.21
Spring Valid  (1) 2024.12.08
Spring Bean 등록  (0) 2024.12.08
IOC / DI  (0) 2024.12.06