dimesum

/ 블로그 / 엔지니어링

엔지니어링

정산 잔액을 정확히 지키는 추가 전용 원장

· 7분 소요 ·

잔액은 언제든 버리고 다시 만들 수 있는 파생값이어야 하며, 이 글은 합이 0인 저널, 수정을 보정으로 기록하는 방식, 커밋 순서로 발송하는 아웃박스로 그것을 안전하게 만드는 구조를 다룹니다

값을 더해 나가는 잔액은 결국 어긋나는 잔액입니다. Dimesum은 잔액을 더하는 법이 없습니다. 모든 지출은 기입 합계가 정확히 0이 되는 복식부기 저널을 기록하며, 그룹 화면에 보이는 숫자는 그 기입들에 대한 파생값으로, 명령 한 번으로 지우고 다시 만들 수 있습니다.

이 주장은 검증할 수 있습니다. 돈이 움직이는 유일한 경로가 추가 전용 저널일 때, 잘못된 잔액은 더 이상 미스터리가 아니라 우리가 실행할 수 있는 쿼리가 됩니다.

잔액은 파생값이며, 더해 나가는 숫자가 아닙니다

Dimesum의 원장은 세 개의 테이블을 가집니다. journals, postings, 그리고 그룹과 멤버와 통화를 키로 하는 balances 파생 테이블입니다. 진실을 담는 것은 postings입니다. 멤버가 그룹에 돈을 넣으면 기입은 양수, 가치를 소비하면 음수가 되므로, 잔액은 단순한 SUM(amount_minor)입니다. 파생 테이블은 속도를 위해 존재할 뿐 권위를 갖지 않습니다. 저널 자신의 트랜잭션 안에서 기록되며, 기입이 완전하므로 언제든 버릴 수 있습니다.

두 계층이 같은 0을 단언하며, 어느 하나만으로는 충분하지 않습니다

Go에서는 buildPostings가 기입 집합의 합이 0이 아니면 트랜잭션을 열지 않습니다. Postgres에서는 지연 제약 트리거가 커밋 시점에 저널마다 SUM(amount_minor) = 0을 다시 확인합니다. 기입은 한 번에 한 행씩 삽입되고, 행 단위 검사라면 모든 저널의 첫 번째 다리를 거부하기 때문입니다. 단언은 계산기의 버그를 잡습니다. 트리거는 계산기를 한 번도 호출하지 않은 작성자를 잡습니다.

세 번째 계층은 권한입니다. 두 테이블 모두에서 UPDATE, DELETE, TRUNCATE 권한이 ledger_app 역할로부터 회수되어 있어, 버그가 있는 코드가 시도하더라도 이력을 다시 쓸 수 없습니다.

추가 전용 원장이 불가능하게 만드는 것, 값을 더해 나가는 잔액 테이블과 비교하여
버그 유형값을 더해 나가는 잔액 테이블추가 전용 원장
채무자에게는 청구되었지만 지불자에게는 결코 입금되지 않음영원히 조용히 틀림합계 0 검사 실패, 쓰기 거부
분담액은 바뀌는데 총액은 그대로임잔액이 조용히 어긋남저널은 균형이 맞지 않으면 커밋될 수 없음
"내 잔액은 왜 412,000원이지?"답할 수 없음모든 1원이 저널로 추적됨
운영 환경에서의 핫픽스추적되지 않는 변경유일한 경로는 새로 감사되는 저널뿐

수정은 되돌림을 기록하며, 결코 UPDATE를 하지 않습니다

지출을 수정해도 기존 버전 위에 아무것도 덮어쓰지 않습니다. 원장은 하나의 expense.amended 이벤트를 소비하고 단일 트랜잭션에서 두 개의 저널을 기록합니다. 먼저 교체되는 버전을 다리 하나하나 그대로 부정하는 EXPENSE_REVERSAL, 그다음 새 버전을 담은 새로운 EXPENSE입니다. 삭제는 되돌림에서 멈춥니다.

