글 목록

분산락 없이 이중청구 막는 법

분산락 대신 '사이클 멱등키'와 'PG 거래조회'를 결합해 원자성 갭을 메웠다. 운영 부담은 늘었으나 데이터 정합성을 확보했다.

주재범3분 읽기

정기결제에서 제일 무서운 버그는 이중청구다. 장애가 나면 전화가 오지만, 돈이 두 번 나가면 해지가 온다. 흔한 처방은 분산락인데, 우리는 락을 버리고 사이클 멱등키와 외부 거래조회 정합의 조합으로 갔다. 왜 그랬는지를 문답으로 정리한다.

분산락으로 안 되나?

동시 실행은 확실히 막는다. 그런데 우리가 실제로 밟은 구멍 두 개는 동시성 문제가 아니었다.

  • 청구 직후 크래시: PG 청구는 성공했는데 로컬 커밋 전에 프로세스가 죽으면, 결제했다는 기록 자체가 없다. 다음 실행은 성공 기록이 없으니 미결제로 판단한다 — 그대로 재청구하면 이중청구, 사이클만 전진시키면 미수금. 어느 쪽이든 틀린다.
  • 응답 타임아웃: PG 응답이 늦으면 로컬은 실패로 적는다. 그런데 PG 쪽에서는 결제가 완료됐을 수 있다. 다음 실행이 실패 기록을 믿고 재청구하면 같은 돈이 두 번 나간다.

둘 다 단독 실행에서 터진다. 락이 풀린 시점과 PG 결제 완료 시점 사이의 갭도 락은 못 메운다. 락은 과녁이 빗나간 처방이었고, 락 유지 관리라는 운영 부담만 얹는다. 문제의 본질은 동시성이 아니라 우리가 아는 상태와 PG가 아는 상태가 갈라지는 순간이다 — 로컬 DB와 PG는 한 트랜잭션으로 묶이지 않고, 이건 바꿀 수 없는 전제다.

그럼 중복은 뭐가 막나?

사이클마다 결정론적인 키가 막는다. 앵커는 "이번에 받을 예정일" — 성공했을 때만 전진하는 값이라, 실패를 재시도하는 동안은 키가 변하지 않고 다음 사이클에서는 반드시 달라진다. 키는 구독 번호와 그 사이클의 예정일을 초 단위로 이어 붙인 한 문자열이다. 같은 사이클의 재시도가 같은 거래번호로 수렴하니 PG가 중복 청구를 스스로 거절한다.

크래시 구멍은 어떻게 메우나?

흔적을 돈보다 먼저 남긴다. PG를 호출하기 전에 "이 사이클을 청구하려 한다"는 의도를 별도 트랜잭션으로 먼저 커밋한다. 바깥 트랜잭션이 어디서 죽든 이 행은 남고, 다음 실행은 의도 행을 보고 재청구 대신 거래조회로 방향을 튼다. 선점 판정은 예외가 아니라 삽입된 행 수로 한다.

-- 같은 키의 결제 의도(intent)는 한 번만 선다 — PG 호출 "전에" 독립 트랜잭션으로 커밋해 흔적을 남긴다.
-- 청구 직후 크래시가 나도 이 행이 남아, 다음 실행은 재청구 대신 거래조회로 정합한다.
CREATE UNIQUE INDEX uq_payment_cycle_key ON payment (cycle_key) WHERE del_yn = 'N';

타임아웃은 실패로 적으면 안 되나?

안 된다. 응답이 안 왔다는 건 결제가 안 됐다는 뜻이 아니다 — 타임아웃은 실패가 아니라 미지다. 실패로 적어 버리는 대신 PG에 그 키의 거래를 조회해서 실제 상태로 로컬을 맞춘다. "모르면 조회한다"가 재시도 루프 전체의 전제가 됐다. 격리 수준도 이 구조에 맞췄다 — 정합의 근거를 락 경합이 아니라 멱등키와 조회에 두었으니, 갱신 경로는 READ_COMMITTED, 조회 경로는 readOnly로 가볍게 간다.

무엇을 지불했나?

조회 왕복만큼의 처리 시간, 그리고 "의도 선기록 → 청구 → 확정"이라는 3박자를 이해해야 코드를 만질 수 있는 진입 장벽. 얻은 것은 이중청구 두 경로(크래시, 타임아웃)의 구조적 차단과, 재시도를 겁내지 않는 운영이다. PG가 타임아웃을 사실상 내지 않는 수준으로 안정화되면 조회 정합을 단순화할 수 있겠지만, 그런 날이 올 것 같지는 않다.

팀에 남긴 규칙은 한 줄이다. 외부 시스템의 상태를 가정하지 말고 조회로 확인한다. 중복 방지가 아니라 상태 정합이 목표라면, 락보다 멱등키와 조회가 먼저다.

이 글 공유
LinkedInThreadsX