Beenslab

거래소에서 배운 잔고 시스템을 AI SaaS 과금에 옮기기

빈 프로젝트에 과금과 복식부기 원장을 설계하고, 구조를 뒤집고, 결함을 고쳐 온 기록입니다.

ChangBeen Seo조회수

올해 2월, 하이브랩에서 AI B2B SaaS인 AVIKO의 백엔드를 맡았습니다. AI 에이전트, 문서, 회의록, 워크플로처럼 AI를 쓰는 기능이 여럿인 업무 플랫폼입니다. 쓴 만큼 크레딧을 차감하는 과금이 필요했는데, 아무것도 없었습니다.

참고할 것은 하나 있었습니다. 직전 직장인 가상자산 거래소의 잔고 시스템입니다.

거래소에서 가져온 두 가지

거래소는 MSA였습니다. 거래소, 주문, 잔고, 지갑, 계정이 각각 따로 있었습니다. 다른 서비스는 잔고와 주문을 직접 건드릴 수 없었고, gRPC로 열어 둔 메서드로만 다뤘습니다.

장부와 잔고 대사도 다뤘습니다. 잔고가 왜 이 값인지 거래 기록으로 설명할 수 있어야 했습니다.

그래서 처음부터 두 가지를 정했습니다.

  1. 잔고는 정해진 입구로만 다룬다.
  2. 원장은 복식부기로 남긴다.

입구는 Facade 하나

지금 잔고 코드는 백엔드 모노레포 안에 같이 있습니다. 그래도 나중에 별도 서비스로 떼어 낼 생각으로, 잔고에 닿는 길을 Facade 하나로 막았습니다. 밖에서 부를 수 있는 건 의도가 드러나는 메서드뿐입니다.

// 예시입니다. 실제 시그니처와 다릅니다.
billing.admit(); // 실행 전 입장 판정과 잔고 예약
billing.release(); // 예약 해제
billing.record(); // 사용 기록
billing.charge(); // 사용량 차감
billing.credit(); // 결제 뒤 크레딧 지급
billing.query(); // 조회

원장의 기본 연산을 Facade를 거치지 않고 가져다 쓰는 코드는 lint 규칙과 hook으로 막았습니다. 차감 알림을 받는 소비자 코드에는 "별도 서비스로 분리돼도 이 consumer만 옮기면 된다"는 주석을 남겼습니다. 잔고를 떼어 내는 날이 오면 이 메서드들이 그대로 gRPC 메서드가 됩니다.

첫 설계를 뒤집은 이유

첫 구현은 단순했습니다. AI 실행이 끝나면 같은 트랜잭션 안에서 바로 차감했습니다. 입장 판정도 "잔고 합이 0 이하면 402"였습니다.

문제는 기능이 늘수록 차감 코드도 기능마다 생긴다는 것이었습니다. 단가를 바꾸거나 대사를 할 때 경로 수만큼 일이 늘어납니다.

그래서 구조를 뒤집었습니다. 기능은 사용 이벤트를 남기는 데까지만 합니다. 단가 계산과 차감은 엔진 하나가 합니다. 기록이 먼저고, 차감은 나중입니다.

과금 한 건의 흐름

사용량 과금 한 건의 흐름

결과 확정과 사용 이벤트 기록을 한 트랜잭션으로 묶은 이유는 하나입니다. 결과는 남았는데 과금 기록이 없는 상태를 아예 만들지 않기 위해서입니다. 처음에는 차감이 빠진 작업을 나중에 찾아 회수하는 워커를 만들었는데, 과거 데이터를 소급 청구할 위험이 있어 버렸습니다.

사용 이벤트의 상태는 세 가지뿐입니다. recorded에서 charged나 skipped(0원)로 한 방향으로만 움직입니다. 차감에 실패하면 recorded에 남아 다시 시도합니다.

차감은 두 길로 합니다. 기록 직후 Redis Stream으로 알림을 받은 소비자가 바로 차감하고, 주기적으로 도는 엔진이 놓친 것을 줍습니다. 처음에는 엔진만 있었습니다. 그러면 예약이 풀리기까지 주기만큼 기다려야 해서 Stream을 더했습니다.

402와 429

admit은 순서대로 봅니다. 워크스페이스 상태, 구독과 한도, 잔고 예약, 동시 실행 수입니다. 중간에 걸리면 앞에서 잡은 예약을 스스로 풉니다.