단일 트랜잭션은 두 저널만큼이나 중요합니다. 되돌림만 홀로 커밋되면, 그룹은 여전히 갚아야 할 지출에 대해 잠시 아무것도 빚지지 않은 상태가 됩니다.

An expense edit posts an EXPENSE_REVERSAL that negates version one, plus a new EXPENSE for version two, in one transaction; the balances projection is a sum over all the postings. one transaction EXPENSE expense:7c1:v1 4 postings sum = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal every leg negated sum = 0 EXPENSE expense:7c1:v2 5 postings sum = 0 balances projection = SUM(postings) per member, per currency disposable: evenly rebuild-balances clears it and replays every posting
수정은 추가입니다. 되돌림은 자신이 부정하는 버전을 명시하고, 교체본은 그 옆에 기록됩니다.

두 멱등 키는 새로 발급되는 것이 아니라 버전에서 파생됩니다. 버전에는 expense:<id>:v<n>, 부정에는 이전 버전의 키에 :reversal을 붙입니다. journals.idempotency_key는 UNIQUE이므로, 다시 전달된 이벤트는 자신의 저널이 이미 있음을 발견하고 아무 일도 하지 않습니다. 파생이야말로 최소 한 번 전달을 안전하게 만듭니다. 재시도는 첫 시도가 사용한 키를 그대로 계산합니다.

커밋 순서 워터마크가 수정을 해당 지출 뒤에 머무르게 합니다

Dimesum이 발행하는 모든 이벤트는 비즈니스 쓰기와 같은 트랜잭션에서 outbox 테이블에 기록됩니다. 지출이 커밋되면 그 알림도 존재합니다. 롤백되면 알림도 함께 사라집니다. 원장은 존재하지 않는 지출에 대해 결코 듣지 않습니다.

더 어려운 절반은 발송 순서입니다. 아이디는 UUIDv7이며 시간순으로 정렬되지만, 트랜잭션이 커밋된 시점이 아니라 아이디가 발급된 시점을 담습니다. 아이디 순서로 읽는 릴레이는 더 이른 아이디를 받고 더 늦게 커밋한 트랜잭션을 지나쳐 버릴 수 있고, 그래서 수정이 자신이 수정하려는 지출을 앞질러 갑니다.

The business write and its outbox row commit in one transaction; the relay then dispatches only outbox rows whose inserted transaction id is below the pg_snapshot_xmin watermark, in transaction id order. one transaction INSERT expense row INSERT outbox row topic id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 writer in flight event bus ledger consumer pg_snapshot_xmin(pg_current_snapshot()). Rows above it were written by transactions that have committed with none older still running. The row below waits for the next pass. Ordering by id would dispatch 019a-1a8 first, so an amendment could land before the expense it amends. Ordering by inserted_xid cannot: a later event has a later xid.
outbox 행은 비즈니스 쓰기와 함께 커밋되고, 릴레이는 워터마크 아래의 행을 트랜잭션 아이디 순서로 발송합니다.

해결책은 컬럼 하나와 조건식 하나입니다. 각 outbox 행은 inserted_xid xid8 DEFAULT pg_current_xact_id()를 가지며, 릴레이는 WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot())인 행만 그 xid 순서로 읽습니다(PostgreSQL 트랜잭션 아이디 함수 참고). 인과적으로 나중에 발생한 이벤트는 언제나 더 큰 xid를 가집니다. 존재하기 위해 이전 행을 읽어야 했기 때문입니다.

수정 소비자는 그 순서를 무작정 믿지 않습니다. 선행 저널이 없는 수정은 아직 이른 동안에는 다시 전달되고, 2분의 유예를 넘기면 사람이 확인하도록 보류됩니다.

돈은 int64 최소 단위이며, 지수가 항상 2인 것은 아닙니다

