복식부기 원장이 소프트웨어 모델링의 중요한 기본 요소임에도 불구하고 현대 소프트웨어에서 충분히 활용되지 않고 있다. 복식부기 원장의 개념, 실제 적용 예시, 다양한 활용 사례와 장점에 대해 알아본다.
나는 오늘날 소프트웨어 개발에서 원장이 충분히 활용되지 않고 있다고 생각한다. 특히 복식부기 원장(double-entry ledger) 모델링은 많은 시스템에서 현재 사용 중인 임시방편의 원장 비슷한 것들보다 더 적합한 경우가 많다.
이 때문에 나는 pgledger라는 순수 PostgreSQL 기반 원장 구현체를 개발하고 있다. 만약 원장 구현을 아주 쉽게 추가할 수 있다면, 더 많은 사람들이 이를 도입할 거라고 기대한다. 그리고 복식부기 원장이 다양한 목적을 달성하기 위한 새로운 모델링 기본 요소로 자리 잡을 수도 있다고 생각한다.
복식부기 원장은 본질적으로 몇 가지 간단한 개념을 결합한 것이다:
이게 전부다. 예를 들어, 앨리스가 밥에게 100달러를 보내면, 원장은 앨리스의 잔고가 0달러에서 -100달러로(밥에게 감) 변하고, 밥의 잔고가 0달러에서 100달러(앨리스에게 받음)로 변한 것을 기록한다. 이 모든 것은 한 번에 기록되고, 모든 금액이 정확히 일치하며, 모든 잔액을 더하면 0이 된다.
참고: 많은 원장은 음수와 양수가 아니라 차변(debit)과 대변(credit)으로 모델링한다. 이 경우, 앨리스는 100달러의 차변, 밥은 100달러의 대변을 가진다. 필자는 음수, 양수 방식을 더 단순하다고 생각한다.1
모든 이체는 오직 기존 금액만을 이동시키고 새로 창출하지 않으므로, 이것 자체가 내장된 오류 검증 기능이 된다.2 이력 기록은 감사 로그(audit log) 역할도 한다.
원장이 실제로 어떻게 구현되고 디스크에 저장되는가는 각 구현체마다 다르지만, 중요한 점은 이 모든 정보가 원자적으로 기록된다는 것이다.
시간에 따라 금액 변화를 추적하기 시작하면, 다양한 곳에서 원장 모델을 볼 수 있다. 실제 소프트웨어에서 볼 수 있는 몇 가지 예시를 살펴보자.
예를 들어, 온라인 비즈니스를 개발한다고 하자. 초기에 누군가 주문을 남기면 orders
테이블만 있으면 되겠다고 생각할 수 있다. 하지만 결제에는 더 복잡한 라이프사이클이 있어서(주문 생성 시 곧바로 결제를 받는 것이 아니기 때문에), 언제 실제로 서비스를 시작하거나 제품을 발송할 수 있는지 알아야 한다. 이에, payments
테이블과 status
컬럼을 두고 waiting_to_receive
또는 complete
와 같은 상태값을 두게 될 것이다.
이것은 일종의 단일 입력식(single-entry) 원장과 비슷하다. 즉, 고객이 우리에게 보내는 "이체" 목록을 담은 테이블이 된다. 하지만 곧 더 복잡해진다. 환불은 어떻게 기록할까? refunds
라는 새 테이블을 만들어야 할까? 아니면 payments
테이블에 마이너스 금액의 행을 남기는 게 나을까? 계좌 잔액이 예상과 다르면 어떻게 할 것인가? 결제가 누락된 것인가, 받은 금액이 예상과 다른 것인가? 어떻게 파악할 것인가?
만약 진짜 복식부기 원장이 있다면, 이러한 상호작용을 더 명확하게 기록할 수 있다:
주문이 생성될 때, 우리는 아직 돈을 받지 못한 상태이므로 받을 어음(receivable)이 생긴다. 이는 외부 사용자로부터 receivables
계정으로의 이체로 표현할 수 있다:
Transfer ID | 설명 | ┃ | user | receivables | available |
---|---|---|---|---|---|
1 | 주문 생성 | ┃ | -$10 | $10 |
여기서 각 행은 하나의 이체를, 수직 바(┃) 오른쪽의 각 컬럼은 하나의 계정을 의미한다. 각 행의 모든 금액 합은 0이 된다.
이후 실제로 돈을 계좌에 받으면, receivables
에서 available
잔고로 옮길 수 있다:
Transfer ID | 설명 | ┃ | user | receivables | available |
---|---|---|---|---|---|
1 | 주문 생성 | ┃ | -$10 | $10 | |
2 | 결제 수령 | ┃ | -$10 | $10 |
이제 내장 오류 체크의 진가가 드러난다. 결제를 받은 후엔 receivables 잔고가 0이어야 한다. 아니라면, 우리가 기대한 금액보다 적게 받았다는 의미 등이 된다. 기존 모델이라면 받은 금액과 기대한 금액 일치 여부를 커스텀 코드로 확인해야 한다. 하지만 원장 방식이라면 receivables
계정의 잔액만 보면 "우리가 아직 받아야 할 돈이 얼마인가?" 같은 질문에 바로 답할 수 있다.
또한, 잔고가 예상과 다르다면 그 원인을 더 쉽게 추적할 수 있다. 특정 계정의 모든 변동 내역을 보고 불일치를 찾을 수 있고, 다른 계정과의 관계도 함께 검토할 수 있다. 예를 들어, 은행 잔고가 100달러 적고 다른 계정이 100달러 더 많다면, 해당 두 계정간 이체 누락 또는 오류를 찾아볼 수 있다.
이 모델을 더 확장해보면, 환불은 보통 반대 방향(금액이 다를 수 있음, 예: 부분환불)으로 기록한다:
Transfer ID | 설명 | ┃ | user | receivables | available |
---|---|---|---|---|---|
1 | 주문 생성 | ┃ | -$10 | $10 | |
2 | 결제 수령 | ┃ | -$10 | $10 | |
3 | 부분 환불 | ┃ | $5 | -$5 |
이제 외부 사용자는 5달러를 돌려받았고, 회사 계정(available)에는 5달러만 남아있음을 한눈에 알 수 있다. 결제 및 환불 내역이 동일한 테이블에 통합되어 있으며, 각 단계에서 돈의 이동 경로가 명확하게 보인다.
물론 이것은 단순한 예제이지만, 원장을 유지하면 원하는 만큼 계정을 추가할 수 있다는 또 다른 장점이 있다. 예를 들어, 단일 receivables
계정 대신 사용자별로 따로 관리할 수 있으며, available
내에 하위 계정(sub accounts)도 만들어 미결제, 동결, 기타 자금을 세분화하여 관리할 수 있다.3
결제 추적은 어찌 보면 명확한 사례지만, 다른 예로 사용자 포인트 이동을 생각해보자. 예를 들어, 사용자가 사이트에서 메시지를 남기거나 친구를 추천하면 포인트를 얻고, 구매 활동에 따라 항공 마일리지처럼 포인트가 쌓일 수도 있다.
아주 단순하게 시작한다면, users
테이블에 포인트 컬럼 하나를 두고, 포인트 획득이나 사용 시 그 값을 곧바로 업데이트할 수 있다:
update users SET points = points + 100 where id = 'u_123';
그런데 포인트 변화 내역을 사용자에게 보여줄 필요가 생긴다면? point_events
테이블을 만들어 포인트 변화에 대한 감사를 기록하게 될 것이다. 이제 포인트 획득/사용 시마다 행을 추가해야 하고, 동시에 잔고 업데이트도 원자적으로 이뤄져야 하며, 동시성 문제도 신경 써야 한다.
시간이 지날수록 요구사항은 점점 더 복잡해진다:
point_events
에 음수 금액의 행이 생긴다. 그런데 포인트는 어디로 갔나? 다른 사용자에게 보냈는가? 써버렸나? 만료됐나? 이를 어떻게 추적할 것인가? 추가 컬럼을 둬야 하나?point_events
에 기록해야 하고, 두 명의 잔고를 동시에 업데이트해야 하므로 원자성과 정확함을 보장해야 한다.기능이 발전할수록 요구사항은 점점 복식부기 원장과 닮아간다. 각 계정의 "통화"만 "포인트"일 뿐이다. 굳이 그때그때 임의로 만드는 데이터 모델을 확장하지 않고, 처음부터 원장 모델을 사용하면 이러한 모든 요구 조건을 자연스럽게 처리할 수 있다.
사용자별로 하나씩 포인트 계정을 두고, 회사 계정에서 전송받는 구조로 시작해보자. 실제로는 포인트 성격별로 회사 계정을 달리 쓸 수도 있다. 포인트 사용 내역은 spent
계정으로 관리할 수도 있다.
Transfer ID | 설명 | ┃ | company | user1 | user2 | spent |
---|---|---|---|---|---|---|
1 | user1 100점 획득 | ┃ | -100 | 100 | ||
2 | user2 200점 획득 | ┃ | -200 | 200 | ||
3 | user2 100점 사용 | ┃ | -100 | 100 | ||
4 | user1이 user2에게 50점 전송 | ┃ | -50 | 50 |
이 흐름 끝에서 user1은 50점, user2는 150점, 회사는 300점을 발급했고 사용자는 그중 100점을 썼음을 쉽게 알 수 있다.
원장은 간단한 감사성(auditability)도 제공한다. 만약 user2가 "내 잔고가 왜 150점이야?" 물으면, 해당 잔고가 형성되는 모든 원장 항목(거래상대, 시각 등) 을 보여줄 수 있다.
나중에는 포인트를 현금/상품권으로 교환하는 것도 "points" → "USD"로 통화 변환(currency conversion)하는 것으로 원장에서 바로 기록할 수 있다. 환산율 역시 원장에 저장하므로 추가 모델링이 필요 없다.4
API 사용량 크레딧 모델링 역시 훌륭한 원장 사용례다. 사용자는 크레딧을 구매해 각종 액션에 소모하고, 크레딧이 0에 근접하는지 모니터링할 수 있다. 크레딧은 또 다른 "통화"가 되고, 다양한 추적 항목은 개별 계정이 된다.
더 나아가 사용자 별 콘텐츠 제재 내역(위반, 경고, 소명 등)도 모델링할 수 있다. 각 사용자는 각종 행동별 계정을 가져, 시간에 따라 누적/총계, 평판 점수 등을 산출할 수 있다.
심지어 재고 관리 시스템도 원장으로 표현될 수 있다. 여러 위치에 있는 품목의 수량, 이동, 현재 상태를 모두 추적한다.
핵심은, 애플리케이션이 원장 모델을 이미 내장하고 있다면, 다양한 목적의 구현에 별도의 복잡한 추가 설계/코딩 없이 그 위에 많은 것들을 쉽게 쌓아올릴 수 있다는 것이다. 매번 새로운 개념, 모델링, 코드를 발명할 필요가 없다. 단지 새 계정/통화 셋으로 원장만 활용하면 된다. 원장은 도입 시 비용이 들지만, 그 가치는 시간이 갈수록 점점 더 커진다.
그리고 원장 컴포넌트는 명확하게 경계를 세우고 인터페이스화할 수 있다. 원장 구현은 독립적으로 존재하고, 비즈니스 로직은 계정 구조와 이체 방식으로 결정된다.
원장을 핵심 요소로 추가하는 방법은 자유롭다. pgledger, TigerBeetle, 직접 구현한 코드, 혹은 그 외의 무엇을 써도 된다. 만약 원장에 대한 흥미로운 활용 사례를 발견한다면 언제든 알려주길 바란다!
복식부기 회계는 Ledger CLI 툴에서 많이 배웠다. 이 역시 음수/양수를 사용한다: https://ledger-cli.org/doc/ledger3.html#Stating-where-money-goes↩
좀 더 엄밀히 말하면, 전체 회계 등식(accounting equation)은 일반적으로 자산(Assets) = 부채(Liabilities) + 자본(Equity)
로 쓴다.↩
더 긴 논의는 https://github.com/pgr0ss/pgledger/discussions/29 참조.↩
통화 변환 예시는 https://github.com/pgr0ss/pgledger?tab=readme-ov-file#currencies 또는 https://docs.tigerbeetle.com/coding/recipes/currency-exchange/ 참조.↩