선불전자지급수단 원장 설계, 이벤트 소싱과 복식부기로 풀어내기
포인트 기반 서비스를 운영하다 보면, 초기에는 문제가 없던 구조가 거래량이 폭발적으로 늘어나는 순간 한계를 드러내요. 사용자가 결제를 시도했는데 네트워크가 끊겼다면? 결제 게이트웨이는 성공했는데 우리 서버는 응답을 받지 못했다면? 유저 수가 수십만에서 수백만으로 늘어나면서 동시 결제 요청이 초당 수천 건으로 치솟는 상황에서, "정말 한 번만 청구했나?"를 확인하는 것이 얼마나 어려운지 아실 거예요.
운영하면서 계속 느낀 건, 원장 시스템이 단순한 데이터베이스 테이블이 아니라 금융 감사를 견딜 수 있는 불변의 기록이어야 한다는 거였어요. 이 글에서는 선불전자지급수단(prepaid electronic payment instrument) 시스템에서 실제로 사용되는 원장 설계 패턴을 다룹니다. 이벤트 소싱, 복식부기, 멱등성 키, CQRS 분리 등 프로덕션 레디한 패턴들을 코드와 함께 살펴볼게요.
기존 원장 설계가 실패하는 이유
많은 팀이 처음에는 이렇게 시작해요. 계정 테이블에 balance 컬럼을 두고, 결제가 들어올 때마다 UPDATE로 잔액을 차감하는 방식이죠.
-- 나쁜 예: 직접 잔액 업데이트
UPDATE accounts SET balance = balance - 10000 WHERE user_id = 'user123';
서비스 초기에는 이 방식으로도 충분해요. 하루 거래가 수백 건이고 동시 접속자가 적을 때는 문제가 드러나지 않거든요. 하지만 프로모션 이벤트로 유저가 폭발적으로 유입되거나, 출퇴근 시간대에 결제가 몰리기 시작하면 이야기가 달라져요.
부분 실패에 취약해요
결제 요청이 들어와서 잔액을 차감했는데, 그 다음 단계(예를 들어 판매자 계정에 입금)에서 실패하면 어떻게 될까요. 돈은 사라졌는데 거래 기록은 없어요. 거래량이 적을 때는 수동으로 추적할 수 있지만, 하루 수만 건이 넘어가면 원인을 추적하는 것 자체가 불가능에 가까워져요.
감사 추적이 없어요
잔액이 100,000원에서 50,000원으로 떨어졌다면, 어떤 거래들이 그렇게 만들었는지 알 수 없어요. 금융감시원이나 ISMS-P 감사에서 "이 잔액 변화의 근거를 보여주세요"라고 물으면 답할 수 없습니다.
동시성 문제가 심각해요
같은 계정에 대해 동시에 여러 결제가 들어오면, 데이터베이스 격리 수준에 따라 잔액이 예상과 다르게 계산될 수 있어요. 이를 "lost update" 문제라고 부르는데, 유저가 급증하는 시점에 실제로 돈이 사라지거나 중복 청구되는 상황으로 이어집니다. 하루 거래량이 수십만 건을 넘기면 이 문제는 확률이 아니라 필연이에요.
복식부기, 500년 된 원칙을 디지털로
복식부기(Double-Entry Bookkeeping)는 15세기 베네치아 상인들이 만든 원칙이에요. 간단해요. 모든 거래는 정확히 두 개의 항목으로 기록된다는 것.
한 계정에서 돈이 나가면, 다른 계정에 반드시 같은 금액이 들어와야 해요. 이 원칙을 지키면 자동으로 다음 식이 성립합니다.
모든 차변(Debit) 합계 = 모든 대변(Credit) 합계
이 불변식(invariant)을 데이터베이스 제약으로 강제하면, 부분 실패가 원천적으로 불가능해져요.
예를 들어, 사용자가 10,000원을 결제한다면 아래처럼 기록해요.
-- 사용자 계정에서 차변(출금)
INSERT INTO journal_entries (account_id, type, amount, description)
VALUES ('user123', 'DEBIT', 10000, 'Payment for order #456');
-- 판매자 계정에 대변(입금)
INSERT INTO journal_entries (account_id, type, amount, description)
VALUES ('merchant789', 'CREDIT', 10000, 'Payment received from user123');
이 두 INSERT가 하나의 원자적 트랜잭션으로 실행되면, 둘 다 성공하거나 둘 다 실패해요. 중간 상태는 없습니다.
그리고 잔액은 저장하지 않아요. 필요할 때마다 계산합니다.
-- 사용자 잔액 조회
SELECT
SUM(CASE WHEN type = 'CREDIT' THEN amount ELSE 0 END) -
SUM(CASE WHEN type = 'DEBIT' THEN amount ELSE 0 END) as balance
FROM journal_entries
WHERE account_id = 'user123';
이렇게 하면 잔액은 항상 journal_entries의 합계와 일치해요. 불일치가 생기면 그건 버그가 아니라 데이터 손상이고, 감사 추적으로 정확히 어디서 잘못됐는지 찾을 수 있습니다.
이벤트 소싱, 상태가 아니라 사건을 기록하기
복식부기와 함께 가면 좋은 패턴이 이벤트 소싱이에요. 상태(state)를 저장하는 대신, 상태 변화를 일으킨 사건(event)을 모두 기록하는 거죠.
원장 시스템에서 이벤트 소싱은 다음과 같은 흐름으로 작동해요.
- 결제 요청이 들어온다
- "PaymentInitiated" 이벤트를 이벤트 스토어에 append한다
- 이벤트를 읽은 후처리 서비스가 "PaymentPosted" 이벤트를 생성한다
- 그 이벤트를 읽은 잔액 계산 서비스가 materialized view를 업데이트한다
이 방식이 거래량 폭증 상황에서 특히 강력한 이유가 있어요.
완전한 감사 추적이 가능해요. 모든 사건이 불변으로 기록되므로, 언제든 "이 계정의 잔액이 왜 이렇게 됐는가"를 재현할 수 있어요. 유저가 "내 포인트가 사라졌다"고 CS를 넣었을 때, 이벤트 로그를 재생하면 정확히 어느 시점에 무슨 일이 있었는지 추적할 수 있습니다.
이벤트 재생도 가능해요. 버그가 발견되면, 이벤트를 다시 재생해서 올바른 상태를 복구할 수 있어요. 수백만 건의 거래가 쌓인 상태에서 잔액 계산 로직에 버그가 있었다면, 이벤트를 처음부터 다시 재생해서 정확한 잔액을 복원하면 돼요.
느슨한 결합도 확보돼요. 결제 처리, 잔액 계산, 알림, 보고서 생성 등이 모두 같은 이벤트 스트림을 구독하면서 독립적으로 동작해요. 트래픽이 폭증해도 각 서비스가 자기 속도로 이벤트를 소비할 수 있습니다.
chronicle-ledger라는 오픈소스 프로젝트를 보면, 이벤트 스토어 스키마가 이렇게 설계돼 있어요.
CREATE TABLE events (
event_id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL, -- 어느 계정의 사건인가
event_type VARCHAR(100) NOT NULL, -- PaymentInitiated, PaymentPosted 등
event_data JSONB NOT NULL, -- 사건의 세부 데이터
event_version INTEGER NOT NULL, -- 이 계정의 N번째 사건
created_at TIMESTAMP DEFAULT NOW(),
metadata JSONB -- 추적용 메타데이터
);
CREATE UNIQUE INDEX idx_events_aggregate ON events(aggregate_id, event_version);
UNIQUE (aggregate_id, event_version) 제약이 핵심이에요. 같은 계정에 대해 두 개의 버전 5 이벤트가 동시에 들어오려고 하면, 하나는 실패합니다. 이것이 **낙관적 잠금(optimistic locking)**이고, 동시성 제어의 기본이에요.
멱등성 키, 중복 결제를 원천 차단하기
여기서 주의할 점이 있어요. 결제 요청이 들어왔을 때, 우리 서버가 성공했는데 클라이언트가 응답을 받지 못했다면? 클라이언트는 "혹시 실패했나?" 싶어서 다시 요청을 보낼 거예요. 그러면 같은 결제가 두 번 처리돼요.
거래량이 적을 때는 이런 상황이 드물어요. 하지만 초당 수천 건의 결제가 몰리는 피크 타임에는 네트워크 타임아웃이 빈번해지고, 클라이언트 재시도도 급격히 늘어나요. 이때 멱등성 보장이 없으면 중복 청구가 대량으로 발생할 수 있습니다.
이를 방지하는 게 **멱등성 키(idempotency key)**예요. Stripe, Square, Omise 같은 결제 게이트웨이들이 모두 지원하는 패턴이에요.
원리는 간단해요.
- 클라이언트가 결제 요청을 보낼 때, UUID를 생성해서
Idempotency-Key헤더에 담아 보낸다 - 서버는 이 키를 본 적이 있는지 확인한다
- 처음 본 키라면 결제를 처리하고, 결과를 캐시한다
- 같은 키로 다시 요청이 들어오면, 캐시된 결과를 반환한다
Stripe의 계약은 명확해요. 같은 키로 다른 파라미터를 보내면 400 에러를 반환합니다. 키는 단순한 라벨이 아니라 약속이거든요.
ledger-core 프로젝트에서는 Redis와 데이터베이스를 함께 사용해요.
// 멱등성 키 확인 (Redis 캐시)
const idempotencyKey = `idempotency:${dto.idempotencyKey}`;
const existingTransferId = await this.redisService.get(idempotencyKey);
if (existingTransferId) {
const existing = await this.prismaService.transfer.findUnique({
where: { id: existingTransferId },
});
if (existing) return existing; // 캐시된 결과 반환
}
// 첫 번째 요청: 트랜잭션 처리
const transfer = await this.prismaService.$transaction(async (tx) => {
// ... 결제 로직 ...
return pgTransfer;
});
// 결과를 Redis에 24시간 캐시
await this.redisService.set(
idempotencyKey,
transfer.id,
86400 // 24 hours TTL
);
여기서 중요한 건 **TTL(Time To Live)**이에요. Stripe와 Omise는 24시간 동안 키를 보관해요. 이 기간 동안은 같은 키로 들어오는 요청이 모두 같은 결과를 받아요. 24시간 후에는 키가 만료되고, 새로운 요청으로 취급돼요.
실전에서는 한 가지 더 고려해야 해요. 동시성 경합(race condition)이에요. 같은 키로 두 개의 요청이 동시에 들어올 수 있거든요. 유저가 급증하는 시점에는 이런 경합이 훨씬 자주 발생해요. 이를 방지하려면 데이터베이스 수준의 원자적 연산이 필요합니다.
-- PostgreSQL: 원자적 "insert if not exists"
INSERT INTO idempotency_keys (key, business_signature)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;
이 쿼리는 원자적이에요. 두 개의 동시 요청이 들어와도, 하나만 성공하고 다른 하나는 실패합니다. 실패한 요청은 이미 저장된 결과를 조회해서 반환하면 돼요.
CQRS로 읽기와 쓰기 분리하기
원장 시스템이 커지면, 읽기와 쓰기의 성능 요구사항이 달라져요. 쓰기는 정확성이 최우선이지만, 읽기는 빨라야 해요. 사용자가 "내 잔액이 얼마예요?"라고 물었을 때 1초를 기다릴 수는 없거든요.
특히 유저가 수백만 명으로 늘어나면 잔액 조회 요청도 폭증해요. journal_entries 테이블에서 매번 SUM을 계산하면 데이터베이스가 버틸 수 없습니다. 이를 해결하는 게 **CQRS(Command Query Responsibility Segregation)**예요. 쓰기 경로와 읽기 경로를 완전히 분리하는 거죠.
쓰기 경로(Write Path)에서는 결제 요청이 들어오면 복식부기 journal_entries에 원자적으로 기록하고, 이벤트를 발행해요.
읽기 경로(Read Path)에서는 이벤트를 구독해서 계산된 잔액을 별도의 테이블(account_balance)에 저장하고, 사용자 요청이 들어오면 이 테이블에서 바로 조회해요.
chronicle-ledger의 스키마를 보면 이 분리가 명확하게 드러나요.
-- 쓰기 경로: 불변의 이벤트 로그
CREATE TABLE events (
event_id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_data JSONB NOT NULL,
event_version INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- 읽기 경로: 계산된 잔액 (물리화 뷰)
CREATE TABLE account_balance (
account_id VARCHAR(255) PRIMARY KEY,
balance DECIMAL(20, 2) NOT NULL DEFAULT 0.00,
currency VARCHAR(3) DEFAULT 'USD',
last_updated TIMESTAMP DEFAULT NOW()
);
-- 읽기 경로: 거래 내역 (비정규화)
CREATE TABLE transactions (
transaction_id UUID PRIMARY KEY,
account_id VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
amount DECIMAL(20, 2) NOT NULL,
balance_after DECIMAL(20, 2) NOT NULL,
timestamp TIMESTAMP DEFAULT NOW()
);
이렇게 분리하면 쓰기는 느려도 정확해요. 복식부기 검증, 동시성 제어, 이벤트 발행 등 모든 게 엄격하게 동작합니다. 읽기는 미리 계산된 값을 조회하기만 하면 되니까 빨라요. 인덱스도 읽기 패턴에 맞게 최적화할 수 있어요.
일관성은 최종적(eventually consistent)이에요. 읽기 모델이 쓰기 모델보다 약간 뒤처질 수 있지만, 결국 일치해요. 거래량이 폭증하는 상황에서도 읽기 서비스가 쓰기 서비스의 부하에 영향받지 않으니, 사용자 경험을 안정적으로 유지할 수 있습니다.
실제로 Uber의 결제 시스템(Gulfstream)도 이 패턴을 사용해요. 드라이버의 잔액을 조회할 때는 캐시된 값을 반환하고, 정산 시간에는 원본 이벤트 로그를 재생해서 정확한 값을 계산합니다.
자동 조정, 불일치를 감지하고 복구하기
아무리 잘 설계해도, 분산 시스템에서는 뭔가 잘못될 수 있어요. 외부 결제 게이트웨이와의 동기화 실패, 메시지 큐의 지연, 네트워크 파티션 등등. 거래량이 급증하는 시점에는 이런 이상 상황의 발생 빈도도 함께 올라가요. 이런 상황에서 우리 원장과 실제 상태가 맞지 않을 수 있어요.
이를 감지하고 복구하는 게 **자동 조정(reconciliation)**이에요. 금융 시스템에서는 이게 선택이 아니라 필수예요.
transaction-processor 프로젝트의 스키마를 보면 조정 이력을 이렇게 관리해요.
CREATE TABLE reconciliation_runs (
id BIGSERIAL PRIMARY KEY,
run_at TIMESTAMP NOT NULL,
window_start TIMESTAMP NOT NULL,
audit_count BIGINT NOT NULL, -- 외부 시스템의 거래 수
ledger_count BIGINT NOT NULL, -- 우리 원장의 거래 수
missing_in_ledger BIGINT NOT NULL, -- 우리가 놓친 거래
status VARCHAR(32) NOT NULL,
notes TEXT
);
조정 프로세스는 보통 매일 자정 이후, 거래가 적은 시간대에 실행돼요. 외부 시스템(결제 게이트웨이, 은행 API 등)에서 거래 목록을 받아서 우리 원장과 비교하고, 각 거래가 우리 시스템에 기록돼 있는지 확인합니다.
불일치가 발견되면 유형에 따라 대응이 달라져요.
- 타이밍 차이는 외부 시스템은 기록했는데 우리는 아직 못 받은 경우예요. 보통 몇 시간 내에 자연스럽게 해결돼요.
- 진짜 불일치는 외부 시스템에는 있는데 우리는 영구적으로 없는 경우예요. 이건 심각한 버그를 의미하고, 즉시 알림이 나가야 해요.
심각도에 따라 자동 복구하거나 수동 개입을 요청합니다.
Trio.dev의 분석에 따르면, 이 조정 프로세스가 자동화되면서 금융 팀의 업무가 극적으로 줄었어요. 한 미국 네오뱅크는 조정 팀이 5명에서 1명으로 줄었대요.
실제 구현, TigerBeetle을 활용한 복식부기 원장
지금까지의 패턴을 모두 구현한 프로덕션 예제가 ledger-core예요. 이 프로젝트는 TigerBeetle이라는 금융 특화 데이터베이스를 사용해요.
TigerBeetle은 복식부기를 위해 특별히 설계된 데이터베이스예요. 계정과 전송(transfer)이 기본 개념이고, 모든 연산이 원자적이에요. 초당 수백만 건의 전송을 처리할 수 있어서, 거래량이 폭증하는 상황에서도 안정적으로 동작합니다.
// TigerBeetle 계정 생성
async createAccounts(dto: { ledger: Ledger; code: AccountType }[]) {
const accounts = dto.map((acc) => ({
id: id(),
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
ledger: acc.ledger,
code: acc.code,
flags: acc.code === AccountType.USER_WALLET
? AccountFlags.debits_must_not_exceed_credits // 잔액 부족 방지
: 0,
}));
const results = await this.tbClient.createAccounts(accounts);
return accounts.map((acc) => acc.id);
}
// 전송 (복식부기 자동 처리)
async createTransfer({
debitAccountId,
creditAccountId,
amount,
ledger,
code,
}: {
debitAccountId: bigint;
creditAccountId: bigint;
amount: bigint;
ledger: Ledger;
code: TransferType;
}) {
const transfer = [{
id: id(),
debit_account_id: debitAccountId,
credit_account_id: creditAccountId,
amount: amount,
ledger: ledger,
code: code,
flags: 0,
}];
const results = await this.tbClient.createTransfers(transfer);
return transferId;
}
TigerBeetle의 핵심 특징을 정리하면 이래요.
원자적 전송이 보장돼요. debit과 credit이 동시에 성공하거나 동시에 실패합니다. 부분 실패가 구조적으로 불가능해요.
2단계 전송을 지원해요. pending에서 posted 상태로 전환되면서, 보류 중인 금액과 확정된 금액을 구분할 수 있어요. 결제 승인과 확정을 분리해야 하는 선불전자지급수단에서 특히 유용합니다.
계정 플래그로 잔액 부족을 데이터베이스 수준에서 방지해요. debits_must_not_exceed_credits 플래그를 설정하면, 잔액이 부족한 결제 시도가 애플리케이션 로직이 아니라 데이터베이스 레벨에서 거부돼요.
핵심 요약
선불전자지급수단의 원장 시스템을 설계할 때 기억해야 할 것들이에요.
- 복식부기를 데이터베이스 제약으로 강제하세요. 부분 실패가 원천적으로 불가능해져요.
- 상태가 아니라 사건을 기록하세요. 이벤트 소싱으로 완전한 감사 추적을 확보해요.
- 멱등성 키로 중복 결제를 방지하세요. 클라이언트 재시도가 안전해져요.
- 읽기와 쓰기를 분리하세요. CQRS로 성능과 정확성을 동시에 확보해요.
- 자동 조정을 처음부터 설계하세요. 불일치는 언제나 발생하고, 감지하고 복구하는 게 중요해요.
운영하면서 계속 느낀 건, 금융 시스템은 "빠르고 편한" 것보다 "정확하고 감사 가능한" 것이 훨씬 중요하다는 거였어요. 처음에는 복식부기와 이벤트 소싱이 복잡해 보일 수 있지만, 한 번 제대로 구축하면 거래량이 10배, 100배로 늘어나도 운영 비용이 극적으로 줄어들어요. 서비스가 성장할수록 이 구조의 가치가 드러납니다.
FAQ
Q. 복식부기가 정말 필요한가요? 단순히 잔액 컬럼을 조심스럽게 업데이트하면 안 되나요?
안 돼요. 동시성 제어가 복잡해지고, 부분 실패 시 복구가 어려워요. 복식부기는 500년 동안 검증된 원칙이고, 데이터베이스 제약으로 강제하면 버그의 여지가 없어집니다. 금융감시원 감사에서도 복식부기 기반 시스템을 훨씬 선호해요.
Q. 이벤트 소싱을 도입하면 성능이 떨어지지 않나요?
쓰기 성능은 약간 떨어질 수 있어요 (이벤트 발행 오버헤드). 하지만 읽기는 CQRS로 분리하면 오히려 빨라져요. 그리고 성능 문제보다 정확성이 중요한 금융 시스템에서는 이 트레이드오프가 합리적이에요.
Q. 멱등성 키의 TTL을 24시간보다 길게 설정해도 되나요?
가능하지만 권장하지 않아요. 24시간은 결제 게이트웨이의 표준이고, 이 기간이면 충분해요. 더 길게 설정하면 저장소 비용이 증가하고, 키 충돌 위험도 커져요.
Q. 자동 조정이 불일치를 발견했을 때 자동으로 수정해도 되나요?
타이밍 차이(timing difference)는 자동 수정해도 돼요. 하지만 진짜 불일치(genuine mismatch)는 반드시 수동 검토 후 수정하세요. 자동 수정은 더 큰 문제를 숨길 수 있어요.
Q. 한국의 선불전자지급수단 규제에서 특별히 고려할 점이 있나요?
금융감시원의 "선불전자지급수단 관리 기준"에서는 거래 기록의 완전성과 추적 가능성을 강조해요. 이벤트 소싱과 복식부기는 이 요구사항을 자연스럽게 만족합니다. 또한 ISMS-P 인증을 받으려면 감사 추적이 필수인데, 이 아키텍처가 그걸 제공해요.