모든 금액은 최소 단위의 int64 개수와 ISO 4217 코드로 이루어지므로, 1,234.56 루피는 {Minor: 123456, Currency: "INR"}입니다. 정수는 규율이 아니라 구조상 정확합니다. 표현 가능한 값 중에 반 센트짜리는 없으므로, 어떤 연산도 조용히 반 센트를 만들어 낼 수 없습니다. Python 사이드카는 자신의 AST를 읽어, 금액 모듈에 float라는 단어가 나타나면 테스트를 실패시킵니다.

그 밑에 깔린 함정은 최소 단위가 100분의 1이라고 가정하는 것입니다. JPY는 최소 단위가 아예 없고 KWD는 소수점 세 자리를 가지므로, 주 단위 사이로 제시된 환율을 최소 단위에 적용하면 10의 거듭제곱만큼 틀립니다. 2,000엔에 0.58을 적용해 보면, 단순 곱은 1160이고 이는 11.60 루피로 읽히지만, 정답은 1,160입니다.

WRITE_OFF가 다섯 번째 저널 유형인 이유는 그 기입이 정산의 기입과 같기 때문입니다

탕감은 정산과 똑같은 두 다리를 기록합니다. 채무자는 올라가고 채권자는 내려가며, 그 금액은 같습니다. 이를 SETTLEMENT에 합치자는 안은 바로 그 모양이 같다는 이유로 기각되었습니다. "Asha가 당신에게 50,000원을 냈다"와 "당신이 Asha에게 50,000원을 면제해 줬다"는 서로 다른 사실이며, 이 둘을 뒤섞은 피드는 아무도 내지 않았는데 누군가 냈다고 말하게 됩니다.

그래서 WRITE_OFF는 2026-08-21에 자체 다리를 가지고 저널 유형 CHECK에 추가되었습니다. 다리에 이름을 붙인 것이 핵심의 절반이었습니다. 탕감이 SETTLE_PAY를 재사용하면 "실제로 얼마가 지불되었는가"를 묻는 모든 쿼리가 조용히 틀리게 되기 때문입니다.

Dimesum 원장의 다섯 가지 저널 유형과 각 유형이 기록하는 다리
저널 유형기록되는 시점다리
EXPENSE생성되거나 새 버전이 기존 버전을 교체할 때PAID, SHARE
EXPENSE_REVERSAL수정되거나 삭제될 때이전 저널의 다리를 부정한 것
SETTLEMENT상환이 단언될 때SETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSAL상대방이 이의를 제기할 때두 정산 다리를 부정한 것
WRITE_OFF채권자가 청구를 포기할 때WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED

두 하위 명령이 불변식을 크론 작업으로 만듭니다

evenly verify-ledger는 스키마 전체에 대해 불변식을 다시 증명하고, 하나라도 걸리면 0이 아닌 값으로 종료합니다. 이 보고서에는 네 개의 필드가 있으며, 모두 비어 있어야 합니다. 합이 0이 아닌 저널, 합이 0이 아닌 그룹, 다시 계산한 SUM(postings)와 어긋나는 파생 행, 그리고 사람을 기다리며 보류된 이벤트입니다. 모든 스캔은 하나의 repeatable-read 스냅샷을 공유하므로, 검증 도중에 도착한 저널이 불일치를 만들어 낼 수 없습니다.

각 스캔은 아이디뿐 아니라 통화별로도 묶으며, 이렇게 피하는 실패는 거짓 음성입니다. 500 루피 기입과 마이너스 500 엔 기입은 쿼리가 통화 컬럼을 무시하면 합이 0이 되므로, 이중으로 손상된 원장이 깨끗하게 읽힐 수 있습니다.

evenly rebuild-balances [group-id]는 복구이자 훈련입니다. 파생 테이블을 비우고 모든 행을 기입으로부터 다시 계산하며, 실시간 쓰기가 하는 것처럼 각 행에 그 멤버를 마지막으로 움직인 저널을 새깁니다. 성질은 행 대 행의 동일성입니다. 재구성과 실시간 파생 테이블이 어긋나면, 옳은 쪽은 원장입니다.

