← Ascendy EN

backend

취소하고 환불하는 기능을 멱등하게 — 터미널 상태 전이는 맨 마지막에

· Ascendy Engineering


TL;DR

소스 노트. 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에 다시 들어올 수 있어야 하니까. 중복 호출 가드(이미 취소됨)는 터미널에 도달한 뒤에만 작동해야 한다. 따라서 정리 실패를 삼키고 터미널로 직행하면 안 된다 — 실패는 예외로 전파해서 “아직 안 끝났으니 다시 들어와”라는 신호를 보존한다.

거꾸로 말하면, 터미널 상태는 “이제 정말 다 됐다”는 단 하나의 진실의 순간이다. 그 앞에 미완의 작업을 두면 안 된다.

⑤·⑥ 나머지 두 경합

가져갈 것


저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.


Tags: idempotency, distributed-systems, race-condition, transactions, reliability