재시도 패턴: 탄력적인 마이크로서비스 구축
마이크로서비스 아키텍처에서 서비스는 메모리 내 호출이 아닌 네트워크를 통해 통신합니다. 이러한 분리를 통해 대규모 수평 확장과 독립적 배포가 가능하지만 네트워크가 불안정합니다라는 주요 취약점도 발생합니다.
언제든지 다운스트림 서비스에서 짧은 네트워크 결함, 일시적인 CPU 스파이크, 빠른 데이터베이스 잠금 경합 또는 롤링 업데이트 다시 시작이 발생할 수 있습니다. 이러한 일시적인 오류를 일시적 오류라고 합니다.
다운스트림 호출이 실패하는 순간 서비스에서 즉시 오류가 발생하고 요청이 실패하면 취약한 사용자 환경이 조성됩니다. 대신, 이러한 일시적인 오류 중 상당수는 잠시 기다렸다가 다시 시도하면 자동으로 해결될 수 있습니다. 여기서 재시도 패턴이 사용됩니다.
이 가이드에서는 재시도 패턴, 내부적으로 작동하는 방식, 단순 구현의 위험성, Java(Resilience4j) 및 Go에서 이를 올바르게 구현하는 방법을 살펴보겠습니다.
실제 비유: 통화 중인 회선에 재다이얼하기
친구에게 전화를 걸려고 한다고 상상해 보세요. 당신이 그 사람의 번호로 전화를 걸었지만, 그 사람이 현재 다른 통화 중이기 때문에 통화 중 신호를 받습니다.
당신은 즉시 포기하고, 연락처를 삭제하고, 다시는 그들과 대화할 수 없다고 가정합니까? 물론 그렇지 않습니다. 전화를 끊고 잠시 기다린 후 다시 전화를 겁니다. 여전히 통화 중이라면 5분 정도 기다린 후 다시 시도하세요.
결국 통화가 종료되고 재시도가 성공합니다.
마이크로서비스에서:
- 호출은 다운스트림 서비스에 대한 API 요청입니다.
- 통화 중 신호는 일시적인 네트워크 오류 또는
503 Service Unavailable응답입니다. - 재다이얼은 재시도를 의미합니다.
- 대기 시간은 백오프 기간입니다.
위험: 순진한 재시도 및 “폭풍 재시도”
재시도 메커니즘을 구현하는 것은 언뜻 보기에는 사소해 보입니다. HTTP 호출을 for 루프로 래핑하고 성공할 때까지 계속 시도하면 됩니다. 그러나 순진한 재시도 구현은 사소한 문제를 시스템 전반에 걸친 치명적인 중단으로 쉽게 변형시킬 수 있습니다.
갑작스러운 트래픽 급증으로 인해 어려움을 겪는 다운스트림 서비스를 상상해 보십시오. 데이터베이스는 99%의 CPU 사용률로 실행되고 있으며 요청 시간 초과가 시작됩니다.
100개의 클라이언트 서비스가 모두 시간 초과를 감지하고 기다리지 않고 즉시 3번 재시도하면 이미 정체된 다운스트림 서비스에 도달하는 트래픽 양이 갑자기 3배로 증가합니다. 이러한 갑작스러운 트래픽 증폭을 재시도 폭풍(또는 Thundering Herd 문제)이라고 합니다.
다운스트림 서비스의 복구를 돕는 대신 재시도가 계속해서 다운스트림을 밀어서 따라잡는 것을 방지합니다.
솔루션: 백오프 및 지터
재시도 폭풍을 방지하려면 복원력이 뛰어난 시스템에서 백오프 및 지터라는 두 가지 필수 전략을 활용해야 합니다.
1. 백오프 전략
백오프는 클라이언트가 후속 재시도를 시도하기 전에 기다려야 하는 시간을 나타냅니다.
- 고정 백오프: 클라이언트는 시도 사이에 일정한 시간(예: 정확히 200ms) 동안 기다립니다. 단순하지만 여전히 동기화된 트래픽 급증의 위험이 있습니다.
- 지수 백오프: 시도가 실패할 때마다 대기 시간이 기하급수적으로 늘어납니다(예: 100ms, 200ms, 400ms, 800ms). 이렇게 하면 오류가 지속됨에 따라 다운스트림 서비스에 점진적으로 복구할 수 있는 시간이 더 많이 제공됩니다.
2. 지터(임의성)
지수 백오프를 사용하더라도 네트워크 문제로 인해 정확히 같은 순간에 1,000개의 요청이 실패하는 경우 1,000개의 클라이언트 모두 정확히 동일한 백오프 지연을 계산합니다. 결과적으로 그들은 모두 동시에 웨이브로 재시도하여 동기화된 스파이크로 다운스트림 서비스에 도달합니다.
지터는 백오프 지연에 무작위 변화를 추가하여 이 문제를 해결합니다.
Without Jitter (Synchronized Waves):
Time: 0ms -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]
With Jitter (Distributed Traffic):
Time: 0ms -> [1000 requests fail]
Time: 92ms -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)
무작위 간격으로 재시도를 분산함으로써 다운스트림 서비스의 로드가 완화되어 정상적으로 복구될 수 있습니다.
재시도의 황금률: 멱등성
API 엔드포인트에 재시도를 적용하기 전에 다음과 같은 중요한 질문을 던져야 합니다. 이 작업은 멱등적입니까?
멱등성 작업은 여러 개의 동일한 요청을 하는 작업이 단일 요청을 하는 것과 완전히 동일한 효과를 갖는 작업입니다.
- 멱등성: 사용자 프로필 읽기(
GET /users/123), 전체 이메일 필드 업데이트(PUT /users/123/email) 또는 항목 삭제(DELETE /items/456). - 비멱등성: 새 주문을 생성하거나(
POST /orders) 신용카드 청구를 처리합니다(POST /payments).
고객에게 $50를 청구하기 위해 POST /payments에 전화했다고 가정해 보겠습니다. 다운스트림 결제 게이트웨이는 요청을 받고 신용 카드에 성공적으로 요금을 청구하지만 200 OK 응답을 사용자에게 다시 보내기 전에 네트워크 문제가 발생합니다.
서비스는 시간 초과를 등록하고 호출이 실패했다고 가정하고 자동으로 재시도합니다. 결제 게이트웨이가 중복 요청을 처리하도록 설계되지 않은 경우 고객에게 요금이 두 번 청구됩니다.
[!경고] 다운스트림 서비스가 멱등성 키(중복 트랜잭션을 감지하고 삭제하는 데 사용되는 고유 요청 식별자)를 지원하지 않는 한 비멱등성 작업을 다시 시도하지 마세요.
구현 예
Java 및 Go에서 재시도 패턴을 구현하는 방법을 살펴보겠습니다.
1. 자바(Resilience4j & Spring Boot)
Resilience4j는 강력하고 고도로 구성 가능한 재시도 모듈을 제공합니다. 외부 청구 서비스에 대해 구성하는 방법은 다음과 같습니다.
구성(application.yml)
resilience4j.retry:
instances:
billingService:
maxAttempts: 3
waitDuration: 100ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2.0
enableRandomizedWait: true
randomizedWaitFactor: 0.5
retryExceptions:
- org.springframework.web.client.ResourceAccessException
- io.netty.channel.ConnectTimeoutException
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried
코드 구현
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class PaymentProcessor {
private final RestTemplate restTemplate;
public PaymentProcessor(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
// Apply the configured retry policy with a fallback method
@Retry(name = "billingService", fallbackMethod = "billingFallback")
public String chargeUser(BillingRequest request) {
return restTemplate.postForObject("http://billing-service/charge", request, String.class);
}
// Executed when all retry attempts fail
public String billingFallback(BillingRequest request, Throwable throwable) {
return "Billing service is currently unavailable. Your transaction will be queued.";
}
}
2. 고(고랭)
Go에서는 표준 라이브러리 타이머를 사용하여 지수 백오프와 무작위 지터를 특징으로 하는 우아한 재시도 실행기를 작성할 수 있습니다.
package main
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
)
// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
MaxAttempts int
MinBackoff time.Duration
MaxBackoff time.Duration
}
// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
var err error
for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
err = operation()
if err == nil {
return nil // Success!
}
if attempt == config.MaxAttempts {
break
}
// Calculate exponential backoff
// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
backoff := config.MinBackoff * (1 << (attempt - 1))
if backoff > config.MaxBackoff || backoff <= 0 {
backoff = config.MaxBackoff
}
// Apply Full Jitter: wait randomly between 0 and backoff
jitter := time.Duration(rand.Int63n(int64(backoff)))
fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)
select {
case <-time.After(jitter):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}
func main() {
config := RetryConfig{
MaxAttempts: 4,
MinBackoff: 100 * time.Millisecond,
MaxBackoff: 2000 * time.Millisecond,
}
// Mock function that fails 3 times and succeeds on the 4th
attempts := 0
mockAPI := func() error {
attempts++
if attempts < 4 {
return errors.New("network timeout (503)")
}
return nil
}
ctx := context.Background()
err := Execute(ctx, config, mockAPI)
if err != nil {
fmt.Printf("Final Outcome: %v\n", err)
} else {
fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
}
}
재시도 대 회로 차단기: 언제 어느 것을 사용해야 합니까?
개발자들은 재시도 패턴을 회로 차단기 패턴과 혼동하는 경우가 많습니다. 둘 다 시스템 복원력을 향상시키는 것을 목표로 하지만 근본적으로 다른 유형의 오류를 처리합니다.
| 미터법 | 재시도 패턴 | 회로 차단기 패턴 |
|---|---|---|
| 주요 목표 | 일시적(단기) 오류를 자동으로 복구합니다. | 지속적인(장기적인) 오류로 인해 호출자가 중단되는 것을 방지합니다. |
| 전략 | 즉시 다시 시도하거나 잠시 백오프를 기다린 후 다시 시도하세요. | 대상 서비스를 호출하지 않고 즉시 Fail-Fast를 수행합니다. |
| 하류 영향 | 다운스트림 서비스의 부하를 일시적으로 증가시킵니다. | 다운스트림 서비스를 트래픽으로부터 보호하여 복구할 수 있도록 합니다. |
| 일반적인 트리거 | 짧은 네트워크 연결 끊김, 데이터베이스 시간 초과, 소켓 삭제. | 다운스트림 서비스가 완전히 다운되어 연속 500초 또는 시간 초과가 반환됩니다. |
강력한 커플: 두 가지를 결합
프로덕션 시스템에서 이 두 패턴은 함께 사용되도록 설계되었습니다.
다운스트림 요청을 할 때 먼저 회로 차단기를 통과한 다음 재시도 래퍼를 통과해야 합니다.
일시적인 결함이 발생하면 재시도 메커니즘이 이를 가로채서 재시도합니다. 그러나 다운스트림 서비스가 중단되면 오류가 쌓이게 됩니다. 회로 차단기는 연속적인 오류를 감지하여 “트립"을 열고 이후의 모든 호출을 차단합니다.
이제 서비스 호출을 시도하면 회로 차단기가 즉시 빠르게 실패하여 재시도 루프를 완전히 우회하고 컴퓨팅 리소스를 절약합니다.
재시도 패턴 모범 사례
- 일시적인 오류만 재시도: HTTP 상태 코드를 검사합니다.
503 Service Unavailable,429 Too Many Requests(있는 경우Retry-After헤더 고려) 및 네트워크 시간 초과를 다시 시도하세요.400 Bad Request,401 Unauthorized또는404 Not Found을(를) 재시도하지 하지 마세요. 재시도에서는 절대 성공하지 못합니다. - 지터 적용: 임의 지터를 도입하지 않고 고정 재시도 타이머를 사용하지 마십시오.
- 최대 시도 횟수 제한: 합리적인 임계값(일반적으로 3~5회 시도) 후에 재시도를 중지합니다. 무제한 재시도는 리소스를 소비하고 대기 시간을 단축합니다.
- 계단식 재시도에 주의하세요: 서비스 A가 서비스 B에 전화하고(3회 재시도) 서비스 B가 서비스 C에 호출하는 경우(3회 재시도) C에서 오류가 발생하면 $3 \times 3 = 9$ 연속 호출이 발생할 수 있습니다. 가장 적합한 경계에서만 재시도를 적용하십시오.
- 항상 엄격한 시간 초과 설정: 네트워크 시간 초과가 백오프 기간보다 짧아야 스레드가 무기한 보류되지 않습니다.
결론
재시도 패턴은 마이크로서비스 아키텍처의 네트워크 불안정성에 대한 강력한 첫 번째 방어선입니다. 지수 백오프, 지터 및 회로 차단기와 함께 사용하면 연속적인 중단으로부터 시스템을 보호하고 사용자를 위한 원활한 자가 복구 환경을 만들 수 있습니다.