Spring Retry란
실패할 경우 그 동작을 자동으로 다시 호출하는 기능이 담긴 라이브러리로, Spring Retry 라이브러리를 통해 사용한다.
간단하게 예를 들어, 특정 비즈니스 로직에서 실패 후 재시도 처리가 필요한 경우가 존재한다. 특정 A라는 로직이 실패한 경우 재시도하는 경우가 될 것이다. 이는 비즈니스 요구 사항에 따라 사용되기도 하며 일시적인 에러가 발생했을 때 재시도 메커니즘을 활용하기도 한다.
Guide to Spring Retry | Baeldung
Guide to Spring Retry | Baeldung
A quick and practical guide to implementing retry logic with Spring Retry
www.baeldung.com
Spring Retry를 사용하는 방식은 두 가지가 있다.
- 프로그래밍 형식의 Retry
- 선언적 Retey
프로그래밍 형식의 경우 Retry Template을 사용하여 구성하는 것이다.
// Retry 구성
fun retryTemplate(): RetryTemplate = RetryTemplate.builder()
.maxAttempts(5)
.fixedBackoff(500)
.retryOn(RuntimeException::class.java)
.build()
// Retry 사용
fun carInfo(id: Long): String {
logger.info("exception will be thrown.")
throw RuntimeException("Failed to get car info")
}
// recover 사용
fun recover(id: Long): String {
return "car default info"
}
- maxAttempts : 재시도 횟수
- fixedBackoff : 재시도 간격
- retryOn : 재시도 할 Exception
위 내용을 간단하게 살펴보면 아래와 같다. recover는 세팅한 횟수만큼 재시도 했음에도, 실패할 경우 후처리를 담당한다. 이는 Retry Config를 직접 설정하고, retryBuilder를 통해 생성하기 때문에 선언형 방식 보다 다소 불편한 사용감이 들 수 있다. 그럼 바로 선언형 Retry 방식을 살펴보자.
선언형 Retry는 Annotation 기반의 AOP로 동작한다. 한 마디로 재시도를 하고 싶은 로직에 @Retryable
를 달아주면 된다. 이때 Annotation의 파라미터로 값을 위에서 언급한 재시도 관련 값들을 설정할 수 있다.
@Retryable(
maxAttempts = 5,
backoff = Backoff(delay = 500),
retryFor = [RuntimeException::class],
recover = "recover"
)
fun carInfo(id: Long): String {
throw RuntimeException("Failed to get car info")
}
@Recover
fun recover(e: RuntimeException, id: Long): String {
logger.info("recover: $e")
return "car default info"
}
@Recover
Annotation을 통해 지정된 메서드가 응답을 대체한다. @Recover
어노테이션의 메서드는 @Retryable
Annotation의 메서드와 동일한 파라미터, 반환타입을 가져야 한다. 파라미터 첫 번째 인자로는 @Retryable
Annotation의 메서드에서 발생한 Exception 이 전달된다.
그리고 notRecoverable
을 통해 특정 Exception이 발생한 경우 재시도를 하지 않도록 설정하는 것도 가능하다.
RetryListener를 통해 Retry의 상황도 파악할 수 있다.
public interface RetryListener {
default <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
return true;
}
default <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
}
default <T, E extends Throwable> void onSuccess(RetryContext context, RetryCallback<T, E> callback, T result) {
}
default <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
}
}
RetryListener를 구현하여 특정 Retry의 상황을 파악하여 아래와 같은 것들을 파악할 수 있다.
- retry 전체 프로세스 시작 전
- retry 중 발생한 예외상황
- retry 전체 프로세스(recover 포함)를 모두 마무리 한 직후
이는 @Retryable
annotation에 listenr를 등록해 주면 된다. 순서는 아래와 같다.
- retry 전체 프로세스가 시작되며
RetryListener
의open
메서드가 호출된다. - retry 중 발생한 예외상황이 발생하면
RetryListener
의onError
메서드가 호출된다. - retry 가 모두 실패하면 recover 메서드가 호출된다.
- recover를 포함한 retry 전체 프로세스가 모두 마무리되면
RetryListener
의close
메서드가 호출된다.
실질적으로 Retry의 활용
Retry를 잘 활용한다면 서비스의 신뢰도를 높이고 일시적인 장애 상황에 대응할 수 있다.
- ex) 낙관적 락을 통해 실패한 경우 재시도 메커니즘이 필요할 때
- ex) 단순 네트워크 이슈로 인해 요청이 처리되지 않았을 때
필자도 위 같은 예시 상황에서 Retry 메커니즘을 통해 특정 비즈니스 요구 사항에 대응한 적이 있다.
하지만 항상 Retry를 적용하는 것은 옳지 않다. Retry는 일시적인 장애를 대응하기 위해 사용한다. 만약 일시적인 장애가 아닌 경우 Retry를 쓴다면 반복적인 장애를 전파하게 되면 서비스 신뢰도가 하락할 수 있다. 또한, 매 요청마다 뒷단 서버, 서비스에 반복된 요청을 시도하므로 서비스 부하가 증가할 수 있다.
만약 이에 더해 클라이언트도 재시도하는 로직을 담고 있다면 그 요청은 제곱으로 늘어날 것이다. 그렇기 때문에 신중하게 Retry를 사용해야 한다.
그렇기 때문에 일시적인 장애가 아니고, 정해진 에러에 Retry 하는 것이 아니라면 서킷 브레이커를 통해 장애를 전파하지 않는 것도 좋은 선택지 중 하나이다.
결론적으로 Retry 횟수와 주기를 너무 적고 짧게 설정하면, 장애 상황이 지속되는 경우 서비스 부하가 증가할 수 있다. 또한, Retry 횟수와 주기를 너무 많고 길게 설정하면, 사용자가 API를 한번 호출했을 때 느끼는 응답시간이 길어질 수 있다.
재시도를 한다는 것 자체가 그 로직에 대한 실패 가능성을 열어두고 구현하는 것이며, 이는 문제 상황을 면밀히 재고하여 사용해야 한다.
'서버 개발(생각과 구현)' 카테고리의 다른 글
락의 필요성과 실제 사용을 위한 분산 락, 낙관적 락, 비관적 락 탐구 (0) | 2024.12.22 |
---|---|
서킷 브레이커(CircuitBreaker)가 필요한 경우 (0) | 2024.12.21 |
외부 API 호출을 위한 OpenFeign, RestClient, WebClient들의 차이점 (0) | 2024.12.20 |
Kotest를 활용한 유닛 테스트 구성 (0) | 2024.12.19 |
Kotlin-jdsl과 Querydls의 차이점과 선택 과정 (0) | 2024.12.19 |