Retry 폭주(Thundering Herd)의 위험성

2,002 단어·5 분·원문(.md)

Thundering Herd 소떼 현상, retry storm(재시도 폭풍)은 시스템에 일시적인 장애가 발생했을 때

이를 복구하려는 클라이언트들의 retry 로직이 동시 다발적으로 겹치면서 오히려 시스템을 완전히 다운시키는 현상을 말한다.

  • Thundering Herd: 캐시가 만료되거나 서버가 재시작될 대 수많은 클라이언트가 동시에 디비나 서버로 요청을 쏟아내는것. (스탬피드)
  • Retry Strom: A 서비스가 B서비스를 호출할때 타임아웃이 발생해 리트라이 했는데 문제가 1만명의 유저가 동시에 실패를 겪고 1만개의 클라이언트가 단 0.1초의 오차없이 동시에 재시도를 때리는 현상

problem #

단순히 트래픽이 늘어나는 수준이 아니라 스스로가 스스로를 공격하는 self ddos 공격으로 변질되는것이 문제다.

트래픽이 기하 급수적으로 증폭하는데 원래 초당 1,000 tps를 처리하던 서버가 1초동안 멈칫했다고 가정해보자.

클라이언트들이 3번씩 재시도를 하도록 설정되어 있다면, 다음 1초 뒤에는 원래 처리해야할 트래픽 1000건, 밀린 트래픽 1000건, * 3번 = 순식간의 4000번이상의 트래픽이 꽂힌다.

연쇄 장애 cascading failure로 이어질수도 있는데 b서비스가 간신히 복구되었는데 a서비스가 쏟아낸 재시도 요청들 때문에 결국 a서비스의 스레드도 고갈되고 a를 호출하던 사용자 게이트웨이까지 죽게된다.

복구 불가능 상태, deadlock 유사한 상태가 될수도 있는데 트래픽을 감당하지 못해 서버를 스케일아웃하거나 재시작해도 서버가 뜨자마자 대기하고 있던 재시도 트래픽이 쏟아져 죽여버릴수도 있다.

Example #

정상적인 상황은 이벤트 페이지라고 쳤을대 초당 500명의 유저가 쿠폰을 발급받고있다 해보자.

서버는 넉넉하게 초당 1,000건을 처리할 수 있으므로 평화롭다.

Retry 폭주로 인해서 db 서버의 네트워크 스위치가 아주 잠깐 흔들려 3초정도 db연결이 끊어졌다해보자.

이 3초동안 1,500명의 유자는 앱에서 실패가 뜨고 앱에 내장된 라이브러리는 무식하게 실패시 3회 재시도가 설정되어있다 치자.

3초뒤 db네트워크가 정상화되는 순간 1500명의 앱에서 동시에 3번씩 총 4500개의 커넥션 요청이 0.001초만에 db로 날아간다면?

db는 최대 커넥션을 초과하고 뻗게된다. 서버 개발자가 급히 db를 재시작해도 사용자들이 리프레시하거나 그런상황때문에 시스템이 아예 뻗어버릴수있다.

Solution #

이 문제의 핵심은 동시성을 깨부수고 재시도 간격을 흩어놓는 것이다.

이를 위해 지수적 백오프와 지터 Exponential Backoff, Jitter(무작위 지연시간 지터) 알고리즘을 결합해야한다.

Wait_Interval=(Base×MultiplierAttempt)+Random_JitterWait\_Interval = (\text{Base} \times \text{Multiplier}^{\text{Attempt}}) + \text{Random\_Jitter}

먼저 터미널 분석을 통해 엑세스 로그의 동시성 스파이크를 확인해보자

# Nginx access.log에서 특정 시간대의 초 단위 요청 수를 집계하여 스파이크(Spike) 현상 증명
$ awk '{print $4}' /var/log/nginx/access.log | cut -d: -f2,3,4 | sort | uniq -c | sort -n | tail -n 5

