Adding Timeouts and Retries to Service-to-Service REST Calls
한국어 원문으로 표시합니다.
목표
불안정한 다운스트림을 상대로 타임아웃, 지수 백오프, 지터, 재시도 금지 조건, 폴백을 직접 구현해 동기 호출의 기본기를 완성한다.
왜 중요한가
동기 호출에서 사고는 대개 "타임아웃이 없어서" 또는 "재시도를 잘못해서" 납니다. 타임아웃이 없으면 다운스트림이 느려질 때 업스트림의 커넥션 풀이 먼저 마르고, 다운스트림과 무관한 요청까지 실패합니다. 반대로 재시도를 고정 간격으로 무제한 걸면 다운스트림이 회복하려는 순간마다 같은 파도가 덮칩니다. 실제 사례에서 주문 인스턴스 20대가 초당 100건을 처리하며 maxAttempts 5 로 재시도하자 결제 서비스가 초당 10,000건을 받았습니다. 재시도는 부하를 더하는 것이 아니라 곱합니다. 그래서 이 실습은 값을 넣는 순서를 중요하게 다룹니다 — 먼저 타임아웃, 그다음 재시도 조건, 그다음 백오프와 지터, 마지막에 폴백입니다.
단계
/opt/app/flaky.py를 127.0.0.1:8110 에 띄운다.GET /fast가 200 이어야 한다./root/rest/call.py를 만들어/fast를 호출하고 응답 본문을/root/rest/fast.out에 저장한다./root/rest/timeout.py를 만들어/slow를 1.0초 타임아웃으로 호출한다./root/rest/timeout.out첫 줄에TIMEOUT, 둘째 줄에elapsed=<초>를 적는다. 경과 시간은 2.0초 미만이어야 한다./root/rest/retry.py를 만들어/flaky?key=lab&fail=2를 지수 백오프로 재시도해 최종 성공시킨다./root/rest/retry.log에attempt=<n> wait=<초>형식으로 시도마다 한 줄씩 남기고 총 3줄이 되게 한다.retry.py의 대기 계산을 full jitter 로 바꾼다. 시도 번호 2의 대기 시간을 20번 뽑아/root/rest/jitter.txt에 한 줄씩 적는다. 서로 다른 값이 15개 이상이어야 한다./root/rest/retry400.py로/bad를 호출한다. 400 은 재시도하지 않으므로/root/rest/retry400.log는 정확히 1줄이어야 한다./root/rest/budget.txt에instances=20,rps=100,max_attempts=5,worst_rps=10000네 줄을 적는다./root/rest/gateway.py를 127.0.0.1:8111 에 띄운다.GET /order는 다운스트림/always500을 호출하되, 타임아웃과 재시도를 적용하고 최종 실패 시 200 과{"degraded":true}를 준다.
참고
- 타임아웃 값은 상대의 p99 에서 출발합니다. p99 의 10배로 잡으면 없는 것과 같습니다.
- full jitter:
wait = random.uniform(0, min(cap, base * 2 ** attempt)) - 흔한 실수 1: 재시도 로그를 성공한 시도만 남기는 것 — 실패한 시도도 남겨야 예산을 계산할 수 있습니다.
- 흔한 실수 2: 타임아웃을 예외로 잡지 않아 스택트레이스만 남고 경과 시간을 못 재는 것.
불안정한 다운스트림 띄우기
/opt/app/flaky.py 를 127.0.0.1:8110 에 띄운다. GET /fast 가 200 이어야 한다.
/opt/app/flaky.py 를 실행하면 8110 에 뜹니다. /fast, /slow, /flaky, /bad 네 경로의 동작을 먼저 눈으로 확인하세요.
정상 호출로 기준선 잡기
/root/rest/call.py 를 만들어 /fast 를 호출하고 응답 본문을 /root/rest/fast.out 에 저장한다.
httpx 나 urllib 로 /fast 를 부르고 본문을 파일로 남깁니다. 아직 타임아웃은 신경 쓰지 않아도 됩니다.
느린 응답을 타임아웃으로 끊기
/root/rest/timeout.py 를 만들어 /slow 를 1.0초 타임아웃으로 호출한다. /root/rest/timeout.out 첫 줄에 TIMEOUT, 둘째 줄에 elapsed=<초> 를 적는다. 경과 시간은 2.0초 미만이어야 한다.
/slow 는 3초 뒤에 답합니다. 클라이언트 타임아웃을 1초로 두면 예외가 납니다. 예외를 잡아 결과와 경과 시간을 함께 기록하세요.
지수 백오프 재시도 구현하기
/root/rest/retry.py 를 만들어 /flaky?key=lab&fail=2 를 지수 백오프로 재시도해 최종 성공시킨다. /root/rest/retry.log 에 attempt=<n> wait=<초> 형식으로 시도마다 한 줄씩 남기고 총 3줄이 되게 한다.
간격을 매 시도마다 두 배로 늘립니다. 각 시도의 번호와 대기 시간을 로그에 한 줄씩 남겨야 채점됩니다.
지터로 재시도 시점 흩뜨리기
retry.py 의 대기 계산을 full jitter 로 바꾼다. 시도 번호 2의 대기 시간을 20번 뽑아 /root/rest/jitter.txt 에 한 줄씩 적는다. 서로 다른 값이 15개 이상이어야 한다.
full jitter 는 0부터 계산된 백오프 사이의 난수를 기다립니다. 같은 시도 번호에서도 값이 매번 달라야 합니다.
확정적 오류는 재시도하지 않기
/root/rest/retry400.py 로 /bad 를 호출한다. 400 은 재시도하지 않으므로 /root/rest/retry400.log 는 정확히 1줄이어야 한다.
400 은 몇 번을 보내도 400 입니다. 상태 코드로 재시도 여부를 가르는 분기를 넣으세요.
재시도 예산 계산하기
/root/rest/budget.txt 에 instances=20, rps=100, max_attempts=5, worst_rps=10000 네 줄을 적는다.
인스턴스 수 x 초당 요청 수 x 최대 시도 수 가 다운스트림이 받는 최악의 초당 요청 수입니다. 세 값과 결과를 각각 적으세요.
폴백이 있는 게이트웨이로 종합하기
/root/rest/gateway.py 를 127.0.0.1:8111 에 띄운다. GET /order 는 다운스트림 /always500 을 호출하되, 타임아웃과 재시도를 적용하고 최종 실패 시 200 과 {"degraded":true} 를 준다.
다운스트림이 죽어도 사용자에게는 200 을 주되, 응답에 품질 저하 표시를 남깁니다. 앞 스텝의 타임아웃과 재시도를 함께 씁니다.