Skip to content
Published on

이중 기입 원장: 돈을 잃지 않는 회계 시스템 설계

Authors

들어가며 — 600년 된 버그 방지 장치

결제 시스템을 만들다 보면 반드시 이런 질문에 부딪힙니다. "사용자의 잔액을 어떻게 저장하지?" 가장 순진한 답은 "계정 테이블에 잔액 컬럼을 하나 두고, 돈이 들어오면 더하고 나가면 빼자"입니다. 그런데 이 순진한 설계는 실제 서비스에서 조용히, 그러나 확실하게 돈을 잃습니다.

돈을 다루는 사람들은 이 문제를 아주 오래전에 풀었습니다. 15세기 이탈리아의 상인들과 수학자 루카 파치올리(Luca Pacioli)가 정리한 복식부기(double-entry bookkeeping), 우리말로 이중 기입 원장이 그것입니다. 600년이 지난 지금도 은행, 카드사, 핀테크의 원장은 이 원리 위에서 돌아갑니다. 그 이유는 단순합니다. 이중 기입은 그 자체로 오류를 잡아내는 구조를 품고 있기 때문입니다.

이 글은 이중 기입 원장을 소프트웨어 엔지니어의 시선으로 다룹니다. 차변과 대변이 왜 항상 같아야 하는지, 왜 기록을 지우거나 고치지 않고 오직 덧붙이기만(append-only) 하는지, 계정과 잔액을 어떻게 모델링하는지, "잔액 컬럼 하나면 안 되나"라는 유혹이 왜 위험한지, 대사와 동시성 정합성을 어떻게 확보하는지, 그리고 TigerBeetle 같은 전용 원장 데이터베이스가 왜 등장했는지를 짚겠습니다. 여기 나오는 스키마와 쿼리는 이 사이트의 SQL 놀이터Postgres 놀이터에서 직접 실행해 보며 감을 잡을 수 있습니다.

순진한 설계는 왜 돈을 잃는가

먼저 "잔액 컬럼 하나" 설계가 왜 위험한지 구체적으로 봅시다. 이런 테이블을 상상해 보세요.

CREATE TABLE accounts (
  id      BIGINT PRIMARY KEY,
  balance BIGINT NOT NULL   -- 현재 잔액을 하나의 숫자로 저장
);

그리고 송금은 이렇게 처리한다고 합시다.

UPDATE accounts SET balance = balance - 1000 WHERE id = 1;  -- 보내는 쪽
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;  -- 받는 쪽

겉보기에는 문제없어 보입니다. 하지만 이 설계에는 치명적인 약점이 셋 있습니다.

첫째, 역사가 사라집니다. 잔액 숫자 하나만 남으므로 "이 잔액이 어떻게 이 값이 됐는지"를 알 수 없습니다. 어떤 거래들이 있었는지, 언제 얼마가 오갔는지 추적할 방법이 없습니다. 회계에서 이것은 치명적입니다. 감사(audit)가 불가능한 시스템이기 때문입니다.

둘째, 부분 실패에 취약합니다. 위의 두 UPDATE 사이에서 프로세스가 죽으면, 한쪽에서는 돈이 빠졌는데 다른 쪽에는 들어오지 않은 상태가 됩니다. 돈이 허공으로 증발합니다. 트랜잭션으로 묶으면 이 특정 문제는 막을 수 있지만, 그것만으로는 아래의 문제가 남습니다.

셋째, 검증할 방법이 없습니다. 잔액이 틀렸을 때, 그것이 틀렸다는 사실조차 알아챌 수 없습니다. 대조할 기준이 없기 때문입니다. 버그나 경쟁 조건으로 잔액이 미묘하게 어긋나도, 시스템은 그저 틀린 숫자를 진실인 양 계속 들고 갑니다.

이중 기입 원장은 이 세 문제를 근본적으로 해결합니다. 핵심 발상의 전환은 이것입니다. 잔액을 저장하지 말고, 거래를 저장하라. 잔액은 거래로부터 계산하라.

차변과 대변 — 모든 거래는 두 번 기록된다

이중 기입의 이름에 답이 있습니다. 모든 거래는 두 번(double) 기록됩니다. 한 번은 **차변(debit)**으로, 한 번은 **대변(credit)**으로. 그리고 하나의 거래에서 차변의 합과 대변의 합은 항상 정확히 같아야 합니다.

이 "차변 합 = 대변 합"이라는 규칙이 이중 기입의 심장입니다. 돈은 무에서 생겨나거나 무로 사라지지 않습니다. 어딘가에서 나오면 반드시 어딘가로 들어갑니다. 이 보존 법칙을 회계가 강제하는 방식이 바로 차변과 대변의 균형입니다.

