Thundering Herds: 확장성 킬러
I’m happy to translate the article for you, but I’ll need the text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source line and all formatting exactly as you’ve requested.
Thundering Herd란 무엇인가?
핵심적으로, Thundering Herd는 많은 수의 프로세스가 어떤 이벤트를 기다리다가 그 이벤트가 발생하면 동시에 모두 깨어나지만, 실제로 그 이벤트를 처리할 수 있는 프로세스는 하나뿐인 상황을 말합니다.
이 용어는 OS 커널 스케줄링에서 유래했지만, 현대 웹 엔지니어가 가장 자주 마주하는 경우는 Cache Stampede(캐시 스탬피드)입니다.
골든 스테이트
- 고트래픽 엔드포인트를 Redis에 캐시해 두었습니다. 모든 것이 빠르게 동작합니다.
- 만료: 캐시 TTL(시간‑대‑존재)이 0이 됩니다.
- 스탬피드: 5 000명의 동시 사용자가 페이지를 새로 고칩니다. 모두 캐시 미스를 경험합니다.
붕괴
5 000개의 요청이 동시에 데이터베이스에 도달해 동일한 데이터를 다시 생성하려 합니다. 데이터베이스에 부하가 급증하고, 지연 시간이 폭등하며, 서비스가 다운됩니다.
참고: 5 000은 임의의 숫자입니다. 실제 숫자는 시스템의 용량에 따라 달라집니다.
무리의 다른 현상들
| 시나리오 | 발생 상황 |
|---|---|
| Auth 서비스 장애 | 주 인증(Auth) 서비스가 5 분 동안 다운됩니다. 모든 다른 서비스가 재시도를 수행합니다. Auth가 복구되면 즉시 재시도 요청이 폭풍처럼 몰려와 Retry Storm이 발생합니다. |
| 공유 액세스 토큰 | 50개의 마이크로서비스가 머신‑투‑머신 JWT를 공유합니다. 토큰이 정확히 같은 순간에 만료되어 모든 서비스가 새로운 토큰을 얻기 위해 Identity Provider에 동시에 “돌진”합니다. |
| 자정에 실행되는 Cron 작업 | 00 * * * * 로 100개의 서버 노드에 무거운 정리 작업이 예약됩니다. 자정에 모든 노드가 동시에 데이터베이스 또는 공유 스토리지에 접근합니다. |
| 모바일 앱 배포 | 새로운 500 MB 모바일 바이너리를 배포하고, 100만 명의 사용자에게 즉시 다운로드하도록 알립니다. 처음 수천 건의 요청이 CDN 엣지를 통과하지 못하고 원본 서버에 동시에 몰려 스토리지 계층이 과부하될 수 있습니다. |
무리 감지
모니터링 대시보드에서 다음과 같은 “무리 서명”들을 찾아보세요:
- 캐시 미스와 지연 시간의 상관관계 – p99 데이터베이스 지연 시간 급증과 정확히 일치하는 캐시 미스 비율의 급증.
- 연결 풀 고갈 – 데이터베이스 연결 풀이 밀리초 내에 최대 한도에 도달.
- CPU 컨텍스트 스위칭 – “System CPU” 또는 컨텍스트 스위치가 대량으로 급증하여 수천 개의 스레드가 동일한 락을 놓고 경쟁하고 있음을 나타냄.
- 오류 로그 – “lock wait timeout” 또는 “connection refused” 오류가 촘촘히 발생.
요청 병합 (Promise 메모이제이션)
요청 병합은 특정 리소스에 대해 동시에 하나의 업스트림 요청만 활성화되도록 보장합니다.
- 만약 Request A가 이미 데이터베이스에서
user_data_123를 가져오고 있다면, Requests B, C, D는 자체적으로 가져오기를 시작하는 대신 A의 결과에 구독해야 합니다.
바쁜 대기 함정
단순한 락은 2차적인 무리를 만들 수 있습니다: 4 999개의 요청이 그 하나의 DB 호출을 기다리고 있다면, 매 10 ms마다 “준비됐나요?”를 폴링하여 CPU를 소모하고 애플리케이션 메모리에서 새로운 무리를 생성합니다.
이벤트 기반 알림으로 전환
push/poll 모델에서 pull/notification 모델로 전환합니다:
- “완료됐나요?”라고 묻는 대신, 대기 중인 요청은 잠자기 상태가 되고 데이터가 준비되면 깨워져야 합니다.
Python이나 Node.js에서는 보통 Promises 혹은 Futures가 이를 네이티브하게 처리합니다. 다른 언어에서는 Condition Variables 혹은 Channels를 사용할 수 있습니다.
asyncio를 사용한 Python 예제
아래는 단일 서버에서 요청 병합을 시연하는 최소 예제입니다. “팔로워”들은 단순히 Event를 await하여 CPU를 전혀 사용하지 않고 “리더”가 작업을 마칠 때까지 기다립니다.
import asyncio
class RequestCollapser:
def __init__(self):
# Stores the events for keys currently being fetched
self.inflight_events = {}
self.cache = {}
async def get_data(self, key):
# 1️⃣ Check if data is already in cache
if key in self.cache:
return self.cache[key]
# 2️⃣ Check if someone else is already fetching it
if key in self.inflight_events:
print(f"Request for {key} joining the herd (waiting)...")
event = self.inflight_events[key]
await event.wait() # Zero CPU usage while waiting
return self.cache.get(key)
# 3️⃣ Be the "Leader"
print(f"Request for {key} is the LEADER. Fetching from DB...")
event = asyncio.Event()
self.inflight_events[key] = event
try:
# Simulate DB fetch
await asyncio.sleep(1)
data = "Fresh Data"
self.cache[key] = data
return data
finally:
# 4️⃣ Notify the herd
event.set() # Wakes up all waiters instantly
del self.inflight_events[key]
이 예제는 단일 서버에서는 완벽히 동작합니다. 하지만 100개의 애플리케이션 서버가 있다면 어떨까요? 여전히 100명의 “리더”가 데이터베이스를 동시에 두드릴 가능성이 있습니다.
여러 인스턴스에 걸친 요청 콜랩싱 확장
플릿 전체에 콜랩싱을 확장하려면 분산 조정 레이어(예: Redis, etcd, Zookeeper, 또는 전용 락 서비스)가 필요합니다. 패턴은 동일합니다:
- 키에 대해 분산 락을 획득 시도합니다.
- 락을 획득하면 → 리더가 되며, 데이터를 가져와 저장한 뒤 락을 해제하고 알림을 발행합니다(예: Pub/Sub 사용).
- 락을 획득하지 못하면 → 알림 채널을 구독하고 결과를 기다립니다.
아래는 Redis와 그 Pub/Sub 기능을 사용한 고수준 스케치입니다(명확성을 위한 의사코드):
import aioredis
import asyncio
REDIS_LOCK_TTL = 30 # seconds
CHANNEL_PREFIX = "herd:"
async def get_data_distributed(redis, key):
cache_key = f"cache:{key}"
lock_key = f"lock:{key}"
channel = f"{CHANNEL_PREFIX}{key}"
# 1️⃣ Try cache first
cached = await redis.get(cache_key)
if cached:
return cached
# 2️⃣ Try to become leader by acquiring a lock
is_leader = await redis.set(lock_key, "1", nx=True, ex=REDIS_LOCK_TTL)
if is_leader:
# Leader: fetch from DB, populate cache, notify herd
try:
data = await fetch_from_db(key) # your DB call
await redis.set(cache_key, data, ex=300) # cache for 5 min
await redis.publish(channel, data) # wake up followers
return data
finally:
await redis.delete(lock_key) # release lock
else:
# Follower: wait for notification
pubsub = redis.pubsub()
await pubsub.subscribe(channel)
async for message in pubsub.listen():
if message["type"] == "message":
return message["data"]
핵심 포인트
- 락은 전체 플릿에서 키당 오직 하나의 리더만 존재하도록 보장합니다.
- 팔로워는 Pub/Sub에서 블록되며, 대기 중에 CPU를 사용하지 않습니다.
- 리더가 충돌할 경우를 대비해 락에는 TTL이 있어 데드락을 방지합니다.
- 알림 채널은 간단한 문자열일 수 있으며, 페이로드는 최신 데이터이거나 팔로워가 캐시에서 읽도록 유도하는 “ready” 플래그가 될 수 있습니다.
동기화된 재시도를 방지하기 위한 지터 추가
요청을 합치더라도 서비스가 일시적으로 사용 불가능해질 때 재시도 폭풍이 발생할 수 있습니다. 무작위 지터를 백오프 간격에 추가하면 재시도가 시간에 걸쳐 분산됩니다.
import random
import asyncio
async def retry_with_jitter(coro, max_attempts=5, base_delay=0.5):
for attempt in range(1, max_attempts + 1):
try:
return await coro()
except Exception as e:
if attempt == max_attempts:
raise
# Exponential back‑off + jitter
jitter = random.uniform(0, base_delay)
delay = (2 ** (attempt - 1)) * base_delay + jitter
await asyncio.sleep(delay)
- Exponential back‑off는 대상 시스템에 과부하가 걸리는 것을 방지합니다.
- Jitter(무작위성)는 다수의 클라이언트에서 발생하는 재시도가 다시 정렬되지 않도록 보장합니다.
핵심 정리
- 군집 서명을 조기에 식별하세요 (캐시 미스, 연결 풀 급증, CPU 컨텍스트 전환, 급증하는 오류).
- 중복 작업을 축소하세요 – 인‑프로세스 또는 분산 요청 축소 기법을 활용합니다.
- 이벤트‑드리븐 알림을 사용하세요 (Futures, Promises, Condition Variables, Pub/Sub) – 바쁜 대기(busy‑waiting)를 피합니다.
- 재시도 로직에 지터(jitter)를 추가하여 동기화된 폭풍을 방지합니다.
- 대규모 테스트 – 수천 개의 동시 요청을 시뮬레이션하여 완화 방안이 부하 하에서도 유효한지 검증합니다.
요청 축소와 지터가 적용된 재시도를 결합하면, 잠재적인 Thundering Herd 현상을 잘 동작하고 회복력 있는 시스템으로 전환할 수 있습니다. 🚀
분산 락, 지터, 그리고 요청 병합
많은 노드가 특정 키에 대한 리더가 되려고 시도하면, 데이터베이스에 한 번에 동시에 부하가 걸릴 수 있습니다. 데이터베이스에 따라 이는 심각한 문제가 될 수 있습니다. 이러한 상황을 방지하려면 분산 락을 사용하여 클러스터 전체에서 해당 키에 대해 단 하나의 노드만 리더가 되도록 합니다.
대규모 솔루션
| 기법 | 작동 방식 | 사용 시점 |
|---|---|---|
| Distributed Locks (Redis/Etcd) | 클러스터 전체에서 키당 단일 리더를 보장하기 위해 Redlock과 같은 라이브러리를 사용합니다. | 여러 인스턴스 간에 엄격한 조정이 필요할 때. |
| Singleflight Pattern (Go) | golang.org/x/sync/singleflight 패키지는 로컬에서 동시 호출을 하나로 합칩니다. 이를 분산 락과 결합하여 애플리케이션 메모리와 데이터베이스를 모두 보호합니다. | 단일 키에 대한 트래픽이 높은 Go 서비스. |
| Jitter | 재시도와 TTL 만료를 분산시키기 위해 의도적인 무작위성을 도입합니다. | 대량 동시 요청 급증(스톰)을 방지하기 위해. |
| X‑Fetch (Probabilistic Refresh) | 캐시 항목이 만료되기 직전에 무작위 “주사위 굴림”으로 어떤 요청이 새로 고침을 수행할지 결정하여 갱신합니다. | 미션 크리티컬한 저지연 데이터. |
지터: 실행 간격 두기
요청이 리소스가 이미 collapsed(다른 요청이 가져오고 있음) 상태임을 발견하면 고정된 간격으로 재시도하지 마세요.
- Bad: 매 50 ms마다 재시도.
- Good: 매 **50 ms + random(0, 20 ms)**마다 재시도.
TTL 예시
대량 키에 대해 고정 TTL을 설정하지 마세요.
- Problem: 10 000개의 제품을 업데이트하고 각각을 정확히 1 hour 후에 만료되도록 설정하면, 정확히 60 분 후에 재앙이 발생합니다.
- Solution:
TTL = 3600 + (rand() * 120) // spreads expirations over a 2‑minute window
X‑Fetch: 확률적 캐시 새로 고침
캐시가 만료될 때까지 기다리는 대신, 지터를 사용해 만료 직전에 새로 고침을 트리거하세요.
- TTL이 0에 가까워지면 각 요청이 “주사위 굴리기”를 수행합니다.
- 주사위 결과가 낮으면 해당 요청이 리더가 되어 데이터를 다시 가져오고 캐시를 재설정합니다.
- 다른 모든 요청은 stale‑but‑safe 데이터를 계속 받습니다.
파이썬 구현 예시
import time
import random
async def get_resilient_data(key):
cached = await cache.get(key)
should_refresh = False
# 1️⃣ Cache miss
if cached is None:
should_refresh = True
else:
# 2️⃣ Time remaining until expiry
time_remaining = cached.expiry - time.time()
# 3️⃣ Expired or probabilistic refresh
if time_remaining < 0: # placeholder condition
should_refresh = True
다음에 캐시 TTL을 설정할 때 물어보세요:
“동시에 10 000명이 이 요청을 하면 어떻게 될까요?”
답이 “모두 DB를 기다린다” 라면, 지터를 추가할 때입니다.
더 탄력적인 분산 시스템을 원하십니까?
이 심층 분석을 즐기셨다면, 견고하고 확장 가능한 아키텍처 구축에 대한 더 많은 인사이트를 팔로우하세요.
Aonnis Valkey Operator – 쿠버네티스에서 고성능 Valkey‑호환 클러스터를 배포하고 관리하며, 신뢰성과 확장성을 위한 내장 베스트 프랙티스를 제공합니다.
🚀 서프라이즈: 제한된 기간 동안 무료입니다.
🔗 자세히 알아보려면 www.aonnis.com 를 방문하세요.