카테고리 없음

돈을 다루는 백엔드는 왜 다른가: 핀테크 엔지니어링 핸드북 실무 정리

TeEm0 2026. 6. 29. 09:00
728x90

일반 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를 내
728x90