본문 바로가기

Programming/Java & JSP & Spring

[Spring] Retry 재시도 로직 구현하기 With Kotlin

서버를 운영하다보면 일시적인 오류가 종종 발생한다.

특히나 어플리케이션 간의 통신에서 이러한 일시적인 오류가 빈번하게 생기는 편이다.

 

이러한 오류는 재시도를 통해 문제가 해결되는 케이스가 대부분이다.

 

이를 위해 스프링에서는 `@Retryable` 이라는 어노테이션을 제공해서 해당 메소드 위에 어노테이션만 붙여주면

간단하게 재시도 할 수 있는 로직을 구현할 수 있다.

 

하지만 AOP 특성 상 어노테이션이 동작하기 위해서는 외부에서 해당 메소드를 호출해야한다.

고작 한줄만 재시도 로직이 필요한 상황인데, 그 한줄을 다른 클래스로 빼야되는 귀찮은 상황이 발생할 수 있다.

 

여기저기 Retryable 어노테이션이 붙어있는 것과 위에서 언급한 AOP의 단점을 보완하기 위해

특정 비즈니스 로직을 재시도할 수 있는 Retry Servie 로직을 구현해보자.


Retryable 어노테이션으로 따로 서비스를 만들기는 더욱 까다로워 보였기 때문에 RetryTemplate를 이용하였다.

1. retry 라이브러리 추가

dependencies {  
	implementation 'org.springframework.retry:spring-retry'
}

2. RetryTemplate 빈 생성

@Configuration
class RetryConfig {
    @Bean
    fun retryTemplate(): RetryTemplate {
        val retryTemplate = RetryTemplate()

        retryTemplate.registerListener(RetryListener()) // 리스너 등록

        val fixedBackOffPolicy = FixedBackOffPolicy() // 재시도 간격 설정
        fixedBackOffPolicy.backOffPeriod = 2000L
        retryTemplate.setBackOffPolicy(fixedBackOffPolicy)

        val maxAttempts = 3 // 재시도 최대 횟수 설정
        val retryPolicy = SimpleRetryPolicy(maxAttempts, Collections.singletonMap(Exception::class.java, true))

        retryTemplate.setRetryPolicy(retryPolicy) // 위 설정을 적용

        return retryTemplate
    }
}

RetryTemplate를 주입받아서 사용하기 위해 또한 싱글턴으로 객체를 관리하기 위해, 위와 같이 빈으로 등록을 한다.

리스너는 아래에서 다시 한번 살펴볼 것이고, 여기서 빈을 등록할 때 재시도 간격, 횟수 등을 형식적으로 설정해주었다.

3. Retry Service 구현

이제 2번에서 생성한 빈을 가지고 실제 재시도 서비스를 구현해보자.

@Service
class RetryableService {

    companion object : KLogging()

    @Autowired
    private lateinit var retryTemplate: RetryTemplate

    @Throws(Exception::class)
    fun <T> run(
        action: () -> T,
        maxAttempts: Int = 3,
        exceptions: List<Class<out Throwable>> = listOf(Exception::class.java),
        maxInterval: Long = 2000L
    ): T {
//        val fixedBackOffPolicy = FixedBackOffPolicy() // fixed back off
        val exponentialBackOffPolicy = ExponentialBackOffPolicy().apply {
            this.initialInterval = 300L
            this.maxInterval = maxInterval
            this.multiplier = 2.0
        } // exponential back off
        retryTemplate.setBackOffPolicy(exponentialBackOffPolicy)

        val exceptionMap = exceptions.map { it to true }.toMap() // 재시도를 원하는 exception 등록
        val retryPolicy = SimpleRetryPolicy(maxAttempts, exceptionMap)
        retryTemplate.setRetryPolicy(retryPolicy)

        return retryTemplate.execute<T, Throwable> { // 재시도 수행
            action()
        }

        /** ADD RECOVERY Version
        return retryTemplate.execute(RetryCallback<T, Throwable> {
            action().also {
                logger.info("retry!")
            }
        }, RecoveryCallback<T>{
            action().also {
                logger.info("recovery!")
            }
        })
        */
    }
}

RetryableService라는 클래스 안에 run이라는 메소드를 생성했다.

해당 메소드는 파라미터로 action(실제 수행되는 비즈니스 로직), maxAttempts(재시도 최대 횟수), exceptions(재시도를 원하는 exception 리스트), maxInterval(재시도 최대 간격시간)을 받는다.

 

kotlin스럽게 비즈니스 로직을 람다식으로 받고, 나머지는 default 값을 셋팅해주는 방법을 택했다.

 

빈을 등록할 때 FixedBackOffPolicy(고정된 재시도 간격 시간)을 설정했지만, 실제 재시도 로직을 구현할 때는 ExponentialBackOffPolicy를 설정해봤다. 

단어만 봐도 예상하겠지만, 재시도 간격 시간이 기하급수적으로 늘어나는 것을 말한다. (ex. 2초 후 재시도 -> 4초 후 -> 8초 후)

 

그 다음, exception으로 받은 파라미터를 map형태로 변환해 RetryTemplate를 등록해주었다.

그 이후, 해당 비즈니스 로직을 Exception이 발생했을 때 재시도를 수행할 수 있게끔 감싸서 리턴해주는 형태로 구현하였다.

마지막으로 Recovery도 설정해줄 수 있는데 필요에 따라 설정해주면 될 것 같다.

4. 실제 사용

3번까지 Retry Service를 구현한 것을 실제로 사용하는 코드를 살펴보자.

@Service
class BusinessService {

    @Autowired
    private lateinit var retryableService: RetryableService

    fun retryTest() {
        ...
        ...
        retryableService.run(action = {
            logger.info("재시도를 적용시킬 비즈니스 로직")
            throw RuntimeException()
        }, maxAttempts = 5, exceptions = listOf(RuntimeException::class.java))
        ...
        ...
    }
}

재시도를 하고 싶은 클래스에 3번에서 만든 RetryableService를 주입하고 재시도를 원하는 로직에 위와 같이 감싸주는 형태로 작성한다.

위 코드는 해당 비즈니스 로직에서 `RuntimeException`이 발생한다면 최대 5번 재시도를 할 것이다.

 


Retry 서비스를 스프링에서 구현해보았다. 

위는 단순한 예제를 위해 간단하게 구현한 것도 있고, 때에 따라 필요없는 로직도 있을 것이다.

상황에 맞게 `@Retryable` 어노테이션을 써도 좋고 재시도 하는 로직이 빈번하다면 위와 같은 Retry 서비스를 따로 만드는 것도 좋은 대안이 될 것 같다.

 

전체 코드는 아래 깃헙에서 확인할 수 있다.

https://github.com/henry-jo/spring-retry-service