ACK 전에 꺼진 간식 자판기 · 재시도로 창고를 더 아프게 만들지 않는다 · 이론
재시도로 창고를 더 아프게 만들지 않는다
한 줄 요약
재시도는 실패를 지우는 버튼이 아닙니다. 어떤 오류를, 언제까지, 몇 번 더 시도할지 정하고 그 예산을 재시작 뒤에도 보존해야 합니다.
왜 이게 필요했나
축제 간식 주문 서버가 잠깐 느려졌습니다. 전달자 열 명이 모두 즉시 다시 보내기 시작하면 서버에는 기존 작업에 재시도까지 겹칩니다. 더 느려진 응답 때문에 다시 재시도하고, 원래는 잠깐이면 회복할 장애가 길어집니다. 앞 모듈에서 같은 업무 ID의 중복 효과를 막았다고 해도 요청을 처리하는 CPU와 연결은 공짜가 아닙니다. 업무 중복 방지와 부하 억제는 다른 문제입니다.
반대로 모든 실패를 영구 실패로 버리면 잠깐의 연결 오류 때문에 접수한 주문을 잃습니다. 그래서 일시적 실패, 영구적 업무 오류, 뜻을 알 수 없는 실패를 나눕니다. 실습의 Retryable은 다시 시도할 수 있다고 전송 어댑터가 분류한 오류이고 Permanent는 사람이 내용을 고쳐야 하는 오류입니다. 알 수 없는 예외는 성공이나 격리로 포장하지 않고 호출자에게 전달합니다. HTTP 상태 하나를 모든 서비스에 똑같이 해석하는 분류기는 이번에 만들지 않습니다.
어떻게 동작하나
한 업무에는 attempts, max_attempts, available_at, deadline을 저장합니다. attempts는 네트워크 성공 횟수가 아니라 선점에 성공해 시도를 시작한 횟수입니다. 요청을 보내기 전에 프로세스가 꺼져도 이미 소비한 시도를 되돌리지 않습니다. 언제 보냈는지 알 수 없는 실패를 공짜 시도로 처리하면 매번 그 위치에서 죽는 일이 무한히 반복될 수 있기 때문입니다. 이 정책은 장애 탐지와 운영자 재조정을 전제로 하는 보수적 예산입니다.
실습의 지연식은 다음과 같습니다. attempt는 1부터 시작하고, jitter는 호출자가 선택한 0부터 1의 비율입니다. 정수 밀리초로 내림하며 상한을 먼저 적용합니다.
window = min(cap_ms, base_ms * 2**(attempt - 1))delay_ms = floor(window * jitter)base=100, cap=1000, jitter=0.5라면 첫 실패 뒤 50ms, 두 번째 뒤 100ms를 기다립니다. base=100, cap=250에서 세 번째 시도는 400을 그대로 쓰지 않고 250의 절반인 125ms입니다. 테스트는 0과 1과 소수 비율을 직접 넣어 경계를 확인합니다. 실제 호출자가 매번 0.5만 사용하면 모든 작업자가 같은 패턴으로 기다리므로 무작위 분산 효과를 얻지 못합니다. 주입 가능한 비율은 시험을 결정적으로 만드는 장치이며 난수 발생기 자체를 검증한 것은 아닙니다.
available_at은 이 시간이 되어야 다시 가져갈 수 있다는 예약입니다. 함수 안에서 sleep하면서 큐의 쓰기 잠금을 잡지 않습니다. 실패 결과를 pending으로 저장하고 호출을 끝낸 뒤, 나중의 작업자가 도착한 예약을 가져갑니다. 처리할 일이 없는 호출은 None으로 끝납니다. 여기서는 상시 폴링 데몬을 만들지 않고 한 번의 실행 단위를 구현하므로, 운영에서는 별도의 깨우기·종료·관측 정책이 필요합니다.
deadline은 새 재시도 때마다 연장하지 않습니다. now+delay가 deadline과 같아도 그때는 시작할 예산이 없으므로 deadline 사유로 격리합니다. 최대 시도에 도달했다면 exhausted입니다. 지수 백오프만 있고 횟수나 마감이 없으면 느리게 무한 반복하는 프로그램일 뿐입니다. 반대로 횟수만 작게 잡아도 하나의 전송 함수가 영영 반환하지 않으면 호출 전체를 끝낼 수 없습니다. 전송 어댑터의 시간 제한은 따로 필요합니다.
현장에서 만나는 모습
장애가 났을 때 재시도를 여러 계층에 중복으로 넣는지 살펴봅니다. 브라우저가 세 번, API 서버가 세 번, 외부 SDK가 세 번 호출한다면 원래 업무 한 번에 더 많은 요청이 생길 수 있습니다. 한 계층의 max_attempts만 보고 전체 시스템의 부하 상한을 단정하지 않습니다. 이 실습의 최대 시도는 jobs 한 행의 예산이며, 서비스 전체의 초당 요청 제한이나 토큰 버킷은 구현하지 않습니다.
시각을 DB에 저장한다는 점도 중요합니다. 프로세스 시작부터 흐른 시간은 재시작하면 기준점이 바뀔 수 있으므로 영속 예약 시각으로 그대로 쓰지 않습니다. 실습에서는 모든 작업자가 같은 기준으로 해석하는 정수 ms를 인자로 줍니다. run_once는 전송 전후 두 번 clock을 호출하고 한 호출 안에서 시각이 뒤로 가면 오류로 알립니다. 실제 여러 호스트의 시계 차이, 재부팅과 시계 보정은 별도 설계 대상입니다. 이 검사가 전 세계 작업자의 시계를 맞춰 주지는 않습니다.
관측할 때는 재시도 횟수뿐 아니라 대기 중인 일, 임대 중인 일, 격리된 일과 가장 오래 기다린 업무의 나이를 나눕니다. pending이 줄었다고 모두 성공한 것은 아닙니다. dead로 옮겨졌을 수도 있습니다. 임대 만료로 다른 작업자가 맡았다면 외부 요청이 중복될 수 있으므로 앞에서 배운 수신 inbox의 동일 ID 처리가 계속 필요합니다. 예산과 중복 방지 중 하나만 남겨서는 충분하지 않습니다.
다음 확인에서 할 것
이어지는 퀴즈에서 지연 상한과 마감 경계, 시도 횟수의 의미를 계산합니다. 뒤의 종합 실습에서는 retry_delay와 영속 큐를 구현하고 bool·무한대·범위를 벗어난 값을 거부합니다. 같은 ID로 다시 접수해도 기존 마감과 소비한 시도가 초기화되지 않는지 확인합니다.
참고: [AWS의 백오프와 지터 설명](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/). 수량·시도 범위와 예약·격리 계약은 이 간식 전달 실습의 설계입니다.