돈을 다루는 백엔드는 왜 다른가: 핀테크 엔지니어링 핸드북 실무 정리
일반 CRUD 백엔드만 굴리다가 결제·정산 시스템에 처음 투입되면 멘붕이 온다. "그냥 잔액 칼럼 하나 두고 더하고 빼면 되는 거 아니야?"라고 생각하다가, 환불이 중복으로 두 번 나가고, 정산 금액이 1원씩 안 맞고, 외부 PG 웹훅이 두 번 와서 같은 거래가 두 번 찍히는 걸 보게 된다. 이게 다 같은 뿌리에서 나온 문제다.
최근 GeekNews에 올라온 핀테크 엔지니어링 핸드북은 이 바닥에서 반복적으로 터지는 함정들을 세 가지 원칙으로 깔끔하게 정리했다. 이 글에서는 그 원칙을 실무 코드와 실제로 만날 법한 에러까지 붙여서 풀어본다.
1. 왜 금융 시스템은 일반 백엔드와 다르게 설계해야 하는가
원문이 제시하는 세 가지 원칙이 전부다. 외워두면 설계 회의에서 싸울 때 근거가 된다.
- No invented data: 돈은 없던 데서 생기지 않는다. 중복 처리와 임의 잔액 변경을 막아야 한다.
- No lost data: 돈에 일어난 모든 일은 추적되고 영속화돼야 한다.
- No trust: 외부 제공자도, 내부 컴포넌트도, 현실 세계도 믿지 말고 검증한다.
일반 CRUD에서는 "최신 상태"가 진실이다. 행 하나 업데이트하면 끝. 그런데 금융 시스템은 "어떻게 그 상태가 됐는지"가 더 중요하다. 규제 감사가 들어오면 "지금 잔액 100만 원입니다"로는 부족하고, "이 100만 원이 어떤 거래들의 합으로 만들어졌는지" 몇 년 전 것까지 재구성할 수 있어야 한다. 그래서 추적성·불변성·검증 가능성이 성능이나 편의성보다 앞선다.
핵심 차이를 한 줄로 요약하면: 일반 백엔드는 상태를 덮어쓰지만, 금융 백엔드는 상태를 누적한다.
2. 금액을 float로 저장하면 안 되는 이유
가장 흔하고 가장 치명적인 실수다. 신입한테 "왜 float 쓰면 안 돼요?"라고 물으면 대부분 "정밀도 문제요"라고 답하는데, 실제로 무슨 일이 벌어지는지 보여주는 게 빠르다.
# Python에서 IEEE-754 double 동작 확인
$ python3 -c "print(0.1 + 0.2)"
0.30000000000000004
$ python3 -c "print(0.1 + 0.2 == 0.3)"
False
이게 왜 무섭냐면, 단건 거래에서는 안 보이다가 수십만 건을 합산하는 정산 배치에서 갑자기 터진다. 외부 정산 데이터랑 1원, 2원씩 안 맞기 시작하는데 원인 찾는 데 며칠 날린다. 원문 표현대로 "예측하기 어려운 정밀도 손실"이라 디버깅이 지옥이다.
해결책은 책임에 따라 조합한다
- 최소 단위 정수 저장: €12.34를
1234(센트)로 저장. 중앙은행 시스템과 같은 고정 정밀도 방식이다. 단, ISO 4217 자릿수를 따라야 하고 "항상 소수점 2자리"라고 가정하면 안 된다. JPY는 0자리, 일부 통화는 3자리다. - BigDecimal 같은 임의 정밀도 타입: 반올림 위치를 명시적으로 제어할 수 있어 FX·이자처럼 연산이 줄줄이 이어지는 중간 계산에 적합하다.
- 유리수(Rational): 정밀도 손실이 절대 허용 안 될 때 가장 강력하지만 느리고 변환이 까다롭다.
저장 방식과 계산 방식은 별개 결정이다. 정수로 저장하고 BigDecimal로 중간 계산하는 조합이 흔하다.
// Java: BigDecimal로 안전하게 계산 (반올림 명시)
import java.math.BigDecimal;
import java.math.RoundingMode;
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b)); // 0.3 (정확)
// 수수료 3.5% 계산 후 소수점 2자리 반올림
BigDecimal amount = new BigDecimal("12340"); // 최소단위(센트)
BigDecimal fee = amount.multiply(new BigDecimal("0.035"))
.setScale(0, RoundingMode.HALF_EVEN);
System.out.println(fee); // 432
주의: Java에서 new BigDecimal(0.1)처럼 double을 직접 넣으면 float 문제가 그대로 따라온다. 반드시 new BigDecimal("0.1")처럼 문자열로 넣어야 한다.
직렬화 경계에서 다시 터지는 함정
내부에서 BigDecimal 잘 써놓고 안심하다가, JSON으로 내보내는 순간 무너진다. 일반 JSON 숫자는 대부분의 파서에서 IEEE-754 double로 파싱되기 때문이다. 그래서 돈은 JSON에서 12.34 같은 문자열이나 최소 단위 정수로 보내야 한다.
// 나쁜 예: 숫자로 직렬화 → 수신측에서 double로 파싱되어 정밀도 손실
{ "amount": 12.34, "currency": "EUR" }
// 좋은 예: 문자열 또는 최소단위 정수
{ "amount": "12.34", "currency": "EUR" }
{ "amount_minor": 1234, "currency": "EUR" }
그리고 돈은 숫자만으로 표현하면 안 된다. 항상 통화와 함께 Money 타입으로 묶어라. 서로 다른 통화 덧셈은 금지하고, 변환은 통제된 환율로만 명시적으로 한다. 이걸 안 하면 USD 금액에 KRW 금액을 더하는 버그가 코드 리뷰를 통과해버린다.
3. 멱등성 설계: 결제 재시도와 중복 처리를 막는 패턴
분산 시스템에서는 exactly-once delivery를 보장할 수 없다. 그래서 재시도가 필요하고, 재시도는 중복 전달을 만든다. 이 모순을 푸는 게 멱등성(Idempotency)이다. 같은 메시지가 두 번 와도 효과는 한 번만 나게 하는 성질.
실무에서 언제 만나냐면:
- 클라이언트가 결제 요청 보냈는데 응답 타임아웃 → 사용자가 결제 버튼 다시 누름 → 두 번 결제
- PG 웹훅이 redelivery policy 때문에 같은 이벤트를 2~3번 보냄
- Kafka consumer가 처리 후 offset commit 전에 crash → 재시작 시 같은 메시지 재처리
명시적 idempotency key가 정답
원문은 payload 기반 deduplication보다 명시적 idempotency key가 보통 더 단순하고 안전하다고 본다. 핵심은 key를 특정 operation과 client 범위로 제한하는 것.
-- idempotency key를 DB unique 제약으로 강제
CREATE TABLE idempotency_keys (
key VARCHAR(255) NOT NULL,
client_id VARCHAR(64) NOT NULL,
operation VARCHAR(64) NOT NULL,
response JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (client_id, operation, key)
);
-- 결제 처리 트랜잭션 안에서 먼저 키를 선점
INSERT INTO idempotency_keys (key, client_id, operation)
VALUES ('pay-20240601-abc123', 'merchant-42', 'charge');
두 번째 요청이 같은 키로 들어오면 unique 제약에 걸린다. 흔히 마주치는 에러가 이거다:
ERROR: duplicate key value violates unique constraint "idempotency_keys_pkey"
DETAIL: Key (client_id, operation, key)=(merchant-42, charge, pay-20240601-abc123) already exists.
이 에러를 예외로 흘려보내면 안 되고, "아 이미 처리된 요청이구나" 하고 저장해둔 원래 응답(response 컬럼)을 그대로 돌려줘야 한다. 이걸 처리 안 하고 500 에러로 던지면 클라이언트가 또 재시도하는 무한 루프에 빠진다.
흔한 함정들
- 오류를 재생할지 재처리할지: 영구 오류(잔액 부족 등)는 그대로 재생하는 게 보통 단순하다. 일시 오류만 재처리.
- 24시간 idempotency window: 구현은 단순해지지만 correctness 비용이 크다. window 밖에서 들어온 재시도는 중복으로 안 잡힌다.
- 동시성: 대규모에서는 같은 키가 동시에 두 번 들어올 때 atomic barrier가 필요하다. unique 제약이 그 역할을 해준다.
- out-of-order retry: 재시도가 원본보다 먼저 도착할 수도 있다. 테스트에 반드시 포함해라.
4. 이중 장부(Double-Entry Accounting)와 불변 원장
잔액 칼럼 하나 두는 설계가 왜 망하는지 여기서 명확해진다. 잔액은 저장하지 않고, 돈의 이동에서 파생해야 한다.
복식부기는 모든 거래를 (credit account, debit account, amount) 형태의 entry로 저장한다. 모든 entry가 한 계정에서 다른 계정으로 같은 금액을 옮기므로 장부는 항상 균형을 이룬다. 돈에는 항상 출처와 목적지가 있다. 외부 PG조차 전용 계정을 가져야 시스템 안팎으로 흐르는 돈을 추적할 수 있다.
-- 불변 원장: append-only, UPDATE/DELETE 금지
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
debit_account VARCHAR(64) NOT NULL,
credit_account VARCHAR(64) NOT NULL,
amount_minor BIGINT NOT NULL, -- 최소단위 정수
currency CHAR(3) NOT NULL,
value_time TIMESTAMPTZ NOT NULL, -- 거래가 실제 발생한 시점
booking_time TIMESTAMPTZ NOT NULL DEFAULT now(), -- 기록된 시점
settlement_time TIMESTAMPTZ, -- 돈이 실제 이전된 시점 (T+X)
reverses_id BIGINT REFERENCES ledger_entries(id) -- 정정 시 원본 연결
);
-- 잔액은 SELECT로 파생
SELECT
SUM(CASE WHEN credit_account = 'user-42' THEN amount_minor ELSE 0 END)
- SUM(CASE WHEN debit_account = 'user-42' THEN amount_minor ELSE 0 END)
AS balance_minor
FROM ledger_entries
WHERE currency = 'KRW';
시간을 created_at 하나로 합치지 마라
이게 진짜 자주 하는 실수다. 거래에는 보통 2~3개의 타임스탬프가 붙는다.
- Value time: 거래가 실제 발생한 시점
- Booking time: 시스템에 기록된 시점
- Settlement time: 돈이 실제 이전된 시점 (T+2면 value date로부터 2일 뒤)
카드 결제를 예로 들면 T1에 결제 발생, T2에 시스템 기록, T3에 PG가 실제 입금. 이걸 created_at 하나로 뭉개면 나중에 재구성 불가능한 정보를 잃는다. 비즈니스 보고서는 value time이나 settlement time을 보는데, created_at밖에 없으면 월별 정산이 틀어진다.
정정은 덮어쓰지 말고 상쇄 entry로
posted entry는 관례상 불변이다. 잘못 찍혔으면 원본을 수정하는 게 아니라 compensating entry를 추가하고 원본과 양방향 연결한다.
- Reversal: 원본을 경제적으로 없었던 것처럼 완전히 상쇄. 단, 원본과 reversal 둘 다 이력에 남는다.
- Correction/adjustment: 실제 값과 올바른 값의 차이를 booking하거나, 되돌린 뒤 다시 posting.
정정은 원본과 다른 보고 기간에 들어갈 수 있다. 이미 닫힌(외부에 보고된) 보고 기간에 backdate하는 건 보통 금지다. 그래서 연결 정보가 있어야 "실제 활동"과 "cleanup"을 보고서에서 구분할 수 있다.
5. 분산 환경에서의 정합성: 2PC vs Saga vs Outbox
"상태 변경은 DB에 commit됐는데 Kafka publish가 실패했다" 또는 "publish는 성공했는데 응답을 못 받아서 DB를 rollback했다" — 이 문제를 어떻게 풀 것인가.
| 방식 | 동작 | 실무 평가 |
|---|---|---|
| 2PC / 분산 트랜잭션 | 여러 시스템을 하나의 원자적 트랜잭션으로 묶음 | 교과서 정답이지만 복잡성·표준화 어려움 때문에 실무에선 드물게 쓰임 |
| Outbox 패턴 | 상태 변경과 "publish 의도"를 같은 트랜잭션으로 전용 테이블에 기록, 별도 relay가 나중에 발행 | 가장 실용적. DB 트랜잭션 하나로 원자성 확보 |
| CDC | DB의 WAL/replication log를 읽어 commit된 변경을 event로 변환 (Debezium, AWS DMS) | raw row 형태라 내부 schema 누출 막으려면 postprocessing 필요 |
| Saga | 외부 효과가 영구 실패하면 compensating action을 posting해서 보상 | 외부 호출은 rollback 불가능하므로 roll-forward 또는 보상 거래로 처리 |
Outbox 패턴 핵심
-- 비즈니스 상태 변경과 outbox 기록을 같은 트랜잭션에
BEGIN;
INSERT INTO ledger_entries (debit_account, credit_account, amount_minor, currency, value_time)
VALUES ('user-42', 'merchant-7', 50000, 'KRW', now());
INSERT INTO outbox (event_id, topic, payload)
VALUES (gen_random_uuid(), 'payment.completed', '{"order_id":"o-123"}');
COMMIT;
-- relay 프로세스가 outbox를 polling하여 Kafka로 발행 후 마킹
어떤 메커니즘을 고르든 delivery는 at-least-once다. relay가 publish 후 기록 전에 죽으면 재시작 때 또 보낸다. 그래서 consumer는 stable event id로 deduplicate하고 멱등적으로 동작해야 한다. 3번 섹션의 멱등성이 여기서 다시 필요해진다. 결국 다 연결돼 있다.
재시작 가능한 흐름과 funds reservation
돈 흐름은 여러 단계를 거치고 단계 사이 어디서든 죽을 수 있다고 가정해야 한다. 진행 상태를 메모리가 아니라 영속 저장소에 두고, 명시적 state machine으로 모델링하고, 각 단계 완료를 다음 단계 시작 전에 commit한다. Temporal, Camunda, AWS Step Functions 같은 durable-execution engine을 쓰거나 직접 만든다.
외부 호출 전에 race condition을 막으려면 funds reservation(hold-and-release)을 쓴다. 외부 상호작용 전에 자금을 예약하고, 성공하면 settle, 실패하면 release. 이때 available = total - reserved로 가용 잔액을 구분한다. 중요한 건 잔액 확인과 reservation 기록이 linearizable해야 한다는 것. stale read에서는 두 거래가 모두 잔액 확인을 통과해 같은 자금을 두 번 쓰게 된다.
6. 실무 체크리스트: 시나리오별 설계 포인트
웹훅 처리 (PG·custodian 연동)
- 순서 가정 금지 — out-of-order로 오거나 stale data를 담는다. 받은 웹훅으로 상태를 덮어쓰지 마라.
- 웹훅 본문은 trigger로만 쓰고, authoritative state는 API를 다시 조회해서 확인. (단 API도 eventually consistent라 retry 필요)
- 빠르게 2xx로 ack하고 비동기 처리. raw payload는 그대로 durable store에 저장.
- 서명 검증은 재직렬화한 payload가 아니라 받은 raw bytes 위에서 해야 한다. 이거 틀리면 HMAC 검증이 간헐적으로 실패한다.
- 웹훅은 "진실"이 아니라 "뭔가 일어났다는 hint"로 취급.
환불·정정
- 원본 entry 수정 금지. compensating entry로 reversal 또는 correction.
- 이미 닫힌 보고 기간에 backdate 가능한지 보고 일정 확인.
- reversal이 자금 빠져나간 뒤 들어오면 의도치 않은 overdraft가 생긴다. 음수 잔액을 0으로 clamp하면 돈을 만들어내는 꼴이니 절대 금지.
정산·대사(Reconciliation)
- 두 시스템 이상의 상태가 어긋나는 data drift는 필연. ledger·PG·bank 셋 이상일 수 있다.
- settlement가 T+3이면 record는 3일간 unreconciled 상태가 정상 — 이걸 process에 반영 안 하면 불필요한 alert가 쏟아진다.
- discrepancy를 단순 overwrite로 맞추지 마라. correction record로 원인을 이해하고 고쳐야 한다.
- external provider id를 내