돈 시스템은 조용히 부러진다: 실패를 하루 안에 보이게 만드는 설계
구독 기반 서비스를 혼자서 만들어 돈까지 직접 받는 개발자라면, 이 글이 주는 답은 하나다. 결제 사고는 크래시도 에러 로그도 남기지 않은 채 수익을 조용히 흘려보낸다. 설계로 줄일 수 있는 것은 사고가 보이지 않는 시간.
고객의 카드는 지난달에 만료됐다. 청구일에 시스템이 결제를 시도했고 실패했고 이틀 뒤에 다시 시도해서 또 실패했다. 그리고 아무 일도 일어나지 않았다. 상태 변화도, 알림 메일도, 로그도 없다. 고객은 한 달 내내 서비스를 계속 썼고 그 달의 매출은 존재하지 않는다. 관리자 화면을 열어 보면 그 구독은 여전히 활성이다.
돈 시스템의 최악의 실패 방식은 결제 실패 자체가 아니다. 시스템이 그 사실을 모를 때 진짜 위험이 시작된다. 이 글은 왜 실패가 소리 없이 수익을 새게 하는지, 그리고 돈 이력과 이벤트로만 움직이는 상태 머신, 멱등 키, 대조 작업이 어떻게 그 틈을 하루 안에 드러내는지 논증한다. 목표는 에러가 하루 안에 보이는 시스템.
글의 핵심 개념을 형상화했습니다.
조용히 부러진다는 말의 뜻
1인 개발자의 코드에는 모든 것이 한 프로세스 안에 있다는 성질이 있다. 데이터베이스도, 파일도, 상태도 거기 있고 틀리면 롤백하면 된다. 결제는 이 성질을 세 가지로 깬다.
첫째, 상대가 내 프로세스 밖에 있다. 결제 요청은 네트워크를 지나 결제 서비스 제공사 PSP로 가고 카드사와 은행으로 간다. 각각은 별개의 회사고, 별개의 시계로 움직인다. 돈의 이동은 어떤 트랜잭션 경계로도 묶을 수 없다.
둘째, 시간이 시스템 안으로 들어온다. 구독에는 청구 주기가 있고 정산에는 지연이 있고 환불은 카드에 도착하는 데 며칠이 걸린다 [추정]. API가 반환된 순간은 끝이 아니다. API 응답은 접수했다는 뜻이고 실제 사실은 나중에 이벤트라는 형태로 온다.
셋째, 효과를 취소할 수 없다. 데이터베이스의 행은 지우면 사라진다. 돈은 움직인 뒤 되돌려 보낼 수 있을 뿐. 환불은 과거를 되돌리는 것이 아니다. 새로운 이동. 그 새로운 이동에는 자기만의 실패 방식이 있다.
정보가 완비되어 있고 순서대로 온다는 가정은 성립하지 않는다. 실패는 쌓이는 기간으로 문제다. 중간 정산 오류는 다음 청구 주기에, 환불 누락은 다음 정산일에, 웹훅 유실은 다음 대조 시점에 조용히 쌓인다. 매출이 조금씩 줄어드는 것과 같은 구조다.

진실은 두 번 온다: 응답과 이벤트
돈 시스템에는 정보가 두 종류 있다. API 응답은 내 요청을 접수했다는 뜻뿐이다. 청구 요청에 200이 돌아온다는 것은 청구하겠습니다라는 약속이지, 돈이 움직였다는 증거가 아니다. 승인도 정산도 아직이다.
웹훅 이벤트, invoice.paid, charge.refunded, customer.subscription.updated가 돈의 사실이다. 이 사실은 비동기로, 최소 한 번씩, 순서를 지키지 않은 채로 온다. 이벤트가 두 번 올 수 있고 오래된 이벤트가 새로운 이벤트보다 늦게 올 수 있다.
그래서 설계 규칙은 하나다. 구독 상태 머신은 이벤트로만 움직여야 한다. 사용자가 해지 버튼을 누르고 API가 200을 반환하는 것은 상태 전이 요청의 접수일 뿐이고, 실제 전이는 뒤에 오는 이벤트 또는 대조 작업이 수행한다. 요청 핸들러가 직접 구독 상태를 바꾸는 코드 경로가 남아 있으면 그것은 구멍이다. 상태의 진실원이 둘이 되면 반드시 어긋난다.
가장 흔한 사고를 하나 보자. 고객이 PSP 화면에서 구독을 해지했다. PSP의 상태는 canceled가 됐고 웹훅이 보내졌다. 그런데 그날 밤 내 서버는 배포 중이었고 웹훅은 500을 받았다. PSP는 며칠 재시도하다 포기했다. 내 데이터베이스는 여전히 active다.
결과는 두 방향 모두 나쁘다. 청구는 멈췄는데 서비스는 켜진 채로, 고객은 쓰는데 매출은 존재하지 않는다. 또는 그 반대, 청구는 계속되고 고객은 이미 해지했다고 믿고 있으며 다음 단계는 분쟁이다. 둘 다 에러를 남기지 않는다. 부러진 곳은 시스템이 아는 사실과 돈이 움직인 사실 사이의 틈이다.

