[Spring] @Retryable과 @Transactional 는 순서에 따라 동작을 다르게 한다?
금주에 확인한 [Spring] 중첩된 @Transactional의 readOnly 동작 확인 관련해서 인터넷에서 찾아보다가 순서에 대한 글들을 발견했다.
1번 글은 트랜잭션 안에서 재처리 처리되는 부분이 문제가 될 수 있겠다는 점에서는 동의를 했다.
2번 글의 @Retryable과 @Transactional 는 순서에 따라 동작을 다르게 한다는 글을 보고 정말로 그런지 궁금해졌다.
왜냐하면 만약 '내가 프레임워크 개발자라는 관점'으로 생각해보았을 때, 만약 Annotation의 선언 순서에 따라 동작이 달라지도록 구현을 했다면
- 개발자들이 예측이 가능하게 애플리케이션을 짜기 어려울 것이고
- 잠재적인 버그가 만들어질 가능성이 높아질 수 있을 것이다.
- 또한 이로 인해 개발에 주의를 기울여야 하는 스트레스를 받을 수 있다.
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 로 최종 릴리스된다.
- PR 333: Introduce order for @EnableRetry
참고로 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() 부분에 디버깅을 걸어보면 아래와 같은 호출 스택을 볼 수 있다.
호출 스택은 아래 부분이 먼저 수행되는 부분이니 트랜잭션이 재처리 부분 안에 들어갔음을 알 수 있다.
만약 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