구독 상품을 만들 때 사람들이 흔히 오해하는 것이 하나 있습니다. 결제사에 정기결제를 등록하면 매달 알아서 청구해 준다는 기대입니다. 적어도 토스페이먼츠 빌링키 방식은 그렇지 않습니다. 빌링키는 한 번 발급받으면 언제든 그 카드로 결제를 올릴 수 있는 열쇠일 뿐, 언제 얼마를 청구할지는 우리 서버가 직접 정해 호출해야 합니다. 자체 호스팅 콘솔에 월 구독을 붙이며 이 청구 시점 관리를 어떻게 무인으로 돌렸는지, 그리고 중복 청구를 어떻게 막았는지를 정리합니다.
청구 시점은 우리가 정한다
정기결제의 핵심은 결국 두 가지 질문입니다. 언제 청구할 것인가, 그리고 실수로 두 번 청구되지 않게 어떻게 막을 것인가. 결제사가 스케줄링을 대신해 주지 않으니 첫 질문의 답은 우리가 크론을 직접 돌리는 것입니다. 여기에 BullMQ의 반복 작업 스케줄러를 썼습니다. 매일 아침 정해진 시각에 만기 점검 작업 하나가 자동으로 만들어지도록 등록해 두었고, 이 등록은 여러 번 실행해도 중복되지 않는 방식이라 서버가 재시작되어도 스케줄이 꼬이지 않습니다.
만기 점검 작업이 하는 일은 단순합니다. 현재 시각을 기준으로 청구 기간이 끝난 활성 구독을 모두 찾아 각각 결제 작업을 만들어 큐에 넣습니다. 점검과 실제 청구를 분리한 이유는 명확합니다. 점검은 가볍게 목록만 뽑고 무겁고 실패 가능한 실제 결제는 개별 작업으로 쪼개 각자 재시도와 실패 처리를 따로 두기 위해서입니다. 구독이 백 건이면 결제 작업도 백 개가 만들어지고 워커가 이를 나눠 처리합니다.
두 번 청구되지 않게 막기
정기결제에서 가장 무서운 버그는 중복 청구입니다. 크론이 두 번 돌거나 작업이 재시도되면서 같은 구독을 그달에 두 번 결제하면 고객의 신뢰가 한 번에 무너집니다. 그래서 멱등성을 여러 겹으로 깔았습니다.
첫 번째 방어선은 작업 식별자입니다. 만기 점검이 결제 작업을 만들 때 구독 아이디와 해당 월을 조합한 식별자를 붙입니다. 같은 달 같은 구독의 결제 작업은 이 식별자가 같으므로 큐가 중복을 걸러 냅니다. 만기 점검이 하루에 여러 번 실행되더라도 그달의 결제 작업은 하나만 남습니다.
두 번째 방어선은 결제를 실제로 올리기 직전의 확인입니다. 청구 로직은 결제사에 요청을 보내기 전에 이번 청구 기간에 이미 완료된 인보이스가 있는지 데이터베이스에서 확인합니다. 이미 결제된 기록이 있으면 결제사를 호출하지 않고 기존 인보이스를 그대로 돌려줍니다. 청구에 쓰는 주문 번호 역시 구독 아이디와 월을 조합한 고정된 값이라, 결제사 쪽에서도 같은 주문이 반복 처리되지 않게 잡아 줍니다. 이렇게 큐, 데이터베이스, 주문 번호 세 겹으로 같은 청구가 두 번 나가지 않도록 막았습니다.
성공과 실패를 원자적으로 기록한다
결제 한 건의 결과는 여러 테이블에 동시에 반영됩니다. 성공하면 완료된 인보이스를 만들고 구독의 다음 청구 기간을 갱신하며 결제 성공 이벤트를 로그로 남깁니다. 이 세 가지가 따로 저장되다가 중간에 실패하면 데이터가 어긋납니다. 결제는 됐는데 다음 청구 기간이 갱신되지 않으면 다음 날 또 청구되니까요. 그래서 이 갱신들을 하나의 트랜잭션으로 묶어 전부 반영되거나 전부 되돌아가도록 했습니다.
실패도 마찬가지로 원자적으로 다룹니다. 결제사가 카드 한도 초과나 만료 같은 이유로 청구를 거절하면 실패한 인보이스를 남기고 구독 상태를 연체로 바꾸며 실패 이벤트를 기록합니다. 이 역시 한 트랜잭션입니다. 고객에게는 결제가 실패했고 유예 기간이 언제까지인지를 안내하는 메일을 보냅니다. 다만 메일 발송은 결제 처리와 분리했습니다. 메일 서버가 잠깐 죽었다고 결제 기록까지 되돌리면 안 되기 때문에, 메일은 실패해도 로그만 남기고 넘어가는 별도 처리로 두었습니다.
한 가지 더 신경 쓴 부분은 재시도 정책입니다. 결제 작업이 실패했을 때 BullMQ가 이를 자동으로 재시도하면 방금 처리한 연체 기록과 메일 발송이 반복될 수 있습니다. 그래서 청구 로직 안에서 실패를 이미 데이터베이스 갱신과 메일로 다 처리한 경우에는 작업 자체는 던지지 않고 정상 종료로 되돌립니다. 실패 처리는 청구 로직이 책임지고 큐의 재시도가 그 위에 겹쳐 부작용을 만들지 않게 했습니다.
정리하며
정기결제를 붙이며 배운 것은 결제사가 청구를 대신해 주지 않는 순간 그 스케줄링과 멱등성이 온전히 우리 몫이 된다는 사실이었습니다. 크론은 직접 돌리고 중복은 작업 식별자와 데이터베이스 확인과 고정 주문 번호로 겹겹이 막고 성공과 실패는 각각 하나의 트랜잭션으로 기록하는 것. 화려하지 않지만 돈을 다루는 흐름에서는 이 지루한 방어가 전부입니다.
결제가 실패한 뒤 구독이 연체에서 정지, 종료로 넘어가는 흐름은 구독 생명주기 상태 머신에서, 서버 SDK 없이 토스에 직접 청구를 올리는 방법은 서버 SDK 없는 토스 정기결제 연동에서 이어집니다. 전체 구조는 SaaS 운영 콘솔 구축기에 있습니다.
자주 묻는 질문
토스페이먼츠가 정기결제를 자동으로 청구해 주지 않나요?
빌링키 방식은 그렇지 않습니다. 빌링키는 그 카드로 언제든 결제를 올릴 수 있는 열쇠일 뿐, 언제 얼마를 청구할지는 우리 서버가 직접 정해 호출해야 합니다. 그래서 매일 한 번 만기 구독을 찾아 청구하는 크론을 직접 돌리는 방식으로 정기결제를 구현했습니다.
같은 달에 두 번 청구되는 사고는 어떻게 막나요?
세 겹으로 막습니다. 결제 작업에 구독과 월을 조합한 식별자를 붙여 큐 단계에서 중복을 거르고 실제 청구 직전에 이번 기간에 완료된 인보이스가 있는지 확인하며 청구 주문 번호도 구독과 월로 고정해 결제사 쪽에서도 같은 주문이 반복되지 않게 합니다.
결제가 실패하면 어떤 일이 일어나나요?
실패한 인보이스를 남기고 구독을 연체 상태로 바꾸며 실패 이벤트를 한 트랜잭션으로 기록한 뒤, 고객에게 유예 기간을 안내하는 메일을 보냅니다. 유예 기간이 지나도 결제가 되지 않으면 생명주기 워커가 구독을 정지하고 더 지나면 서버까지 정리하며 종료합니다.