재시도 전략 비교: Constant vs Exponential Backoff vs Jitter in Go (시뮬레이션 포함)

발행: (2026년 2월 1일 오전 03:42 GMT+9)
14 min read
원문: Dev.to

I’m happy to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source line at the top exactly as it is and preserve all formatting, markdown, and code blocks.

천둥 무리 문제

서버가 다운됩니다. 이미 1 000명의 클라이언트가 대기하고 있습니다. 서버가 다시 살아나면 어떻게 될까요?

초당 200개의 요청을 처리할 수 있는 서버를 상상해 보세요. 의존 서비스가 약 10 초 동안 다운되어 모든 진행 중인 요청이 실패하고, 모든 클라이언트가 재시도를 시작합니다.

서버가 복구되면 1 000명의 클라이언트가 한 번에 재시도합니다. 이것을 thundering herd problem이라고 하며, 여러분의 재시도 전략에 따라 서버가 몇 초 만에 복구될 수도 있고, 불필요한 요청의 물결에 휩쓸려 버릴 수도 있습니다.

대부분의 엔지니어는 지수 백오프(exponential backoff)를 사용해야 한다는 것은 알고 있지만, 여전히 동기화된 급증을 일으킬 수 있다는 점을 잘 모릅니다. 그리고 decorrelated jitter가 실제로 요청 분포 곡선에 어떤 영향을 주는지 본 사람은 더 적습니다.

내가 만든 것

나는 1 000개의 동시 Go 클라이언트, 고정 용량 서버, 그리고 복구를 위해 경쟁하는 네 가지 재시도 전략의 시뮬레이션을 만들었습니다. 이 시뮬레이션은 실제 운영 시나리오를 모방합니다:

  • 고정된 서버 용량
  • 심각한 장애 기간
  • 성공할 때까지 계속 재시도하는 클라이언트

각 요청은 초 단위로 추적되므로, 급증하는 요청(‘thundering herd’)이 형성되고 정점에 도달하며 (가능하면) 사라지는 과정을 볼 수 있습니다.

전략 시그니처

모든 전략은 동일한 시그니처를 공유합니다:

type Strategy func(attempt int, prevDelay time.Duration) time.Duration

재시도 전략

1. 고정 재시도 (백오프 없음)

// Fixed delay, no backoff.
func constantRetry(_ int, _ time.Duration) time.Duration {
    return 1 * time.Millisecond
}

Formula: delay = 1 ms (constant)

최악의 경우 패턴: 백오프가 없는 아주 짧은 고정 지연. 엔지니어가 백오프를 고려하지 않고 간단히 재시도 루프를 작성했을 때 발생한다.

2. 지수 백오프

// Double the delay on each attempt, starting at 100 ms and capped at 10 s.
func exponentialBackoff(attempt int, _ time.Duration) time.Duration {
    d := time.Duration(float64(baseSleep) * math.Pow(2, float64(attempt)))
    if d > capSleep {
        d = capSleep
    }
    return d
}

Formula: delay = min(base * 2^attempt, cap)

고정 재시도보다 나은 방법이지만 함정이 있다. 1 000개의 클라이언트가 모두 동시에 시작하면, 모두가 t = 100 ms에 시도 1, t = 300 ms에 시도 2, t = 700 ms에 시도 3 등으로 동일한 시점에 재시도한다.

3. 전체 Jitter

// Randomize the delay between 0 and the exponential backoff value.
func fullJitter(attempt int, _ time.Duration) time.Duration {
    d := time.Duration(float64(baseSleep) * math.Pow(2, float64(attempt)))
    if d > capSleep {
        d = capSleep
    }
    return time.Duration(rand.Int63n(int64(d)))
}

Formula: delay = random(0, min(base * 2^attempt, cap))

이 전략은 AWS Architecture Blog에 소개되어 있으며 일반적으로 가장 적은 총 호출 수를 만든다. 각 클라이언트가 무작위 지연을 선택하므로 더 이상 동시에 재시도하지 않는다. 1 000개의 클라이언트가 모두 한 번에 서버에 접근하는 대신, 시간에 걸쳐 분산되어 도착한다.

4. 상관 없는 Jitter

// Each delay is random between base and 3 * previous_delay.
func decorrelatedJitter(_ int, prev time.Duration) time.Duration {
    if prev  capSleep {
        d = capSleep
    }
    return d
}