예를 들어 A가 B에게 1000원을 보내는 거래를 봅시다. 이것은 하나의 거래이고, 두 줄의 기입으로 표현됩니다.

  거래 T1: A -> B 로 1000원 이체

  계정   차변(debit)   대변(credit)
  ----   -----------   ------------
  A          -            1000       (A에서 1000 나감)
  B         1000           -         (B로 1000 들어옴)
  ----   -----------   ------------
  합계      1000          1000       <- 차변 합 == 대변 합 (균형!)

여기서 중요한 것은 용어의 방향입니다. 회계에서 차변/대변은 "증가/감소"와 단순히 일치하지 않고, 계정의 종류(자산, 부채 등)에 따라 의미가 달라집니다. 하지만 소프트웨어 원장을 설계하는 엔지니어에게 핵심은 이 한 가지입니다. 모든 거래는 여러 개의 기입으로 이루어지고, 그 기입들의 차변 합과 대변 합은 반드시 같아야 한다. 이 불변식(invariant)을 코드로 강제하는 것이 이중 기입 원장의 첫걸음입니다.

이 규칙의 아름다움은 자기 검증에 있습니다. 시스템 전체의 모든 기입을 다 더하면, 차변의 총합과 대변의 총합은 언제나 같아야 합니다. 만약 다르다면? 어딘가에 버그가 있다는 뜻입니다. 이 등식이 깨지는 순간을 감지하는 것만으로, 돈이 새는 버그를 조기에 잡을 수 있습니다.

변경 불가능한 append-only 기록

이중 기입의 두 번째 기둥은 **불변성(immutability)**입니다. 원장의 기입은 한 번 쓰이면 절대 수정하거나 삭제하지 않습니다. 오직 새로운 기입을 덧붙일(append) 뿐입니다.

왜 그럴까요? 회계 기록은 "무슨 일이 있었는가"에 대한 역사적 사실이기 때문입니다. 이미 일어난 일은 바꿀 수 없습니다. 만약 어떤 거래가 잘못됐다면, 그 기록을 지우는 게 아니라 그것을 되돌리는 새로운 거래를 추가합니다. 이것을 역분개 또는 보정 거래(reversing/adjusting entry)라 부릅니다.

  잘못된 거래를 수정하는 방법:

  (X) 기존 기입을 UPDATE 하거나 DELETE 한다        <- 절대 안 됨
  (O) 원래 거래를 상쇄하는 새 거래를 append 한다    <- 올바름

  예: T1이 잘못됐다면
      T2: T1과 반대 방향의 기입을 추가해 상쇄
      T3: 올바른 거래를 새로 추가
      -> T1의 흔적은 그대로 남고, 최종 잔액만 올바르게 됨

이 append-only 원칙은 몇 가지 강력한 성질을 줍니다.

  • 완전한 감사 추적(audit trail). 모든 거래의 역사가 그대로 보존되므로, 어떤 잔액이든 "왜 이 값인지"를 처음부터 재구성할 수 있습니다. 규제 산업에서 필수적인 성질입니다.
  • 재현 가능성. 잔액은 기입들의 합으로 언제든 다시 계산할 수 있습니다. 잔액이 의심스러우면 처음부터 다시 합산해 검증하면 됩니다.
  • 경쟁 조건에 강함. 기존 값을 읽어서 고치는(read-modify-write) 대신 새 행을 추가하기만 하므로, 여러 거래가 동시에 들어와도 서로의 기록을 덮어쓰지 않습니다.

append-only 원장은 개념적으로 이벤트 소싱(event sourcing)과 통합니다. 상태(잔액)를 직접 저장하는 대신, 상태를 만들어 낸 사건들(거래)을 저장하고, 상태는 그 사건들로부터 도출합니다. 사건의 로그가 진실의 원천(source of truth)이 됩니다.

계정과 잔액을 모델링하기

이제 실제 스키마를 그려 봅시다. 이중 기입 원장의 최소 구성은 계정(accounts)과 기입(entries) 두 테이블입니다.

-- 계정: 돈이 담기는 논리적 그릇
CREATE TABLE accounts (
  id         BIGINT PRIMARY KEY,
  name       TEXT NOT NULL,
  currency   TEXT NOT NULL          -- 통화를 계정 단위로 고정
);

