본문 바로가기
Spring

Spring Kotlin JPA에서의 @Transactional 활용 및 트랜잭션 전파 전략

by PROMISE_YOO 2025. 3. 14.

안녕하세요. 이번 글에서는 Spring 기반 애플리케이션에서 트랜잭션 관리가 어떻게 동작하는지, @Transactional 어노테이션을 사용하면 발생할 수 있는 이슈와 그에 따른 해결책을 트러블슈팅 경험을 바탕으로 설명드리고자 합니다.

 

특히, 전파 옵션 중 REQUIRES_NEW의 필요성과 설정 방법, 그리고 Unchecked Exception과 Checked Exception에 따른 예외 처리 정책 차이에 대해 구체적으로 다루어보겠습니다.


1. Spring의 트랜잭션 관리 원리 및 전파 옵션

Spring에서는 AOP 기반 프록시를 통해 @Transactional 어노테이션이 적용된 메서드를 호출할 때 자동으로 트랜잭션을 시작하고, 정상 실행 시 커밋, 예외 발생 시 롤백하는 방식으로 트랜잭션을 관리합니다.

  • 프록시와 메서드 인터셉션: 외부에서 호출되는 메서드에 한해서 트랜잭션 어드바이스가 적용됩니다. 다만, 같은 클래스 내에서의 메서드 호출(자기 호출)은 프록시를 우회하게 되어 각 메서드별 개별 트랜잭션 경계를 생성하지 못할 수 있습니다.
  • 전파 옵션: 기본적으로 REQUIRED가 적용되어 이미 진행 중인 트랜잭션을 그대로 사용합니다. 그러나, 상황에 따라 부모 트랜잭션과 별도로 독립된 트랜잭션이 필요할 때가 있습니다.

자기 호출(Self-invocation) 문제

같은 클래스 내의 한 메서드가 다른 @Transactional 메서드를 직접 호출하면, 해당 호출은 프록시를 거치지 않아 트랜잭션 경계가 제대로 설정되지 않는 문제가 발생합니다. 이 경우, 해당 메서드를 별도의 빈(bean)으로 분리해 외부에서 호출되도록 설계하는 것이 좋습니다.


2. REQUIRES_NEW 전파 옵션과 독립 트랜잭션 적용 사례

부모 트랜잭션 내의 모든 작업이 하나의 트랜잭션으로 묶이면, 부모 트랜잭션이 롤백될 때 모든 작업이 함께 롤백됩니다. 실제 결제 처리 로직을 개발하던 중, 결제 내역을 기록하는 save() 메서드가 부모 트랜잭션에 종속되어 함께 롤백되는 문제가 있었습니다.

결제 내역은 부모 로직의 성공 여부와 상관없이 반드시 기록되어야 하는 중요한 데이터입니다. 그래서 결제 내역 저장 메서드에 전파 옵션을 REQUIRES_NEW로 설정하여, 부모 트랜잭션과는 별개의 독립된 트랜잭션으로 동작하도록 구성했습니다.

REQUIRES_NEW의 동작 원리와 주의사항

  • 동작 원리: 부모 트랜잭션이 존재하면 이를 일시 중단(suspend)한 후, 별도의 새로운 트랜잭션을 시작합니다.
  • 주의사항:
    • 트랜잭션 중단 및 재개 과정에서 오버헤드가 발생할 수 있습니다.
    • 부모 트랜잭션이 롤백되더라도 REQUIRES_NEW로 시작한 트랜잭션은 이미 커밋될 수 있으므로, 데이터 일관성 측면에서 신중한 설계가 필요합니다.

3. 예외 처리 정책 차이

