Spring @Transactional 완전 정리: Propagation 옵션과 내부 구현 원리
Spring @Transactional 완전 정리: Propagation 옵션과 내부 구현 원리
Spring의 @Transactional 어노테이션을 완전히 이해하기 위한 가이드입니다. Propagation 옵션부터 내부 구현 원리까지, 실제 프로젝트 코드와 함께 살펴봅니다.
목차
1. @Transactional이란?
1.1. 기본 개념
@Transactional은 Spring에서 제공하는 **선언적 트랜잭션 관리(Declarative Transaction Management)**를 위한 어노테이션입니다.
트랜잭션이란?
- 데이터베이스 작업의 논리적 단위
- 모든 작업이 성공하거나 모두 실패해야 함 (ACID 원칙)
BEGIN→ 작업 수행 →COMMIT또는ROLLBACK
선언적 관리란?
- 코드에 직접 트랜잭션 관리 코드를 작성하지 않음
- 어노테이션으로 선언만 하면 Spring이 자동으로 처리
1.2. 사용 방법
// 클래스 레벨: 모든 public 메서드에 적용 @Service @Transactional class CreditPaymentService { // 모든 public 메서드가 트랜잭션 내에서 실행됨 } // 메서드 레벨: 특정 메서드에만 적용 @Transactional fun approvePayment(...) { // 이 메서드만 트랜잭션 내에서 실행됨 }
2. Propagation 옵션 완전 정리
propagation은 트랜잭션이 이미 존재할 때 새로운 트랜잭션이 어떻게 동작할지를 결정합니다.
2.1. REQUIRED (기본값) ⭐ 가장 많이 사용
동작:
- 트랜잭션이 있으면 → 기존 트랜잭션에 참여
- 트랜잭션이 없으면 → 새로운 트랜잭션 생성
특징:
- 같은 트랜잭션 내에서 실행
- 하나라도 실패하면 전체 롤백
- 가장 일반적으로 사용
@Transactional // propagation = REQUIRED (기본값) fun approvePayment(...) { // 기존 트랜잭션에 참여하거나 새로 생성 paymentRepository.save(...) }
2.2. REQUIRES_NEW ⭐⭐ 독립적인 트랜잭션 필요 시
동작:
- 항상 새로운 트랜잭션 생성
- 기존 트랜잭션이 있어도 독립적인 트랜잭션으로 실행
- 기존 트랜잭션은 일시 중지(suspend)
언제 사용하나?
- 각 단계를 독립적으로 커밋해야 할 때
- Saga 패턴 구현 시
- 부분 커밋이 필요한 경우
@Transactional(propagation = Propagation.REQUIRES_NEW) fun markAsPgApproved(...) { // 항상 새로운 트랜잭션에서 실행 // 즉시 커밋됨! paymentRepository.save(...) }
시각적 설명:
다이어그램 로딩 중...
2.3. SUPPORTS
동작:
- 트랜잭션이 있으면 → 참여
- 트랜잭션이 없으면 → 트랜잭션 없이 실행
사용 예시: 읽기 전용 작업
@Transactional(propagation = Propagation.SUPPORTS) fun readOnlyOperation() { // 트랜잭션이 있으면 참여, 없으면 그냥 실행 }
2.4. MANDATORY
동작:
- 반드시 기존 트랜잭션이 있어야 함
- 없으면
IllegalTransactionStateException발생
사용 예시: 트랜잭션 내에서만 호출되어야 하는 메서드
@Transactional(propagation = Propagation.MANDATORY) fun mustBeInTransaction() { // 반드시 트랜잭션 내에서만 호출되어야 함 }
2.5. NOT_SUPPORTED
동작:
- 트랜잭션을 사용하지 않음
- 기존 트랜잭션이 있으면 일시 중지
사용 예시: 로깅, 캐시 작업 등
@Transactional(propagation = Propagation.NOT_SUPPORTED) fun nonTransactionalOperation() { // 트랜잭션 없이 실행 }
2.6. NEVER
동작:
- 트랜잭션을 사용하지 않음
- 트랜잭션이 있으면 예외 발생
사용 예시: 트랜잭션 내에서 호출되면 안 되는 메서드
@Transactional(propagation = Propagation.NEVER) fun mustNotBeInTransaction() { // 트랜잭션 내에서 호출되면 안 됨 }
2.7. NESTED
동작:
- 중첩 트랜잭션 생성 (Savepoint 사용)
- 부모 트랜잭션이 롤백되면 중첩 트랜잭션도 롤백
- 중첩 트랜잭션이 롤백되어도 부모는 계속 진행 가능
사용 예시: 부분 롤백이 필요한 경우
@Transactional(propagation = Propagation.NESTED) fun nestedOperation() { // Savepoint 생성 // 실패하면 이 지점까지만 롤백 }
2.8. Propagation 옵션 비교
다이어그램 로딩 중...
2.9. Propagation 옵션 비교표
| Propagation | 기존 트랜잭션 | 동작 | 사용 빈도 |
|---|---|---|---|
| REQUIRED (기본) | 있음 | 참여 | ⭐⭐⭐⭐⭐ |
| 없음 | 생성 | ||
| REQUIRES_NEW | 있음 | 새로 생성 (기존 일시 중지) | ⭐⭐⭐⭐ |
| 없음 | 생성 | ||
| SUPPORTS | 있음 | 참여 | ⭐⭐ |
| 없음 | 트랜잭션 없이 실행 | ||
| MANDATORY | 있음 | 참여 | ⭐ |
| 없음 | 예외 발생 | ||
| NOT_SUPPORTED | 있음 | 일시 중지 | ⭐ |
| 없음 | 트랜잭션 없이 실행 | ||
| NEVER | 있음 | 예외 발생 | ⭐ |
| 없음 | 트랜잭션 없이 실행 | ||
| NESTED | 있음 | 중첩 트랜잭션 | ⭐⭐ |
| 없음 | 생성 |
3. 실제 프로젝트에서의 활용
3.1. 문제 상황: REQUIRED의 한계
결제 승인 프로세스를 생각해봅시다:
- PG 승인
- 상태를
PG_APPROVED로 저장 - Credit 발급
- 최종 완료
만약
REQUIRED를 사용하면?
@Transactional fun approvePayment(...) { // 같은 트랜잭션 내에서 실행 creditPaymentStatusService.markAsPgApproved(...) // REQUIRED // Credit 발급 실패 supportersServiceClient.issueCredit(...) // ❌ 예외 // 전체 롤백 → PG_APPROVED 상태도 롤백됨! ❌ // 문제: PG는 승인되었는데 DB에는 기록이 없음! }
문제점: PG는 이미 승인되었는데, DB에는 기록이 없어서 추적이 불가능합니다.
다이어그램 로딩 중...
3.2. 해결책: REQUIRES_NEW 사용
// BasePaymentStatusService.kt @Transactional(propagation = Propagation.REQUIRES_NEW) protected open fun markStatus(...) { // 항상 새로운 트랜잭션에서 실행 val updated = changeStatus(payment) return savePayment(updated) // 즉시 커밋됨 } // CreditPaymentService.kt @Transactional // 메인 트랜잭션 fun approvePayment(...) { try { // Step 1: PG 승인 nicePayService.approvePayment(...) // Step 2: 별도 트랜잭션에서 상태 저장 (REQUIRES_NEW) currentPayment = creditPaymentStatusService.markAsPgApproved(payment, tid) // ↑ 이 시점에 이미 DB에 저장되고 커밋됨! // Step 3: Credit 발급 supportersServiceClient.issueCredit(...) // Step 4: 최종 완료 return paymentRepository.saveCreditPayment(updated) } catch (e: Exception) { // Credit 발급 실패 시 // 이미 PG_APPROVED 상태는 별도 트랜잭션에서 커밋되어 있음 // 따라서 롤백되지 않음! // 보상 트랜잭션 실행 compensatePgApproval(currentPayment, tid) // PG 취소 creditPaymentStatusService.markAsIssueCreditFailed(currentPayment) } }
장점:
- ✅ PG 승인 후 상태가 즉시 저장됨
- ✅ Credit 발급 실패해도 상태는 유지됨
- ✅ 보상 트랜잭션으로 PG 취소 가능
다이어그램 로딩 중...
3.3. Saga 패턴과 REQUIRES_NEW
Saga 패턴이란?
- 분산 트랜잭션을 여러 단계로 나누어 처리
- 각 단계를 독립적으로 커밋
- 실패 시 보상 트랜잭션(Compensation) 실행
다이어그램 로딩 중...
4. 트랜잭션 내부 구현 원리
4.1. AOP 프록시 패턴
Spring의 @Transactional은 **AOP (Aspect-Oriented Programming)**를 기반으로 동작합니다.
다이어그램 로딩 중...
프록시 생성 과정
// 원본 클래스 @Service @Transactional class CreditPaymentService { fun approvePayment(...) { // 비즈니스 로직 } }
Spring이 내부적으로 생성하는 프록시 (의사 코드):
class CreditPaymentServiceProxy( private val target: CreditPaymentService, private val transactionInterceptor: TransactionInterceptor ) : CreditPaymentUseCase { override fun approvePayment(...) { // 1. 트랜잭션 시작 val transactionInfo = transactionInterceptor.createTransactionIfNecessary(...) try { // 2. 실제 메서드 호출 val result = target.approvePayment(...) // 3. 트랜잭션 커밋 transactionInterceptor.commitTransactionAfterReturning(transactionInfo) return result } catch (e: Exception) { // 4. 예외 발생 시 롤백 transactionInterceptor.completeTransactionAfterThrowing(transactionInfo, e) throw e } } }
프록시 확인 방법
@SpringBootTest class TransactionProxyTest { @Autowired private lateinit var creditPaymentService: CreditPaymentService @Test fun checkProxy() { // 프록시인지 확인 println(creditPaymentService.javaClass.name) // 출력: com.sclass.paymentservice.application.service.CreditPaymentService$$SpringCGLIB$$0 // AOP 프록시인지 확인 val isProxy = AopUtils.isAopProxy(creditPaymentService) println("Is Proxy:$isProxy") // true } }
4.2. TransactionSynchronizationManager
트랜잭션 상태를 ThreadLocal에 저장하여 관리합니다.
내부 동작 원리
// TransactionSynchronizationManager 내부 (의사 코드) object TransactionSynchronizationManager { // ThreadLocal로 각 스레드별 트랜잭션 정보 저장 private val resources = ThreadLocal<Map<Any, Any>>() private val synchronizations = ThreadLocal<MutableList<TransactionSynchronization>>() private val currentTransactionName = ThreadLocal<String>() private val currentTransactionReadOnly = ThreadLocal<Boolean>() private val actualTransactionActive = ThreadLocal<Boolean>() fun bindResource(key: Any, value: Any) { val map = resources.get() ?: mutableMapOf() map[key] = value resources.set(map) } fun getResource(key: Any): Any? { return resources.get()?.get(key) } }
실제 사용 예시
@Service class CreditPaymentService { fun approvePayment(...) { // 현재 트랜잭션 확인 val isActive = TransactionSynchronizationManager.isActualTransactionActive() println("Transaction Active:$isActive") // true // 트랜잭션 이름 확인 val name = TransactionSynchronizationManager.getCurrentTransactionName() println("Transaction Name:$name") // 출력: com.sclass...CreditPaymentService.approvePayment } }
4.3. REQUIRES_NEW의 내부 구현
REQUIRES_NEW는 어떻게 독립적인 트랜잭션을 생성할까요?
내부 동작 흐름
// AbstractPlatformTransactionManager 내부 (의사 코드) class AbstractPlatformTransactionManager { fun getTransaction(definition: TransactionDefinition): TransactionStatus { val transaction = doGetTransaction() // 현재 트랜잭션 확인 if (definition.propagation == Propagation.REQUIRES_NEW) { // 기존 트랜잭션이 있으면 일시 중지 if (transaction != null) { val suspendedResources = suspend(transaction) // ThreadLocal에서 트랜잭션 정보 제거 // 새로운 트랜잭션 시작 return startTransaction(definition, suspendedResources) } else { // 트랜잭션이 없으면 새로 생성 return startTransaction(definition, null) } } // ... REQUIRED 등의 처리 } private fun suspend(transaction: Any): SuspendedResourcesHolder { // 1. 현재 트랜잭션 정보 저장 val name = TransactionSynchronizationManager.getCurrentTransactionName() val readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly() val wasActive = TransactionSynchronizationManager.isActualTransactionActive() // 2. ThreadLocal에서 트랜잭션 정보 제거 TransactionSynchronizationManager.unbindResource(dataSource) TransactionSynchronizationManager.clear() // 3. 저장된 정보를 SuspendedResourcesHolder에 보관 return SuspendedResourcesHolder( transaction = transaction, name = name, readOnly = readOnly, wasActive = wasActive ) } private fun resume(holder: SuspendedResourcesHolder) { // 일시 중지된 트랜잭션 재개 TransactionSynchronizationManager.bindResource(dataSource, holder.transaction) TransactionSynchronizationManager.setCurrentTransactionName(holder.name) TransactionSynchronizationManager.setCurrentTransactionReadOnly(holder.readOnly) TransactionSynchronizationManager.setActualTransactionActive(holder.wasActive) } }
REQUIRES_NEW 실행 흐름 시각화
다이어그램 로딩 중...
ThreadLocal 상태 변화 요약:
다이어그램 로딩 중...
4.4. Connection 관리
같은 트랜잭션 내에서의 Connection 재사용
@Transactional fun approvePayment(...) { // 첫 번째 DB 작업 val payment1 = paymentRepository.findCreditPaymentByPgOrderId(...) // Connection1 획득 // 두 번째 DB 작업 val payment2 = paymentRepository.findCreditPaymentById(...) // Connection1 재사용 (같은 트랜잭션) // 세 번째 DB 작업 paymentRepository.saveCreditPayment(...) // Connection1 재사용 } // Connection1.commit() 또는 rollback()
다이어그램 로딩 중...
REQUIRES_NEW에서의 별도 Connection
@Transactional fun approvePayment(...) { // 메인 트랜잭션: Connection1 val payment = paymentRepository.findCreditPaymentByPgOrderId(...) // REQUIRES_NEW: Connection2 (새로운 Connection!) creditPaymentStatusService.markAsPgApproved(...) // Connection2.commit() ← 즉시 커밋 // 메인 트랜잭션 재개: Connection1 paymentRepository.saveCreditPayment(...) // Connection1.commit() (나중에) }
다이어그램 로딩 중...
5. 성능 최적화 팁
5.1. 트랜잭션 범위 최소화
// ❌ 나쁜 예: 트랜잭션이 너무 길게 유지됨 @Transactional fun processPayment() { // 외부 API 호출 (느림) val pgResult = nicePayService.approvePayment(...) // 2초 소요 // DB 작업 paymentRepository.save(...) // 0.01초 소요 } // 트랜잭션이 2초 동안 유지됨 → Connection이 오래 점유됨 // ✅ 좋은 예: 트랜잭션 범위 최소화 fun processPayment() { // 외부 API 호출 (트랜잭션 밖) val pgResult = nicePayService.approvePayment(...) // 2초 소요 // 트랜잭션 내에서만 DB 작업 savePaymentInTransaction(pgResult) // 0.01초만 트랜잭션 유지 } @Transactional private fun savePaymentInTransaction(pgResult: Any) { paymentRepository.save(...) }
5.2. 읽기 전용 트랜잭션
@Transactional(readOnly = true) fun findPayment(id: String) { // 읽기 전용 트랜잭션 // 성능 최적화 (쓰기 작업 방지) return paymentRepository.findCreditPaymentById(id) }
5.3. Connection Pool 설정
# application.properties spring.datasource.hikari.maximum-pool-size=10 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=30000 spring.datasource.hikari.idle-timeout=600000 spring.datasource.hikari.max-lifetime=1800000
5.4. 트랜잭션 로그 활성화 (개발 환경)
# application.properties logging.level.org.springframework.transaction=DEBUG logging.level.org.springframework.orm.jpa=DEBUG logging.level.org.hibernate.SQL=DEBUG
6. 주의사항
6.1. 같은 클래스 내 메서드 호출
class CreditPaymentService { @Transactional fun publicMethod() { // ❌ 트랜잭션이 적용되지 않음! privateMethod() // 프록시를 거치지 않음 } @Transactional private fun privateMethod() { // 트랜잭션 미적용 } }
해결 방법:
@Transactional은 public 메서드에만 적용- 다른 클래스의 메서드를 호출하거나
self.method()호출 (권장하지 않음)
6.2. 예외 처리
@Transactional fun mayFail() { try { // 예외 발생 throw RuntimeException() } catch (e: Exception) { // 예외를 잡으면 롤백되지 않음! // 기본적으로 RuntimeException만 롤백 } }
해결 방법:
@Transactional(rollbackFor = [Exception::class]) fun mayFail() { // 모든 예외에 대해 롤백 }
마무리
Spring의 @Transactional은 강력한 도구이지만, 내부 동작을 이해하지 못하면 예상치 못한 문제가 발생할 수 있습니다.
핵심 포인트:
- REQUIRED: 기본값, 대부분의 경우에 사용
- REQUIRES_NEW: 독립적인 트랜잭션이 필요할 때 (Saga 패턴 등)
- AOP 프록시:
@Transactional은 프록시를 통해 동작 - ThreadLocal: 트랜잭션 상태는 ThreadLocal에 저장
- Connection 관리: 같은 트랜잭션은 같은 Connection, REQUIRES_NEW는 별도 Connection
이러한 원리를 이해하면 트랜잭션 관련 문제를 더 쉽게 디버깅하고 최적화할 수 있습니다!
참고 자료: