서론
File-Crafter
간단하게 얘기해보자면 특정 API URL에 반복적으로 요청을 보내거나, API 응답 결과를 모아서 다시 File로 내려받을 수 있는 서비스이다. 대량 데이터의 추출/입력을 정기적으로 처리해야 할 때 쓰기 좋게하기 위한 자체 툴이고, 내부적으로는 Object Storage(OBS) 를 사용하고 있다고 생각해주면 된다.
정리해보자면, 해당 OBS에 요청을 보내기 위해서는
- 인증 토큰 발급
- 캐시에 저장
- 토큰이 만료되면 다시 갱신
이라는 흐름이 필요하다. 여기서 토큰 갱신 부분을 자동화하기 위해 Spring Retry를 사용하고 있고, 기존에는 1.3.3 기준으로 구현해두었다가, 최근 Spring Boot 3.x + Spring Retry 2.0.0 조합으로 마이그레이션 하면서 겪었던 내용을 공유하고자 정리하게 되었다.
본문

의존성 세팅

가장 먼저 의존성을 2.0.0 버전에 맞게 수정해주었다. 이때 Spring Retry 2.0.0은 Java 17을 기본으로 요구하기때문에, JDK 버전이 맞지 않으면 컴파일 에러가 날 수도 있다 (제가 겪은 감동 실화입니다)
@EnableRetry 설정

또한 두번째 세팅으로는 @EnableRetry 설정을 해주었는데, 해당 내용 자체는 1.3.x 때와 크게 다르지 않았다. 리스너 구현은 예전에 구현되어 있던 방식이였던 RetryListenerSupport 를 상속해서 쓰는 방법도 있었고, 2.x 라인에서 권장하는 것처럼 RetryListener 인터페이스를 직접 구현해도 되니까 자유롭게 선택해주면 될거같다.
(참고로 최신 2.0.x 기준으로는 RetryListener에 기본 메서드가 들어가 있고, RetryListenerSupport는 앞으로 제거될 예정이라 되어 있었긴해서 RetryListener를 직접 구현하는 것을 권장하긴한다.)
Object Storage 요청 + 토큰 갱신 플로우
이제 초기세팅까지 끝났으니까, 세부 플로우와 함께 내용을 공유하려고 한다.
먼저 Spring Retry 없이, File Crafter가 OBS에 요청을 보내면서 토큰을 갱신하는 순수 비즈니스 플로우를 시각화 하면 아래와 같다.

여기에다가 Spring Retry를 붙인 이유는, 이 플로우를 Spring Retry 위에 올려서 인증 실패(401) 시에만 토큰 갱신 + 재시도를 하고싶었던 의도임을 크로스 체크로 알 수 있었다.
@Retryable 적용 (include → retryFor)

가장 먼저 기존 레거시 버전은 위처럼 되어있었다.

해당 내용을 개선하여 2.x 버전에서는 notRecoverable이라는 속성이 새로 들어와서, 특정 예외에 대해서는 @Recover를 건너뛰고 바로 예외를 던지게 하도록 설계해주었다.
@Recover 재정의 (예외별 분리)
또한 @Recover Javadoc에도 나와있었던 내용이긴한데, 첫 번째 파라미터는 Throwable(혹은 서브 타입)이여야 하고, 리턴 타입은 대응하는 @Retryable 메서드와 같아야 한다는 조건이 있었다.

당연하게도 위 부분에서의 예외처리라던지, backoff 전략은 실제로 구현한 내용을 공유할 수 없어, 예시로 붙여 넣었으니 구현시에 참고만 해주면 좋을거같다.
다시 돌아와서 해당 내용처럼 구현을 해주게 되면
- HttpClientErrorException.Unauthorized 가 재시도 끝까지 해결되지 않았을 때 → recoverUnauthorized(...)
- 그 외 예외로 재시도가 중단되는 경우 → defaultRecover(...)
로 분기되어 전보다 깔끔하고 명확하게 처리할 수 있었다.
레거시 RetryListener 트러블 슈팅 내용 (open / onError / close)
다시 RetryListener 부분으로 돌아와보자. 일단 내가 이번 마이그레이션 작업을 맡기 전에 이전에 문서화 되어있었던 트러블 슈팅 내용을 전달받았었는데, 내용은 다음과 같았었다.
3.3에서 겪었던 문제는 두 가지였습니다.
1. 예외가 없어도(심지어 요청이 성공해도) open()이 한 번 실행된다.
2. 지정한 예외가 아닌데도 @Recover가 호출되는 경우가 있다.
따라서 해당 트러블 슈팅 오류가 또 다시 재현될 수 있는 문제가 있었기에, 주의하며 설계에 들어갔었다.
좀 더 구체적으로는 말씀해주신 오류가 크리티컬한 이유가 open()에서 토큰 갱신을 걸어두었다가, “예외가 없어도 토큰이 갱신되는” 문제였었기에, 토큰 갱신 로직은 전부 onError() 쪽으로 옮기고, 예외 타입을 직접 분기하는 방식으로 설계하여 구현하였다.

