정기결제 이중청구 차단: 사이클 멱등키와 트랜잭션 격리
단순 분산락 대신 사이클 기반 멱등키와 트랜잭션 격리 레벨을 결합해 정기결제 이중청구를 차단한 결정 과정과 운영 부담 감소 효과.
주기적 결제 시스템에서는 "결제 요청은 보냈는데 결과가 안 왔다"는 상태가 가장 치명적이다. PG사 응답 지연이나 시스템 크래시로 인해 실제 결제는 성공했지만 기록은 남지 않으면, 다음 주기 스케줄러가 이를 미결제 상태로 보고 중복 청구하기 때문이다. 우리 솔루션에서도 이 갭으로 인해 실제 자금 손실이 발생한 사례가 확인되었다. 단순한 분산락은 설정과 모니터링 부담이 크고, 락 타임아웃 시 비즈니스 로직이 중단되는 리스크가 있다. 따라서 락 없이도 중복을 막을 수 있는 데이터 구조적 접근이 필요했다.
제약
이중청구를 차단하려면 다음 조건을 동시에 만족해야 한다.
- 원자성 보장: 청구 요청과 결과 기록이 동시에 성공하거나 실패해야 한다.
- 주기적 식별: 같은 구독자의 같은 결제 주기는 단 한 번만 처리되어야 한다.
- 운영 단순성: 추가적인 분산 락 서비스(예: Redis)의 가용성 의존도를 피해야 한다.
- 데이터 정합성: 조회와 갱신이 같은 트랜잭션 내에서 일관된 상태를 보여야 한다.
선택지
고려한 주요 대안들은 다음과 같다.
분산 락 (Redis Redlock 등)
- 장점: 구현이 표준화되어 있고, 락 획득 실패 시 즉시 재시도 또는 폴백이 가능하다.
- 단점: 락 서비스 장애 시 전체 결제 파이프라인이 마비된다. 락 획득과 비즈니스 로직 실행 사이의 시간 차이를 고려한 복잡한 타임아웃 설정이 필요하다.
DB 고유 인덱스 제약 (Unique Constraint)
- 장점: 데이터베이스 수준에서 중복을 강제하므로 가장 확실하다.
- 단점: 중복 시 예외 발생으로 인해 애플리케이션 로직이 예외 처리에 집중된다. 또한, "이미 처리됨" 상태를 조회하여 재시도 로직을 구성하기 어렵다.
사이클 멱등키와 READ COMMITTED 격리
- 장점: 락 서비스 없이 DB의 트랜잭션 격리 특성을 활용해 중복을 방지한다. 실패 시에도 상태가 명확하게 기록되므로 재시도 로직이 안정적이다.
- 단점: 트랜잭션 격리 레벨에 대한 이해도가 높아야 하며, 동시성 충돌 시 재시도 로직이 필요할 수 있다.
결정
우리는 사이클 멱등키와 트랜잭션 격리 레벨을 결합한 접근법을 선택했다. 핵심은 "결제 주기"를 고유 키로 사용하는 것이다.
subscription_id와 cycle_date(예: 2026-07-01)를 조합한 값을 unique_id로 설정했다. 이 키는 DB 테이블의 고유 인덱스로 동작하며, 중복 삽입 시 오류를 발생시킨다. 하지만 단순히 인덱스만 있는다면 실패 시 처리가 복잡해진다. 이를 위해 @Transactional(readOnly = false)와 함께 READ COMMITTED 격리 레벨을 명시적으로 사용했다.
구체적인 흐름은 다음과 같다.
- 조회: 현재 주기 상태와 이전 주기 상태를 확인한다. 이 단계에서
READ COMMITTED격리는 다른 트랜잭션에 의해 이미 committed된 데이터만 보게 한다. - 검증: 이전 주기가 미결제 상태라면 이중청구 가능성이 있다. 이 경우 즉시 실패 처리하거나 별도 알림을 보낸다.
- 삽입: 새로운 결제 기록을 삽입한다. 이때
unique_id가 중복되면 DB 예외가 발생한다. - PG 호출: DB 삽입이 성공한 후에만 PG사에 결제를 요청한다. 이렇게 하면 "PG는 성공했지만 DB 기록은 실패"하는 상황을 방지한다.
이 결정의 핵심 판단 기준은 운영 부담과 장애 반경이다. 분산 락은 외부 서비스 의존도를 증가시키지만, 이 방법은 기존 PostgreSQL 인프라만으로 해결한다. 또한, @Transactional 범위 내에서 조회와 갱신이 이루어지므로, 중간에 다른 트랜잭션이 상태를 변경하더라도 일관된 결과를 보장받는다.
[코드 예시 필요: 사이클 멱등키 생성 로직과 DB 삽입 쿼리]
결과와 한계
- 얻은 것: 이중청구 사고가 0건으로 감소했다. 락 서비스 관리 부담이 사라졌다.
- 잃은 것: 동시성 충돌 시 재시도 로직이 필요할 수 있다. 하지만 주기적 특성상 즉시 재시도보다는 다음 주기를 기다리는 것이 더 안전하다.
- 되돌릴 조건: DB의 동시성 처리 성능이 급격히 저하되거나, 주기 외의 실시간 중복 방지가 필요해지면 재검토해야 한다.
이 결정은 주기적 비즈니스 로직에서 데이터 정합성을 확보하는 기본기가 된다. 다른 시스템에도 적용할 때, "락"보다는 "데이터의 고유성"과 "트랜잭션의 경계"를 먼저 고려하라. 특히, 외부 응답의 비결정적 특성을 내부 트랜잭션으로 어떻게 포장할지가 핵심이다.
팀에 남긴 규칙: "PG 응답 전에는 DB에 성공 기록을 남기지 않는다." 이 결정을 다시 봐야 할 조건: PG사의 응답 지연 시간이 트랜잭션 타임아웃보다 길어질 때.