-- 기입: 변경 불가능한 거래 라인. 오직 INSERT만 함
CREATE TABLE entries (
  id             BIGSERIAL PRIMARY KEY,
  transaction_id BIGINT NOT NULL,    -- 같은 거래에 속한 기입들을 묶는 ID
  account_id     BIGINT NOT NULL REFERENCES accounts(id),
  direction      TEXT NOT NULL,      -- 'debit' 또는 'credit'
  amount         BIGINT NOT NULL CHECK (amount > 0),  -- 항상 양수, 방향으로 부호 표현
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

몇 가지 설계 포인트를 짚겠습니다.

첫째, 금액은 정수로 저장합니다. 부동소수점(float)은 절대 쓰면 안 됩니다. 0.1 + 0.2가 정확히 0.3이 아닌 부동소수점의 성질은 돈에 치명적입니다. 대신 통화의 최소 단위(예: 센트, 원)를 정수로 저장합니다. 100 USD는 정수 10000(센트)으로, 이런 식입니다.

둘째, 금액은 항상 양수로 두고 방향(direction)으로 부호를 표현합니다. 이렇게 하면 "차변의 합"과 "대변의 합"을 각각 집계해 균형을 검증하기가 쉽습니다.

셋째, 잔액 컬럼이 없습니다. 잔액은 저장하는 값이 아니라 기입으로부터 계산하는 값입니다. 특정 계정의 잔액은 그 계정에 속한 모든 기입을 방향에 따라 합산해서 얻습니다.

-- 계정 42의 잔액 = (그 계정으로 들어온 것) - (나간 것)
SELECT
  COALESCE(SUM(CASE WHEN direction = 'debit'  THEN amount ELSE 0 END), 0)
  - COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0)
    AS balance
FROM entries
WHERE account_id = 42;

거래를 삽입할 때는 반드시 하나의 트랜잭션 안에서 균형 잡힌 기입들을 함께 넣습니다.

BEGIN;
  -- 거래 T1: 계정 1 -> 계정 2 로 1000
  INSERT INTO entries (transaction_id, account_id, direction, amount)
  VALUES (1, 2, 'debit',  1000),   -- 받는 계정으로 1000 들어옴
         (1, 1, 'credit', 1000);   -- 보내는 계정에서 1000 나감
  -- 애플리케이션(또는 제약)이 이 거래의 debit 합 == credit 합 을 검증
COMMIT;

이 두 INSERT는 하나의 트랜잭션이므로, 둘 다 커밋되거나 둘 다 롤백됩니다. 앞서 본 순진한 설계의 "한쪽만 반영되는" 부분 실패가 원천적으로 불가능해집니다. 이런 스키마와 트랜잭션을 실제로 돌려 보고 싶다면 Postgres 놀이터에서 그대로 실행해 볼 수 있습니다.

"그냥 잔액 컬럼 하나 두면 안 되나?"

여기서 많은 엔지니어가 던지는 질문에 정면으로 답해 봅시다. "매번 모든 기입을 합산하는 건 느리지 않나? 그냥 잔액 컬럼을 하나 두고 캐시처럼 쓰면 안 되나?"

핵심은 잔액 컬럼을 진실의 원천으로 삼지 않는 것입니다. 성능을 위해 잔액을 어딘가에 미리 계산해 두는(materialized) 것 자체는 흔하고 합리적입니다. 하지만 그것은 어디까지나 기입으로부터 파생된 캐시여야 합니다. 진실은 언제나 append-only 기입의 로그에 있고, 잔액 캐시는 그것을 빠르게 읽기 위한 보조물일 뿐입니다.

이 구분이 왜 중요한지는 두 설계를 비교하면 분명합니다.

관점잔액 컬럼이 진실기입이 진실 (+ 잔액 캐시)
역사 추적불가능완전한 감사 추적
검증대조 기준 없음기입 합산으로 언제든 재검증
부분 실패돈 증발 위험트랜잭션으로 원천 차단
잔액이 틀렸을 때알 수 없음재계산으로 감지·복구
성능빠름캐시로 빠름, 진실은 보존

즉 답은 "잔액 컬럼을 두지 마라"가 아니라 "잔액 컬럼을 진실로 착각하지 마라"입니다. 잔액을 미리 계산해 두되, 그것을 언제든 기입으로부터 다시 만들어 낼 수 있어야 하고, 주기적으로 재계산해 캐시가 진실과 어긋나지 않았는지 검증해야 합니다. 이것이 순진한 설계와 견고한 원장을 가르는 결정적 차이입니다.

실무에서 흔한 패턴은 계정마다 "running balance(누적 잔액)"를 기입과 함께 기록하되, 그 값이 항상 이전 잔액에 이번 기입을 더한 것과 일치하도록 강제하는 것입니다. 이렇게 하면 빠른 조회와 검증 가능성을 동시에 얻습니다.