이렇게 개선하여 HttpClientErrorException.Unauthorized 인 경우에만 토큰을 갱신하는 정상 동작을 의도하였고, 다른 예외는 “그냥 재시도”만 할 수 있었다.
@Retryable 적용된 전체 플로우
전체 플로우를 시각화하면 아래와 같다.
LINE 로그인 화면에서 사용자 인증 및 권한 동의 동의 시, LINE이 등록된 콜백 URL로 code와 state를 전달하며 리다이렉트

트러블 슈팅
RetryTemplate 내부에서 발생한 문제
실제 RetryTemplate 코드를 따라가 보면, open()은 retry 루프 밖에서, 첫 execute 전에 무조건 호출되고, 예외가 발생한 뒤에는 예외 종류와 상관없이 onError가 한 번 실행된 뒤 canRetry() 판단이 들어가기 때문에 내가 의도한 대로 동작하지 않았다.

시각화 하면 위와 같았고, 해당 구조로 1.3.x 시절에 재현됐던 아래 현상들이 2.0.0에서도 동일하게 발생했던 것이다
- open()은 “재시도 대상 예외 발생 시점”과 상관없이 항상 한 번 호출
- canRetry()가 false인 경우라면 “재시도 횟수 소진”이든 “재시도 대상이 아닌 예외”든 모두 @Recover로 흘러갈 수 있음
→ 결론적으로 리스너 / @Recover 안에서 예외 타입을 한 번 더 분기해주어 처리하는 것을 알게되어 해당 방식으로 개선하였다.
@Retryable – retryFor / noRetryFor / notRecoverable 조합
좀 더 구체적으론, 토큰 갱신이 필요한 예외(401 Unauthorized)만 retryFor에 명시해주었고, “재시도는 하되 recover는 태우고 싶지 않은” 예외가 있다면 notRecoverable로 분리하였으며 나머지는 noRetryFor로 제외하여 설계하였다.

- Unauthorized → 재시도 + 필요 시 @Recover
- Forbidden → 재시도하지 않고 바로 @Recover
- IllegalStateException → 재시도도, @Recover도 없이 바로 호출자에게 던짐
마치며
사실 위에 나온 트러블 슈팅과 관련한 내용들은 이전에 선임분이 문서화 하셨던 내용을 참고할 수 있어서 빠르게 해결할 수 있었다고 생각한다. (왜 마이레이션을 다 안하는지도 알거같다) 물론 내가 구현한 방식이 아니여도, RetryListener로 open은 가볍게 해줘서 아래처럼도 구현해줄 수 있다고 한다
- open() : 로깅·트레이싱·컨텍스트 초기화 등 항상 1번 해도 괜찮은 작업만
- onError() : 예외 타입을 보고 토큰 갱신/모니터링 등 실제 사이드 이펙트 처리
- close() : 마지막 정리 작업
해당 내용도 정말 여러가지 방법이 있었구나를 나중에 깨닫게 되면서, 더 좋은 코드를 작성하기 위해서는 더 깊은 고민이 필요하다는 생각도 해볼 수 있었다. 그 생각은 이 공유 글을 작성하면서도 마찬가지로
해당 글을 보고 비슷한 고민을 하는 사람이 얻어갈 것이 있었으면 좋겠다
참고한 문서
- Spring Retry 2.0.0 Javadoc – RetryTemplate
https://www.javadoc.io/doc/org.springframework.retry/spring-retry/2.0.0/org/springframework/retry/support/RetryTemplate.html - Spring Retry 2.0.x API – @Retryable
https://docs.spring.io/spring-retry/docs/current/apidocs/org/springframework/retry/annotation/Retryable.html docs.spring.io - Spring Retry 2.0.x API – @Recover
https://docs.spring.io/spring-retry/docs/current/apidocs/org/springframework/retry/annotation/Recover.html docs.spring.io - Spring Retry 2.0.x API – RetryListener / RetryListenerSupport
https://www.javadoc.io/doc/org.springframework.retry/spring-retry/2.0.0/org/springframework/retry/RetryListener.html