ocwokocw 2023. 10. 31. 23:27

- 출처: https://learn.microsoft.com/en-us/azure/architecture/patterns/retry

 

Retry pattern - Azure Architecture Center

Learn how to use the Retry pattern to enable an application to handle anticipated, temporary failures when the app tries to connect to a service or network resource.

learn.microsoft.com

 

- 개요

Application이 서비스나 네트워크 리소스에 접속할 때, 실패한 연산을 재시도 함으로써 일시적인 오류를 다룰 수 잇게 해주는 패턴이다. Application의 안정성을 향상 시키는 패턴이다.


- Context and problem

클라우드 환경에서 다른 요소와 통신할 때 일시적으로 발생하는 오류는 피할 수 없기 때문에 application은 이를 적절히 다루어야 한다. 이에 해당하는 오류에는 컴포넌트나 서비스에 대한 네트워크 접속이 유실되거나 일시적으로 서비스가 사용 불능 상태에 빠지는 경우 또는 서비스에 부하가 걸려서 timeout이 발생하는 경우 등이 있다.

 

이런 오류들의 특징은 자가 회복이 된다는점인데 오류를 일으킨 action을 일정 간격을 두고 재시도하면 성공할 가능성이 높다. 예를 들어 많은 병렬 요청을 받는 DB 서비스의 경우 throttling 전략을 사용하여 감당가능한 부하 수준을 넘어서면 일시적으로 요청을 거부하도록 구현할 수 있다. 이런 상황에서 application이 처음 DB 서비스 접근에 실패할 수 있는데, 일정 간격 후에 재시도하면 성공할 가능성이 높다. 


- Solution

클라우드 환경에서 일시적인 오류는 드문일이 아니기 때문에 application은 이를 우아하고 투명하게 다룰 필요가 있다. 이렇게 하면 application이 수행하는 비즈니스 작업에 미치는 오류를 최소화할 수 있다.

 

요청을 원격 서비스에 보낼 때 오류가 발생하면 아래 전략 중 택1 하여 처리한다.

  • Cancel: 만약 오류가 일시적이지 않거나 반복 요청한다고해도 성공할것 같지 않다면 연산을 취소하고 예외를 발생시켜야 한다. 예를 들어 인증 실패와 같은 오류는 재시도 한다고해서 성공할만한 종류의 에러는 아니다.
  • Retry: 발생한 오류가 전송중 네트워크 패킷이 유실된것과 같이 일반적이지 않은 경우가 있다. 이런 경우에는 같은 오류가 발생할 가능성이 적으므로 즉시 재시도하는 전략을 취할 수 있다.
  • Retry after delay: 오류가 일반적인 연결 또는 부하 문제에 의해 발생했다면 네트워크나 서비스는 연결 이슈가 해결되거나 작업을 해결하기 위해 잠깐의 시간이 필요할 수 있다.

더 일반적으로 발생하는 일시적인 오류들의 경우 재시도 간격은 가능한 균등하게 application의 여러 인스턴스로 요청이 분산될 수 있도록 설정되어야 한다. 그래야 서비스에 부하가 계속 걸리는 상황을 줄일 수 있다. 만약 application의 많은 인스턴스가 계속 재시도 요청으로 서비스에 부하를 가한다면 서비스는 회복되는데 더 오랜 시간이 걸릴것이다.

 

만약 요청이 계속 실패하면 application은 기다리거나 다른 시도를 할 수 있다. 필요한 경우 최대 요청수가 시도될때까지 재시도 간격 delay를 늘리면서 이 과정을 반복할 수 있다. Delay를 선형적으로 혹은 지수적으로 증가시키는 방식 중 어떤 것을 선택해야 하느냐는 실패 유형과 해당 시간동안 문제가 해결될 가능성등을 종합해서 결정한다.

 

Application은 위에 나열된 전략 중 하나와 일치하는 재시도 정책을 구현하는 코드에서 원격 서비스에 접근하려는 부분에 적용해야 한다. 다양한 서비스로 전송되는 요청들은 서로 다른 전략이 적용될 수 있다. 어떤 vendor 들은 재시도 정책을 구현하는 라이브러리를 제공하는데, application이 재시도의 최대 횟수나 재시도 사이의 간격, 그 외의 parameter를 제공하는등 다양한 설정을 제공한다.

 

Application은 오류와 연산 실패에 대한 로그를 자세하게 남겨야 한다. 이런 정보는 시스템 운영에 매우 유용하다. 쓸데없는 알람이 넘쳐나는것을 방지하려면 마지막 시도 이전까지의 실패는 info 수준의 log로 남겨두고 마지막 시도에 실패했을 때만 실패 오류로 기록하는것이 합리적이다.

 

