다운스트림과 업스트림 탄력성
- 16.1 Timeout
- 16.2 Retry
- 16.3 Circuit breaker
- 16.4 Load shedding
- 16.5 Load leveling
- 16.6 Rate-limiting
- 16.7 Constant work
장애는 양방향으로 전파돼. 내가 의존하는 하위 서비스가 죽으면 나도 느려지고, 상위 클라이언트가 트래픽을 쏟아부으면 내가 죽지. 이 장은 다운스트림(하위 서비스 장애로부터 자신을 보호)과 업스트림(과도한 부하로부터 자신을 보호) 양쪽의 탄력성 패턴을 다뤄. 장애 전파를 막는 동전의 양면이야.
먼저 다운스트림부터. 네트워크 호출에는 반드시 타임아웃을 설정해야 해. 타임아웃 없으면 호출이 영원히 반환되지 않을 수 있고, 리소스 누수로 이어지거든. 놀랍게도 JavaScript의 XMLHttpRequest는 기본 0(무한), Python의 requests도 기본 무한이야. 적절한 값은 하위 서비스 응답 시간의 99.9th 퍼센타일을 기준으로 잡으면 돼.
요청이 실패하면 재시도할 수 있지. 근데 하위 서비스가 과부하면 즉시 재시도는 상황을 악화시켜. **지수 백오프(exponential backoff)**로 간격을 늘리고, 여러 클라이언트의 동시 재시도로 인한 thundering herd는 **랜덤 지터(jitter)**로 해결해. A->B->C 체인에서 각 레벨이 재시도하면 총 재시도 수가 곱셈으로 증폭되는 **재시도 증폭(retry amplification)**도 주의해야 해 — 체인의 한 레벨에서만 재시도하고 나머지는 즉시 실패하는 게 답이야.
타임아웃과 재시도가 일시적 결함용이라면, 서킷 브레이커는 장기간 불능에 대응하는 패턴이야. "가장 빠른 네트워크 호출은 하지 않는 호출이다." Closed(정상)에서 실패가 임계값을 넘으면 Open(차단)으로 전환해서 호출을 시도하지 않고 즉시 실패시켜. 비핵심 의존성이면 graceful degradation으로 처리하고, 일정 시간 후 Half-open 상태에서 시험 호출을 해보지. 핵심 구분 — 재시도는 "다음 호출이 성공할 것"을 기대하고, 서킷 브레이커는 "다음 호출이 실패할 것"을 기대해.
이제 업스트림. 서버는 들어오는 요청 수를 제어할 수 없잖아. 용량 한계에 도달하면 load shedding — 초과 요청을 503으로 거부해서 처리 중인 요청에 리소스를 집중해야 해. 똑똑하게 하려면 낮은 우선순위, 가장 오래된 요청을 먼저 거부하지. 클라이언트가 즉각적 응답을 기대하지 않는 경우엔 load leveling — 메시징 큐를 사이에 두고 서비스가 자기 속도로 처리하게 하는 거야.
Rate limiting은 특정 쿼타를 초과하면 요청을 거부하는 건데, load shedding이 프로세스의 로컬 상태로 판단하는 반면 rate limiting은 시스템의 글로벌 상태(특정 API 키의 전체 인스턴스 합산 요청)로 판단해서 분산 구현이 필요해.
마지막으로 constant work 패턴. 시스템이 부하에 따라 다르게 행동하면 드문 모드에서 버그가 터지기 쉽잖아. 항상 같은 양의 작업을 수행하게 만들면 — 예를 들어 변경분만 보내지 말고 모든 설정을 주기적으로 전체 덤프하면 — 변경 수에 무관하게 작업량이 일정해지고, 파일이 손상돼도 다음 업데이트가 고치는 자가 치유(self-healing) 속성까지 생기지.
정리
16장 읽고 기억할 거 세 가지:
- 타임아웃 + 지수 백오프 + 서킷 브레이커가 다운스트림 방어의 세 기둥이야 — 재시도는 일시적 결함용, 서킷 브레이커는 장기 불능용이지.
- Load shedding은 로컬 상태, rate limiting은 글로벌 상태 기반이야 — 둘 다 "부하를 처리하는 게 아니라 보호하는 것"이고, 실제 처리량을 늘리려면 오토스케일링이 필요해.
- Constant work 패턴은 항상 같은 양의 작업을 수행해서 multi-modal behavior를 제거하고 자가 치유 속성까지 얻어.