Formula: delay = random(base, prev * 3) (capped)

*이 역시 AWS 블로그에서 소개된 방법이다. 시도 번호 대신 이전 지연값에 따라 다음 지연이 결정된다. 곱셈 인자(3)는 AWS에서 실용적으로 선택한 값으로, 지연이 얼마나 빨리 증가할지를 제어한다. 3을 사용하면 평균 지연이 재시도마다 약 1.5배씩 증가한다(random(base, prev*3)의 중간값). 이는 클라이언트를 충분히 분산시키면서도 과도한 대기 시간을 초래하지 않는다. 2나 4와 같은 다른 값을 사용해도 동작하지만, 그에 따라 트레이드‑오프가 달라진다.

서버 모델

type Server struct {
    mu       sync.Mutex
    capacity int           // requests per second the server can handle
    downFor  time.Duration // outage duration
    start    time.Time
    requests map[int]int // second -> total request count
    accepted map[int]int // second -> accepted request count
}

서버는 매 초마다 수신하는 요청 수를 계산합니다. 서버가 아직 장애 기간 내에 있거나 이미 용량에 도달한 경우, 요청을 거부합니다.

클라이언트 루프

func clientLoop(srv *Server, strategy Strategy, metrics *Metrics) {
    start := time.Now()
    attempt := 0
    prevDelay := baseSleep

    for {
        if srv.Do() { // success
            metrics.Record(time.Since(start), attempt)
            return
        }
        delay := strategy(attempt, prevDelay)
        prevDelay = delay
        attempt++
        time.Sleep(delay)
    }
}

각 클라이언트는 자체 고루틴에서 실행되며 성공할 때까지 계속 재시도합니다.

시뮬레이션 매개변수

매개변수
클라이언트 수1 000 (모두 동시에 시작)
서버 용량200 req/s
중단 기간10 seconds
요청 처리중단 중 또는 용량 초과 시 거부
수집된 메트릭초당 요청 수 (히스토그램), 총 낭비된 요청, p99 지연시간

시뮬레이션은 터미널에 막대 차트를 그려서 실제로 폭주하는 무리를 볼 수 있게 합니다.

Observations

Constant Retry

  • 각 클라이언트가 1 ms마다 재시도합니다.
  • 1 000개의 클라이언트가 있을 경우, 서버는 수십만 건의 요청을 초당 받지만, 처리할 수 있는 용량은 200에 불과합니다.
  • 테스트 실행 동안 8 백만 건 이상의 낭비된 요청이 1 000개의 클라이언트를 서비스하기 위해 발생했습니다.
  • 서버가 복구된 후에도 몇 초 동안 폭풍이 계속되며, 각 클라이언트가 최종적으로 성공할 때까지 멈추지 않습니다.

히스토그램: 0 초, 1 초, 3 초, 6 초, 12 초, 22 초, 32 초, 42 초, 52 초에 스파이크가 나타나고 그 사이에는 요청이 없습니다. 모든 클라이언트가 동일한 비율로 지연 시간을 두 배로 늘리면서 동기화된 미니‑무리가 형성됩니다.

Exponential Backoff

  • constant retry와 유사한 스파이크 패턴이지만, 스파이크 사이의 간격이 점점 늘어납니다.
  • 여전히 눈에 띄는 무리 효과가 존재하며, 전체 낭비된 요청 수는 감소하지만 여전히 높은 수준을 유지합니다.

Full Jitter

  • 히스토그램이 부드러운 곡선을 보여주며, 0 초에 약 5 000건의 요청에서 19 초에 약 13건으로 감소합니다.
  • 급격한 스파이크가 없으며—클라이언트가 고르게 분산됩니다.
  • 낭비된 요청: 약 8 468건 (constant‑retry 경우의 약 0.1 %).
  • p99 지연시간: 52 초 (일부 클라이언트가 불운하게 긴 지연을 겪기 때문에 여전히 높음).

Decorrelated Jitter

  • 히스토그램 역시 부드럽고, full jitter와 비슷하지만 약간의 잡음이 더 있습니다.
  • 낭비된 요청: 약 10 695건 (full jitter보다 약간 높음).
  • 용량 초과 요청: 137건 (가끔 짧은 지연이 발생해 연쇄적으로 짧은 재시도가 일어나기 때문).