대사 — 원장이 진실인지 확인하기

이중 기입 원장은 검증을 스스로 돕는 구조지만, 그 검증을 실제로 돌리는 작업이 필요합니다. 이것이 **대사(reconciliation)**입니다. 원장 맥락에서 대사는 크게 두 층위로 이루어집니다.

내부 정합성 검증. 원장 자체가 자기 규칙을 지키고 있는지 확인합니다. 가장 기본은 앞서 본 전역 등식입니다. 시스템의 모든 기입을 다 더하면 차변 총합과 대변 총합이 같아야 합니다.

-- 전역 불변식: 모든 기입의 debit 합 == credit 합 이어야 함
SELECT
  SUM(CASE WHEN direction = 'debit'  THEN amount ELSE 0 END) AS total_debit,
  SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END) AS total_credit
FROM entries;
-- 두 값이 다르면 -> 원장이 깨졌다는 알람. 즉시 조사 대상.

또한 잔액 캐시를 쓴다면, 캐시된 잔액이 기입을 처음부터 합산한 값과 일치하는지 주기적으로 대조합니다. 어긋나면 캐시를 재계산하고 원인을 추적합니다.

외부 대사. 내부 원장이 스스로 일관되더라도, 그것이 바깥 세계(은행 계좌, PG 정산 내역)와 맞는지는 별개의 문제입니다. 그래서 원장의 기록을 은행 명세나 PG 정산 파일과 대조합니다. 앞선 결제 시스템 글에서 다룬 대사가 바로 이 층위입니다. 내부 원장이 "우리가 기록한 진실"이라면, 외부 대사는 "그 진실이 실제 돈과 일치하는가"를 확인합니다.

잘 설계된 원장은 이 두 대사를 매일 자동으로 돌리고, 불일치를 지표로 관측합니다. 대사가 깨끗하게 맞는다는 것은 시스템이 돈을 잃지 않고 있다는 가장 강력한 증거입니다. 그리고 대사가 가능한 유일한 이유는, 원장이 애초에 모든 거래를 변경 불가능하게 보존하고 있기 때문입니다.

동시성 아래의 정합성

원장은 여러 거래가 동시에 몰려도 정합성을 지켜야 합니다. 여기서 두 가지 대표적인 동시성 문제를 짚겠습니다.

첫째, 잃어버린 갱신(lost update). 순진한 잔액 컬럼 설계에서, 두 거래가 동시에 잔액을 읽고 각자 계산한 뒤 쓰면, 나중에 쓴 것이 앞선 것을 덮어써 갱신 하나가 사라집니다. 이중 기입의 append-only 방식은 이 문제에 근본적으로 강합니다. 기존 값을 읽어 고치는 대신 새 행을 추가하기만 하므로, 두 거래가 동시에 들어와도 각자 자기 기입을 삽입할 뿐 서로를 덮어쓰지 않습니다.

둘째, 잔액 제약의 원자적 검증. "잔액이 마이너스가 되면 안 된다" 같은 제약은 동시성 아래에서 까다롭습니다. 두 출금이 동시에 각각 "지금 잔액이 충분하네"라고 판단한 뒤 둘 다 진행하면, 합쳐서 잔액을 초과할 수 있습니다. 이를 막으려면 해당 계정에 대한 접근을 직렬화해야 합니다. 흔한 방법은 그 계정 행에 락을 걸어(예: SELECT ... FOR UPDATE) 한 번에 하나의 출금만 잔액을 검증·반영하도록 만드는 것입니다.

BEGIN;
  -- 출금 전, 해당 계정을 잠가 동시 출금을 직렬화
  SELECT id FROM accounts WHERE id = 1 FOR UPDATE;

  -- (여기서 계정 1의 현재 잔액을 기입 합산으로 계산해 충분한지 확인)
  -- 충분하면 균형 잡힌 기입을 INSERT
  INSERT INTO entries (transaction_id, account_id, direction, amount)
  VALUES (99, 2, 'debit', 500),
         (99, 1, 'credit', 500);
COMMIT;

트랜잭션의 격리 수준(isolation level)도 중요합니다. 더 강한 격리(예: serializable)는 이런 이상 현상을 데이터베이스가 막아 주지만, 경합이 잦으면 성능 비용과 재시도(직렬화 실패 시)가 따릅니다. 여기서 다시, 앞선 글에서 강조한 원칙이 이어집니다. 그런 재시도가 안전하려면 거래가 멱등해야 하고, 그래서 각 거래에 고유한 transaction_id를 부여해 중복 삽입을 막습니다. 원장, 멱등성, 정합성은 이렇게 서로 맞물립니다. 이런 락과 격리 수준의 동작을 직접 관찰하고 싶다면 SQL 놀이터Postgres 놀이터에서 실험해 볼 수 있습니다.

