본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 25. 12:11

AI SaaS 크레딧을 단일 정수(Integer)로 저장했습니다. 그러다 환불 요청이 시작되었습니다.

요약

AI SaaS 운영 중 단일 정수(Integer)로 크레딧을 관리할 때 발생하는 데이터 유실 및 추적 불가 문제를 다룹니다. 잔액을 직접 업데이트하는 대신, 모든 변경 사항을 행(Row)으로 기록하는 원장(Ledger) 방식의 필요성을 강조합니다.

핵심 포인트

  • 단일 컬럼 방식은 변경 이력을 추적할 수 없어 환불 및 오류 대응이 불가능함
  • 재시도나 중복 결제 발생 시 데이터 정합성을 보장하기 어려움
  • 잔액을 직접 수정하지 말고, 모든 변동 사항을 별도 행으로 추가하는 원장 방식을 사용해야 함
  • 금융 데이터는 합산(Aggregation)을 통해 잔액을 산출하는 구조가 안전함

가끔 팀원 중 누군가는 기록해 둘 만한 가치가 있는 실수를 저지르곤 합니다. 이것은 그 실수를 직접 겪은 엔지니어가 작성한 결제(Billing)에 관한 이야기입니다.

AI SaaS를 출시할 때 무서운 점은 텅 빈 출시 당일이 아닙니다. 실제 사람들이 결제를 시작하고, 크레딧(Credits)을 소모하며, 요청을 재시도하고, 환불을 요구하며, 크론 잡(Cron jobs)을 실행하고, 결제 코드에 남겨둔 모든 지름길을 찾아내기 시작하는 첫 일주일입니다.

사용량 기반 결제(Usage-based billing)는 가격 페이지에 있을 때는 단순해 보입니다. 하지만 모든 AI 작업에 비용이 발생하고, 모든 재시도가 누군가에게 이중 청구될 수 있으며, 모든 고객 지원 티켓이 데이터베이스가 설명할 수 없는 숫자로 시작될 때는 엉망이 됩니다.

저의 첫 번째 크레딧 시스템은 users 테이블의 단일 컬럼인 credits였습니다. 팩을 구매하면 올라가고, 생성을 실행하면 내려갑니다. 저는 그것을 오후 한나절 만에 구축했고, 코드가 아주 적게 들어간다는 사실에 스스로 똑똑하다고 느꼈습니다.

실제 돈이 등장하기 전까지는 잘 버텨주었습니다. 어느 화요일, 저는 결제 대시보드를 열었다가 환불, 제가 알지 못하는 이름으로부터의 차지백(Chargeback), 그리고 요청 시간이 초과되어 재시도되는 바람에 두 번 결제된 구매자를 발견했습니다. 그러고 나서 저는 아직도 토씨 하나 틀리지 않고 기억하는 이메일을 받았습니다:

"왜 제 크레딧이 37개뿐이죠? 500개를 샀고 거의 쓰지도 않았는데요."

저는 psql 셸을 열고 제가 가진 유일한 쿼리를 실행했습니다:

select credits from users where id = '...'; -- 37

37개. 그것이 제 데이터베이스가 저에게 말해줄 수 있는 이야기의 전부였습니다.

나머지 463개가 어디로 갔는지, 언제 갔는지, 왜 갔는지는 알 수 없었습니다. 모든 credits = credits - 1 연산은 이전 숫자를 덮어쓰고 그 이유를 바닥에 떨어뜨려 버렸습니다. 저는 "확인해 보겠습니다"라고 답장을 쓰기 시작했다가, 확인할 수 있는 것이 아무것도 없다는 사실을 깨달았습니다.

그 이메일 때문에 저는 시스템 전체를 뜯어내고 원장(Ledger) 방식으로 재구축했습니다. 여기 그 모델과, 각 부분이 저에게 무엇을 가르쳐 주었는지에 대한 정확한 순간들이 있습니다.

카운터(Counter)는 마지막에 일어난 일만을 기억합니다

카운터(Counter)를 사용하면, 고객이 제품에서 쌓아온 전체 금융 이력은 어떤 코드 경로에서도 짓밟을 수 있는 하나의 가변적인 정수(Integer)가 되어버립니다. 지급, 사용, 환불, 버그, 그리고 그 버그에 대한 수정까지. 이 모든 것이 단 하나의 숫자로 붕괴되며, 그 순간 당신은 질문할 가치가 있는 모든 질문을 스스로 던져버리게 됩니다:

  • "왜 잔액이 이 수치인가?" 이를 알기 위해서는 해당 잔액을 만든 모든 변경 사항이 필요합니다.
  • "이 구매 건을 환불해 주세요." 이를 처리하려면 해당 구매가 실제로 무엇을 지급했는지 알아야 합니다.
  • "재시도(Retry)로 인해 사용자에게 크레딧이 중복 지급되었는가?" 이를 확인하려면 중복된 쓰기(Write)를 찾아내야 합니다.
  • "우리의 크레딧이 Stripe와 일치하는가?" 이를 위해서는 양측 모두에 추적 가능한 기록(Paper trail)이 필요합니다.

