프레임워크/Spring

Spring의 @Retryable, @Recover 정리하기!

SeongOnion 2024. 10. 20. 21:14
728x90

왜 쓸까?

Spring Retry에선 특정 메서드를 지정한 만큼 재시도 처리할 수 있도록 하는 기능을 제공한다.

 

일반적인 유스케이스는 외부 인프라(DB, API, 메시지 큐 등등..)와의 소통 및 처리과정에서 재시도 처리가 필요한 로직들일 것이다.

 

대개는 간헐적이고 일시적으로 발생하는 장애 상황에 대응하기 위한 전략으로 사용된다.

 

따라서 외부 API 호출 시 함께 사용되는 경우가 많으며, 관련 예제들도 보통은 Feign, RestTemplate 등의 클라이언트 관련 기술들과 함께 작성된 경우가 많다.


사용해보기

Spring Retry를 사용하기 위해선 아래 두 개의 의존성이 필요하다.

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

 

aop의 경우 조금 더 라이트한 모듈만 추가해도 되긴해보이는데 그냥 맘편하게 starter를 넣도록 하자.

 

어차피 일반적으로 많이 사용되는 모듈(spring-data-jpa 등..)에 함께 딸려져 들어오는 경우가 많다.

 

다음은 @EnableRetry를 Configuration에 등록해줘야한다.

 

가장 간단한 방법은 @SpringBootApplication 쪽에 해당 어노테이션을 같이 붙여주면된다.

@SpringBootApplication
@EnableRetry
public class RetryApplication {
	public static void main(String[] args) {
		SpringApplication.run(RetryApplication.class, args);
	}
}

 

@Retryable

해당 어노테이션을 재시도할 메서드에 부착함으로써 특정 Exception에 대하여 동일 로직을 특정 주기, 횟수만큼 재시도하도록 할 수 있다.

@Service
public class RetryableService {

    @Retryable
    public void retryableSomething() {
        ...
    }
}

 

사용법은 매우 간단하다.

 

중요하게 봐야하는 부분은 해당 어노테이션에서 설정할 수 있는 설정값들이다.

 

@Retryable에는 재시도 처리를 조금 더 디테일하게 다룰 수 있는 여러가지 옵션을 제공한다.

  • retryFor (include)
    • 재시도 처리를 할 Exception을 정의한다. (기존엔 include 옵션이었으나, Deprecated 되고 retryFor로 대체)
  • noRetryFor (exclude)
    • 재시도 처리를 하지 않을 Exception을 정의한다. (기존엔 exclude 옵션이었으나, Deprecated 되고 noRetryFor로 대체)
  • maxAttempts
    • 최대 재시도 횟수를 정의하며, 해당 값은 재시도 횟수 뿐 아닌 최초 시도 횟수를 포함해 카운팅한다.
      • Ex) 값이 3라면 최초시도 1회 + 재시도 2회 실행
  • backoff
    • 재시도 간 딜레이를 설정할 수 있다. 단위는 ms이다.
@Slf4j
@Service
public class RetryableService {

    private int tryCount = 1;

    @Retryable(
            maxAttempts = 3,
            backoff = @Backoff(delay = 2000),
            retryFor = { RetryableException.class }
    )
    public void retryableDo() {
        log.info("시도 횟수 - {}, 현재 시각 - {}", tryCount, LocalDateTime.now());
        tryCount++;
        throw new RetryableException();
    }
}

위와 같이 설정하면 해당 메서드를 총 3번, 2초 간격으로 실행하겠다는 뜻이된다.

 

동작을 확인하기 위해 아래의 간단한 테스트를 돌려보자.

@SpringBootTest
class RetryableServiceTest {

    @Autowired
    private RetryableService retryableService;

    @Test
    void test() {
        retryableService.retryableDo();
    }
}

기대했던대로 해당 메서드는 2초 간격으로 총 3번 실행되었다.

 

Backoff 옵션을 조금 더 만져주면 재시도 주기를 더 우아하게 구성할 수도 있다. 이건 좀 유용해보이니 나중에 따로 더 딥하게 파보자.

 

@Recover

@Recover는 재시도 작업 실패 이후에 필요한 복구 메서드를 트리거해주는 어노테이션이다.

 

별도 옵션도 따로 없어서 @Retryable보다 훨씬 더 사용하기 간단하다.

 

@Retryable에서 지정한 최대 실행횟수(maxAttempts)까지 예외가 발생하게 된다면 @Recover가 부착된 메서드가 실행된다.

 

물론 @Recover 메서드가 여러 개 존재할 수 있고, 어떤 메서드를 실행할지는 @Retryable쪽에서 지정해줘야한다.

@Slf4j
@Service
public class RetryableService {

    @Retryable(
            ...
            recover = "recover"
    )
    public void retryableDo() {
        ...
    }

    @Recover
    public void recover(RetryableException e) {
        log.info("재시도 전체 실패. 리커버 실행");
    }
}

@Retryable에선 String 타입의 메서드명을 입력해 메서드를 지정하므로 오류 사실을 컴파일 시점에서 잡을 수 없다.

 

"오타나지 않게 조심하자"보단 테스트를 잘 짜두면 된다. 휴먼에러는 불가피한 존재다.

 

@Recover를 설정하고 테스트를 진행하면, 아래와 같은 결과를 얻을 수 있다.

물론 maxAttempts를 초과하기 전에 메서드 수행에 성공하게 되면 리커버 메서드도 실행되지 않는다. 

 

중요한 비즈니스 로직이 특정 횟수 이상 실패 시 관련 내용을 별도 DB 및 큐에 쌓거나 로깅을 하는 용도 정도가 가장 커먼케이스이지 않을까싶다.

 

 

한 가지 재미있는 사실은 @Recover 메서드에서 Exception과 함께 @Retryable 메서드의 파라미터값을 그대로 받을 수 있다는 것이다.

@Slf4j
@Service
public class RetryableService {

    @Retryable(
            ...
            recover = "recover"
    )
    public void retryableDo(LocalDateTime initialInvokedDateTime) {
        ...
    }

    @Recover
    public void recover(RetryableException e, LocalDateTime initialInvokedDateTime) {
        log.info("재시도 전체 실패. 리커버 실행. 최초 수행시각: {}", initialInvokedDateTime);
    }
}

해당 부분을 잘 활용하면 리커버 처리를 훨씬 더 유용하게 할 수 있겠다.

 

물론 파라미터 없이 그냥 Exception만 받더라도 동작에는 문제가 없다.

 

하지만, @Retryable 메서드와 시그니처가 다르면 ExhaustedRetryException 예외가 발생한다.

 

아래 코드는 모두 해당 예외가 발생할 수 있는 대표적인 예시이다.

 

1) 파라미터 타입이 다른 경우

@Slf4j
@Service
public class RetryableService {

    @Retryable(
            ...
            recover = "recover"
    )
    public void retryableDo(LocalDateTime initialInvokedDateTime) {
        ...
    }

    @Recover
    public void recover(RetryableException e, String anotherType) { // 파라미터 타입이 다름
        ...
    }
}

2) 파라미터 순서가 다른 경우

@Slf4j
@Service
public class RetryableService {

    @Retryable(
            ...
            recover = "recover"
    )
    public void retryableDo(LocalDateTime initialInvokedDateTime, String stringValue) {
        ...
    }

    @Recover
    public void recover(RetryableException e, String stringValue, LocalDateTime initialInvokedDateTime) { // 파라미터 순서가 다름
        ...
    }
}