서비스가 자원을 모두 소모하여 사용 불능상태이거나 부하가 걸리는 경우가 있다. 이런 경우 서비스를 scale-out 하여 이런 오류의 빈도를 줄일 수 있다. 예를 들어 만약 DB 서비스가 계속해서 부하상태인 경우 파티셔닝을 하면 부하가 여러 서버로 분산되는 효과가 있다.


- Issues and considerations

이 패턴을 사용할때는 아래 사항을 고려한다.

  • Retry 정책은 application 비즈니스 요구사항과 오류의 특성을 고려해야 한다. 치명적인 연산이 아닌 경우에는 application의 처리량을 위해 재시도를 많이 하는대신 빠른 실패를 하는것이 더 나은 경우도 있다. 예를 들어 원격 서비스에 접근하는 반응형 웹 application 의 경우, 간격을 짧게 하고 재시도 횟수도 적게하여 실패했음을 빠르게 판단하고 유저에게 "다시 시도해주세요"와 같은 적절한 메시지를 뿌려주는것이 UI/UX에 더 긍정적인 영향을 미칠 수 있다. 반대로 Batch 성 application의 경우 재시도 간격을 지수적으로 증가시키고 재시도 횟수도 늘리는 편이 더 나을수도 있다. 
  • 재시도 간격을 짧게 하고 많은 횟수를 시도하는 공격적인 정책을 취하면 service에 부하가 많이 걸리는 상황에서 더 나쁜 영향을 줄 수 있다. 이런 재시도 정책은 실패하는 연산을 계속 시도할 경우 application 응답성에도 영향을 줄 수 있다.
  • 많은 재시도 후에도 요청이 여전히 실패하는 경우 같은 리소스에 대해 요청을 방지하고 오류를 즉시 발생시키는편이 application에 좋다. 기간이 만료되면 application은 성공 여부를 확인하기 위해 하나 이상의 요청을 잠정적으로 허용할 수 있다. (자세한 사항은 circuit breaker 패턴을 참조)
  • 연산이 멱등성을 갖는지 고려해야한다. 만약 그렇다면 재시도 연산은 안정적이라고 할 수 있다. 그렇지 않으면 재시도시 연산이 1번 이상 수행되어 원치 않은 결과를 초래할 수 있다. 예를 들어 서비스가 요청을 받을 때 요청을 성공적으로 수행하고나서 응답에 실패할 수 있다. 이때 재시도 로직에 의해 요청이 다시 전달되면 처음 요청을 받지 못했다고 간주할 수 있다.
  • 서비스에 대한 요청은 오류의 특성에 따라 다양한 예외가 발생하므로 이유 또한 다양하다. 일부 예외는 빠르게 해결될 수 있지만 그렇지 않은 예외들도 있다. 예외 유형에 따라 재시도 횟수 간격을 적절하게 설정하는것도 고려해봄직하다.
  • 트랜잭션의 일부분에 대한 연산을 재시도하면 전체 일관성에 영향을 줄 수 있으므로 이를 고려해야 한다. 트랜잭션 연산에 대한 적절한재시도 정책은 성공 확률을 높이고 트랜잭션의 모든 단계를 취소해야하는 상황을 줄여준다.
  • 모든 재시도 코드가 실패 상태에 대해 완벽하게 테스트되었는지 확인해야 한다. Application의 성능과 신뢰성에 심각한 영향을 주지 않는지 확인해야 하고, 서비스나 자원에 추가적인 부하를 일으키진 않는지 또는 경쟁 상태나 병목 현상을 일으키지 않는지도 확인해야한다.
  • 실패 연산의 모든 문맥을 이해한 상태에서 재시도 로직을 구현해야 한다. 예를 들어 만약 특정 작업이 다른 작업을 호출하고 다른 작업이 재시도 정책을 갖고 있다면 절차가 수행되는데 지연이 발생할 수 있다. 하위 수준의 작업(특정 작업으로 부터 호출 당한 작업)은 빠르게 실패하도록 구성하고, 해당 작업을 호출하는 호출자에게 실패에 대한 이유를 빠르게 알려주는것이 더 좋은 방법이 될 수 있다. 고 수준의 작업(특정 작업을 호출한 작업)은 자신의 정책에 기반하여 실패를 다룰 수 있다. 
  • Application, 서비스, 리소스의 근본 원인을 파악할 수 있도록 재시도를 발생시키는 실패에 대해 로그를 남기는것이 중요하다.
  • 오류들이 오래 지속되는지 파악하기 위해 서비스나 리소스에서 발생할 수 있는 오류들을 파악하는것이 좋다. 만약 오래 지속되는 상황이면 오류를 예외로 다룬다. Application은 예외를 로깅 및 반환하고 이용가능한 대체 서비스가 있따면 이를 실행하거나 일부 기능을 포기해야 한다.