이 순서로 실행하세요

먼저 검증하고, 그다음 재구성하세요. 보고서는 어긋나는 모든 행에 대해 저장된 잔액과 다시 계산한 잔액을 함께 보여 주고, 재구성은 저장된 값을 덮어쓰므로, 먼저 재구성하면 증거가 사라집니다.

잔액을 파생 가능하게 만들면 어긋남은 쿼리가 됩니다

추가 전용 원장은 증명할 수 있을 때에만 두 번째 저널을 얻습니다. 그러니 그것이 필요한 기능보다 먼저 검증기와 재구성을 만드세요. 아무도 실행해 본 적 없는 재구성은 비상구가 아니라 희망일 뿐입니다. 이번 주에 가장 위험한 돈 파생값을 하나 골라, 그것을 원본으로부터 다시 계산하는 쿼리를 작성하고, 둘이 어긋나면 스스로에게 호출이 가도록 설정하세요.

자주 묻는 질문

정산 앱에서 추가 전용 원장이란 무엇인가요?

추가 전용 원장은 모든 돈 이벤트를 합이 0이 되는 기입들의 저널로 기록하고, 그것을 결코 수정하거나 삭제하지 않습니다. Dimesum에서는 지출, 수정, 정산, 이의 제기, 탕감이 각각 새로운 저널을 추가합니다. 그러면 잔액은 기입을 합산해 도출되므로, 1원 단위까지 그것을 움직인 이벤트로 거슬러 올라갑니다.

추가 전용 원장은 수정된 지출을 어떻게 처리하나요?

수정은 하나의 트랜잭션에서 두 개의 저널을 기록합니다. 먼저 원본을 다리 하나하나 부정하는 EXPENSE_REVERSAL, 그다음 교체 버전을 담은 새 EXPENSE입니다. 아무것도 다시 쓰지 않으며, 삭제는 되돌림만 기록합니다. 두 저널 모두 지출 버전에서 파생된 멱등 키를 사용하므로, 다시 전달된 이벤트는 그것들이 이미 있음을 발견하고 아무것도 바꾸지 않습니다.

돈을 float 대신 정수로 저장하는 이유는 무엇인가요?

정수 최소 단위는 구조상 정확하지만, 이진 부동소수점은 0.1을 정확히 표현하지 못해 그룹의 이력 전반에 걸쳐 어긋납니다. Dimesum은 모든 금액을 최소 단위의 int64 개수와 ISO 4217 코드로 저장합니다. 지수는 통화에서 나옵니다. JPY는 최소 단위가 아예 없으므로, 100분의 1을 가정하는 것은 100배 오류입니다.

정산 잔액이 어긋나지 않았는지 어떻게 확인하나요?

evenly verify-ledger를 실행하세요. 스키마 전체에 대해 불변식을 다시 증명하고 하나라도 걸리면 0이 아닌 값으로 종료합니다. 합이 0이 아닌 저널, 합이 0이 아닌 그룹, 다시 계산한 SUM(postings)와 어긋나는 파생 행, 그리고 보류된 이벤트를 보고합니다. 밤마다 크론으로 돌리고, 잘못된 파생값은 evenly rebuild-balances로 복구하세요.

탕감에는 왜 별도의 저널 유형이 필요한가요?

탕감은 정산과 똑같은 다리를 기록하며, 바로 그렇기 때문에 저널 유형이 달라야 했습니다. "Asha가 당신에게 50,000원을 냈다"와 "당신이 Asha에게 50,000원을 면제해 줬다"를 가르는 것은 그 한 단어뿐이고, 둘을 뒤섞은 피드는 아무도 내지 않았는데 누군가 냈다고 말하게 됩니다. 그 다리는 WRITE_OFF_FORGIVENWRITE_OFF_GRANTED로 이름 붙여져 지불 쿼리가 정확하게 유지됩니다.