backend
취소하고 환불하는 기능을 멱등하게 — 터미널 상태 전이는 맨 마지막에
· Ascendy Engineering
TL;DR
- 비용이 드는(아이템마다 과금 + 비싼 외부 호출) 비동기 작업에 **“전체 취소 + 환불”**을 붙일 때, 진짜 문제는 정상 경로가 아니라 크래시·재시도 한가운데서 죽었을 때다.
- 세 가지가 멱등성을 만든다: ① 환불 마커와 잔액 증가를 한 트랜잭션에(이중환불 차단), ② 정리는 마커 기준으로 매번 전체 재스캔(고아 리소스 차단), ③ 작업의 터미널 상태 전이는 맨 마지막에(중간 크래시가 재진입 가능하도록).
- 상태 기계에 **동시 행위자(취소 엔드포인트 vs 실행 워커)**가 생기는 순간, 모든 전이는 조건부
UPDATE ... WHERE ...+ rowcount 체크여야 한다. - 이 race들(부활·이중환불·고아 리소스)은 전부 적대적 plan review가 구현 전에 잡았다.
소스 노트. backend 팀 인테이크(
docs/intake/from-backend/2026-06-07-idempotent-cancel-refund.md)를 정제한 글이다. 테이블/엔드포인트/과금 단가 등 식별자는 일반화했고, 결제 수치는 제외했다. 이 race들을 구현 전에 잡은 두 번째 AI를 어떻게 부르고 언제 멈추나의 적대적 리뷰가 여기서도 일했다.
정상 경로는 쉽다. 죽을 때가 어렵다.
요구사항은 단순해 보였다. 사용자가 비용이 드는 일괄 작업(아이템마다 포인트가 빠지고 비싼 외부 호출이 일어난다)을 중간에 통째로 취소하면 — 실행 중이면 다음 아이템 경계에서 멈추고, 아직 결정 안 난 성공분은 포인트를 환불하고, 임시 산출물을 정리한다.
정상 흐름만 보면 30분짜리다. 문제는 이 모든 단계가 언제든 죽을 수 있다는 것이다. 환불하다가 죽으면? 정리하다가 죽으면? 취소 요청과 실행 워커가 동시에 같은 작업을 건드리면? 재시도가 들어오면 그게 또 환불하지 않는다고 누가 보장하나?
적대적 plan review가 1라운드에서 blocker 세 개를 정확히 짚었고, 설계가 멱등성 패턴으로 수렴했다.
① 부활 race — 무조건 UPDATE가 작업을 되살린다
첫 함정. 실행 워커가 작업을 집어들 때 상태를 running으로 무조건 덮어쓰고 있었다. 그런데 취소 엔드포인트가 pending 작업을 막 취소한 직후에 워커가 그 위에 running을 써버리면 — 취소된 작업이 부활한다.
해법은 전이를 조건부로 만드는 것:
UPDATE job SET status = 'running'
WHERE id = :id AND status IN ('pending', 'queued') AND cancel_requested = false;
-- rowcount = 0 이면, 누군가 먼저 취소했다는 뜻 → 워커는 조용히 물러난다
rowcount를 확인해 0이면 워커가 claim을 포기한다. 메시지 큐가 같은 작업을 두 번 배달해도(at-least-once) 같은 조건으로 안전하게 재-claim된다.
② 이중환불 — 마커와 잔액은 한 트랜잭션이어야 한다
“환불하고 → 상태를 바꾼다”를 두 개의 커밋으로 나누면, 그 사이에 죽는 순간 재시도가 또 환불한다. 같은 성공분을 두 번 돌려주는 것이다.
마커와 잔액을 한 트랜잭션에 묶는다:
BEGIN;
-- 아직 결정 안 난 아이템만 'cancelled'로 (rowcount로 실제 뒤집힌 것 확인)
UPDATE item SET decision = 'cancelled' WHERE job_id = :id AND decision IS NULL;
-- 서버사이드 증가 — read-modify-write 아님 (동시성 안전)
UPDATE wallet SET points = points + :refund WHERE user_id = :uid;
COMMIT;
핵심은 두 가지다. 마커(decision='cancelled')와 잔액 증가가 원자적이라 재시도가 이미 뒤집힌 아이템을 건너뛴다. 그리고 잔액을 애플리케이션에서 읽어와 더한 뒤 쓰는 게 아니라 DB 안에서 points + :refund로 증가시킨다 — 동시 갱신에 안전하다.
③ 고아 리소스 — 정리는 “미결정”이 아니라 “마커” 기준
②의 마커가 커밋된 직후 크래시하면 어떻게 될까? 만약 정리 패스가 “아직 결정 안 난 아이템의 임시 산출물만 지운다”고 짜여 있으면 — 그 아이템은 이미 cancelled로 결정됐으므로 영원히 정리에서 제외된다. 외부 저장소에 산출물이 고아로 남는다.
그래서 정리 패스는 “미결정”이 아니라 decision='cancelled' 마커 기준으로 매번 전체 재스캔한다. 외부 저장소 삭제는 키가 없으면 no-op이라, 재실행이 공짜다 — 몇 번을 다시 돌려도 같은 결과.
④ 터미널 전이는 맨 마지막에
이 셋을 묶는 원칙이 이것이다. 작업을 cancelled(터미널 상태)로 바꾸는 건 환불도 정리도 다 끝난 뒤여야 한다.
왜? 중간에 죽으면 작업이 non-terminal로 남고, 재호출이 같은 teardown에 다시 들어올 수 있어야 하니까. 중복 호출 가드(이미 취소됨)는 터미널에 도달한 뒤에만 작동해야 한다. 따라서 정리 실패를 삼키고 터미널로 직행하면 안 된다 — 실패는 예외로 전파해서 “아직 안 끝났으니 다시 들어와”라는 신호를 보존한다.
거꾸로 말하면, 터미널 상태는 “이제 정말 다 됐다”는 단 하나의 진실의 순간이다. 그 앞에 미완의 작업을 두면 안 된다.
⑤·⑥ 나머지 두 경합
- 완료 vs 취소. 마지막 아이템을 처리한 직후 취소가 도착하는 창이 있다. 그래서 완료 전이도 조건부다 —
UPDATE ... WHERE cancel_requested = false, rowcount 0이면 완료 대신 teardown으로 간다. - 실행 중 취소의 응답은 ACCEPTED. 엔드포인트가 실행 중 작업을 그 자리에서 동기로 teardown하면 워커와 환불 race가 난다. 플래그만 세우고 **“접수됨”**으로 응답한 뒤, 워커가 아이템 경계에서 teardown하고 최종 합계를 푸시로 알린다. 폴링 주기는 “취소 신호를 보기 전에 1 아이템 더 처리”가 감당 가능한 비용이 되도록 잡는다.
가져갈 것
- 멱등성은 “한 번 더 실행해도 같은 결과”가 아니라 “어느 지점에서 죽어도 재시도가 수렴”이다. 모든 중간 상태가 재시도의 올바른 출발점이어야 한다.
- 그 핵심 도구가 터미널 상태 전이를 맨 마지막에 두는 것 — 터미널은 단 하나의 “정말 끝났다”는 순간이고, 그 앞엔 미완을 남기지 않는다.
- 동시 행위자(엔드포인트 vs 워커)가 생기면 모든 전이는 조건부
UPDATE ... WHERE+ rowcount 체크. 무조건 UPDATE는 부활·이중처리의 문이다. - 비용을 되돌리는 연산(환불)은 그 마커와 함께 한 트랜잭션에. 정리는 마커 기준 재실행 가능하게.
- 이런 race는 프로덕션에서 디버깅하면 비싸다 — 구현 전 적대적 리뷰가 훨씬 싸게 잡는다.
저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.
Tags: idempotency, distributed-systems, race-condition, transactions, reliability