Resilience4j, Spring Retry 재시도 패턴 구현
이 글에서는, Resilience4j 및 Spring Retry 라이브러리를 사용해서 "재시도 패턴"을 구현한다.
목차
1. Resilience4j 를 사용해서 구현
2. Spring Retry 를 사용해서 구현
3. (필자에게) 재시도 패턴 구현이 어려운 이유
이 글을 읽기 적합한 개발자는 아래와 같다.
- 스프링부트 애플리케이션 환경에서 "실패에 대한 재시도 로직"을 구현해야하는 개발자
- 그냥 심심한 개발자
이 글을 읽기 조금 애매한 개발자는 아래와 같다.
- 스프링부트를 전혀 모르는 개발자
- 재시도 로직이 필요 없는, 완벽한 시스템을 개발/운영 하는 개발자
예제는 스프링부트 & 자바 샘플 코드라서, 그 외 개발자는 도움이 되지 않을 것이다. 그리고 Resilience4j 라이브러리는 재시도(Retry) 기능만 사용하는 경우는 거의 없고, 대부분 서킷 브레이커와 같이 사용할 것이다. 즉, 필자의 1장 Resilience4j 관련 샘플 코드는 중요하지 않다.
필자의 허접한 샘플 코드는 중요하지 않으니, 그냥 2장 부터 대충 읽어보면 된다.
이번 주 좀 피곤해서 주말에 쉬고 싶었지만, 블로그 발행 약속을 미리 해놔서 의무적으로(?) 빠르게 대충 작성했다. 막상 글을 쓰고나니, 재시도 패턴에 대한 필자의 생각이 조금은 정리가 되어서 개인적으로도 도움이 되었다. 이 글을 읽는 개발자들에게도 조금이라도 도움이 되길 바라며, 내용이 살짝 허접할 수 있으니, 필요한 내용만 골라서 잘 읽어보길 바란다.
1장에서는, Resilience4j Retry 에 대해서 살펴볼 예정이지만, 그 전에 C# 샘플 예제를 먼저 살펴보자. 필자가 작성한 코드는 아니다. MSDN 에서 참고하였다.
For 루프를 주목해서 보자. try/catch 블록으로 예외를 처리하며, 비즈니스 로직을 수행하는 TransientOperationAsync 메서드를 실행한다. 비즈니스 로직이 성공하면 For 루프가 종료된다. 반면에, 비즈니스 로직에 해당하는 TransientOperationAsync 메서드가 실패하면 currentRetry 를 +1 증가시킨 후, For 루프에 의해서 재시도를 실행한다. 재시도는 바로 실행하지 않고, Task.Delay 구문에 의해서 5초의 딜레이 시간을 준다. 또한, currentRetry 숫자가 3회 초과하면 throw 를 발생시킨다. 위 코드에서 가장 중요한 구문은 아래와 같다.
- 재시도를 몇번 실행할 것인지? 3회
- 재시도를 하기 전에 지연시간을 어떻게 줄 것인지? 5초
이 글에서는 위 두가지에 대해서 지겹도록 얘기할 예정이다.
Java 코드로 작성해도 위 코드와 유사하게 작성할수 있다. 하지만, 매번 위와 같이 재시도 구문을 모든 소스코드에 작성하면, 애플리케이션이 너무 지저분해질 수 있다. AOP 를 사용해서 재시도 로직을 공통 모듈로 개선해보자.
먼저, resilience4j-retry 모듈을 사용하기 위해서 아래와 같이 디펜던시 의존성을 추가해준다.
아래와 같이 @CustomRetry 라는 이름의 AOP 어노테이션을 정의한다.
@CustomRetry 어노테이션은 재시도를 수행할 메서드 상단에 선언해준다.
그리고, AOP Aspect 코드를 작성한다.
필자의 허접한 소스 코드에 대한 자세한 설명은 생략한다.
응용서비스 레이어에 실패하는 메서드를 개발하자. randomFail 라는 이름의, 랜덤하게 실패하는 메서드를 작성하였다. 그리고, @CustomRetry 어노테이션을 메서드 상단에 선언해서, 해당 메서드에서 실패가 발생하는 경우, 재시도를 수행하도록 선언하였다. 해당 메서드는, 랜덤 값이 0.5 이하인 경우 실패-재시도 가 발생한다.
간단하게 RestController 에서 호출해보자.
0.5 초 이하인 경우 실패하는 로직이다. 만약 재수없게 3번 연속 0.5초 이하가 발생하면, 4번째 재시도를 실행하지 않고, 재시도를 종료한다. 아래와 같이 로그를 보면, 최대 3회 수행하였고, 매번 5초의 지연 시간을 주었다.
- 재시도를 몇번 실행할 것인지? 최대 3번
- 재시도를 하기 전에 지연시간을 어떻게 줄 것인지? 5초
재시도가 필요한 메서드에 @CustomRetry 어노테이션만 선언해주면 된다. 하지만, 모든 메서드가 동일한 재시도 조건(총 재시도 수행 횟수, 지연시간)을 갖지는 않을 것이다. 조금 유연하게 코드를 개선해보자. @CustomRetry 어노테이션에, 최대 재시도 횟수 와 지연 시간을 설정할 수 있도록 필드를 추가해준다.
사실... 재시도 횟수, 지연시간 도 중요하지만, 어떤 Exception 이 발생했을 때 재시도를 하는지에 대해서도 설정해주는게 좋다. 해당 내용은 글 후반의 Spring Retry 에서 더 자세히 다루겠다. 어쨋든, AOP 코드에서도, 어노테이션에 의해 설정된 값으로 동작하도록 아래와 같이 변경한다.
재시도를 실행하는 메서드에서 재시도 최대 횟수와 지연시간을 아래와 같이 정의해야 한다.
자, 아주 심플하게 Resilience4j 를 사용해서, 재시도 패턴을 구현하였다.
샘플코드
https://github.com/sieunkr/retry-pattern/tree/master/resilience4j-retry
레퍼런스
https://resilience4j.readme.io/docs/retry
스프링부트에서는 좀 더 심플하게 구현할 수 있다. 아래와 같이 디펜던시 의존성을 추가한다.
application.yml 파일에, 재시도를 위한 속성 값을 설정한다. 재시도 최대 횟수, 지연시간을 설정해야 한다. 아래와 같은 설정은, 스프링부트 AutoConfiguration 에 의해서, 애플리케이션 실행시 자동으로 컨피그 설정이 될 것이다. (스프링부트에 대해서 이미 경험이 있다라는 가정하에 상세한 설명은 생략한다. )
그리고, Resilience4j 라이브러리의 @Retry 어노테이션을 선언해주고, 반드시 name 을 지정해줘야 한다.
@Retry 어노테이션에는, fallBack 메서드를 지정할 수도 있지만, 필자의 샘플 코드에서는 생략하였다. 자세한 내용은 생략한다. 레퍼런스를 참고하길 바란다.
https://resilience4j.readme.io/docs/getting-started-3
Resilience4j 는 서킷 브레이커 용도로 많이 사용하는 것으로 알고 있다. 오로지, Retry 만을 위해서 사용하는 경우에 대해서는 필자가 들어본 적은 없다. 서킷 + Retey 로 구현된 좋은 사례가 있다면 꼭 찾아보길 바란다.
샘플코드
https://github.com/sieunkr/retry-pattern/tree/master/resilience4j-retry-springboot
Spring Retry 에 대해서 알아보자. 사실... 2장도 별 내용은 없다...
아래와 같이, Spring-Retry 디펜던시 의존성을 추가해준다.
Spring Retry 기능을 사용하기 위해서는, @EnableRetry 를 선언해야 한다.
사용 방법은 매우 간단하다. @Retryable 어노테이션을 사용하면 된다.
그리고, FallBack 처리를 할 수 있는 기능을 제공하는데, @Recover 어노테이션을 사용하면 된다.
Recover 메서드의 반환 타입은 반드시 맞춰야 하는데, randomFail 메서드의 반환은 double 이기 때문에, Recover 메서드 역시 double 로 해주다. 암튼, 필자의 재시도 설정에 의해서 최대 3번 실패하면 recover 메서드가 실행된다. 비즈니스 도메인(요구사항)에 맞게 개별적으로,
임시의 값을 리턴해줄지,
exception 을 throw 할지
선택해서 잘 구현하면 된다.
- 재시도를 몇번 실행할 것인지? 최대 3번
- 재시도를 하기 전에 지연시간을 어떻게 줄 것인지? 5초
- 재시도가 발생하는 Exception 은? CustomRuntimeException
- 재시도 최대 회수가 넘어서면 어떻게 처리할지? @Recover 에서 처리
Spring Retry 는 @Retryable 어노테이션을 사용하는 방법 외에, RetryTemplate 를 사용할수도 있다. @Configuration, @Bean 어노테이션을 사용해서 RetryTemplate Bean 을 정의해준다. 이번에는 재시도 지연 시간을 1초로 하고, 최대 수행 회수를 5회로 설정해보자.
아래와 같이 retryTemplate 를 사용할 수 있다. 상세한 설명은 생략한다....
위 코드는, 람다식으로 아래와 같이 변경할 수도 있다.
상세한 설명은 생략한다...
https://github.com/sieunkr/retry-pattern/tree/master/spring-retry
구현 자체는 어렵지 않다. 하지만, 재시도 로직 구현은 정말 난이도가 높다고 생각한다. 필자처럼 개발을 잘 못하는 경우에는 더더욱 주의가 필요하다.
솔직히,
어설프게 구현한 재시도 로직 보다는,
차라리 재시도를 하지 않고, 로그를 잘 쌓고 추후에 처리하는게 더 좋은 경우도 있다.
어떻게 설명하면 좋을지... 쉽지 않지만...
혹시, 이후 필자의 3장 내용이 이해가 잘 되지 않는다면, 아래 링크를 읽어보길 바란다. ^^;
https://docs.microsoft.com/ko-kr/azure/architecture/patterns/retry
https://aws.amazon.com/ko/builders-library/timeouts-retries-and-backoff-with-jitter/
필자에게 마이크로서비스 환경에서의 재시도 패턴 구현은, 생각처럼 쉽지 않았다...ㅠㅠ 지금도 너무 어렵다. 다른 서버의 Rest API 를 호출하는 로직이 있다고 가정하자.
Spring Retry 를 사용해서 아래와 같이 구현할 것이다.
그런데, 아래와 같이 B 서버에서 장애가 발생하였다. 그래서 500 에러가 발생하고 있다.
일시적인 네트워크 순단 현상인 경우에는, 재시도 패턴으로 쉽게 해결될 수는 있다. 하지만, B 서버의 상태가 매우 심각하면 어떻게 될까? 수많은 재시도에 의해서 B 서버는 회생 불능 상태가 될 수 있다. 게다가 일반적으로 서버는 1대가 아니다. 수많은 서버에서 요청이 끊임없이 들어오면, B 서버는 살아날수가 없다.
물론, 일반적으로 이런 경우에는 중간에 "서킷 브레이커"를 구현하면, 장애 전파를 최소화할수는 있다.
이 글에서는, 서킷 브레이커 에 대해서는 다루지 않겠다.
어쨋든, 그래서 우리는... 재시도 패턴을 도입할 때 반드시 고려해야할 사항이 있다.
- 최대 몇번까지 재시도를 할 것인가?
- 재시도 전에 지연시간을 얼마로 할 것인가?
B 서버가 언제 살아날지 모르는 상황에서, B 서버에 무한적으로 재시도를 수행하는 것은 바람직하지 않다. 또한, 지연 시간 없이 바로 재시도를 호출하는 것은, B 서버에게 회복할 시간을 줄 수 없게 될수 있다. B 서버는 시간이 필요하다. 그래서, A 서버에서는 재시도에 대한 최대 회수를 제한해야 하며, 재시도하기 전에 지연시간을 줘야 한다. 물론, 서킷브레이커 가 있다면 더 좋을 것이다. (요 내용은 이 글에서는 생략)
위 두가지 고려할 점 외, 재시도 패턴에서 고려해야할 사항이 또 있다.
필자가 예전에 구축했던 플랫폼에서, 외부 API 를 호출하는 신규 기능을 개발하게 되었다. 외부 API 는 Rest API 로 구축되어 있었다. 근데, 이 시스템은 중간에 API GateWay 를 통해서 호출해야 하는데, A 와 B 개발자는 타임아웃 시간을 7초로 협의하였다. 하지만, 중간에 위치한 게이트웨이에서의 타임아웃 설정은 5초였다. (나중에 한참후에 장애가 발생한 이후 알게 되었다.....)
B 모듈의 개발자에게 최대 7초 정도 응답이 느릴수 있다는 얘기를 듣고, 필자는 A 모듈의 FeignClient 타임아웃을 7초로 설정하였다. 그런데, API GateWay 에서 B 로부터의 응답을 5초 동안 받지 못하면, 중간에 요청을 끊어버리고, A에 타임아웃 응답을 보내버린다. 하지만, B에서는 7초까지는 해당 요청에 대해서 처리한다. 비록, API GateWay 에서 연결을 끊어버렸기 때문에, B는 A 에 처리결과를 전달할 방법이 없었다. A는 B가 실패했다고 판단해서(실제로는 성공하였지만), 재시도 요청을 시도한다. 실제로는 B 로 보낸 첫번째 요청은 잘 수행하였기 때문에, 이런 경우에는 중복 처리가 되어버린다.
아.......... ㅠㅠ
일단, API Gateway 에서의 TimeOut 시간을 7초로 조정할 수 있는지 검토하였다. 물론, 7초나 걸리는 느린 API 자체가 문제이긴 했다. 왜이리 오래 걸리지... 어쨋든, A에서는 B 장애 발생 시 재시도를 바로 보내지 않고, 지연시간을 설정해서 잠시 후 호출하도록 개선하였다. (물론, 너무 오랜 대기시간은 쓰레드풀의 부족이 발생할 수 있기 때문에 적당한 지연시간으로 설정하는게 중요하다.) 또한, 재시도를 하기 전에, 바로 전 요청이 잘 처리 되었는지 확인하는 중복 처리 여부 API 호출을 추가하였다. 바로 전 요청이 잘 처리되었는지 확인하기 위해서, 트랜잭션 ID 를 정의해야 했다. 트랜잭션 ID 는 A 에서 정의하지만, B 에서도 트랜잭션 ID 를 체크해서, 중복 처리인지 사전에 검증을 하는 로직이 추가되었다. 여러가지 방어로직을 추가하였는데, 그럼에도 불구하고 아주 간헐적으로 중복 처리가 가끔 발생하였다. 아마도 필자의 추측으로는 B 에서 중복처리를 제대로 하지 못하는 것으로 생각이 되었지만, B 는 우리팀이 아니라, 다른 팀이기 때문에, 커뮤니케이션이 쉽지 않은 상황이었다. (더 상세한 얘기는 회사 업무라서 생략하겠다. 퇴사해서 기억도 잘 안난다.)
암튼, 이런저런 경험을 하게 되니, 재시도 패턴에 대해서 많은 생각을 하게 된다. 쉽지 않다. 차라리, 재시도를 하지 않고, 로그를 잘 쌓는게 좋은 경우도 있을 것이다. 즉, 실패한 건에 대해서만 정해진 시간에 배치를 돌려서 재처리를 하는 로직을 구현할 수도 있다. 아니면, 메시지 브로커를 사용해서, HTTP 통신의 단점을 메시지 통신으로 보완해서 처리하는게 좋을수도 있겠다. 즉, 실패에 대한 재시도 처리를 위해서 메시지 큐를 활용하는 것이다. 이런저런 다양한 방법이 있을 것이다. 위 사례는 단지 하나의 사례를 설명했을 뿐, 예상하지 못한 더 많은 케이스가 있을 것이고, 그에 대한 더 많은 해결책, 구현 사례가 있을 것이다.
재시도 패턴 경험에 대한, 경험 및 의견이 있으시면 꼭 댓글 부탁드립니다...
이번 글에서는, 재시도 패턴에 대해서 설명하였다. Resilience4j, Spring Retry 두가지 라이브러리를 사용하였다. 하지만, 어떤 라이브러리를 사용하는지가 중요하다고 생각하진 않는다. 어떤 라이브러리를 사용하든지, 재시도 패턴이 왜 필요한지, 재시도 패턴 시 최대 횟수를 몇번으로 할지, 지연시간은 어떻게 할지 등 재시도에 의해서 발생할 수 있는 영향도에 대해서 꼼꼼하게 검토하고 구현하는게 중요할 것이다. 이만 글을 마치겠다. 끝..