S3 PUT이 성공해도 DB에는 pending이 남을 수 있습니다. 두 작업 사이에서 프로세스가 종료될 수 있기 때문입니다. 다음 실행은 이미 올라간 파일을 다시 보낼지, 실패한 작업으로 남겨 둘지 결정해야 합니다. 메모리 큐에는 이 결정을 복원할 기록이 없었습니다.
저는 DB의 미완료 상태에서 다시 시작하고 같은 S3 키로 재시도하도록 기준을 정했습니다. 중복 PUT 가능성과 DB 재조회 비용을 받아들이는 대신, 프로세스를 잃어도 할 일을 잃지 않게 한 것입니다. 구현·테스트는 AI와 협업했습니다.
검증도 두 단계로 나눴습니다. 대기 1,100건은 9회차에 모두 접수됐고, 실제 PostgreSQL과 가짜 PUT을 연결한 복구 시나리오는 6개씩 5회, 총 30회 통과했습니다. 접수와 상태 복구를 나눠 본 이유는 큐가 비었다는 사실만으로 S3와 DB의 일치를 판단할 수 없어서입니다.
접수, 저장, 확정을 하나의 성공으로 묶지 않기
파일 등록 직후에는 DB에 pending 상태가 남습니다. 작업자는 계약으로 정한 S3 키에 파일을 올리고, 예상한 이전 상태와 크기가 맞을 때만 완료를 확정합니다. 이 조건부 상태 갱신이 CAS입니다. 업로드가 끝났는데 DB 확정 전에 죽었다면, 스위퍼가 pending 행을 다시 읽고 같은 키를 대상으로 재시도합니다.
PUT의 성공 여부가 불명확할 때도 미완료 행은 남습니다. 재시도는 같은 키를 대상으로 하고 DB 확정은 예상 상태를 확인하므로, 외부 호출을 한 번만 실행하려 애쓰기보다 완료를 다시 판단하는 쪽에 책임을 뒀습니다. 실제 상태 전이·worker·sweeper는 업로드 코드 스냅샷에 있습니다.
| 멈춘 지점 | 남는 상태 | 복구가 시작하는 곳 |
|---|---|---|
| 파일 등록 후 PUT 전 | pending, 객체 없음 | 미완료 행 재수거 |
| PUT 성공 후 DB 확정 전 | pending, 객체 존재 가능 | 같은 키로 재시도 후 조건부 확정 |
| 오래된 실패 누적 | 앞부분에 실패 행 잔류 | 조회 커서를 이어 뒤의 작업도 접수 |
| 허용 루트 밖 경로 | 업로드하면 안 되는 입력 | 파일 경계에서 PUT 차단 |
큐는 작업량을 제한하는 장치로 남깁니다. 과거 구현의 256개 bounded queue, worker, 백오프, circuit breaker는 실패 압력을 조절하지만, 그 자체가 영속적인 완료 장부는 아닙니다. 파일 경로가 허용 루트를 벗어난 경우에도 재시도 대상으로 무작정 밀어 넣지 않습니다.
실패한 앞부분만 다시 읽으면 뒤가 굶습니다
미완료 행을 남기자 다음 문제가 생겼습니다. 스위퍼가 매번 첫 페이지부터 읽으면 오래된 ghost 행이 접수 자리를 차지하고, 뒤에 있는 새 녹화 작업은 기다리게 됩니다. 그래서 실패한 행을 다시 볼 수 있게 하면서도 조회 커서는 앞으로 이어 가도록 했습니다. 이 정책은 재시도 로그가 아니라 뒤쪽 작업의 접수 회차로 확인했습니다. 2026년 10월 5일 접수 시험에서는 회차당 128건, 큐 용량 4,096건으로 조건을 고정했습니다. 이것은 제품 기본 큐 설정과 다른 계측용 조건입니다.
| 대기 작업 | 전부 접수한 회차 | 반복 | 큐 포화 |
|---|---|---|---|
| 128건 | 1회차 | 5회 | 0 |
| 1,100건 | 9회차 | 5회 | 0 |
| 4,096건 | 32회차 | 5회 | 0 |
이 표는 실제 스위퍼 코드와 가짜 DB 목록을 사용한 접수 검증입니다. 1,100건이 9회차에 접수됐다는 값은 9초 복구나 1,100건의 S3 업로드 완료를 뜻하지 않습니다. 전체본의 업로드 검증에서도 접수와 완료를 분리했습니다.
DB 상태가 끝까지 바뀌는지도 따로 확인했습니다
다음 시험은 실제 PostgreSQL 16과 실제 재포장기를 사용했습니다. 원본 업로드 실패, 재생용 파일 업로드 실패, init 미확정을 DB에 넣고, 스위퍼 조회부터 큐 접수·재포장·상태 갱신까지 이어 갔습니다. 방송 종료, 컷오프 제외, init 복구 뒤 대기 작업 해제도 별도 조건으로 두었습니다.
6개 시나리오를 각 5회, 총 30회 통과했고 skip은 0이었습니다. 다만 PUT은 가짜 구현을 썼습니다. 실제 DB의 전이와 복구 배선은 확인했지만 AWS 네트워크 장애의 회복 시간을 재현한 시험은 아닙니다. 코드 스냅샷 4aab73df와 계측용 테스트를 분리해 보존했습니다.
이전 60초 송출 기록에서는 15파일=uploaded 15행=S3 key 15개, DB/S3 차이 0을 확인했습니다. 당시 요약에 남은 ghost 1,100행의 9회 순회와 root 이탈 PUT 0건은 당시의 기록입니다. 원시 로그가 정리된 과거 결과와 새 접수·통합 시험을 한 실험처럼 섞지 않았습니다.
원본 성공이 재생용 실패를 감추지 않게
이 복구 규칙은 재생 조각과 init을 추가하면서 다시 필요해졌습니다. 원본 업로드가 성공했더라도 재생 조각이 실패할 수 있고, 재생 조각이 준비돼도 init이 확정되지 않으면 재생을 열 수 없습니다. 하나의 uploaded 값으로 덮어 두면 실패한 축을 찾지 못하므로 각 축에 CAS와 수거 커서를 뒀습니다. 생산 계층의 실제 연결은 PR #192와 생성·저장 검증에 정리했습니다.
처음의 pending 행을 지우거나 큐에 다시 넣었다는 사실만으로 복구를 끝내지 않았습니다. 어디까지 접수했는지, 같은 키에 객체가 있는지, DB가 어느 축의 완료를 확정했는지를 나눠 확인했습니다. 그 결과 작업 목록은 메모리에서 사라져도 DB의 미확정 상태에서 다시 시작할 수 있습니다. 아직 실제 AWS 장애의 복구 시간을 측정한 것은 아니지만, 그 시험에 연결할 재시작 기준과 상태 전이 경로는 분명해졌습니다.
이 글은 PokeClip의 개발·검증 기록을 2026년 10월 15일 기준으로 정리한 CASE 16입니다. 전체 기술 기록에서 연결되는 결정을 함께 읽을 수 있습니다.