상태코드는 사용자가 할 일로 갈랐습니다. 기다려도 풀리지 않으면 402, 기다리면 풀리면 429입니다. 잔고가 없는 사람에게 "잠시 후 다시 시도"를 보여 주면 안 되니까요.

단가 계산

LLM 사용량은 토큰을 다섯 칸으로 나눠 셉니다. 캐시 없는 입력, 캐시 읽기, 5분 캐시 쓰기, 1시간 캐시 쓰기, 출력입니다. 칸마다 백만 토큰당 단가를 곱해 더합니다. 이미지 생성이나 검색처럼 공급사가 금액을 확정해 주는 것은 그 금액을 그대로 씁니다.

단가는 실행한 시각에 유효했던 것을 씁니다. 나중에 단가를 고쳐도 이미 쓴 사용량은 다시 계산하지 않습니다.

단가가 없으면 기본값으로 넘어가지 않고 실패시킵니다. 실패한 이벤트는 recorded에 남아 기다리다가, 단가를 등록하면 저절로 차감됩니다.

캐시 쓰기를 5분과 1시간으로 나누다가 누수를 하나 찾았습니다. 캐시 쓰기가 0원으로 과금되고 있었습니다.

원장

테이블 네 개

  • transactions: 거래 하나. 멱등 키가 UNIQUE입니다.
  • transaction_entries: 거래의 분개 행. 적용 뒤 잔액을 같이 남깁니다.
  • customer_balances: 고객의 계정별 잔고.
  • credit_lots: 지급된 크레딧 묶음. 남은 양과 만료일이 있습니다.

계정

고객 쪽 계정은 플랜, 충전, 체험, 프로모션, 보상, 계약 여섯 가지입니다. 회사 쪽은 발행, 토큰 부채, LLM 원가 풀, creative 원가 풀, 자본, 예기치 않은 손실 여섯 가지입니다.

크레딧은 만료가 빠른 것부터 씁니다.

분개

거래계정금액(USD)
충전발행-10.00
충전고객 충전 계정+10.00
사용 차감고객 충전 계정-0.12
사용 차감LLM 원가 풀+0.12

예시 값입니다. 발행 계정은 음수로 쌓입니다. 지금까지 나간 크레딧의 총량을 반대편에서 보여 주는 계정입니다.

실패한 턴에서 이미 쓴 부분 사용량은 사용자에게 차감하지 않습니다. 회사 손실 계정에서 원가 풀로 옮깁니다. 돈은 실제로 나갔으니 기록은 남기고, 고객은 내지 않습니다.

합은 0, 세 번 확인합니다

  1. 앱이 분개를 적용하기 전에 검사합니다.
  2. DB가 커밋할 때 지연 제약 트리거로 한 번 더 확인합니다.
  3. 대사 SQL이 불변식 여덟 개를 봅니다. 합계 0, 잔액과 분개 누적의 일치, 차감액과 원가의 일치 같은 것들입니다.

장부는 INSERT만

거래소에는 몰릴 때 초당 수천 건의 거래가 들어왔습니다. 실시간으로 쌓이는 데이터가 엄청났습니다. 테이블이 커질수록 삽입도, 인덱스 정렬도, Auto Vacuum도 부담이 됐습니다. 결국 데이터 클렌징 프로젝트를 했고, 인덱스 튜닝도 여러 번 했습니다.

그 뒤로 장부 데이터는 UPDATE 없이 INSERT만 하려고 합니다. Postgres에서 UPDATE는 행을 고쳐 쓰는 게 아니라 새 버전을 하나 더 씁니다. 옛 버전은 Vacuum이 치울 때까지 남습니다. 계속 쌓이기만 하는 장부 테이블에서 이게 반복되면 비용이 커집니다. 기록이 바뀌지 않으니 나중에 따라가기도 쉽습니다.

AVIKO 원장도 그렇게 만들었습니다. 코드에서 transactions와 transaction_entries를 UPDATE하거나 DELETE하는 곳은 한 군데도 없습니다. 거래를 되돌릴 때도 원래 거래는 건드리지 않고, 반대 방향의 거래를 새로 씁니다. 결제가 취소돼 크레딧을 회수할 때가 그렇습니다.

UPDATE하는 테이블도 셋 있습니다.