TigerBeetle — 원장을 위한 전용 데이터베이스

지금까지 본 원장은 일반 관계형 데이터베이스(예: Postgres) 위에서도 충분히 구현할 수 있습니다. 실제로 많은 핀테크가 그렇게 시작합니다. 하지만 거래량이 극단적으로 커지면, 범용 데이터베이스로는 성능과 정합성을 동시에 만족시키기 어려워집니다. 이 지점에서 원장 전용 데이터베이스라는 새로운 종류가 등장했습니다. 대표적인 것이 TigerBeetle입니다.

TigerBeetle이 던지는 문제의식은 이렇습니다. 금융 원장의 작업 부하는 아주 특수합니다. 대부분이 "계좌 간 이체"라는 좁고 반복적인 연산이고, 각 이체는 여러 계정을 동시에 건드리며, 무엇보다 절대 틀리면 안 됩니다. 범용 데이터베이스는 온갖 쿼리를 다 잘 처리하도록 만들어졌지만, 바로 그 범용성 때문에 이 특수한 부하에서는 오버헤드가 큽니다.

TigerBeetle의 설계 선택을 엔지니어의 관점에서 요약하면 이렇습니다.

  • 도메인 특화. 범용 SQL 대신 "계정(account)"과 "이체(transfer)"라는 두 가지 원장 원시 개념에 집중합니다. 스키마가 고정돼 있어 최적화가 극단적으로 가능합니다.
  • 차변=대변을 데이터베이스가 강제. 균형 규칙을 애플리케이션이 아니라 데이터베이스 코어가 보장합니다. 잘못 균형 잡힌 이체는 아예 커밋되지 않습니다.
  • 높은 처리량을 위한 배치. 이체를 개별이 아니라 묶음(batch)으로 처리해, 초당 수십만 건 이상을 목표로 합니다.
  • 내장된 복제와 합의. 금융 데이터의 내구성을 위해 합의 프로토콜 기반 복제를 코어에 내장해, 노드가 죽어도 데이터가 유실되거나 어긋나지 않게 합니다.
  • 결정론적 설계와 혹독한 테스트. 결정론적으로 동작하도록 만들어, 시뮬레이션으로 온갖 장애(디스크 오류, 네트워크 분단)를 주입해 정합성을 검증합니다.

TigerBeetle을 반드시 써야 한다는 이야기가 아닙니다. 핵심 교훈은 이것입니다. 원장은 아주 특수하고 아주 중요한 작업 부하라서, 그것만을 위한 도구가 존재할 만큼 진지하게 다뤄진다. 대부분의 서비스는 Postgres 위의 잘 설계된 이중 기입 원장으로 충분히 시작할 수 있고, 규모가 커졌을 때 이런 전용 도구를 고려하게 됩니다. 어느 쪽이든, 그 밑에 깔린 원리는 600년 전의 그 복식부기 그대로입니다.

마치며

돈을 잃지 않는 시스템의 비밀은 화려한 기술이 아니라, 600년 된 회계 원리를 소프트웨어에 충실히 옮기는 데 있습니다. 잔액을 저장하지 말고 거래를 저장하라. 모든 거래를 차변과 대변으로 두 번 기록하고, 그 합이 항상 같도록 강제하라. 기록은 절대 고치거나 지우지 말고 오직 덧붙여라. 잔액은 그 기입들로부터 계산하고, 잔액 캐시를 쓰더라도 그것을 진실로 착각하지 마라.

이 원칙들이 함께 작동할 때, 원장은 스스로를 검증하는 시스템이 됩니다. 차변과 대변의 균형이 오류를 드러내고, append-only 로그가 완전한 감사 추적을 제공하며, 대사가 매일 진실을 확인하고, 트랜잭션과 락이 동시성 아래에서도 정합성을 지킵니다. 규모가 극단적으로 커지면 TigerBeetle 같은 전용 원장을 고려하되, 그 밑바탕의 원리는 변하지 않습니다.

결제 시스템 3부작을 관통하는 하나의 문장으로 마무리하겠습니다. 돈을 다루는 시스템은 틀릴 여지를 구조적으로 없애야 한다. 멱등성이 중복을 없애고, 이중 기입이 오류를 드러내고, 대사가 진실을 확인합니다. 이 세 가지가 함께 있을 때, 비로소 우리는 "돈을 잃지 않는 시스템"이라고 자신 있게 말할 수 있습니다.

참고 자료