728x90
반응형

[Spring] @Transactional 동작하지 않는 경우

환경 : Spring Boot 3.x, Kotlin 1.9, JPA



1. 원인

  • private/protected 메서드에 적용
  • 같은 클래스 내부에서 호출 (self-invocation, 프록시 우회)
  • checked exception 발생 — 기본 롤백 대상 아님
  • readOnly = true 인데 데이터 변경 시도
  • propagation 옵션 잘못 설정


2. 해결 방법

2.1) public 메서드로 선언

Spring AOP 프록시는 public 메서드만 가로챔. private, protected, package-private 은 어드바이스가 적용되지 않음

// 동작 안 함
@Service
class MemberService(private val memberRepository: MemberRepository) {
    @Transactional
    private fun saveInternal(member: Member) { // private → 무시
        memberRepository.save(member)
    }
}

// 정상
@Service
class MemberService(private val memberRepository: MemberRepository) {
    @Transactional
    fun save(member: Member) { // public
        memberRepository.save(member)
    }
}

2.2) 자기호출 회피 — 별도 빈으로 분리

@Cacheable, @Async와 동일한 문제. 같은 클래스 내부에서 호출하면 프록시를 거치지 않아 어드바이스가 무시됨

// 동작 안 함
@Service
class OrderService(private val orderRepository: OrderRepository) {
    fun createOrder(request: OrderRequest) {
        validate(request)
        save(request) // 내부 호출 → @Transactional 무시
    }

    @Transactional
    fun save(request: OrderRequest) {
        orderRepository.save(Order.from(request))
    }
}

// 정상 — 별도 빈으로 분리
@Service
class OrderService(private val orderTransactionService: OrderTransactionService) {
    fun createOrder(request: OrderRequest) {
        validate(request)
        orderTransactionService.save(request) // 외부 빈 호출
    }
}

@Service
class OrderTransactionService(private val orderRepository: OrderRepository) {
    @Transactional
    fun save(request: OrderRequest) {
        orderRepository.save(Order.from(request))
    }
}

2.3) checked exception 롤백 — rollbackFor 명시

기본 롤백 대상은 RuntimeExceptionError만. checked exception(예: IOException, 사용자 정의 BusinessException : Exception)은 던져도 커밋됨

// 동작 안 함 — IOException 던져도 커밋됨
@Transactional
fun process() {
    memberRepository.save(member)
    throw IOException("외부 호출 실패") // 롤백 안 됨
}

// 정상 — Exception 전체 롤백
@Transactional(rollbackFor = [Exception::class])
fun process() {
    memberRepository.save(member)
    throw IOException("외부 호출 실패") // 롤백됨
}

Kotlin은 모든 예외를 unchecked 로 처리하지만, Java 라이브러리에서 던지는 checked exception 은 여전히 위 규칙을 따름. 확실히 하려면 rollbackFor = [Exception::class] 명시


2.4) propagation 옵션 정리

가장 자주 헷갈리는 3가지

// REQUIRED (기본값) — 기존 트랜잭션에 참여, 없으면 새로 생성
@Transactional(propagation = Propagation.REQUIRED)
fun save(member: Member) { ... }

// REQUIRES_NEW — 항상 새 트랜잭션 (기존 것 잠시 보류)
// 부모가 롤백돼도 살아남아야 하는 로그 기록 등에 사용
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun writeAuditLog(log: AuditLog) { ... }

// NESTED — savepoint 기반 부분 롤백 가능
// 부모 롤백 시 같이 롤백, 자식만 롤백 시 부모는 살아남음
@Transactional(propagation = Propagation.NESTED)
fun trySave(member: Member) { ... }

주의 — REQUIRES_NEW 도 자기호출 시 동일하게 무시됨. 같은 클래스에서 호출하면 propagation 전체가 의미 없어짐

@Service
class OrderService {
    @Transactional
    fun createOrder() {
        save()
        writeLog() // 자기호출 → REQUIRES_NEW 무시, 같은 트랜잭션에서 실행
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun writeLog() { ... }
}


3. 트랜잭션 적용 여부 디버깅

의심될 때는 코드와 로그로 직접 확인

3.1) 코드로 확인

import org.springframework.transaction.support.TransactionSynchronizationManager

@Service
class OrderService {
    @Transactional
    fun process() {
        val active = TransactionSynchronizationManager.isActualTransactionActive()
        val name = TransactionSynchronizationManager.getCurrentTransactionName()
        println("트랜잭션 활성: $active, 이름: $name")
    }
}

active = false 면 어드바이스가 적용되지 않은 것


3.2) 로그로 확인

# application.yml
logging:
  level:
    org.springframework.transaction.interceptor: TRACE

정상 적용 시 출력

Getting transaction for [com.example.OrderService.createOrder]
Completing transaction for [com.example.OrderService.createOrder]

위 로그가 안 찍히면 어드바이스 자체가 적용되지 않은 것. 1번 원인부터 점검


※ 대부분의 케이스가 자기호출 또는 private 메서드. 디버깅 전에 먼저 의심할 것


728x90
반응형

+ Recent posts