테이블바뀌는 값이유
customer_balances계정별 잔고차감할 때마다 원장을 다 더할 수는 없어서 둔 스냅샷
credit_lots남은 양어느 묶음에서 얼마를 뺄지 정하려면 필요
usage_events상태recorded에서 charged나 skipped로 한 번만 바뀜

스냅샷이 틀어지면 원장으로 다시 맞출 수 있습니다. 분개 행마다 적용 뒤 잔액을 남기고, 대사 SQL이 잔고와 분개 누적이 같은지 봅니다.

다만 INSERT만 한다는 건 아직 규칙입니다. DB 권한이나 트리거로 UPDATE를 막지는 않았습니다.

같은 사용량을 두 번 청구하지 않게

"정확히 한 번"은 전달이 아니라 반영에서 보장한다.

설계 문서에 적어 둔 문장입니다. 메시지는 두 번 올 수 있습니다. 대신 두 번 반영되지 않게 세 겹으로 막았습니다.

  1. 사용 이벤트 id는 결정적입니다. 요청과 라인 순서로 UUID v5를 만들고, 이미 있으면 넣지 않습니다.
  2. 거래 멱등 키는 이벤트 id이고 UNIQUE입니다. 크레딧 묶음 감액은 새 거래일 때만 실행됩니다.
  3. 상태는 WHERE status = 'recorded' 조건으로만 바꿉니다.

결정적 id에는 함정이 하나 있습니다. 라인 순서가 id에 들어가므로, 기록 전에 라인 순서를 바꾸면 같은 사용량이 다른 id가 됩니다. 그러면 이중 과금입니다.

죽고, 끊기고, 밀릴 때

상황동작
결과 확정 전에 워커가 죽음사용 이벤트가 남지 않는다. 다시 받아서 같은 id로 처리한다. 너무 오래 걸리면 오류로 닫고 예약을 푼다
기록은 됐는데 차감 알림을 못 보냄주기적으로 도는 엔진이 남은 이벤트를 줍는다
차감 소비자가 죽음다시 뜰 때 확인하지 못한 메시지부터 처리한다
단가가 없음recorded에 남아 간격을 늘려 가며 재시도하고, 오래 걸리면 알림을 보낸다
오류로 닫은 뒤 결과가 늦게 옴사용자에게 차감하지 않고 회사 손실로 기록한다

진실은 DB의 recorded 행에 있습니다. Redis는 빨리 처리하라는 힌트일 뿐입니다. 그래서 무언가 실패해도 과금이 사라지지 않고 늦어질 뿐입니다.

동시에 몰리는 요청

사용 이벤트를 모았다가 차감하면, 그 사이에 잔고가 음수로 내려갈 수 있습니다. 같은 고객이 요청을 한꺼번에 보내면 더 그렇습니다.

그래서 입장할 때 Redis에 작은 금액을 고정으로 예약합니다. 비용을 추정하는 게 아닙니다. 동시에 몰린 요청이 잔고를 얼마나 깊이 파고들 수 있는지를 제한하는 장치입니다.

판정은 Lua 스크립트 하나로 원자적으로 합니다. 쓸 수 있는 잔고에서 진행 중인 예약과 이번 예약을 뺀 값이 0 이하면 거절합니다. 같은 고객의 키는 hash tag로 같은 클러스터 슬롯에 둡니다.

처음에는 LLM과 creative 예약을 따로 셌습니다. 둘이 각자 잔고 전액을 예약할 수 있었습니다. 지금은 합산합니다.

DB에서는 같은 고객의 차감을 직렬화합니다. 크레딧 묶음을 id 순서로 FOR UPDATE 잠그고, 잠금 순서는 묶음, 고객 계정, 회사 계정 순으로 어디서든 같게 맞췄습니다.

처음에는 이벤트 200개를 한 트랜잭션으로 차감했습니다. 그러면 원가 풀 행의 잠금을 배치 내내 쥐게 되고, 다른 경로와 잠금 순서가 뒤집혀 교착이 날 수 있었습니다. 지금은 이벤트마다 트랜잭션을 따로 엽니다. 하나가 실패해도 나머지는 차감됩니다.

Redis가 죽으면

예약 Redis가 응답하지 않을 때, 처음에는 그냥 통과시켰습니다(fail-open). 8월에 반대로 바꿨습니다. 지금은 503을 내고 알림을 보냅니다. 코드 주석에 이유를 적어 두었습니다.

과금 누수 0 우선, 가용성 후순위