이력을 먼저 쓰라
돈과 관련된 모든 일, 청구, 환불, 상태 전이는 상태를 바꾸기 전에 기록 테이블에 한 줄을 쓰는 것입니다. 이를 돈 이력이라고 부르자. 최소 필드는 이벤트 식별자, 타입, 시각, 정수 금액과 통화, 전후 상태, PSP 외부 참조, 출처다.
한 줄이 실제로 생긴다. evt_00217, 타입 invoice.paid, 시각 2026-07-08T09:14:03Z, 구독 sub_42, 금액 33000 KRW, 전 상태 open, 후 상태 paid, 출처 webhook, PSP 참조 in_9f2c. 이 한 줄은 그날 무슨 일이 있었는지를 답하는 유일한 문장.
웹훅이 중복되고 순서를 지키지 않는 세계에서, 도착 순서대로 처리하면 상태가 꼬인다. 이력이 있으면 꼬인 상태를 기록에서 다시 유도할 수 있고, 이력이 재현 가능한 유일한 진실원이 된다. 고객이 돈을 냈는데 서비스가 안 켜진다고 할 때, 그날의 일은 기억나지 않는다. 이력이 기억해준다. 해당 구독의 이벤트를 시간순으로 읽으면 대부분의 분쟁은 로그 읽기 단계에서 끝난다.
대조 작업에도 이력이 필요하다. 내 데이터베이스와 PSP 데이터를 비교하는 밤 작업에는 내가 처리한 이벤트 목록이 들어야 한다.
실무적 경고가 하나 있다. 이벤트 기록과 상태 변경은 같은 트랜잭션에 넣을 것. 상태를 먼저 바꾸고 기록을 쓰다가 죽으면, 기록 없는 상태가 생긴다. 왜 바뀌었는지 알 길이 없다. 기록을 먼저 쓰고 상태 변경 도중 죽으면, 기록만 있고 상태는 제자리다. 이것은 기록을 재생하면 된다. 돈 시스템에서는 항상 다시 쓸 수 있는 쪽을 고른다.

상태는 네 개면 충분하다
구독 상태 머신의 최소 구성은 네 개다. 이 네 개를 움직이는 것은 이벤트뿐이고 past_due에는 반드시 나가야 할 길이 있어야 한다. 고객이 카드를 갱신하고 재시도가 성공하면 active로 돌아오는 경로다. 이런 경로가 없으면 past_due는 unpaid 앞의 대기실이 된다.
| 상태 | 의미 | 서비스 효과 |
|---|---|---|
| active | 정상 청구 진행 중 | 이용 가능 |
| past_due | 청구 실패, 재시도 중 | 유예 정책에 따름 |
| canceled | 갱신 중단 | 유료 기간까지 이용 |
| unpaid | 재시도 소진 | 이용 중단 |
가장 자주 빠지는 상태가 past_due다. active와 canceled만으로 만들면 청구 실패의 선택지는 둘 중 하나다. 즉시 canceled로 보내면 카드가 만료됐을 뿐 해지할 의도는 없던 고객이 갱신 기회조차 잃고 이탈한다. 영원히 active로 두면 수익이 새어나간다. 세 번째 상태가 없는 자리에서 돈이 조용히 흐른다.
실패 청구에는 최종 절단이 있는 스케줄이 필요하다. 청구일에 1차 청구를 하고 실패하면 past_due로, 3일 뒤에 2차 청구와 안내 메일을, 7일 뒤에 3차, 14일 뒤에 최종 청구를 보내고 실패하면 unpaid로 만든다. 초기 간격이 짧은 이유는 대부분의 실패가 일시적이기 때문이다. 잔액 부족, 카드 만료, 은행 쪽 오류. 후기 간격은 길다. 거기까지 남은 경우가 카드가 정말 죽었거나 갱신할 의사가 없는 쪽이니까. 절단이 없으면 past_due가 상시 상태가 되고 이 고객은 지금 돈을 내고 있는지에 답할 수 없게 된다.
유예 기간, 실패 동안 서비스를 유지할지 중단할지는 비즈니스 결정이다. 다만 문서로 적어 두고 상태에 매겨야 하며 시간에 매기지 말아야 한다. 첫 실패에 즉시 끊으면 고객은 갱신 기회 없이 서비스를 잃고, 영원히 유지하면 수익이 새어 나간다. 안내 메일은 두 통이면 충분하다. 첫 실패 때 PSP의 호스티드 카드 갱신 화면 링크를 넣은 메일과, 최종 절단 직전의 마지막 기회 메일.

