銀行はなぜ残高をUPDATEしないのか
한국어 원문으로 표시합니다.
한 줄 요약
은행 코어는 잔액을 고쳐 쓰는 대신 거래를 쌓고 합산한다 — 잔액만 보관하면 그 숫자가 틀렸을 때 대조할 상대가 세상에 하나도 남지 않기 때문이다.
왜 이게 필요했나
일반적인 서비스에서 잔액을 다루는 방법은 단순합니다. UPDATE users SET balance = balance - 1000 WHERE id = 7. 한 줄이고, 빠르고, 읽기도 편합니다.
이 방식의 문제는 성능이 아니라 되짚을 수 없다는 것입니다. 어느 날 잔액이 이상하다는 문의가 들어왔을 때, 우리 손에 있는 것은 지금의 숫자 하나뿐입니다. 이 숫자가 맞는지 틀린지 판단하려면 다른 곳에서 계산한 값과 비교해야 하는데, 비교할 대상이 없습니다. 로그를 뒤져 보자는 말이 나오지만 로그는 보존 기간이 있고 형식이 제각각이고 무엇보다 장부가 아닙니다.
은행은 이 문제를 수백 년 전에 이미 풀어 두었습니다. 답은 복식부기입니다.
어떻게 동작하나
복식부기의 규칙은 하나입니다. 모든 거래는 두 줄 이상으로 기록되고, 차변 금액의 합과 대변 금액의 합은 반드시 같다.
차변(Debit) 자산이 늘거나, 부채가 줄거나, 비용이 생기는 쪽
대변(Credit) 자산이 줄거나, 부채가 늘거나, 수익이 생기는 쪽
고객이 현금 100만 원을 예금하면 이렇게 기록됩니다.
전표 JE-20260825-0001 (입금)
차변 1010 현금 1,000,000
대변 2010 요구불예수금 1,000,000
은행 입장에서 현금이라는 자산이 100만 원 늘었고(차변), 동시에 그 돈을 고객에게 돌려줘야 할 의무인 예수금이라는 부채가 100만 원 늘었습니다(대변). 은행 예금이 은행의 자산이 아니라 부채라는 것 — 이게 처음 은행 시스템을 볼 때 가장 많이 헷갈리는 지점입니다.
전표(journal entry)는 거래 한 건이고, 분개행(journal line)은 그 전표를 이루는 각 줄입니다. 한 전표에 세 줄 이상이 붙는 경우도 흔합니다. 예금 이자를 지급하면서 원천세를 떼면 차변 한 줄에 대변 두 줄이 붙습니다.
이 구조에서 잔액은 저장된 값이 아니라 유도된 값입니다. 어떤 계정의 잔액은 그 계정에 붙은 모든 분개행을 합산해서 나옵니다. 그리고 모든 계정을 합산한 표가 시산표(trial balance) 이고, 시산표의 차변 총계와 대변 총계는 반드시 같아야 합니다.
이 항등식이 은행 시스템이 가진 유일한 자가 검증 장치입니다. 하루에 수백만 건이 들어와도, 그 전부를 한 줄로 요약해 "0 인가" 를 물을 수 있습니다.
현실의 코어는 성능 때문에 계정마다 잔액 컬럼을 함께 들고 있습니다. 조회할 때마다 수억 건을 합산할 수는 없기 때문입니다. 하지만 그 컬럼은 원장의 사본이지 원본이 아닙니다. 둘이 어긋나면 언제나 원장이 이깁니다.
현장에서 만나는 모습
첫째, 정정은 UPDATE 가 아니라 역분개입니다. 틀린 전표를 발견하면 그 전표를 고치는 것이 아니라, 원 전표를 거울처럼 뒤집은 전표(역분개)를 넣어 상쇄하고, 올바른 전표를 새로 기표합니다. 원장에는 세 장이 나란히 남습니다. 이게 번거로워 보이지만, 두 달 뒤 감사에서 "이 전표가 원래 이 금액이었나" 를 묻는 사람에게 답할 수 있는 유일한 방법입니다. 원장에 UPDATE 를 친 흔적이 있는 시스템은 그 자체가 감사 지적 사항이 됩니다.
역분개를 할 때 금액은 기록된 대로 씁니다. 틀린 금액을 그대로 뒤집어야 틀림이 정확히 상쇄됩니다. "어차피 틀렸으니 맞는 금액으로 뒤집자" 고 하면 그 순간 차이가 절반만 지워지고, 남은 절반은 아무 데도 기록되지 않은 채 장부에 남습니다.
둘째, 잔액이 안 맞는다는 문의의 원인은 대개 셋 중 하나입니다. 원장이 틀렸거나, 잔액 사본이 틀렸거나, 둘 다 맞는데 보는 시점이 다른 경우입니다. 이 셋은 대응이 완전히 다릅니다. 원장이 틀렸으면 정정 전표를 넣어야 하고, 사본이 틀렸으면 원장에서 다시 계산해 덮으면 되고, 시점 문제면 아무것도 고치지 않고 설명만 하면 됩니다. 어느 쪽인지 가르지 않고 손대는 것이 이 판에서 가장 비싼 실수입니다.
셋째, 잔액 컬럼만 보는 조사는 반드시 막힙니다. 잔액 하나는 그냥 숫자입니다. 맞는지 틀리는지를 그 숫자 자체로는 알 수 없습니다. 반면 원장을 합산해 낸 값은 잔액 컬럼과 비교할 수 있고, 전표 단위 차대변 합은 그 자체로 검증됩니다. 조사할 때 어느 표를 먼저 여는가가 그날 몇 시에 퇴근하는지를 정합니다.
원장을 데이터베이스에 앉힐 때의 규칙
복식부기를 표로 옮기는 방법은 여러 가지인데, 뒤에 후회하지 않는 형태는 대체로 같다.
분개는 한 트랜잭션에 두 줄 이상, 합이 0. 차변과 대변을 다른 표에 두거나 한 행에 두 금액을 넣으면 언젠가 한쪽만 들어간다. 같은 표에 여러 줄로 넣고, 그 묶음의 합이 0인지 데이터베이스가 확인하게 만든다.
create table entries(
id bigserial primary key,
txn_id uuid not null,
account_id bigint not null references accounts(id),
amount_minor bigint not null, -- 차변은 양수, 대변은 음수
currency char(3) not null,
posted_at timestamptz not null,
business_date date not null);
create index on entries(txn_id);
-- 묶음의 합이 0이 아니면 커밋을 막는다(지연 제약 또는 트리거로).
갱신도 삭제도 하지 않는다. 잘못 넣은 분개는 지우는 것이 아니라 반대 분개를 하나 더 넣어 상쇄하고, 두 묶음을 서로 잇는다. 그래야 "어느 시점에 잔액이 얼마였나" 에 언제나 답할 수 있다.
잔액은 저장하되 계산으로 검증한다. 매번 전부 더하면 느리므로 잔액을 따로 들고 있게 되는데, 그 값과 분개의 합이 어긋나는 순간이 반드시 온다. 일정 시점의 스냅샷 + 그 뒤의 분개 형태로 두고, 야간에 전체를 다시 더해 대조한다.
금액은 최소 단위의 정수로. 원은 1, 달러는 센트다. 부동소수는 쓰지 않는다. 통화가 여럿이면 금액과 통화를 절대 떼어 놓지 않는다 — 통화가 다른 두 금액을 더하는 코드가 있으면 그것은 버그다.
업무일자와 기표시각을 나눈다. 새벽 1시에 들어온 거래가 전날 영업일에 속할 수 있다. 하나로 뭉개면 마감 숫자가 사람마다 달라진다.
동시성은 계좌 단위로 잡는다. 같은 계좌에 동시에 기표하면 잔액이 어긋난다.
계좌 행을 select ... for update 로 잡거나, 잔액을 아예 분개의 합으로만 보고
잔액 행을 두지 않는 설계를 고른다.
다음 실습에서 할 것
사흘치 원장 스냅샷을 직접 만들고, 그 안에 숨어 있는 차대변 불일치 1건을 찾아냅니다. 시산표에서 시작해 전표 한 건, 분개행 한 줄까지 좁히고, 잔액 컬럼과 원장 합산을 대사해 두 어긋남의 성질이 왜 다른지 확인합니다. 마지막으로 원 전표를 손대지 않고 역분개와 재기표로 바로잡습니다.