질문의 역사
재시도 논리는 마이크로서비스 아키텍처가 모놀리스를 대체하면서 기본적인 회복력 패턴으로 떠올랐고, 이는 시스템이 일시적인 네트워크 장애 및 일시적 가용성 부족에 노출되게 했습니다. 초기 구현은 나쁜 즉시 재시도를 사용했으며, 이로 인해 복구 중 이미 어려움을 겪고 있는 서비스에 과도한 "우르르 쾅!" 현상을 초래했습니다. 업계는 클라이언트 재시도 폭풍을 비동기화하기 위해 지수 백오프 알고리즘(비상관된, 동일한, 완전 지터)으로 발전해 왔습니다. 그러나 이러한 비결정론적 타이밍 행동을 테스트하고 멱등성 키가 재시도 체인 전반에 걸쳐 지속되는지 확인하며 회로 차단기 상태 기계(닫힘, 열림, 반열림)를 검증하는 것은 대부분의 자동화 스위트에서 여전히 중요한 맹점으로 남아 있습니다. 전통적인 동기화 테스트 주장은 가변 대기 시간 창이나 분산 상태 검증을 처리할 수 없습니다.
문제
핵심 과제는 클라이언트 의도와 서버 인식 간의 관측 가능성 격차에 있습니다. 클라이언트가 실패한 결제 요청을 재시도할 때, 자동화 프레임워크는 네 가지 동시적인 문제를 검증해야 합니다: (1) 클라이언트가 시도 간에 적절한 가변 기간(지터)을 기다리는지 여부(서버를 압도하지 않도록); (2) 서버가 중복 멱등성 키를 인식하고 원래 응답을 재처리 없이 반환하는지 여부; (3) 회로 차단기가 실패 임계값에 도달한 후 열림으로 전환하여 자원 고갈을 방지하는지 여부; (4) 반열림 상태에서 정확히 한 프로브 요청이 백엔드를 관통하여 복구를 테스트하는지 여부, 동시에 다른 요청은 즉시 거부됩니다. 표준 모킹 도구는 현실적인 TCP 수준의 행동(패킷 손실, 연결 재설정, 가변 대기 시간)을 시뮬레이션할 수 없거나 이러한 이벤트를 애플리케이션 레이어 메트릭과 연관짓는 데 실패합니다.
해결책
Toxiproxy 또는 Envoy 사이드카를 테스트 지휘자로 직접 제어하는 프로그래머블 프록시 아키텍처를 구현하십시오. 이는 테스트 클라이언트와 테스트 중인 서비스(SUT) 간의 "혼돈 레이어"를 생성합니다.
회복력 프록시 제어: 사이드카로 Toxiproxy를 배포합니다. 테스트 스위트는 Toxiproxy HTTP API를 사용하여 특정 타임스탬프에서 지연, 타임아웃, 또는 reset_peer와 같은 "독성"(실패 모드)을 동적으로 추가/제거합니다.
텔레메트리 상관관계: 재시도 시도에 대한 스팬/메트릭을 방출하도록 SUT에 OpenTelemetry 또는 Micrometer를 계측합니다. 테스트 프레임워크는 추적 ID를 사용하여 프록시 독성 이벤트와 애플리케이션 스팬을 연관시켜 재시도가 독성이 활성화된 창에서만 발생했음을 증명합니다.
멱등성 검증: 첫 번째 요청 전에 UUIDv4 멱등성 키를 생성합니다. 이를 스레드 지역 컨텍스트에 저장합니다. 앞의 두 시도가 실패하도록 구성된 프록시를 통해 요청을 발행합니다. 최종 성공적인 응답이 헤더 X-Idempotency-Replay: true를 포함하고 있는지(또는 데이터베이스 쿼리를 통해 해당 키에 대한 원장 항목이 하나만 존재하는지 확인) 주장합니다.
상태 기계 검증: 프록시가 회로 차단기 임계값(예: 10초에 5회 실패)에 도달할 때까지 503 오류를 반환하도록 강제합니다. 회로 차단기의 헬스 엔드포인트(또는 메트릭을 검사하여) 검증하여 OPEN으로 전환되었는지 주장합니다. 그런 다음 독성을 제거하고 반열림 대기 시간을 기다리며 분산 추적을 통해 정확히 한 프로브 요청이 백엔드에 도달하고 평행 요청은 즉시 503 서비스 불가를 받는지 확인합니다.
코드 예제
import requests import toxiproxy import time import statistics from assertpy import assert_that class ResilienceTest: def test_retry_jitter_and_circuit_breaker(self, proxy_client): # 설정: 프록시를 구성하여 500ms 지연 후 타임아웃 proxy = proxy_client.get_proxy("payment_service") # 단계 1: 재시도가 있는 멱등성 idem_key = "idem-12345" proxy.add_toxic("slow", "latency", attributes={"latency": 500}) start = time.time() r = requests.post( "http://localhost:8474/proxy/payment_service", headers={"Idempotency-Key": idem_key}, json={"amount": 100}, timeout=10 ) duration = time.time() - start # 기본 0.5s와 지수 백오프 2^시도 + 지터 # 시도 1: 0.5s (실패), 시도 2: 1.0s + 지터 (실패), 시도 3: 2.0s (성공) assert_that(duration).is_between(3.0, 4.5) # 지터로 인해 변동 허용 # 단계 2: 회로 차단기 임계값 proxy.add_toxic("error", "timeout", attributes={"timeout": 0}) failure_times = [] for i in range(7): # 5 초과 try: requests.get("http://localhost:8474/proxy/payment_service/health", timeout=1) except: failure_times.append(time.time()) # 회로가 열리면 빠르게 실패(재시도 지연 없음) 확인 if len(failure_times) >= 2: gap = failure_times[-1] - failure_times[-2] assert_that(gap).is_less_than(0.1) # 백오프 지연 없음 = 회로 열림
맥락 및 문제 설명
핀테크 회사에서는 결제 게이트웨이가 레거시 은행 API와 REST를 통해 통합되었습니다. 블랙 프라이데이 세일 당시, 은행은 30초 동안 503 오류를 반환하는 블립을 경험했습니다. 우리 서비스는 나쁜 즉시 재시도(3회 시도, 0ms 대기 시간)로 구성되어 있어, 2,000개의 정당한 결제 요청을 6,000개의 요청/초로 변환하여 은행의 복구 엔드포인트를 타격했습니다. 이러한 "재시도 폭풍"은 은행의 인프라를 무너뜨려 45분간의 중단과 200만 달러의 거래 손실을 초래했습니다. 기존 자동화 스위트는 WireMock을 사용하여 고정된 200ms 지연을 주었으며, 모든 테스트를 통과했지만, 재시도 시도 간의 타이밍과 가변 네트워크 대기 시간을 시뮬레이션하지 못해 우르르 쾅! 현상을 놓쳤습니다.
고려된 다른 해결책
해결책 A: 고정 실패 시나리오가 있는 정적 모의 서버
우리는 C WireMock 구성을 확장하여 처음 N 요청에 대해 503 오류를 반환한 후 200을 반환하는 것을 고려했습니다. 이 접근 방식은 결정론적 주장을 제공하고 하위 초 단위 테스트 실행이 가능했습니다. 그러나 TCP 수준의 네트워크 파티션(연결 재설정, 패킷 손실)을 시뮬레이션하거나 클라이언트의 재시도 간격이 지수 백오프 곡선을 따르는지를 검증할 수 없었습니다. 장점은 간단함과 빠른 속도였고, 단점은 환경의 정확성이 낮고 회로 차단기 임계값을 테스트하는 데 불가능했습니다.
해결책 B: 컨테이너 수준의 혼돈 엔지니어링
우리는 Docker 데몬 수준에서 네트워크 지연을 도입하기 위해 Pumba를 평가했습니다(예: pumba netem --duration 1m delay --time 5000). 이것은 현실적인 네트워크 저하를 제공했지만, 정밀한 타겟팅이 부족했습니다. 특정 API 엔드포인트를 목표로 삼거나 특정 테스트 동작과 실패 주입을 동기화할 수 없었기 때문에 재시도 타이밍에 대한 주장을 하기 거의 불가능했습니다. 장점은 높은 현실성이었지만, 단점은 낮은 테스트 격리, CI 결과의 우연한 결정을 유발하는 비결정적인 실행, 중복 키를 확인할 수 없으므로 멱등성을 검증하는 데 불가능했습니다.
해결책 C: 분산 추적이 가능한 프로그래머블 프록시 (선택됨)
우리는 Docker Compose 테스트 환경에서 사이드카로 Toxiproxy를 구현하여 pytest 픽스처에서 REST API로 제어했습니다. 이를 통해 테스트가 요청을 발행할 때 서비스와 모의 은행 컨테이너 간의 특정 독성 행동(예: timeout, reset_peer)을 정확히 주입할 수 있었습니다. 우리는 이를 통해 Jaeger 추적을 통해 각 재시도 시도의 정확한 타임스탬프를 캡처했습니다. 장점에는 실패 타이밍에 대한 세밀한 제어와 분산 추적에 대한 주장(백오프 간격 검증) 및 재현 가능한 시나리오가 포함되었습니다. 단점은 추가 인프라 복잡성과 운영자가 프록시 구성을 이해하는 데 필요한 학습 곡선이었습니다.
어떤 해결책이 선택되었고 그 이유는 무엇입니까?
우리는 해결책 C를 선택했습니다. 왜냐하면 그것이 재시도 정책과 회로 차단기의 교차점을 검증하는 데 필요한 관측 가능성 및 제어를 제공했기 때문입니다. 프로그래머블 프록시는 프로덕션에서의 "503 불빛 뒤의 우르르 쾅!" 시나리오를 재현할 수 있게 해주었습니다. 프록시 독성 이벤트와 애플리케이션 로그를 상관시켜 "풀 지터"(0과 지수 값 사이의 무작위 지연)를 구현하여 최대 재시도 부하를 6,000 req/s에서 340 req/s로 94% 감소시켰습니다. 결정론적 제어를 통해 이러한 테스트를 CI에서 불안정함 없이 실행할 수 있었고, 회복력 구성이 퇴보하지 않도록 확신할 수 있게 되었습니다.
결과
자동화 스위트는 Half-Open 상태 검증 중에 회로 차단기가 성공적인 프로브 복구 후 실패 카운터를 재설정하지 않아 다음 미세한 결함에서 조기 Open으로 전환되는 비판적인 버그를 발견했습니다. 상태 기계 논리를 수정한 후, 시스템은 후속 은행 API 사건 동안 원활하게 저하되어 캐시된 결제 확인을 제공했습니다. 이제 테스트 스위트는 매 풀 리퀘스트의 일환으로 4분에 실행되어 재시도 및 회로 차단기 구성이 퇴보하지 않도록 방지합니다.
지터가 지수 백오프에서 우르르 쾅! 현상을 어떻게 방지하며, 고정된 대기 주장을 사용하지 않고 자동화된 테스트에서 그 효과를 통계적으로 어떻게 검증하시겠습니까?
지터는 재시도 간격에 무작위성을 도입합니다(예: delay = random_between(0, min(cap, base * 2^attempt))), 동기화된 클라이언트 재시도를 방지하여 복구 중인 서버를 압도합니다(우르르 쾅!). 이를 자동화에서 검증하기 위해, 3번의 재시도 시도로 구성된 실패하는 엔드포인트에 대해 100개의 병렬 요청을 실행합니다. 각 재시도 시도의 타임스탬프를 분산 추적 또는 프록시 로그를 통해 캡처합니다. 정확한 값에 대해 주장을 하는 대신, 서버에서의 도착 간격 표준 편차를 계산합니다. 표준 편차가 임계값을 초과하는지(예: 1초 기본 대기 시간에 대해 >800ms) 주장하여 비동기화를 증명합니다. 또는, 재시도가 서로 100ms 간격 이내에 발생하지 않도록 주장하여 효과적인 무작위화를 확인합니다. 고정 대기 주장은 지터의 확률론적 특성을 무시하고 느리고 불안정한 테스트를 초래하기 때문에 실패합니다.
재시도 간격 중 멱등성 키 회전이 위험한 이유와 테스트 프레임워크가 서버 측 중복을 적절히 검증하기 위해 멱등성 키 저장을 처리해야 하는 방법은 무엇입니까?
재시도 간격 중 멱등성 키를 회전(재생성)하면 안전 보장이 깨져 중복 청구 또는 중복 재고 할당을 초래할 수 있습니다. 서버는 각 요청을 별개의 작업으로 인식하게 되기 때문입니다. 키는 단일 논리 작업 동안 전체 재시도 체인에서 동일하게 유지되어야 합니다. 테스트 자동화에서, 재시도 루프에 들어가기 전에 UUIDv4를 사용하여 키를 생성하고, 이를 스레드 지역 또는 테스트 범위 컨텍스트에 저장합니다. 경합 조건을 테스트하기 위해 동일한 키를 사용하여 10개의 스레드를 동시에 생성하여 엔드포인트를 타격합니다. 정확히 한 스레드가 HTTP 200을 받고, 나머지는 409 Conflict 또는 동일한 성공적인 응답 본문을 받는지 주장하여 서버 측 원자성 중복을 확인합니다. 절대로 재시도 루프의 catch 블록 내에서 새로운 키를 생성하지 마십시오.
회로 차단기에서 "반열림" 상태의 특정 위험은 무엇이며, 왜 자동화 스위트에서 이 상태를 테스트하는 것이 특히 어려운가요?
반열림 상태는 회로 차단기 타임아웃이 만료된 후 발생하며(예: 열린 상태에서 60초), 제한된 수의 프로브 요청(대개 1개)이 하류 서비스가 복구되었는지를 테스트합니다. 위험은 여러 요청이 이 창에서 통과하거나 프로브가 백그라운드 헬스 체크에 의해 오염되면 회로가 서비스가 아직 실패하는 동안 잘못 닫힘으로 전환되거나, 복구에도 불구하고 여전히 열리게 될 수 있다는 것입니다. 이를 테스트하는 것은 시간적 정밀성 및 트래픽 격리를 요구하기 때문에 도전적입니다. 공유 환경에서는 배경 프로세스 또는 다른 테스트가 요청을 보내 프로브 수를 방해할 수 있습니다. 솔루션은 프로그래머블 프록시를 사용하여 반열림 창 동안 단일 프로브 요청을 제외한 모든 트래픽을 차단하거나, SUT 내에 회로 차단기 제어 엔드포인트(예: /actuator/circuitbreakers)를 노출하여 내부 상태 기계를 직접 확인하는 것입니다. 이를 통해 테스트에서 타이밍 기반 대기가 필요하지 않습니다.