- When to use this pattern

재시도 패턴은 원격 서비스나 자원과 통신할 때 일시적인 오류를 겪는 경우 사용을 고려할 수 있다. 이런 오류들은 일시적인 경우가 많으므로 로 실패한 요청을 반복하여 결과적으로 요청을 성공시킬 수 있다.

 

그러나 만약 아래와 같은 상황이라면 재시도 패턴을 사용하지 않는것이 좋다.

  • 오류가 오래 지속될것 같은 경우에는 Application 응답성에 영향을 줄 수 있다. 실패할것 같은 요청을 반복하는 행위는 시간과 자원을 낭비하는 지름길이다.
  • 예를 들어 application 비즈니스 로직에서 발생하는 오류때문에 내부적인 예외가 발생하는것과 같이 일시적인 오류가 아닌 경우
  • 시스템의 확장성 문제를 해결하는 대안으로 사용하는 경우. 만약 application 부하로 인해 오류가 자주 발생한다면 접근중인 서비스나 리소스가 확장되어야한다는 신호일 수 있다.

- Golang example

이번 예제에서 사용할 library는 exponential backoff 라이브러리(https://github.com/cenkalti/backoff) 이다.

 

우선 Example을 먼저 살펴보자.

func TestRetry(t *testing.T) {
   i := 0
   operation := func() error {
      i += 1
      fmt.Printf("try the %v th operation\n", i)
      if i == 10 {
         return nil
      }
      return fmt.Errorf("dummy error")
   }
   notify := func(err error, t time.Duration) {
      fmt.Printf("%v th call duration: %v\n", i, t)
   }
   err := backoff.RetryNotify(operation, backoff.NewExponentialBackOff(), notify)
   require.NoError(t, err)
}

 

Notify가 없는 기본적인 Retry 함수도 제공하지만 결과를 더 명료하게 보기 위해 notify 기능도 제공하는 RetryNotify 함수를 사용하였다.

 

함수의 첫번째 인자로는 func() error 형의 함수를 받는다. 연산할 함수를 넘겨준다. 2번째 인자로는 ExponentialBackOff 설정값들을 넘겨준다. 설정값은 아래와 같다. 3번째 인자인 notify 함수는 operation에서 nil이 아닌 error를 반환하면 실행된다.

InitialInterval:     DefaultInitialInterval,
RandomizationFactor: DefaultRandomizationFactor,
Multiplier:          DefaultMultiplier,
MaxInterval:         DefaultMaxInterval,
MaxElapsedTime:      DefaultMaxElapsedTime,
Stop:                Stop,
Clock:               SystemClock,

 

재시도할 때 "3초 마다 재시도"와 같이 단순한 정책을 구사하면 몇 가지 문제점이 있다. 우선 재시도를 여러 번 해도 실패를 했다는것은 원격 서비스나 리소스가 오류를 복구하는데 오래걸릴 가능성이 크다는 얘기가 된다. 이럴 때는 exponential 로 이전에 재시도한 간격보다는 더 길게 기다려보고 재시도를 하는것이 합리적이다. 위의 함수에서는 Multiplier가 그 역할을 한다.

 

또 한 가지 고려할점은 exponential을 적용하여 1초 -> 2초 -> 4초 -> 8초 와 같이 재시도를 할 때, 병렬 요청이 똑같은 순간에 원격 서비스나 리소스에 부하를 준다는것이다. 가능하면 부하를 균등하게 분배해야하는데 똑같은 시점에 부하가 한꺼번에 가지 않도록 RandomizationFactor를 적용한다.

 

위의 코드를 실행하면 출력은 아래와 같다.

1 th call duration: 627.354251ms
try the 2 th operation
2 th call duration: 642.870173ms
try the 3 th operation
3 th call duration: 1.153788554s
try the 4 th operation
4 th call duration: 851.326331ms
try the 5 th operation
5 th call duration: 2.013269682s
try the 6 th operation
6 th call duration: 3.078053793s
try the 7 th operation
7 th call duration: 6.511825652s
try the 8 th operation
8 th call duration: 5.201080371s
try the 9 th operation
9 th call duration: 8.112972092s
try the 10 th operation

 

이외에도 추가적인 기능을 제공하므로 관심이 있다면 https://pkg.go.dev/github.com/cenkalti/backoff/v4 를 읽어보길 바란다.