요약 (TL;DR)
재시도된 백그라운드 작업 하나가 고객 37명에게 결제를 두 번 청구하는 사고가 발생했고, 이 사건으로 인해 저는 저희 Python/Celery 스택의 모든 140개 작업을 2주 동안 감사하게 되었습니다. 저는 Claude Code를 사용하여 각 작업의 부작용(side effects)을 분류하고, Idempotency 키를 추가하며, '두 번 실행' 테스트 하네스를 구축했습니다. AI는 지루한 80% 부분에서는 매우 뛰어났지만, 제가 나중에 설명할 특정 한 가지 부분에서는 놀라울 정도로 형편없었습니다.
문제점 (The Problem)
사건을 한 단락으로 요약합니다.
워커가 charge_subscription_renewal 작업을 가져와 결제 제공업체(payment provider)를 호출했고, 성공적인 청구가 이루어졌습니다. 그러자 메시지를 승인(acknowledge)하기도 전에 파드(pod)가 OOM-killed 되었습니다. 브로커는 승인되지 않은 메시지에 대해 해야 할 일을 정확히 수행했습니다: 재전송한 것입니다. 두 번째 워커가 작업을 처음부터 다시 실행하여 카드를 또다시 청구했습니다. 고객 37명, 한 번의 나쁜 화요일 아침, 그리고 수많은 환불 이메일이 발생했습니다.
근본 원인은 OOM 문제가 아니었습니다. 문제는 저희가 시스템에서 **최소한 한 번 전달(at-least-once)**만 약속하는 곳에 **정확히 한 번 전달(exactly-once delivery)**을 조용히 가정하고 있었다는 것입니다. 제가 다뤄온 모든 큐(Celery on RabbitMQ, SQS, Sidekiq, Cloud Tasks)에는 동일한 작은 글씨가 있습니다: 귀하의 작업은 여러 번 실행될 수 있습니다. 재시도, 가시성 시간 초과(visibility timeouts), 워커 충돌, 진행 중인 작업을 종료시키는 배포 등이 원인이 될 수 있습니다. 그 어떤 것도 가능합니다.
그래서 진짜 질문은 이것이었습니다: 140개의 작업 중 어느 것이 두 번 실행되어도 안전하고, 어느 것이 시한폭탄인가?
팀원 누구도 알지 못했습니다. 이 작업들은 6년 동안 어쩌면 20명의 다른 사람들에 의해 작성되었기 때문입니다. 어떤 것은 명백히 무해했습니다(검색 인덱스 재구축), 어떤 것은 명백히 위험했습니다(카드 청구), 그리고 약 90개는 애매한 중간 지점에 있었습니다.
참고 스택:
- Python 3.12, Django 5.1
- RabbitMQ 기반 Celery 5.4, 대부분의 작업에
acks_late=True 설정
- PostgreSQL 16
- 코딩 에이전트로서 Claude Code (CLI), 2026년 10월 빌드
해결 방법 (How I Solved It)
저는 이 작업을 네 단계로 나누었습니다. 핵심 결정은 어떤 코드를 건드리기 전에 AI가 구조화된 인벤토리(inventory)를 먼저 생성하도록 하는 것이었습니다.
1단계: 모든 부작용 목록화하기 (Inventory every side effect)
저는 Claude Code에게 모든 작업을 순회하며 고정된 스키마에 내용을 채우도록 요청했습니다. 요약이 아니라, 스키마입니다. 자유 형식의 요약은 환각(hallucinations)이 숨는 곳이며, 엄격한 표는 모델이 검증 가능한 무언가에 전념하도록 강제합니다.
# 작업당 한 항목, 에이전트 생성, 제가 검토
- task: billing.tasks.charge_subscription_renewal
side_effects:
...
evidence 필드가 가장 중요한 부분이었습니다. 모든 주장은 파일과 라인 범위(line range)를 가리켜야 했기 때문에, 전체 작업을 다시 읽을 필요 없이 몇 초 만에 검증할 수 있었습니다.
프롬프트는 대략 다음과 같았습니다:
레포지토리의 각 Celery 작업에 대해 이 정확한 스키마를 가진 YAML 항목 하나를 생성하세요.
도우미(helpers) 내부로 최소 3단계 깊이까지 호출을 추적하세요.
만약 어떤 호출이 부작용(side effect)을 일으키는지 확신하지 못한다면, `unknown`으로 표시하고,
...
class IdempotencyRecord(models.Model):
key = models.CharField(max_length=200, unique=True)
task_name = models.CharField(max_length=200)
...
고유 제약 조건(unique constraint)이 실제 역할을 합니다. 동일한 키에 대해 경쟁하는 두 워커가 모두 승리할 수는 없습니다. 왜냐하면 Postgres가 허용하지 않기 때문입니다.
특히 결제 작업의 경우, 로컬 가드만으로는 충분하지 않습니다. 충돌은 청구(charge) 이후이지만 DB 커밋(DB commit) 이전에도 발생할 수 있기 때문입니다. 그래서 저희는 결정론적(deterministic) Idempotency 키를 결제 제공업체 자체에 전달합니다:
def charge_subscription_renewal(subscription_id: int, period: str):
key = f"renewal:{subscription_id}:{period}"
provider.charge(...)
💡 키는 런타임에서 생성되는 것이 아니라 비즈니스 이벤트로부터 파생되어야 합니다. 작업 내부에 uuid4()를 사용하는 것은 쓸모가 없습니다. 왜냐하면 재시도(retry)할 때마다 새로운 UUID가 생기기 때문입니다. renewal:{subscription_id}:{period}는 작업이 몇 번 실행되든 동일합니다.
Phase 3: 에이전트에게 배치로 배포하도록 맡기기
프리미티브(primitive)를 구현한 후, 에이전트의 임무는 기계적이었습니다. 안전하지 않은(non-safe) 각 작업에 대해 비즈니스 이벤트 키를 선택하고, 부수 효과(side effects)를 래핑하며, AlreadyProcessed 상태를 실패가 아닌 성공으로 처리하는 것이었습니다 (아니면 재시도 로직이 계속해서 작업을 시도할 것입니다).
저는 약 10개의 작업 단위로 배치를 실행했고, 배치당 하나의 PR을 만들었으며, 위험 순서대로 진행했습니다: 중요도가 높은 것부터, 그다음 높은 것, 그리고 중간 정도인 순서였습니다.
flowchart LR
A[작업 수신] --> B{claim_once key}
B -- 새로운 키 --> C[부수 효과 실행]
...
Phase 4:
이 테스트 장치(harness)를 처음 실행했을 때, 인벤토리에서 수정되었거나 안전하다고 표시했던 14개의 작업이 실패했습니다. 바로 이 숫자가 제가 이것을 구축하게 되어 기쁘다고 느끼게 만든 이유입니다.
배운 교훈 (Lessons Learned)
1. 구조화된 출력(Structured output)이 "검토해 주세요"보다 낫다
에이전트에게 "이 작업들이 멱등성(idempotency) 문제를 확인해 달라"고 요청하면 자신감 넘치는 에세이를 얻게 됩니다. 하지만 evidence 라인 범위를 가진 스키마를 채우도록 요청하면 실제로 검증할 수 있는 무언가를 얻습니다. 이 스키마가 바로 당신의 리뷰 인터페이스입니다. ✅
2. AI는 호출(calls)을 따르는 데 능하지만, 암묵적 부작용(implicit side effects)에는 약하다
제가 약속했던 구체적인 약점입니다. 샘플에 포함된 3가지 오분류와 14개 테스트 장치 실패 건수의 대부분은 함수 호출로 보이지 않는 부작용에서 비롯되었습니다:
- 웹훅을 전송하는 Django
post_save 시그널
- 카운터를 증가시키는 모델의
save() 오버라이드
- 감사(audit) 행을 삽입하는 데이터베이스 트리거 (!)
에이전트는 세 단계 깊이에 걸친 명시적 호출을 아무 어려움 없이 따라갔습니다. 하지만 "이 .save()는 다른 앱에 등록된 시그널도 발생시킨다"와 같은 것은 원거리의 유령 같은 행동(spooky action at a distance)이며, 에이전트는 이를 반복적으로 놓쳤습니다. ⚠️ 만약 코드베이스가 시그널이나 트리거에 의존한다면, 에이전트에게 먼저 이들을 열거하도록 명시적으로 지시해야 합니다.
3. 안전성 기본 요소(safety primitive)는 직접 작성하라
저는 에이전트에게 diff의 약 90%를 작성하게 했지만, claim_once와 그 기반 모델은 제가 직접 만든 것입니다. 정확성 보장(correctness guarantee)의 핵심은 작고 지루하며, 고장 날 때 호출될 사람이 작성해야 합니다. AI가 발매(rollout)를 하도록 맡기고, 발명(invention)은 맡기지 마십시오.
4. "이미 처리됨(Already processed)"은 오류가 아닌 성공이다
저희의 첫 번째 배치에서는 AlreadyProcessed를 예외(exception)로 처리했기 때문에, Celery가 이를 다섯 번 재시도한 후 데드 레터 큐(dead-letter queue)에 버렸습니다. 이로 인해 거짓 경보가 홍수처럼 발생했습니다. 이를 포착하고 INFO 레벨로 로깅하며 메시지에 대한 확인 응답(ack)을 보내야 합니다.
5. 무언가를 두 번 실행하는 테스트는 저렴한 보험이다
이 시스템을 구축하는 데 에이전트가 약 20분을, 제가 스테이트 스냅샷(state snapshot)을 조정하는 데는 약 두 시간을 사용했습니다. 이제 이 기능은 태스크와 관련된 모든 PR에서 CI 환경에서 실행됩니다. 새로운 작업들이 배포되기 전에 포착되므로, 이 감사 시스템이 6개월 후에 쓸모없어지지 않도록 하는 유일한 방법입니다. 🚀
다음 계획 (What's Next)
IdempotencyRecord 정리 정책. 현재 무한정 증가하고 있습니다. 각 태스크의 최대 재시도 기간에 맞춰 TTL(Time To Live) 스윕을 추가할 예정입니다.
- 신호 인벤토리(Signal inventory). 암묵적인 부작용(implicit side effects)이 에이전트의 사각지대였기 때문에, 모든 신호와 트리거의 정적 맵(static map)을 구축하도록 하고, 이 맵을 향후 감사 시 컨텍스트로 제공할 예정입니다.
- 스테이징 환경에서의 카오스 테스트(Chaos test in staging). 하루 동안 작업 중간에 워커를 무작위로 종료시키고 아무것도 중복 실행되지 않음을 확인해야 합니다. 믿되, 검증하라(Trust, but verify).
마무리 (Wrap-up)
백그라운드 작업을 실행한다면, 두 번 실행될 것이라고 가정하십시오. 결국에는 그렇게 될 것이기 때문입니다. AI 코딩 에이전트는 감사 과정을 극적으로 빠르게 만들 수 있지만, 오직 엄격한 스키마(rigid schema)를 제공하고, 핵심 가드를 직접 작성하며, 모든 것을 실제로 각 작업을 두 번 실행하는 테스트로 뒷받침할 때만 가능합니다.
👉 이것이 유용했다면, Dev.to에서 저를 팔로우하여 Claude Code로 실제 시스템을 배포하는 더 많은 빌드 로그를 확인하세요. 그리고 저는 궁금합니다: 가장 최악의 중복 작업 버그는 무엇이었나요? 댓글에 남겨주세요. 하나하나 읽어보겠습니다.