트랜잭션 처리 시 Unchecked Exception과 Checked Exception의 처리 방식에는 차이가 있습니다.

  • Unchecked Exception (런타임 예외):
    • 발생 시 자동으로 트랜잭션이 롤백됩니다.
    • 예를 들어, 결제 로직 중 ArithmeticException 같은 예기치 않은 예외가 발생하면 부모 트랜잭션 전체가 롤백되어 모든 작업이 취소됩니다.
  • Checked Exception:
    • 기본 설정에서는 롤백하지 않고 커밋하는 정책이 적용됩니다.
    • 결제 내역처럼 중요한 기록은 예외 발생 여부와 관계없이 반드시 저장되어야 하므로, 이를 독립된 트랜잭션으로 처리하는 것이 필요합니다.

이와 같이, 예외 처리 정책에 따라 트랜잭션 커밋 및 롤백 동작이 달라지므로, 필요에 따라 rollbackFor 옵션 등을 통해 세밀하게 조정하는 방법도 고려할 수 있습니다.


4. 실제 사례: 결제 내역 저장을 위한 독립 트랜잭션 적용

다음은 부모 트랜잭션 내에서 결제 처리 로직을 실행하면서, 결제 내역 저장은 별도의 독립 트랜잭션으로 처리하도록 구성한 예시 코드입니다.

부모 트랜잭션을 사용하는 결제 처리 서비스

@Service
class PaymentService(
    private val paymentHistoryService: PaymentHistoryService,
    private val orderService: OrderService,
    private val productService: ProductService,
    private val customerService: CustomerService
) {
    @Transactional // 부모 트랜잭션
    fun processPayment() {
        // 예시 엔티티 조회 (실제 코드는 보안상의 이유로 단순화됨)
        val orderEntity = orderService.getOrElseThrow("orderId123")
        val productEntity = productService.getOrElseThrow("productId456")
        val customerEntity = customerService.getOrElseThrow(id = 1)
        var response: Any? = null

        try {
            // 결제 로직 실행 중 문제가 발생했다고 가정 (예: 100 / 0으로 ArithmeticException 발생)
            val result = 100 / 0
            println(result)
        } catch (e: Exception) {
            log.error("결제 처리 중 예외 발생", e)
            throw e
        } finally {
            // 결제 내역 저장은 부모 트랜잭션과 별개로 독립된 트랜잭션으로 실행되어야 함.
            val paymentStatus = PaymentStatus.FAILED
            val amount = BigDecimal.ZERO
            log.info("결제 내역 저장 전 데이터 확인: orderId = ${orderEntity.id}, " +
                     "paymentStatus = $paymentStatus, amount = $amount, paymentMethod = ${PaymentMethod.CARD}")
            
            // 결제 내역 저장 메서드는 REQUIRES_NEW 옵션으로 독립된 트랜잭션을 사용
            val savedHistory = paymentHistoryService.savePaymentHistory(
                PaymentHistory(
                    order = orderEntity,
                    product = productEntity,
                    customer = customerEntity,
                    paymentStatus = paymentStatus,
                    orderStatus = orderEntity.status,
                    amount = amount,
                    paymentMethod = PaymentMethod.CARD,
                    responseData = response?.let { objectMapper.convertValue(it, Map::class.java) as Map<String, Any> } ?: emptyMap(),
                    details = productEntity.details?.let {
                        objectMapper.convertValue(it, Map::class.java) as Map<String, Any>
                    } ?: emptyMap()
                )
            )
            log.info("결제 내역 저장 완료: savedHistory id = ${savedHistory.id}")
        }
    }
}

독립 트랜잭션을 위한 결제 내역 저장 서비스

@Service
class PaymentHistoryService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun savePaymentHistory(paymentHistory: PaymentHistory): PaymentHistory {
        // 결제 내역을 DB에 저장하는 로직을 실행합니다.
        return paymentHistoryRepository.save(paymentHistory)
    }
}



실제 프로젝트에서 겪었던 경험을 바탕으로 작성한 이 글이 여러분의 Spring 기반 트랜잭션 관리 전략 수립에 도움이 되길 바랍니다.
읽어주셔서 감사합니다.  :)