Programing/Framework

[Spring] @Retryable과 @Transactional 는 순서에 따라 동작을 다르게 한다?

나모찾기 2024. 10. 27. 01:00

금주에 확인한 [Spring] 중첩된 @Transactional의 readOnly 동작 확인 관련해서 인터넷에서 찾아보다가 순서에 대한 글들을 발견했다.

  1. [끄적끄적] @Transactional 안에서 retry 사용을 주의하세요 ✔
  2. 스프링 @Retryable 과 @Transactional의 주석 순서에 따른 프로세스 차이

1번 글은 트랜잭션 안에서 재처리 처리되는 부분이 문제가 될 수 있겠다는 점에서는 동의를 했다.
2번 글의 @Retryable과 @Transactional 는 순서에 따라 동작을 다르게 한다는 글을 보고 정말로 그런지 궁금해졌다.

 

왜냐하면 만약 '내가 프레임워크 개발자라는 관점'으로 생각해보았을 때, 만약 Annotation의 선언 순서에 따라 동작이 달라지도록 구현을 했다면

  1. 개발자들이 예측이 가능하게 애플리케이션을 짜기 어려울 것이고
  2. 잠재적인 버그가 만들어질 가능성이 높아질 수 있을 것이다.
  3. 또한 이로 인해 개발에 주의를 기울여야 하는 스트레스를 받을 수 있다.

Bean 초기화 우선순위

  • @Retryable → @EnableRetry: order=Ordered.LOWEST_PRECEDENCE - 1
  • @Transactional → @EnableTransactionManagement: order=Ordered.LOWEST_PRECEDENCE

spring retry의 EnableRetry의 주석에보면 알 수 있지만 Ordered.LOWEST_PRECEDENCE 우선순위를 가지는 어드바이스들보다 먼저 수행될 수 있도록 우선순위가 높음을 알 수 있다. LOWEST_PRECEDENCE 우선순위의 예도 @Transactional 로 적혀있다.

관련 Spring Retry 이슈 목록

  • Issue 22: support custom RetryConfiguration.getOrder() via @EnableRetry like @EnableAsync
    • PR 333: Introduce order for @EnableRetry
      • 최초 PR에서는 Ordered.LOWEST_PRECEDENCE 였다.
      • xak2000가 99%의 빈도로 @Retry -> @Transactional 순으로 사용하는데 동일한 순서로 사용하면 서로 간섭(interferes)이 있을 것에 대한 의견이 있었다. 여기에 대해 artembilan 도 동의해서 이후 335 PR이 생겼다.
    • PR 335: Change default order for @EnableRetry
      • Ordered.LOWEST_PRECEDENCE - 1 로 최종 릴리스된다.

참고로 Issue 22는 2.0.1로 릴리스되었다. Closed on 2023.3.21.

 

블로그 게시일은 2024년 5월 31일이었는데 아마도 spring-retry-2.0.1.jar 보다 낮은 버전을 사용해서 순서에 따른 이슈가 있었지 않나 생각한다.

2.0.1 버전 이후를 사용한다면 annotation을 정의한 순서와 관계없이 재처리부분이 높은 우선 순위를 가진다고 안심할 수 있겠다.

Sprint Retry 2.0.1 미만 동작

Demo 프로그램으로 확인해 볼 수 있다.

먼저 2.0.1 이상 버전으로 테스트했다.

  • Spring Boot: 3.3.5 / 3.1.0 / 3.0.13
  • Spring Retry: 2.0.10 / 2.0.1 / 2.0.4
  • Spring Transaction(tx): 6.1.14 / 6.0.9 / 6.0.14
@Service
class ApplicationService(
    private val domainService: DomainService,
) {
    private val counter: AtomicLong = AtomicLong(0)

    @Retryable(maxAttempts = 3, retryFor = [RuntimeException::class], recover = "recover")
    @Transactional
    fun run() {
        println("ApplicationService.run() - ${counter.incrementAndGet()}")
        domainService.somethingFail()
    }

    @Recover
    fun recover(e: RuntimeException) {
        println("ApplicationService.recover() - ${counter.get()}")
    }

}

@Component
class DomainService {

    fun somethingFail() {
        throw RuntimeException("Fail")
    }

}

ApplicationService의 run()는 DomainService의 somethingFail() 함수를 호출한다.

항상 RuntimeException 예외를 던지기에 실패를 하도록 의도적으로 만들었다.

 

위의 애플리케이션을 실행하면 아래와 같은 에러가 로그가 찍힌다.

ApplicationService.run() - 1
ApplicationService.run() - 2
ApplicationService.run() - 3
ApplicationService.recover() - 3

 

 

run() 부분에 디버깅을 걸어보면 아래와 같은 호출 스택을 볼 수 있다.

노란색 부분이 sprint retry의 부분이고 빨간색이 트랜잭션 인터셉터 부분이다.

호출 스택은 아래 부분이 먼저 수행되는 부분이니 트랜잭션이 재처리 부분 안에 들어갔음을 알 수 있다.

만약 annotation의 순서를 바꾸어본다.

@Service
class ApplicationService(
    ..
) {

    @Transactional
    @Retryable(maxAttempts = 3, retryFor = [RuntimeException::class], recover = "recover")
    fun run() {
        println("ApplicationService.run() - ${counter.incrementAndGet()}")
        domainService.somethingFail()
    }

    // ..

}

바꾸고 나서도 @Retryable > @Transactional 가 먼저 적용되는 것을 확인할 수 있었다.

 

의도적으로 순서를 바꾼다면 트랜잭션 관리자의 빈 우선순위의 값을 재처리보다 작게해서 우선 순위를 높게 할 수 있다.

@EnableRetry	// (order = Ordered.LOWEST_PRECEDENCE + 1): overflow로 불가능!
@EnableTransactionManagement(order = Ordered.LOWEST_PRECEDENCE - 2)
@SpringBootApplication
class SpringRetryApplication(
	private val applicationService: ApplicationService,
) {

	@Bean
	fun run(): CommandLineRunner {
		return CommandLineRunner {
			println("Hello, Spring Retry!")
			applicationService.run()
		}
	}

}

fun main(args: Array<String>) {
	runApplication<SpringRetryApplication>(*args)
}

이렇게 하면 호출 스택의 순서가 바뀌었음을 알 수 있다.

 

이제 2.0.1 이하 버전으로 테스트한다.

  • Spring Boot: 2.7.5
  • Spring Retry: 1.3.4
  • Spring Transaction(tx): 5.3.23

컴파일 에러가 발생한다. Java 21에서 17로 내려야겠다.

Unsupported class file major version 65

 

@Retryable(maxAttempts = 3, include = [RuntimeException::class], recover = "recover")
@Transactional
fun run() {
    println("ApplicationService.run() - ${counter.incrementAndGet()}")
    domainService.somethingFail()
}

 

@Transactional
@Retryable(maxAttempts = 3, include = [RuntimeException::class], recover = "recover")
fun run() {
    println("ApplicationService.run() - ${counter.incrementAndGet()}")
    domainService.somethingFail()
}

 

 

Spring Retry 1.3.4 버전에서 테스트 해보았는데 annotation 순서를 바꾸었다고 수행되는 순서에 영향을 주지 않은 것으로 확인했다.

 

관련한 전체 소스코드는 아래 PR에서 확인 가능합니다.

https://github.com/namhokim/studySpring/pull/12