중복은 키로, 유실은 대조로
결제 요청에는 중복의 세 경로가 있다. 타임아웃이라 다시 보낸 내 재시도, 모르고 켜 둔 게이트웨이의 재시도, 그리고 사용자의 이중 클릭. 이 셋은 전부 같은 결제 의도를 두 번 실행시키려 한다.
규율은 결제 의도 하나에 키 하나다. 클라이언트가 UUID를 만들어 idempotency-key로 보내면, PSP는 같은 키에 같은 결과를 돌려주고 두 번 청구하지 않는다. 키가 없으면 타임아웃은 미스터리다. 첫 요청이 청구됐는지 알 수 없기 때문이다. 키가 있으면 타임아웃은 정보 부재가 되고 같은 키로 다시 보내면 된다. 다만 키는 다른 의도에 재사용하면 안 된다.
실패 청구 관리에도 같은 함정이 있다. PSP에 자동 재시도 기능이 있고 나도 내 스케줄을 만들면, 두 재시도 엔진이 동시에 돈다. 한 번의 실패에 PSP가 재시도하고 나도 재시도하면, 고객은 같은 주기에 두 번 청구를 받을 수 있다. PSP 자동 재시도에 맡기면 내 스케줄은 폴백으로만, 내 스케줄을 만들면 PSP 자동 재시도를 끈다. 둘 다 켜 놓는 것은 우연.
웹훅 재시도는 유실을 전부 막아주지 않는다. PSP는 실패한 웹훅을 간격을 늘리며 며칠 정도 재시도하는데 [추정], 내 엔드포인트가 그 기간보다 오래 죽어 있으면 이벤트는 영영 사라진다. 재시도는 짧은 장애를 덮는 안전망일 뿐이다. 그래서 대조 작업이 필요하다. 매일 밤, 또는 주 1회, 스케줄로 돌아가는 비교다. PSP가 믿는 구독 상태와 내가 믿는 상태를, 청구서와 환불의 금액과 상태를 비교한다.
차이를 처리할 때 원칙은 하나다. PSP를 믿는 것. PSP의 데이터가 돈의 상태이고 내 것은 사본이며 어긋난 쪽은 사본이다. 다만 사본을 고치는 행위도 이력에 이벤트로 기록한다. 출처는 reconciliation. 그 기록이 나중에 상태가 왜 바뀌었는지를 설명하는 증거가 된다. 결제했는데 서비스가 안 켜진다, 해지했는데 또 청구됐다라는 티켓도 이력과 대조 차이로 답한다. 대조가 없으면 은행 입금 내역과 지원 티켓을 옆에 붙여 놓고 맞추는 것, 즉 발굴 작업이 남는다.
환불은 버튼이 아니라 트랜잭션
환불 요청은 돈 시스템이 진짜로 시험받는 순간이다. 청구는 PSP 뒤로 숨길 수 있지만 환불은 내 코드와 PSP와 고객의 카드가 같은 숫자에 동의해야 하는 자리다. 가장 흔한 설계 실수는 환불을 버튼으로 만드는 것이다. 버튼을 누르면 상태가 refunded가 되는데, 실제로는 아직 아무 일도 일어나지 않았다.
환불은 상태가 있는 트랜잭션이다. pending, succeeded, failed. failed 환불은 실제로 일어난다. 카드 만료, 계좌 폐쇄, 은행 거절의 경우다. succeeded 환불도 즉시 끝나지 않는다. 고객의 카드에 도착하는 데 며칠이 걸린다 [추정]. 요청 순간에 refunded를 만들면, 일어날지도 모르는 사실을 기록한 것이 된다. 환불 요청은 이벤트이고 환불 결과도 이벤트이며 상태는 결과 이벤트로만 움직인다.
청구한 돈은 아직 내 것이 아니다. PSP가 거두고 보유하고 정산 사이클에 맞춰 입금하는데, 사이클은 PSP와 계좌에 따라 다르다 [추정]. 거둔 돈에서 수수료와 환불과 분쟁 손실이 빠진다. 33000원 청구서 한 장에 약 4퍼센트 [추정] 수준의 수수료가 붙고 나중에 전액 환불이 발생하면 그 청구분의 수수료는 돌아오지 않는다 [추정]. 환불은 돈을 돌려주고 수수료를 잃는 것이다. 분쟁이 된 청구라면 청구액에 분쟁 수수료가 더해지고, 진다면 금액은 회복되지 않는다. 매달 장부 매출과 실제 입금을 맞보는 습관이 필요한 이유다. 차이가 설명되면 시스템은 건강하고, 설명이 안 되면 이력에서 빠진 기록이 있다. 우연이 아니다.
끝으로 늘 뒤섞이는 두 개념, 취소와 환불을 갈라 놓는다. 취소는 향후 청구를 멈추는 것이지, 돈은 움직이지 않는다. 환불은 돈을 되돌리는 것이고 보통 현재 유료 기간을 끝낸다. 환불이라는 제목의 티켓 상당수는 사실 더 이상 청구되지 않게 해 달라라는 요청이며, 해지 요청 안에는 낸 돈을 돌려받고 싶다는 기대가 숨어 있다. 그래서 UI에는 버튼이 두 개, 코드에는 경로가 두 개, 지원 답변의 첫 문장은 둘 중 무엇을 원하시냐는 질문이다. 이 구분이 흐려지는 순간, 매출 예측은 깨진다.
환불 요청이 오면 순서대로 세 가지를 묻는다. 법적 또는 계약적 의무가 있는가. 의무가 있다면 판단은 거기서 끝이다. 거절의 비용은 얼마인가. 분쟁에 드는 시간, 부정 리뷰, 몇 주간의 지원 대화. 시간은 이 사업에서 가장 비싼 비용 항목이다. 환불의 비용은 얼마인가. 이미 소비된 사용량, PSP 수수료, 그리고 선례. 판단 결과와 이유는 항상 이력에 기록한다. 다음에 같은 요청이 오면 일관성으로 답하고 분쟁이 오면 그 판단 이유가 곧 증빙이 된다.
목표는 에러가 없는 시스템이 아니다
왜 에러를 막을 수 없느냐. 상대가 내 프로세스 밖에 있고 시간이 시스템 안에 들어와 있고 효과가 취소할 수 없어서다. 셋 중 어느 하나도 내가 통제하지 않는다. 에러가 전혀 없는 돈 시스템은 기원으로 얻는 것이지, 설계로 얻는 것이 아닙니다.
목표는 에러가 하루 안에 보이는 시스템이다. 이 글의 도구를 다시 보자. 이력은 실패가 쌓인 기간을 읽을 수 있는 로그로 되돌리고 이벤트로만 움직이는 상태 머신은 진실원을 하나로 묶고 멱등 키는 중복을 원천에서 없애고 대조는 유실을 밤에 찾아내고, 환불 트랜잭션은 아무 일도 일어나지 않은 버튼이라는 실수를 없앤다. 전부 같은 한 마디를 위해 있다. 나면 하루 안에 보이게 하기 위해서다.
1인 개발자에게 이 목표는 팀 기준보다 높다. 감시기는 사람 수만큼 줄어든다. 팀이 있으면 웹훅이 밤에 죽어도 온콜이 있고 대조를 돌릴 사람이 따로 있고 은행 입금을 보고 있을 사람이 따로 있다. 혼자면 그 역할이 전부 나다. 새벽에 고객이 보낸 환불 요청 메일이 알람이고 정산 불일치의 첫 탐지자가 내가 보는 은행앱이다.
그래서 두 가지 규율이 더 중요해진다. 첫째, 자동화되지 않은 감시기는 없다. 내가 눈으로 볼 수 없는 주기로 돌아가는 대조 작업은 없는 것과 같다. 둘째, 이력을 읽을 수 있어야 한다. 장애가 났을 때 내 기록에서 원인을 10분 안에 찾아내는 수준. 돈 시스템의 운영 능력은 이력 하나의 품질에 걸려 있습니다.
결국 돈이 지나가는 자리의 일은 모든 에러가 하루 안에 보이게 만드는 것이다. 이력이 정렬되어 있고 대조 차이는 화면에 있고 돈은 조용히 새어나가지 않는 한, 서비스는 잘 돌아가고 있다는 신호는 믿을 수 있다. 각 도구의 세부 구현, 중간 정산의 엣지 케이스부터 세금의 경계까지는 이 글을 함께 내는 전자책에서 다룬다.