레거시 금융 시스템을 마이그레이션하면서 원장 설계를 다시 해야 했다. 기존 시스템은 단식부기 — 입금/출금 이력 테이블 하나에 전부 때려넣은 구조였다.
문제는 간단했다. 숫자가 안 맞는데, 어디가 틀린지 모른다.
복식부기로 재설계하면서 정리한 내용을 공유한다. P2P 금융 서비스를 예시로 쓰지만, 재고 관리, 포인트 시스템, 정산 시스템 등 “수량이 이동하는 모든 서비스”에 동일하게 적용된다.
단식부기의 한계
대부분의 서비스가 이렇게 시작한다.
transactions
id | user_id | type | amount | created_at
1 | 42 | deposit | +1000000 | 2026-04-01
2 | 42 | withdrawal | -300000 | 2026-04-02
“42번 유저 잔고 = SUM(amount) = 70만원.” 단순하고 직관적이다.
그런데 서비스가 커지면 질문이 복잡해진다.
- “투자자 잔고 총합이 예치금 계좌 잔액과 안 맞는데?”
- “이 상환금에서 원금이 얼마고 이자가 얼마고 수수료가 얼마지?”
- “이번 달 플랫폼 수수료 매출이 얼마지?”
단식부기에서는 이 질문마다 별도 집계 로직을 만들어야 한다. 그리고 그 로직들이 서로 안 맞기 시작한다.
복식부기의 핵심 원리
모든 거래를 두 줄로 기록한다. 한쪽이 늘면 반드시 다른 쪽이 줄어야 한다.
단식: "투자자가 100만원 입금했다"
→ 1줄: 투자자 +100만원
복식: "투자자가 100만원 입금했다"
→ 차변(debit): 예치금 계좌 +100만원 (돈이 들어온 곳)
→ 대변(credit): 투자자 계좌 +100만원 (돈의 출처)
→ 차변 합계 = 대변 합계. 안 맞으면 저장 자체를 막는다.
이게 **자기 검증(self-balancing)**이다. 모든 거래에서 양쪽이 균형을 이루니까, 어딘가 안 맞는 순간 바로 알 수 있다. 단식부기는 정산할 때까지 오류를 모른다.
용어 정리 — 회계와 소프트웨어의 차이
본격적으로 테이블을 설계하기 전에, 회계 용어와 소프트웨어 용어를 구분해두자. 이걸 섞어 쓰면 나중에 혼란이 온다.
| 회계 용어 | 소프트웨어 테이블 | 설명 |
|---|---|---|
| 계정과목표 (Chart of Accounts) | accounts | 계정 코드 목록. 어떤 지갑이 존재하는지. |
| 분개장 (Journal) | journal_entries + journal_lines | 시간순 거래 기록. 불변. INSERT only. |
| 원장 (Ledger) | 별도 테이블 없음 | journal_lines를 계정별로 조회한 뷰. |
| 시산표 (Trial Balance) | account_balances | 계정별 잔액 합산. 캐시. |
핵심: journal_lines는 분개 원본이지 원장이 아니다. 원장은 분개 원본을 계정별로 잘라본 관점이다. 같은 데이터, 다른 뷰.
-- 분개장 (시간순): "4/8에 무슨 일이 있었지?"
SELECT * FROM journal_lines
JOIN journal_entries ON ... ORDER BY created_at;
-- 원장 (계정별): "투자자 김씨 계좌에 무슨 일이 있었지?"
SELECT * FROM journal_lines
WHERE account_id = 42 ORDER BY created_at;
테이블 설계 — 4개면 충분하다
ERD
accounts ─────────< journal_lines >───────── journal_entries
│
└── account_balances (1:1)
1. accounts — 계정 코드 (어떤 지갑이 존재하는지)
accounts
id BIGINT PK
owner_type ENUM('investor','borrower','platform')
owner_id BIGINT -- users FK. platform 계정은 NULL
type ENUM('cash','loan_receivable','interest_income','fee_revenue')
name VARCHAR -- "투자자 김씨 예치금"
created_at TIMESTAMP
돈이 머무는 모든 곳이 계정이다. 사람의 지갑도 계정이고, “수수료 매출”이라는 추상적 개념도 계정이다.
계정 생성 시점:
투자자 가입 → cash 계정 1개 자동 생성
대출자 가입 → cash 계정 1개 자동 생성
대출 실행 → loan_receivable 계정 1개 생성 (이 대출건의 채권)
시스템 시딩 → platform cash(에스크로), fee_revenue, interest_income
2. journal_entries — 거래 묶음
journal_entries
id BIGINT PK
type ENUM('deposit','withdrawal','loan_execute','repayment')
description VARCHAR
reference_type VARCHAR -- 'App\Models\Loan', 'App\Models\Repayment'
reference_id BIGINT -- 연관 도메인 객체
created_by BIGINT
created_at TIMESTAMP
“무슨 일이 있었다”를 기록하는 헤더. 하나의 entry에 여러 개의 line이 붙는다.
reference_type/reference_id는 polymorphic 관계로, 이 분개가 어떤 대출건, 어떤 상환건에서 발생했는지 추적한다.
3. journal_lines — 분개 상세
journal_lines
id BIGINT PK
entry_id BIGINT FK → journal_entries
account_id BIGINT FK → accounts
debit DECIMAL(15,2) DEFAULT 0
credit DECIMAL(15,2) DEFAULT 0
created_at TIMESTAMP
실제 돈의 이동. 이 테이블이 분개 원본(journal)이다. INSERT만 한다. UPDATE/DELETE 절대 금지. 이 데이터를 계정별로 조회하면 그게 원장(ledger)이 된다.
4. account_balances — 잔액 캐시
account_balances
account_id BIGINT PK FK → accounts
balance DECIMAL(15,2)
version INT -- 동시성 제어용
updated_at TIMESTAMP
journal_lines에서 계산하면 정확하지만 느리다. 조회 성능을 위한 캐시 테이블. 회계에서는 시산표(Trial Balance)에 해당한다. 불일치가 의심되면 분개 원본에서 재계산한다.
시나리오별 분개
P2P 금융의 전체 흐름을 따라가보자.
1. 투자자가 100만원 예치
돈의 이동: 투자자 은행 → 플랫폼 예치금
차변: 플랫폼 에스크로 +1,000,000 (돈 들어옴)
대변: 투자자 김씨 지갑 +1,000,000 (김씨 잔고 생김)
──────────────────────────────────
합계: 1,000,000 = 1,000,000 ✓
투자자 계정의 credit = “플랫폼이 김씨에게 돌려줘야 할 돈.” 부채의 개념이다.
2. 대출 실행 (투자자 → 대출자)
돈의 이동: 투자자 잔고에서 차감 → 대출자에게 지급
-- 투자자 잔고 차감 + 채권 생성
차변: 대출채권 (loan#1) +1,000,000 (받을 돈 생김)
대변: 투자자 김씨 지갑 +1,000,000 (김씨 잔고 차감)
-- 대출자에게 지급
차변: 대출자 박씨 지갑 +1,000,000 (박씨가 돈 받음)
대변: 플랫폼 에스크로 +1,000,000 (예치금에서 빠짐)
──────────────────────────────────
합계: 2,000,000 = 2,000,000 ✓
이 시점의 각 계정:
투자자 잔고: 0원 (돈 나감)
대출채권: 1,000,000원 (돌려받을 권리)
대출자: 1,000,000원 (갚아야 할 돈)
에스크로: 0원 (통과만 함)
3. 1회차 상환 (원금 10만 + 이자 1만 + 수수료 0.1만)
가장 복잡한 분개. 한 번의 상환에서 돈이 세 곳으로 흩어진다.
-- 대출자 상환금 입금
차변: 플랫폼 에스크로 +111,000 (돈 들어옴)
대변: 대출자 박씨 지갑 +111,000 (박씨 채무 줄어듦)
-- 원금 → 투자자 (채권 회수)
차변: 투자자 김씨 지갑 +100,000 (김씨 잔고 늘어남)
대변: 대출채권 (loan#1) +100,000 (채권 줄어듦)
-- 이자 → 투자자
차변: 투자자 김씨 지갑 +10,000 (이자 수익)
대변: 이자수익 +10,000 (이자 수익 발생)
-- 수수료 → 플랫폼
차변: 플랫폼 수수료 +1,000 (플랫폼 매출)
대변: 플랫폼 에스크로 +1,000 (에스크로에서 차감)
──────────────────────────────────
합계: 222,000 = 222,000 ✓
4. 투자자 출금
차변: 투자자 김씨 지갑 +110,000 (김씨 잔고 차감)
대변: 플랫폼 에스크로 +110,000 (은행으로 송금)
──────────────────────────────────
합계: 110,000 = 110,000 ✓
구현 — Laravel 코드
분개 생성 (상환 예시)
DB::transaction(function () use ($loan, $repayment) {
$entry = JournalEntry::create([
'type' => 'repayment',
'description' => "대출 #{$loan->id} {$repayment->installment}회차 상환",
'reference_type' => Repayment::class,
'reference_id' => $repayment->id,
]);
$lines = [
// 대출자 상환금 입금
['account_id' => Account::escrow()->id, 'debit' => 111000, 'credit' => 0],
['account_id' => $loan->borrower->cashAccount->id, 'debit' => 0, 'credit' => 111000],
// 원금 → 투자자
['account_id' => $loan->investor->cashAccount->id, 'debit' => 100000, 'credit' => 0],
['account_id' => $loan->receivableAccount->id, 'debit' => 0, 'credit' => 100000],
// 이자 → 투자자
['account_id' => $loan->investor->cashAccount->id, 'debit' => 10000, 'credit' => 0],
['account_id' => Account::interestIncome()->id, 'debit' => 0, 'credit' => 10000],
// 수수료 → 플랫폼
['account_id' => Account::platformFee()->id, 'debit' => 1000, 'credit' => 0],
['account_id' => Account::escrow()->id, 'debit' => 0, 'credit' => 1000],
];
// 자기 검증
$totalDebit = collect($lines)->sum('debit');
$totalCredit = collect($lines)->sum('credit');
if ($totalDebit !== $totalCredit) {
throw new UnbalancedEntryException(
"차변({$totalDebit}) ≠ 대변({$totalCredit})"
);
}
$entry->lines()->createMany($lines);
// 잔액 캐시 업데이트
$accountIds = collect($lines)->pluck('account_id')->unique();
foreach ($accountIds as $id) {
AccountBalance::recalculate($id);
}
});
잔액 조회
class AccountBalance extends Model
{
// 캐시 조회 (평소)
public static function getBalance(int $accountId): int
{
return self::where('account_id', $accountId)->value('balance');
}
// 분개 원본에서 재계산 (정산/검증)
public static function recalculate(int $accountId): void
{
$balance = JournalLine::where('account_id', $accountId)
->selectRaw('COALESCE(SUM(credit), 0) - COALESCE(SUM(debit), 0) as balance')
->value('balance');
self::updateOrCreate(
['account_id' => $accountId],
['balance' => $balance]
);
}
}
정산 배치 — 불일치 탐지
// 매일 새벽에 실행
Schedule::command('ledger:verify')->dailyAt('03:00');
// 전체 계정 잔액을 분개 원본에서 재계산, 캐시와 비교
public function handle()
{
$mismatches = DB::select("
SELECT
a.id,
a.name,
ab.balance as cached,
COALESCE(SUM(jl.credit), 0) - COALESCE(SUM(jl.debit), 0) as calculated
FROM accounts a
LEFT JOIN account_balances ab ON a.id = ab.account_id
LEFT JOIN journal_lines jl ON a.id = jl.account_id
GROUP BY a.id, a.name, ab.balance
HAVING cached != calculated
");
if (count($mismatches) > 0) {
// 슬랙 알림 + 자동 보정
Notification::send($mismatches);
}
}
취소/환불 — 분개 원본은 절대 수정하지 않는다
잘못된 거래가 있으면? 원래 분개를 DELETE 하는 게 아니라, 역방향 분개를 추가한다.
-- 원래 거래: 투자자에게 이자 지급
차변: 투자자 김씨 +10,000
대변: 이자수익 +10,000
-- 취소: 똑같은 금액을 반대로
차변: 이자수익 +10,000
대변: 투자자 김씨 +10,000
두 거래를 합치면 순효과 0원. 하지만 둘 다 이력에 남는다. “왜 취소했는지”가 journal_entries.description에 기록되고, 감사 추적이 가능하다.
금융에서는 이걸 **역분개(reversal entry)**라고 부른다. 분개 원본의 불변성을 지키면서 오류를 수정하는 유일한 방법이다.
왜 복식부기인가 — 정리
| 단식부기 | 복식부기 | |
|---|---|---|
| 기록 | ”100만원 들어옴" | "에스크로 +100 / 투자자 +100” |
| 오류 감지 | 정산할 때 발견 | 입력 시점에 차변 ≠ 대변으로 발견 |
| 돈의 출처 | 모름 | 어디서 와서 어디로 갔는지 자동 추적 |
| 재무 질문 | 질문마다 별도 쿼리 | 계정별 합산으로 즉시 답 |
| 취소/수정 | 레코드 UPDATE | 역분개 추가 (이력 보존) |
| 확장 | 새 거래 유형마다 테이블/로직 추가 | journal_entry + lines 조합만 변경 |
4개 테이블로 입금, 출금, 대출, 상환, 수수료, 환불까지 전부 표현된다. 새로운 거래 유형이 생겨도 테이블 추가 없이 분개 조합만 바꾸면 된다.
남의 돈을 다루는 서비스라면, 1원이라도 안 맞는 순간을 스스로 감지하는 구조가 필수다. 복식부기의 자기 검증이 그 구조다.