Cloudflare Workers AI 파이프라인, 재시도해도 안전하게 만드는 법

재시도 로직을 넣고 이틀 뒤, 사용량 로그에서 이상한 숫자를 봤다. 똑같은 장면 하나의 음성만 두 번 합성된 흔적이었다. 파이프라인은 그 구간에서 딱 한 번 재시도했을 뿐인데 청구는 두 번 났다. 로그를 거슬러 올라가 보니 원인은 하나가 아니라 둘이었다.
외부 AI 프로바이더를 부르고 그 결과를 R2와 D1에 쌓는 파이프라인에서는 재시도 버튼 하나가 이런 식으로 배신한다. 실패한 줄 알았던 호출이 사실은 서버 쪽에서 끝까지 처리됐을 수도 있고 저장은 됐는데 그걸 가리키는 행이 안 생겼을 수도 있다. 재시도는 원래 있던 실패를 지우는 대신 새로운 실패를 하나 더 만들 뿐이다.
그 뒤로 코드나루는 Cloudflare Workers AI 영상 생성 파이프라인을 손보며 이 규칙들을 정리했다. 경쟁 상태·부분 실패·멱등성 같은 동시성 기초는 건너뛰고 여기서는 그 위에서 재시도 판정과 버전 라우팅과 데드레터큐(DLQ, 처리하지 못한 메시지를 따로 보관해 두는 큐) 설계를 실제로 어떻게 짰는지 본다.
멱등성 계약이 없으면 재시도는 진행이 아니라 차단이다
재시도를 넣기 전에 먼저 확인할 게 있다. 이 프로바이더가 같은 요청을 두 번 보내도 결과는 한 번만 반영되는 멱등성(idempotency) 계약을 실제로 지키는가. 지킨다면 재시도는 안전하다. 안 지키면 재시도는 도박이 된다.
이미지·음성·영상 생성 프로바이더 여러 곳을 모달리티(modality, 텍스트·이미지·음성·영상 같은 데이터 유형)별로 나눠 하나씩 확인했다. SDK 문서에 멱등성 키가 있다고 적혀 있어도 서버가 실제로 그 키를 보고 중복 요청을 걸러 주는지는 완전히 다른 문제 아닐까. 같은 요청을 두 번 보내 붙여 확인해 보면 내가 확인한 프로바이더들 중에는 문서에 적힌 대로 동작하는 멱등성 키를 제공하는 곳이 하나도 없었다. 이름만 있고 실제로는 무효였다.
확인 방법도 프로바이더마다 달랐다. 같은 요청을 짧은 간격으로 두 번 보내서 응답이 실제로 하나로 합쳐지는지 눈으로 봤고 안 되면 문서에 뭐라고 적혀 있든 무효로 판정했다. 같은 회사 안에서도 모달리티마다 계약이 다르다는 점이 특히 까다롭다. 이미지 생성은 한 호출로 끝나는 동기 API였고 영상 생성은 작업을 접수한 뒤 상태를 물어보는 비동기 구조였다. 회사 하나를 통째로 같은 규칙에 묶었으면 절반은 틀렸을 거다.
그래서 판정을 세 갈래로 나눴다. 요청과 응답이 한 호출 안에서 끝나는 동기 호출은 전부 애매한 실패로 취급해 자동 재시도를 금지하고 사람 확인을 기본값으로 둔다. 영상 생성처럼 프로바이더가 작업 ID를 돌려주고 나중에 그 ID로 진행 상태를 물어볼 수 있는 비동기 작업은 조회 대상으로 분류해 재시도 대신 상태 조회로 복구했다. 그 어디에도 안 걸리면 재시도 가능이라는 이름표는 아예 붙이지 않는다. 결과적으로 안전하게 그냥 재시도해도 되는 경우는 없었다.
| 호출 상황 | 판정 | 처리 |
|---|---|---|
| 멱등성 키가 문서에는 있지만 서버 동작으로 확인 안 됨 | 애매한 실패 | 자동 재시도 금지, 사람 확인 |
| 동기 호출(요청·응답이 한 번에 끝남) | 애매한 실패 | 자동 재시도 금지 |
| 비동기 작업 + 외부 job id 제공(예: 영상 생성) | 조회 가능 | job id로 상태 조회 후 복구 |
| 위 셋 어디에도 안 맞음 | 재시도 안전 후보 없음 | fail-closed, 기본은 차단 |
이 표를 코드보다 먼저 채우게 했다. 새 프로바이더를 파이프라인에 붙이는 절차 자체를 표 완성 이후로 미뤘다는 뜻이다. 표의 네 칸 중 하나에 못 넣는 프로바이더는 자동 재시도 로직 없이 첫 실패에서 바로 사람에게 알림이 간다. 판정이 안 끝난 프로바이더에게 재시도 권한부터 주고 나중에 문제가 생기면 고치던 순서를 뒤집었을 뿐인데 그 뒤로는 이중 과금 신고가 리뷰 단계 밖으로 넘어간 적이 없었다.
R2가 성공해야 D1 행이 생긴다
저장 실패보다 무서운 건 저장은 됐는데 아무도 그걸 못 찾는 상태다. 최종 렌더 파일을 메모리에 통째로 올렸다가 R2에 쓰는 방식부터가 위험했다. 대용량 영상 파일을 워커(Worker) 메모리에 다 담으면 메모리 한도에 걸리기 쉽고 쓰다가 끊기면 뭘 얼마나 썼는지도 애매해진다.
지금은 최종 렌더를 메모리에 쌓지 않고 R2로 바로 스트리밍한다. D1에 자산 행을 만드는 건 R2 쓰기가 끝까지 성공한 뒤로 미뤄 완료된 객체만 참조되게 한다. 순서를 반대로 하면 D1에는 파일이 있다고 적혀 있는데 실제 R2에는 없는 유령 행이 생긴다.
반대 상황도 처리해야 했다. R2 쓰기는 성공했는데 그다음 D1 쓰기가 실패하면 아무도 가리키지 않는 파일만 R2에 남는다. 이 경우는 이번 실행이 만든 객체를, 다른 성공한 작업이 참조하지 않을 때만 지워서 고아 객체가 쌓이지 않게 한다. 재시도로 같은 렌더를 다시 만들 때는 결정론적 키(deterministic key, 같은 입력이면 항상 같은 값이 나오는 키)를 써서 이미 있는 파일을 새로 만들지 않고 그대로 재사용한다. 같은 키를 여러 시도가 가리킬 수 있다는 뜻이라서 실패한 시도가 그 객체를 무작정 지우면 이미 성공한 다른 시도까지 함께 지워질 위험이 있다. 그래서 순서를 소유권 행 기록 → R2 저장 → 자산 행 생성으로 고정했다. 고유 시도 ID를 먼저 D1 소유권 행에 기록해 이번 시도가 이 키를 쓰고 있다는 걸 남기고 R2 저장이 끝난 뒤에야 자산 행을 만든다. 실패해서 지울 때도 이 순서를 그대로 따라가 소유권 행이 이 시도 것일 때만 조건부로 삭제해 확인과 삭제 사이 경쟁을 막는다.
메모리에 안 올리고 스트리밍부터 챙긴 이유는 워커의 한도 때문이다. 워커는 요청 하나에 쓸 수 있는 메모리와 실행 시간에 한도가 있다. 영상처럼 큰 파일을 다 받아서 메모리에 쌓은 다음 R2로 옮기면 그 한도에 먼저 걸릴 위험이 있고 옮기다가 끊기면 어디까지 썼는지도 애매해진다. 받는 대로 그대로 R2로 흘려보내면 한도 문제와 애매한 중단 지점 문제가 동시에 줄어든다. 자산 행을 R2 성공 뒤로 미루는 순서 하나가 저 두 문제 위에 얹혀 있는 셈이다.
버전이 안 갈리면 폴백이 아니라 데드레터큐로 보낸다
같은 파이프라인에 구버전(V1)과 신버전(V2) 로직이 동시에 떠 있던 시기가 있었다. 신버전으로 옮기는 도중에는 어느 작업이 어느 버전으로 가야 하는지 애매한 순간이 반드시 생긴다. 이 애매함을 매 실행 시점의 판단에 맡기면 같은 작업이 재시도할 때마다 다른 버전으로 처리될 위험이 있다. 첫 시도는 V2 로직을 타고 재시도는 V1으로 떨어지는 식이면 한 작업의 결과물이 두 버전의 산출물을 뒤섞어 놓은 꼴이 된다.
라우팅을 실행 시점 판단이 아니라 이름 자체에 새겼다. 워크플로우 인스턴스 ID 앞에 v1- 또는 v2- 접두사를 붙이고 그 접두사만 보고 어느 큐·바인딩으로 보낼지 결정론적으로 정한다. 같은 ID는 언제 다시 봐도 같은 버전으로 간다.
여기서 제일 중요한 결정은 예외 처리 쪽이었다. v2- 접두사가 붙은 작업인데 V2 바인딩이 아직 안 붙어 있으면 어떻게 해야 할까. 조용히 V1으로 흘려보내는 폴백을 처음엔 고려했다. 그렇게 두면 V2로 갈 줄 알았던 작업이 눈에 안 띄게 V1 로직으로 처리된다. 이 경우는 조용한 폴백 대신 데드레터큐로 보낸다. 처리를 대충 미루는 대신 사람이 볼 수 있는 큐에 세워 둔다.
롤아웃 비율은 별도 설정값(WORKFLOW_V2_ROLLOUT_PERCENT) 하나로 관리하고 기본값은 0으로 잡았다. 코드를 배포한다고 트래픽이 자동으로 신버전에 흐르지 않는다는 뜻이다. 값을 손으로 올려야만 그만큼 트래픽이 V2로 넘어가고 올리기 전까지는 100% V1이 처리한다. 신버전 로직이 배포는 됐지만 트래픽은 하나도 안 받는 상태를 기본값으로 둔 셈인데 문제가 생겨도 되돌릴 건 숫자 하나뿐이라 롤백이 배포 되돌리기보다 훨씬 가벼웠다.
- 워크플로우 인스턴스 ID에
v1-/v2-접두사가 있는가 — 없으면 기본 버전, 있으면 그 버전 전용 큐로. v2-인데 V2 바인딩이 없는가 — 그렇다면 V1 폴백이 아니라 데드레터큐로.- 롤아웃 비율이 명시적으로 올라가 있는가 — 기본값은 항상 0%.
단계 경계를 넘을 때는 await로 순서를 강제한다
음성 이중 합성 사고는 사실 두 가지 문제가 겹쳐서 났다. 하나는 단계 경계에서 진행 상황을 쓰는 순서 문제였고 다른 하나는 재시도가 이미 끝난 작업을 다시 만드는 문제였다.
먼저 순서 문제부터 짚는다. 워크플로우의 각 단계가 끝나면 진행 상황을 모아서 D1에 한 번에 쓰는 플러시(flush, 쌓아 둔 값을 한 번에 내보내는 동작) 로직이 있었다. 그런데 단계를 실행하는 코드는 return으로 바로 빠져나가면서 그 반환을 기다리지 않는다. 그 바람에 마지막 플러시가 단계가 실제로 끝나기 전에 먼저 실행돼 진행 기록 하나가 통째로 사라졌다. 고치는 방법은 간단한 편이다. 단계 완료를 끝까지 기다린 뒤에만 마무리 블록의 플러시를 실행하도록 순서를 강제했다.
다음은 재시도가 이미 값을 치른 작업을 다시 만들어야 하는 문제였다. 영상 생성은 준비(prepare)·장면별 실행(execute-per-unit)·합치기(apply)·마무리(finalize) 네 단계로 쪼갰다. 실행이 끝난 장면의 결과물은 그 즉시 저장해 두고 재시도가 앞 단계로 돌아가도 이미 합성된 장면은 다시 만들지 않고 저장된 결과를 그대로 재사용하게 했다. 합치기와 마무리 단계는 프로바이더를 다시 부르지 않는다. 애초에 부를 이유가 없도록 설계했다.
동시성 처리를 맡던 공용 함수 하나가 에러를 조용히 삼키는 버그도 같이 나왔다. 여러 장면을 동시에 처리하다가 하나가 실패해도 그 실패가 위로 안 올라가고 묻혀서 전체가 성공한 것처럼 보였다. 이런 버그는 유닛 테스트 커버리지만으로는 잘 안 잡힌다. 실제로 이 파이프라인은 유닛 테스트 커버리지가 높은 편이었는데도 이 버그를 처음엔 놓쳤다. 겉보기 커버리지와 실제 검증은 다른 층위에 있다.
이 버그를 잡아낸 건 작성자와 분리된 리뷰였다. 코드를 짠 쪽이 자기 코드를 그대로 통과시키지 않고 별도 리뷰가 각 커밋을 다시 확인하고 핵심 불변식(예: 같은 장면은 두 번 과금되지 않는다)을 뮤테이션 테스트(mutation test, 코드를 일부러 조금씩 망가뜨려 테스트가 그 실수를 잡아내는지 확인하는 검증법)로 재검증했다. 에러 삼킴 버그와 음성 이중 합성 버그도 이런 리뷰·뮤테이션 테스트 과정 없이는 드러나기 어려웠을 것이다. 작성한 쪽이 놓친 걸 다른 쪽이 잡아내는 구조 자체가 재시도 정책만큼이나 사고를 줄이는 데 몫을 했다.
최종 렌더가 진짜로 재생되는 MP4인지는 유닛 테스트가 답해 주지 않는다. Docker 환경에서 실제 파일을 만들고 ffprobe(미디어 파일의 스트림·길이 정보를 읽는 도구)로 확인하는 검증이 따로 필요했고 그 환경이 없는 곳에서는 이 검증 자체가 보류 상태로 남는다는 것도 그대로 인정했다. 코드가 맞다는 것과 파일이 실제로 재생된다는 것은 서로 다른 주장이고 하나를 확인했다고 다른 하나까지 확인된 건 아니다.
지금 이 파이프라인에 남겨 둔 원칙은 결국 세 문장으로 줄어든다. 멱등성 계약이 확인 안 되면 재시도하지 않는다. 저장은 R2가 성공한 뒤에만 기록한다. 버전이 안 갈리면 조용히 넘기지 않고 큐에 세운다. 이 세 문장을 어긴 자리에서 사고가 났고 지킨 자리에서는 안 났다.
세 문장 모두 방향이 같다. 애매하면 진행 대신 정지 쪽을 기본값으로 둔다는 방향이다. 재시도 가능 여부가 애매하면 막고 저장 순서가 애매하면 늦은 쪽을 성공 기준으로 삼고 버전이 애매하면 처리 대신 대기 큐로 보낸다. 편의를 위해 관대한 기본값을 고르고 싶은 유혹은 매 단계마다 있었지만 그 유혹을 따라간 지점에서만 사고가 재현됐다.
남은 과제도 있다. 이 프로그래밍 작업에서 이 세 원칙을 코드 리뷰 체크리스트로 못 박는 일이 먼저다. Docker 기반 최종 렌더 승인 테스트는 여전히 환경에 종속적이라 모든 곳에서 자동으로 돌지 않는다. 남은 과제는 이 검증을 CI로 옮겨 산출물 검증도 코드 리뷰만큼 매번 자동으로 돌게 만드는 일이다.
아직 댓글이 없습니다.