120 22:45:01
    125 22:45:02
      0 22:45:03   <-- 네트워크 Blip 발생 (요청 처리 0건)
      0 22:45:04
   8540 22:45:05   <-- 복구 직후 Retry 폭풍이 도달하여 트래픽이 70배 폭증함

22시 45분 05초에 120건이던 트래픽이 8540으로 폭증한걸 확인.

이렇게 검출해낼 수 있다.

그리고 애플리케이션 레벨 코드에서 jitter 방어코드를 추가해주자.

반드시 랜덤한 시간 jitter를 섞어서 트래픽이 동시에 가지 않도록 해야한다.

import io.github.resilience4j.retry.Retry
import io.github.resilience4j.retry.RetryConfig
import io.github.resilience4j.core.IntervalFunction
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Duration
import java.util.concurrent.TimeoutException

@Configuration
class ResilientRetryConfig {

    @Bean
    fun customRetryConfig(): RetryConfig {
        // 지수적 백오프(Exponential Backoff) + 지터(Jitter) 설정
        // 초기 대기 시간 1초, 재시도 시 2배씩 증가, 여기에 랜덤 오차(Jitter) 0.5(50%)를 섞음
        // 예: 1차 실패 후 (1초 ± 0.5초) 대기, 2차 실패 후 (2초 ± 1초) 대기
        val intervalFunction = IntervalFunction.ofExponentialRandomBackoff(
            Duration.ofSeconds(1), // Initial interval
            2.0,                   // Multiplier
            0.5                    // Jitter randomization factor
        )

        return RetryConfig.custom<Any>()
            .maxAttempts(3) // 최대 3회까지만 재시도
            .intervalFunction(intervalFunction) // 위에서 만든 분산 대기열 적용
            .retryExceptions(TimeoutException::class.java, CustomNetworkException::class.java)
            .build()
    }

    @Bean
    fun paymentRetry(retryConfig: RetryConfig): Retry {
        return Retry.of("paymentServiceRetry", retryConfig)
    }
}

// 비즈니스 로직에서의 활용 예시
class PaymentService(private val paymentRetry: Retry) {
    private val log = LoggerFactory.getLogger(this::class.java)

    fun processPaymentWithRetry(orderId: String): String {
        // Retry 데코레이터를 씌워 실행. 
        // 이제 10,000명이 동시에 실패해도, 재시도 타이밍이 0.5초~1.5초 사이로 랜덤하게 흩어짐.
        val decoratedSupplier = Retry.decorateSupplier(paymentRetry) {
            callExternalPaymentApi(orderId)
        }

        return try {
            decoratedSupplier.get()
        } catch (e: Exception) {
            log.error("[결제 실패] 최대 재시도 횟수 초과로 최종 실패 처리 - orderId: $orderId", e)
            "결제 시스템 지연. 잠시 후 다시 시도해주세요."
        }
    }

    private fun callExternalPaymentApi(orderId: String): String {
        // 실제 외부 통신 로직 (타임아웃 발생 가능성 있음)
        return "SUCCESS"
    }
}

이렇게 jitter를 섞어주면 4,500개의 재시도도 트래픽이 0.001초만에 꽂히는것이 아닌 수 초에 걸쳐 넓게 퍼지게 되므로 복구중인 서버가 트래픽을 분산해 감당할 수 있게 된다.

그 밖에도 리트라이를 꼭해야하는가도 의사결정하는것이 좋고, 바로 쏠것인가 지수적 백오프 (간격을 더 두어 쏠것인가)를 결정해 리트라이 정책을 확립시켜보자.

retry폭주를 막는것도 중요하지만 애초에 죽어있는 서버에는 재시도 조차 보내지 않도록 빠르게 에러를 반환하는 서킷브레이커패턴을 연동하는 방어기법이 사실 존재해야한다.

그게 더 중요할것같으니까.

SRE/question/q_35.md