단일 숫자는 이 중 그 어떤 것에도 답할 수 없습니다. 환불이 발생하는 순간부터, 당신에게 필요한 것은 잔액이 아닙니다. 그 잔액을 만들어낸 항목들의 목록입니다.

잔액을 업데이트하지 마세요. 행(Row)을 추가하세요.

해결책은 지루하며, 바로 그 점이 핵심입니다. 다시는 잔액을 업데이트하지 마세요. 변경 사항당 하나의 부호가 있는 행(Signed row)을 작성하고, 숫자가 필요할 때 그것들을 모두 합산하면 됩니다.

create table credit_entries (
  id              bigint generated always as identity primary key,
  user_id         uuid not null references users(id),
...

updated_at은 없습니다. 행은 한 번 작성되면 다시는 건드리지 않습니다. 잔액은 합계입니다:

select coalesce(sum(amount), 0) as balance
from credit_entries
where user_id = $1;

37크레딧을 가졌던 그 남자를 기억하시나요? 원장(Ledger) 상에서 그의 미스터리는 그저 순서대로 나열된 그의 이력일 뿐입니다:

+500   purchase                    2026-05-02 09:14
 -463  generation (x463 entries)   2026-05-02 -> 05-09
-----
...

"거의 건드리지도 않았다"던 사람이 일주일 만에 463번의 생성(Generation)을 수행했습니다. 저는 답장에 이 이력을 붙여넣었습니다. 그는 이렇게 답장을 보냈습니다: "아, 안 돼. 그건 제 크론 잡(Cron job)이었네요."

데이터가 마침내 스스로를 말할 수 있게 되었기에, 단 한 메시지로 상황이 종료되었습니다.

무료 크레딧을 퍼주는 재시도(Retry)

이것은 실제로 저에게 돈을 지출하게 만든 경우입니다. Stripe의 웹훅 전송 방식은 exactly-once가 아니라 at-least-once입니다. 중복 전달은 버그가 아니라 계약 사항입니다.

어느 날 오후에 Stripe가 checkout.session.completed를 발생시켰고, 제 핸들러는 500 크레딧을 부여했습니다. 제 서버가 200 응답을 보내는 것이 느렸기 때문에, Stripe는 전달이 실패했다고 가정하고 동일한 evt_ id로 다시 전송했습니다. 그리고 세 번째로도요.

구매자가 한 시간 뒤에 저에게 메시지를 보냈습니다:

"ㅋㅋ 제가 갑자기 1,500 크레딧을 갖게 되었네요"

같은 이벤트, 같은 시그니처가 세 번 처리되었고, 저는 그 비용을 지불했습니다.

유혹적인 해결책은 쓰기 전에 확인하는 것입니다. 하지만 그렇게 하지 마세요. 그것은 경주(race)이며, 두 개의 전달 모두 검사를 통과할 수 있습니다:

// 금지: 두 개의 동시 전달이

직렬화 가능(serializable) 수준으로 실행하거나, 사용자별 어드바이저리 락(advisory lock)을 사용하여 두 트랜잭션이 동일한 시작 잔액에 대한 검사를 동시에 통과할 수 없도록 하세요. `jobId`를 키(key)로 사용하면 재시도된 작업이 두 번 청구되지 않음을 의미하기도 합니다.

## 환불은 그저 행(row) 하나를 더 추가하는 것일 뿐입니다

판매 후 40일이 지나 결제 취소(chargeback)가 들어옵니다. 그 결제로 지급되었던 크레딧은 이미 2주 전에 사용되었습니다. 카운터(counter) 방식을 사용 중이라면, 더 이상 존재하지 않는 과거 기록을 수정할지, 아니면 그런 일이 없었던 것처럼 가장할지 사이에서 갈등하게 됩니다.

추가 방식(append-only)의 원장(ledger)을 사용한다면 다음과 같습니다:

```sql
insert into credit_entries (user_id, amount, reason, ref_type, ref_id)
values ($user, -500, 'refund', 'credit_entry', $original_entry_id);

잔액은 정직하게 감소하며, 필요하다면 마이너스가 됩니다. 그리고 새로운 행은 취소하려는 해당 구매 건을 직접 가리킵니다.

어뷰징 가입(farmed signup)을 적발했을 때도 방식은 같습니다. 컬럼(column) 값을 직접 수정하며 산술 계산이 맞기를 기도하는 대신, 마이너스 회수(clawback) 행을 추가하세요. 환불, 결제 취소, 사기, 고객 지원팀의 수동 수정 모두 동일한 작업입니다. 행을 하나 더 쓰면 됩니다.

의도적으로 지루하게 만드는 것이며, 돈과 관련된 곳에서는 바로 그 지루함이 당신이 원하는 것입니다.

"SUM 연산이 느려지지 않을까요?"

출시 당시 해당 잔액 쿼리는 2ms였고, 저는 이에 대해 전혀 생각하지 않았습니다. 행이 약 400만 개 정도 쌓이자 200ms로 늘어났고, 잔액을 확인하는 모든 엔드포인트의 p95(95번째 백분위수)가 느려지기 시작했습니다.

해결책은 원장(ledger)을 포기하는 것이 아닙니다. 특정 시점까지의 잔액을 스냅샷(snapshot)으로 찍는 체크포인트(checkpoint) 행을 주기적으로 작성한 다음, 그 이후의 내역만 합산(sum)하면 됩니다.

원장은 신뢰할 수 있는 단일 원천(source of truth)으로 유지되며, 체크포인트는 신뢰할 수 없게 될 때 언제든 다시 구축할 수 있는 캐시(cache) 역할을 합니다. 프로파일러(profiler)가 요구하기 전까지는 미리 만들지 마세요. (user_id, created_at)에 인덱스(index)를 거는 것만으로도 생각보다 훨씬 오래 버틸 수 있습니다.

초기에 시작할 가치가 있는 또 하나의 습관은, 제휴사(affiliates)에게 비용을 지불하거나 추천 보너스(referral bonuses)를 지급하는 날, 그 이동을 복식부기(double-entry)로 처리하는 것입니다. 한 계정에서 나가는 모든 크레딧(credit)은 다른 계정으로 들어가는 크레딧이 되어야 하며, 따라서 장부의 총합은 항상 0이 되어야 합니다. 제 장부가 처음으로 0이 되지 않았을 때, 단 1센트의 오차가 발생했습니다. 하지만 복식부기 덕분에 아무도 계획하지 않았던 6주 뒤의 대조(reconciliation) 작업 대신, 그날 오후에 바로 문제를 잡아낼 수 있었습니다.

과거의 나에게 해주고 싶은 말

  • 정수(integer) 단위로 저장하세요, 절대 부동 소수점(float)을 사용하지 마세요. 부동 소수점 연산은 시간이 흐르며 오차가 누적되어 잔액이 0.000001로 끝나는 상황을 맞이하게 만드는 주범입니다.
  • 원장(ledger) 행을 절대 삭제하지 마세요. 잘못된 입력인가요? 그렇다면 취소(reversal) 항목을 추가하세요. DELETE는 감사 추적(audit trail)을 영구적으로 망가뜨립니다.
  • 모든 항목에 이유와 그것을 유발한 원인에 대한 참조(reference)를 부여하세요. 그 컬럼 하나가 분쟁에 대해 10초 만에 답변하느냐, 아니면 추측만 하느냐의 차이를 만듭니다.
  • 속도를 원한다면 캐시된 잔액(cached balance)을 유지하되, 원장(ledger)을 유일한 진실(source of truth)로 취급하고 두 값이 일치하지 않는 즉시 다시 유도(re-derive)하세요.

솔직한 트레이드오프(tradeoff)를 말씀드리자면, 이는 credits -= 1보다 더 많은 코드를 필요로 합니다. 하지만 이는 고객에게 자신의 이용 내역을 그대로 읽어줄 수 있느냐, 아니면 확인할 내용도 없으면서 "이 부분을 확인해 보겠습니다"라고 말하느냐의 차이이기도 합니다.

우리는 AI 제품을 위해 Stripe 결제(checkout), 사용량 기반 과금(usage-based billing), 사용량 측정(usage metering), 고객 크레딧(customer credits), 환불(refunds), 멱등성 웹훅(idempotent webhooks), 그리고 고객 지원팀이 실제로 설명할 수 있는 원장(ledger)까지 동일한 스택을 계속해서 재구축해야 했습니다. 그래서 우리는 이를 MIT 라이선스로 오픈 소스화하여 Harness라는 이름으로 공개했습니다: github.com/velobase/velobase-harness.

만약 여러분이 AI SaaS를 구축 중이고, 첫 실제 사용자들이 여러분의 결제 테스트 스위트(billing test suite)가 되는 것을 원치 않는다면, 이 방식이 고통스러운 몇 주를 아껴줄 수 있을 것입니다.

이 시스템을 운영 환경(production)에서 실행해 보셨다면: SUM(합계) 대 스냅샷(snapshot)의 트레이드오프를 어떻게 처리하시나요? 누적 합계(running totals), 체크포인트 행(checkpoint rows), 아니면 크론(cron) 작업으로 대조하는 캐시된 잔액을 사용하시나요? 무엇이 잘 버텨주었는지 진심으로 듣고 싶습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0