왜? 클라이언트가 무작위로 짧은 지연을 받으면, 다음 지연이 그 짧은 값에 기반해 설정되어 몇 번의 빠른 재시도가 가능해지고, 이후에 jitter가 확대됩니다.

요약

전략스파이크?총 낭비된 요청p99 지연시간
Constant RetryYes> 8 M> 50 s
Exponential BackoffYes~ 1 M> 40 s
Full JitterNo~ 8.5 k52 s
Decorrelated JitterNo~ 10.7 k~ 45 s
  • Constantexponential 백오프는 동기화된 스파이크를 발생시킵니다 (고전적인 폭주하는 무리).
  • Full jitter는 스파이크를 없애지만 여전히 일부 클라이언트에게 매우 긴 지연을 초래하여 꼬리 지연을 증가시킬 수 있습니다.
  • Decorrelated jitter는 재시도를 분산시키면서 평균 지연 증가를 적당히 유지하여 요청률 평활화와 지연 사이의 좋은 균형을 제공합니다.

References

  • AWS Architecture Blog – “Exponential Backoff & Jitter” (전체 jitter와 decorrelated jitter를 다룸).
  • Google Cloud Blog – “Retry Strategies for Distributed Systems”.

코드와 매개변수를 자신의 환경에 맞게 자유롭게 조정하세요. 핵심 아이디어는 간단합니다: 무작위성을 추가하여 재시도 간격을 분산시켜 동기화된 폭발을 방지하는 것입니다.

Overview

Full jitter가 이 시뮬레이션에서 다른 모든 전략보다 우수한 성능을 보였습니다.
하지만 이 테스트는 all 1,000 clients fail simultaneously라는 최악의 경우를 나타내며, 실제 운영에서는 드문 패턴입니다. 실제 배포에서는 클라이언트 실패가 순차적으로 발생하고, decorrelated jitter는 각 클라이언트가 단일 동기화된 스케줄을 따르지 않고 자체 재시도 리듬을 결정하기 때문에 이러한 상황을 보다 우아하게 처리하는 경향이 있습니다.

전략 요약

전략운영 가능성비고
Constant retry❌ 절대 운영 환경에서는 권장되지 않음대규모 동시 요청(스톰) 문제를 일으킵니다.
Exponential backoff (no jitter)⚠️ 매우 적은 수의 클라이언트에만 사용군집이 커지면 동기화 문제가 발생합니다.
Full jitter✅ 기본 선택시뮬레이션에서 최고의 결과를 보이며 구현이 가장 간단합니다.
Decorrelated jitter✅ 단계적 장애에 권장전체 지터와 비슷한 성능을 보이지만, 각 클라이언트가 자체 기록에 따라 조정되어 장애가 시간에 걸쳐 분산될 때 더 견고합니다.

How to Run the Simulator

# Clone the repository
git clone https://github.com/RafaelPanisset/retry-strategies-simulator
cd retry-strategies-simulator

# Run each strategy (each execution takes ~15–60 seconds)
go run main.go -strategy=constant
go run main.go -strategy=backoff
go run main.go -strategy=jitter
go run main.go -strategy=decorrelated

실행 중 프로그램은 선택한 전략에 대한 재시도 간격 분포를 시각화하는 실시간 ASCII 히스토그램을 출력합니다.

추가 참고 자료

Marc Brooker, “Exponential Backoff and Jitter”
Marc Brooker, “Timeouts, retries, and backoff with jitter”
Google Cloud, “Retry Strategy”

핵심 요점: 전체 지터(full jitter) 를 기본 재시도 정책으로 사용하되, 클라이언트 오류가 시간에 걸쳐 고르게 발생할 것으로 예상되는 경우 데코릴레이티드 지터(decorrelated jitter) 를 고려하세요. 이 접근 방식은 무리 효과(herd effect)를 최소화하면서 구현을 간단하게 유지합니다.

Back to Blog

관련 글

더 보기 »

Go의 비밀스러운 삶: Context 패키지

런어웨이 goroutine을 멈추고 메모리 누수를 방지하는 방법. 16장: 언제 그만둘지 알기. 아카이브는 조용했으며, 서버 랙의 윙윙거리는 소리만이 코…