예약을 푸는 쪽은 반대입니다. 해제에 실패해도 통과시키고, 예약에 걸린 만료 시간이 지나면 저절로 풀립니다.

첫날 E2E가 잡은 결함

구조를 뒤집은 날, 실제 대화 AI로 결제부터 차감까지 끝까지 돌려 봤습니다. 결함이 네 개 나왔습니다. 가장 큰 건 차감이 전부 실패한 것이었습니다.

잔고 행은 upsert로 갱신하고 있었습니다.

-- 예시 스키마
CREATE TABLE balance (
  customer_id bigint PRIMARY KEY,
  amount      numeric NOT NULL CHECK (amount >= 0)
);

-- 잔고 100에서 30 차감
INSERT INTO balance (customer_id, amount) VALUES (1, -30)
ON CONFLICT (customer_id)
DO UPDATE SET amount = balance.amount + EXCLUDED.amount;
-- CHECK 제약 위반으로 실패

충돌이 나면 UPDATE로 가서 70이 될 줄 알았습니다. 하지만 Postgres는 충돌을 확인하기 전에, 넣으려던 행(-30)으로 CHECK를 먼저 검사합니다. 지급처럼 양수인 거래는 통과하고 음수인 차감은 모두 막혔습니다. UPDATE를 먼저 하고, 행이 없을 때만 INSERT하는 CTE로 고쳤습니다.

나머지 셋은 이랬습니다.

  • B2B 요청이 조직이 아니라 실행한 개인으로 잡히고 있었습니다. 조회 SQL에서 컬럼 하나가 빠져 있었습니다.
  • 모델 이름 형식이 단가표와 달라 단가를 찾지 못했습니다.
  • 앱이 부팅되지 않았습니다. 의존성 주입이 하나 빠져 있었습니다.

이 비음수 CHECK는 나중에 일부러 뺐습니다. 기록이 먼저인 구조에서는 차감이 실패하면 안 됩니다. 실패하면 recorded가 쌓이고 차감이 영영 밀립니다. 잔고가 모자라면 막는 대신, 모자란 만큼을 회사 손실 계정에 기록합니다.

외부 과금 서비스는

Stripe가 사용량 과금을 지원하는 줄은 그때 몰랐습니다. 알았더라도 쓰지 않았을 것 같습니다. 잔고처럼 중요한 시스템은 내부 자산이어야 한다고 봤습니다. 요구사항은 계속 바뀌고, 남의 시스템 위에서는 그 변화를 따라가기 어렵습니다.

감수한 것, 남은 일

AI 서버에서 한 턴은 LangGraph 노드 여러 개를 거칩니다. 차감은 턴이 끝난 뒤 한 번에 합니다. 노드마다 잔고를 보지 않습니다. 빨리 만들기 위해 고른 방법입니다. 후속 턴이나 워크플로 단계는 단계마다 입장과 차감을 따로 합니다.

예약이 고정 소액이라, 비싼 실행 한 번이 잔고를 넘을 수 있습니다. 넘친 양은 고객 잔고를 음수로 만들지 않고 회사 손실 계정에 쌓이며, 그때마다 알림이 뜹니다. 이 손실을 이후 크레딧에서 상계하거나 청구하지는 않습니다.

놓친 과금도 있었습니다. 번역 같은 몇몇 기능이 아예 차감되지 않고 있었고, 워크플로 반복 단계의 후속 턴도 그랬습니다. 찾은 뒤 고쳤지만 지나간 몫은 소급하지 않았습니다.

남은 일은 이렇습니다.

  • 노드마다 잔고를 보고 바로 차감하기
  • 잔고가 모자라면 중간 노드에서 멈추기
  • 사람이 돌리는 대사 SQL을 상시 검사로 바꾸기

돌아보면

지금까지 원장이나 대사가 잡아낸 사고는 없습니다. 첫날 차감을 전부 막은 것도 원장이 아니라 원장을 지키려고 넣은 CHECK 제약이었습니다.

다시 한다면 복식부기 말고 더 쉬운 방법도 만들어 비교해 보고 싶습니다. 복식부기가 나빠서가 아닙니다. 비교하는 과정을 겪어 보고 싶어서입니다. 더 나은 선택지가 나올 수도 있고, 복식부기가 낫다는 결론이 날 수도 있습니다.

공유XLinkedIn

함께 읽으면 좋은 글