핀테크 엔지니어링 핸드북 (Fintech Engineering Handbook)
이 자료는 돈이 시스템의 핵심 대상일 때 소프트웨어 엔지니어링에서 쓰이는 가장 중요한 패턴을 설명합니다.
누구를 위한 문서인가요?
핀테크에 합류하는 사람. 돈을 다루는 시스템을 신뢰할 수 있게 만드는 도메인과 패턴에 익숙해질 수 있습니다.
이미 핀테크에서 일하는 사람. 특정 문제를 만났을 때 참고하고, 동료에게 공유할 공통 어휘로 사용할 수 있습니다.
핀테크 밖에 있는 사람. 돈을 대상으로 만드는 일이 익숙한 개발과 어떻게 다른지, 왜 다른지 이해할 수 있습니다.
이 문서는 계속 갱신되는 문서이며, 기여를 환영합니다.
Principles
아래에서 다룰 모든 내용은 세 가지 원칙을 지키기 위한 방법입니다.
없는 데이터를 만들지 않는다. 돈은 아무 데서나 생겨날 수 없습니다. 따라서 중복이나 임의의 잔액 변경을 허용할 수 없습니다. 멱등성, 중복 제거, 대사를 통해 이를 강제합니다.
데이터를 잃지 않는다. 돈에 일어나는 모든 일은 추적되고 저장되어야 합니다. 전체 정밀도, at-least-once 전달, 이벤트 소싱, 감사 추적, 불변성으로 이를 보호합니다.
신뢰하지 않는다. 외부 제공자도, 내부 컴포넌트도, 세상도 신뢰하지 않습니다. 웹훅을 검증하고, 여러 출처의 데이터를 교차 확인하며, 깨진 가정은 크게 실패하게 만들어 이 원칙을 지킵니다.
Representing money
돈을 옮기거나 기록하기 전에 먼저 돈을 표현해야 합니다. 이는 금액 값을 어떻게 모델링하고, 저장하고, 계산하고, 변환할지에 대한 결정입니다. 여기서 잘못 결정하면 그 위의 모든 계층이 오류를 물려받습니다.
정밀도 처리
돈 표현은 금융 시스템에서 가장 근본적인 결정 중 하나입니다. 주된 방법은 네 가지입니다.
부동소수점. 내장 float 또는 double 타입입니다. 예측하기 어려운 정밀도 손실을 만들 수 있어 거의 언제나 좋지 않은 선택입니다. 다만 가장 빠르고 메모리 효율이 좋으며, 추가 라이브러리나 데이터 구조가 필요 없습니다.
임의 정밀도. Java의
BigDecimal같은 타입은 계산 정밀도를 정확히 제어할 수 있게 해줍니다. 코드는 예측 가능하고, 반올림이 어디서 어떻게 일어날지 직접 정할 수 있습니다. FX나 가격 계산처럼 많은 연산이 이어지는 중간 작업에 잘 맞습니다.최소 단위 정밀도. 대부분의 법정화폐는 연결된 중앙은행 시스템에서 쓰는 것과 같은 고정 정밀도만 유지해도 괜찮습니다. 자릿수는 ISO 4217이 설명합니다. 항상 2자리라고 가정하면 안 됩니다. 실제로는 금액을 가장 작은 단위의 정수로 저장한다는 뜻입니다. €12.34는
1234가 됩니다. Crypto도 같은 정수-최소-단위 아이디어를 씁니다. BTC의 satoshi, ETH의 wei가 예입니다. 다만 두 가지 차이가 있습니다. 정밀도는 자산별로 다르고 토큰 자체가 정의합니다. 예를 들어 ERC-20의decimals이며, 흔히 18자리입니다. 또 결과 숫자가 64-bit 정수를 넘치게 하므로 이를 담으려면 임의 폭 정수가 필요합니다.유리수. 어떤 정밀도 손실도 허용할 수 없을 때 씁니다. 가장 강력한 접근이지만 주의점도 있습니다. 첫째, 다른 방법보다 느립니다. 둘째, 정밀도 손실 없이 다른 형식으로 변환할 수 없습니다. 셋째, 보통 커스텀 데이터 타입이나 라이브러리가 필요합니다.
어느 것을 선택할지는 시스템의 종류와 책임에 달려 있습니다. 부동소수점을 쓰지 말라는 것 말고는 일반 규칙이 없습니다. 이 표현들은 서로 배타적이지도 않습니다. 금액을 저장하는 방식과 금액으로 계산하는 방식은 별개의 결정이고, 시스템은 자주 이들을 조합합니다. 예를 들어 저장은 정수로 하고 중간 계산은 BigDecimal로 할 수 있습니다.
금액을 직렬화할 때도 같은 주의가 필요합니다. 대부분의 파서에서 bare JSON number는 IEEE-754 double입니다. 따라서 내부에서 아무리 조심스럽게 표현해도 돈을 number로 직렬화하면 시스템 경계에서 부동소수점 문제가 다시 들어옵니다. 돈은 문자열("12.34")이나 가장 작은 단위의 정수로 보내세요.
관련 원칙:
데이터를 잃지 않는다. 잘못된 표현은 나중에 복구할 수 없는 정밀도를 조용히 버립니다.
반올림 전략
반올림은 피할 수 없습니다. 명시적으로 해야 합니다. 나눗셈, 통화 변환, 수수료, 이자나 비율 적용, 정밀도 간 이동은 모두 반올림을 필요로 할 수 있습니다.
반올림은 비즈니스 결정입니다. 반올림 전략마다 의미가 다릅니다. 때로는 보수적으로 처리해야 해서, 예를 들어 가진 것보다 더 쓰지 않기 위해 내림해야 합니다. 때로는 통계적 효과가 중요해서 half-even을 씁니다. 소수 부분을 누가 가져가는지는 법적·세무적 의미를 가질 수도 있습니다.
가능한 한 적게 반올림하세요. 전체 정밀도를 오래 유지할수록 알맞은 맥락에서 올바른 결정을 내릴 선택지가 많아집니다. 반올림은 보통 경계에서 일어나야 합니다. 예를 들어 숫자를 저장하거나 사용자에게 보여주기 직전입니다.
반올림은 합계를 깨뜨립니다. 숫자를 여러 부분으로 나누고 각각에 반올림을 적용하면, 부분들의 합이 원래 숫자와 더 이상 같지 않을 수 있습니다. 맥락에 따라 이를 명시적으로 처리해야 할 수 있습니다. 예를 들어 별도의 반올림 계정이 필요할 수 있습니다.
관련 원칙:
데이터를 잃지 않는다. 잔여분은 버리지 말고 추적해야 합니다.
없는 데이터를 만들지 않는다. 반올림이 없던 돈을 만들어내서는 안 됩니다.
통화 처리
돈은 숫자만으로 표현할 수 없습니다. 통화와 함께 다닙니다. 통화를 다룰 때는 몇 가지 뉘앙스가 있습니다.
금액과 통화를 함께 묶으세요.
Moneynewtype(struct, class, record 등)은 오류 가능성을 줄입니다.통화가 다른 금액끼리 산술하지 않습니다. 시스템은 서로 다른 통화의 두 금액을 더하지 못하게 해야 합니다. 변환은 엄격히 제어된 환율로 아주 명시적으로 일어나야 합니다.
통제된 통화 집합을 사용하세요. 커스텀 설정 항목, JDK 데이터베이스, 전용 서비스 등 무엇이든 좋습니다. 임의의 통화 코드를 받아들이지 말고 시스템 경계에서 검증하세요.
코드는 법정화폐만 식별합니다. 통화 코드는 법정화폐에 대해서만 고유하고 식별자로 쓸 수 있습니다. Crypto currency에는
(network, contract address)같은 더 복잡한 접근이 필요합니다.통화에는 메타데이터가 붙습니다. 심볼, 정밀도, 이름 등이 있습니다. 보통 표시 목적에는 이런 세부 정보가 필요하지만, 비즈니스 로직에는 드뭅니다.
페그된 자산은 기초 자산이 아닙니다. Pegged, bridged, wrapped crypto currency는 기초 자산과 동등하지 않습니다.
관련 원칙:
신뢰하지 않는다. 시스템 경계에서 통화를 통제된 집합과 대조해 검증합니다.
없는 데이터를 만들지 않는다. 서로 다른 통화나 자산을 교환 가능한 것으로 취급하면 가치를 만들어낸 셈이 됩니다.
FX Rates
FX(Forex, foreign exchange currency market) rate는 통화 간 돈을 변환할 수 있게 해줍니다.
환율에는 항상 방향이 있습니다. EUR/USD 환율은 뒤집은 USD/EUR 환율과 같은 것이 아닙니다. 거래소에서 매수와 매도는 서로 다른 가격의 서로 다른 주문입니다. bid/ask spread가 있기 때문에 두 방향은 단순히 역수가 되지 않습니다.
환율의 시점이 중요합니다. 기술적으로는 어느 시점의 환율이든 쓸 수 있지만, 가장 흔히 쓰이는 것은 다음입니다.
현재 시점 환율. 현재 보유분이나 거래 가치를 지금 일어난 것처럼 계산할 때 씁니다.
Value-date rate. 가치 변동이나 세액을 계산할 때 씁니다.
변환에서 중요한 환율은 두 종류입니다.
거래 환율. 실제 변환이 일어난 환율입니다. 이를 직접 저장하지는 않습니다. 원래 금액과 결과 금액에서 도출됩니다.
기준 환율(mid-market 또는 중앙은행 환율). 평가와 동등성 판단에 쓰입니다. 지금 보유분의 가치가 얼마인지, value date의 과세 기준이 얼마인지 같은 용도이며, 누군가 실제로 거래하는 가격은 아닙니다.
표준 환율은 없습니다. 환율은 시장에서 나오며 거래 장소나 계산 방식에 따라 달라집니다. 표준에 가장 가까운 것은 중앙은행 환율이지만, 이것도 기준 환율로만 쓸 수 있습니다. 그마저도 똑같이 유효한 대체 출처가 있을 수 있습니다.
관련 원칙:
데이터를 잃지 않는다. 금액을 보존하고, 기준 환율의 경우 출처로 돌아갈 방법도 보존합니다.
신뢰하지 않는다. 표준 환율은 없으므로 출처가 데이터의 일부여야 합니다.
Recording money: the ledger
돈을 표현했다면, 돈의 이동은 장부가 맞고 감사에 견디며 몇 년 뒤에도 재구성할 수 있는 방식으로 기록되어야 합니다. 여기에는 장부, 타임스탬프, 이력이 들어갑니다.
복식부기
복식부기는 금융 거래를 (credit account, debit account, amount) 형식의 엔트리 목록으로 저장하는 널리 쓰이는 방식입니다. 이는 압축된 형식이고, 고전적인 표현은 이동마다 debit 행과 credit 행을 따로 둡니다. 모든 엔트리는 같은 금액을 한 계정에서 빼서 다른 계정에 넣기 때문에 장부는 항상 균형을 이룹니다. 돈은 이동할 뿐, 생성되거나 사라지지 않습니다.
돈에는 항상 출처와 목적지가 있습니다. 외부 제공자도 전용 계정을 받기 때문에, 시스템에 들어오고 나가는 돈도 계속 추적됩니다.
잔액은 저장하지 않습니다. 잔액은 돈의 이동에서 도출됩니다.
계정에는 타입이 있습니다. 자산, 부채, 자본입니다. 그래서 회계 등식(
assets = liabilities + equity)이 성립하고, 각 계정은 어느 쪽에서 증가하는지가 정의됩니다. 실제로는 수익과 비용 계정도 필요합니다. 예를 들어 수수료를 수익으로 기록하거나 write-off를 손실로 기록할 때입니다(assets = liabilities + equity + revenue - expenses).하나의 거래, 여러 이동. 하나의 거래는 보통 여러 이동을 만듭니다. 예를 들어 순금액에 대한 이동 하나, 수수료에 대한 이동 하나가 생깁니다.
게시된 엔트리는 불변입니다. 관례상 수정은 원래 엔트리를 상쇄하는 새 보정 엔트리를 추가해 처리합니다.
관련 원칙:
없는 데이터를 만들지 않는다. 돈은 계정 사이에서만 이동하며, 총액은 보존됩니다.
Value time vs booking time vs settlement time
거래에는 보통 최소 두 개, 때로는 세 개의 타임스탬프가 붙습니다.
Value time. 거래가 발생한 시점입니다.
Booking time. 거래가 시스템에 기록된 시점입니다.
Settlement time. 돈이 실제로 이전되거나 실체화된 시점입니다. 모든 거래에 있는 것은 아닙니다. 보통 T+X로 표현하며, X는 value 이후 며칠 뒤에 settlement가 일어나는지를 뜻합니다. 예를 들어 T+2는 value 2일 뒤입니다.
처음 두 시점은 거의 언제나 달라집니다.
Backdated(booking > value). 기술적으로는 거의 모든 거래가 backdated이지만, 이 용어가 가장 큰 의미를 갖는 때는 booking time과 value time이 서로 다른 보고 기간에 속할 때입니다. 예를 들어 일, 월, 연도가 달라질 수 있습니다.
Forward-dated(booking < value). 더 드물지만, 예약 결제나 미래일자 결제에서 발생합니다. 오늘 기록했지만 다음 주에 효력이 생기는 standing order가 예입니다.
예: 카드 결제가 T1(value time)에 발생했고, 당신은 T2(booking time)에 이를 기록했지만, 결제 제공자가 T3(settlement time)에야 당신의 계정으로 돈을 이체했습니다.
비즈니스 보고서와 비즈니스가 소비하는 보고서는 보통 value time이나 settlement time을 중요하게 보고, booking time은 추적 가능성에 유용합니다.
관련 원칙:
데이터를 잃지 않는다. 관련 타임스탬프를 모두 기록하세요. 이를 하나의
created_at으로 합치면 나중에 재구성할 수 없는 정보를 잃습니다.
감사와 감사 추적
금융 시스템은 다양한 감사 형태로 규제 검토를 받습니다. 감사 중 검증될 수 있는 것들은 다음과 같습니다.
회사 자금이 사용자 자금과 섞이거나 회사 비용으로 쓰이지 않았는가?
모든 수익이 등록되고, 보고되고, 설명 가능한가? 예를 들어 특정 기간의 특정 수익 흐름에 기여한 거래를 정확히 짚을 수 있는가?
외부 세계, 예를 들어 사용자나 세무서에 제공한 정보가 현실과 맞는가? 예를 들어 회사가 사용자에게 진 부채만큼 자산을 보유하고 있는가?
자금이 외부 위협으로부터 보호되는가? 예를 들어 누가 어떤 방식으로 자금에 접근할 수 있는가?
이 질문들과 그 밖의 많은 질문에 답하려면 금융 시스템은 현재 상태뿐 아니라 그 상태가 어떻게 만들어졌는지에 대한 전체 이력을 추적해야 합니다. 이 이력이 감사 추적입니다. 어떤 잔액, 보고서, 결정도 설명하고 재현할 수 있을 만큼 자세히 기록된, 일어난 모든 일의 기록입니다.
유용한 감사 추적은 모든 변경에 대해 다음을 포착합니다.
무엇이 일어났는지.
언제 일어났는지(value time vs booking time 참고).
누가 또는 무엇이 촉발했는지. 사용자, 운영자, 자동 작업일 수 있습니다.
왜 일어났는지. 이를 일으킨 주문, 지시, 사고에 대한 참조입니다.
돈의 이동은 당연한 대상이지만, 수동 개입, 설정 변경(수수료표, 환율 출처, 한도), 권한 변경도 추적이 필요합니다.
왜는 그 자체로 어떤 결정의 산출물인 경우가 많습니다. 예를 들어 컴플라이언스 검사나 위험 점수입니다. 결과("blocked")만 기록하는 것은 감사에서 거의 충분하지 않습니다. 그 결과에 어떻게 도달했는지 질문받기 때문입니다. 그 로직이 imperative code 안에 묻혀 있지 않고 decision table이나 rules engine(DMN, Drools, Decisions4s)에 있다면, 결정은 어떤 입력에서 어떤 규칙이 발동했고 어떤 결과가 나왔는지를 말해주는 구조화되고 재실행 가능한 산출물이 됩니다.
관련 원칙:
데이터를 잃지 않는다. 현재 상태만으로는 감사 질문에 답할 수 없습니다. 전체 이력만 답할 수 있습니다.
Event sourcing
Event sourcing은 감사 추적을 만드는 가장 원칙적이고 체계적인 접근일 것입니다. ES에서는 현재 상태를 저장하고 옆에 로그를 두는 대신, 이벤트만 저장하고 상태는 그 이벤트에서 도출합니다. 복식부기 원장은 이 패턴을 돈에 적용한 예입니다. 잔액은 저장하지 않고, 저장된 엔트리에서 계산합니다. 이 접근에서는 추적 기록이 1차 산출물이며 현실과 어긋날 수 없습니다.
몇 가지 실무 메모입니다.
모든 곳에 필요하지는 않습니다. 원장은 이미 돈을 다룹니다. 주변 도메인에는 신뢰할 수 있는 변경 로그를 둔 전통적인 모델이면 충분할 수 있습니다.
도출된 상태는 캐시할 수 있습니다. 성능을 위해 잔액과 projection을 캐시하거나 snapshot할 수 있습니다.
Projection은 작업량이 큽니다. 많이 필요할 수 있고, 1차 데이터셋인 이벤트를 효과적으로 쿼리할 수 없으므로 데이터를 들여다보기 위한 전용 또는 범용 projection을 만들어야 합니다.
스키마 진화를 계획하세요. 이벤트는 수년 동안 살아 있으므로, 오늘의 코드는 오래전에 작성된 이벤트도 계속 읽을 수 있어야 합니다.
다시 말해 event sourcing은 감사 추적이 필요할 때 매우 좋은 해법이지만, 시스템 복잡도 측면에서 매우 높은 비용을 동반합니다.
관련 원칙:
데이터를 잃지 않는다. 상태가 이벤트에서 도출되면 추적 기록은 현실과 어긋날 수 없습니다. 그것이 곧 source of truth이기 때문입니다.
불변성
수정할 수 있는 감사 추적은 아무것도 증명하지 못합니다. 따라서 기록은 업데이트되거나 삭제될 수 없어야 합니다. 로그는 append-only여야 하며 모든 수정은 새 기록이어야 합니다(아래 참고).
불변성은 invariant이며, 일반적인 도구들이 적용됩니다.
구조적으로 보장. Append-only 테이블, 데이터베이스 권한 수준에서
UPDATE/DELETE회수.런타임 검사. 애플리케이션 계층이 게시된 기록에 대해 변경 작업을 노출하지 않습니다.
사후 검증. 변조 증거: 기록 위의 checksum이나 hash chain을 주기적으로 검증해 사후 수정이 감지되게 합니다.
실제 시스템을 만들 때 버그는 피할 수 없고, event log나 audit trail을 고쳐야 할 수도 있습니다. 그런 경우 추적 기록을 엄격히 불변으로 유지하는 대신 제자리에서 수정하는 편이 더 쉬울 때가 있습니다. 이 두 세계의 균형을 맞추려면 보고 일정과 의무를 이해하는 것이 중요합니다. 보통 데이터는 보고된 뒤에야 돌에 새겨진 것처럼 고정되어야 합니다. 예를 들어 월말에 재무제표가 공유된 뒤입니다. 그 전에는 문제가 시스템 밖으로 나가기 전에 발견하고 고친다면 데이터를 제자리에서 수정할 수 있을지도 모릅니다.
관련 원칙:
신뢰하지 않는다. 수정 가능한 이력은 아무것도 증명하지 못합니다. 불변성과 변조 증거는 외부자, 그리고 사고를 조사하는 당신 자신에게도 추적 기록을 신뢰할 수 있게 만듭니다.
Reversals and corrections
실수는 여전히 일어납니다. 예를 들어 잘못된 금액이 게시되거나 거래가 잘못된 계정에 들어갈 수 있습니다. 불변성은 앞으로 고치는 것을 뜻합니다. 새 보정 엔트리를 게시하고, 그것이 수정하는 기록과 양방향으로 연결합니다.
Reversal. 원래 기록을 경제적으로 없었던 것처럼 전액 상쇄합니다. 하지만 원래 기록과 함께 이력에는 계속 보입니다.
Correction(adjustment). 기록된 값과 기록됐어야 하는 값의 차이를 장부에 반영하거나, reversal 후 올바른 값으로 다시 게시합니다.
보고 기간을 주의하세요. Correction은 원래 기록과 다른 보고 기간에 들어가는 경우가 많습니다(value time vs booking time 참고). 이 연결이 있어야 보고서가 이를 올바르게 귀속하고 실제 활동과 정리 작업을 구분할 수 있습니다.
마지막 지점이 특히 중요합니다. correction/reversal을 게시할 때 이벤트를 backdate할지, 즉 과거의 value time을 지정할지 결정해야 합니다. 여기서도 많은 부분은 보고 일정에 달려 있습니다. 보통 이미 닫힌 기간에는 어떤 것도 backdate할 수 없습니다. 이미 외부 세계에 보고되었기 때문입니다.
관련 원칙:
없는 데이터를 만들지 않는다. 실수는 원래 기록을 상쇄하는 연결된 보정 엔트리를 게시해 고칩니다.
불변성과 GDPR
GDPR의 삭제권은 불변 원장과 모순되는 것처럼 보입니다. 실제로는 꽤 쉽게 문제가 아니게 만들 수 있습니다.
금융 기록은 대체로 예외입니다. 법적 보존 의무(회계법, AML, 보통 5-10년)가 거래 데이터에 대한 삭제 요청보다 우선합니다. 그 기간에는 posting을 삭제하지 않습니다.
PII와 금융 데이터를 분리하세요. 예외가 적용되는 것은 보존할 의무가 있는 데이터이지, 보존하고 싶은 모든 데이터가 아닙니다. 불변 원장은 사용자를 불투명한 내부 식별자로만 참조하고, PII(이름, 주소, 문서)는 별도의 변경 가능한 저장소에 두어 독립적으로 마스킹하거나 삭제할 수 있게 합니다.
내장된 PII에는 crypto-shredding을 사용하세요. 개인 데이터가 불변 기록에 포함되어야 하는 경우, 예를 들어 이벤트 payload에 들어가는 경우, 사용자별 키로 각 사용자의 개인 필드를 암호화하고 키를 삭제해 지웁니다. 삭제는 이력 재작성이나 아니라 키 삭제가 됩니다.
관련 원칙:
데이터를 잃지 않는다. PII를 금융 데이터와 분리하면 보존 의무가 있는 금융 이력을 잃지 않으면서 삭제권을 지킬 수 있습니다.
Executing money flows
돈을 다루는 작업은 거의 단일 write가 아닙니다. 여러 단계, 동시성, 실패를 가로지르며, 그 모든 과정에서 돈을 만들거나 잃지 않고 올바르게 유지되어야 합니다. 여기서는 단일 flow를 올바르게 유지하는 패턴을 다룹니다. 지켜야 할 invariant부터 중간에 crash가 나도 살아남는 방법까지입니다.
Invariants
어떤 시스템에도 항상 성립해야 하는 특별한 속성이 있습니다. 이를 invariant라고 부릅니다. 위에서 언급한 회계 등식도 그런 invariant 중 하나입니다. 비즈니스 이해관계자들은 이와 같은 조건을 많이 정의할 수 있고, 시스템은 이를 강제해야 합니다.
Invariant를 강제하는 주된 방법은 3가지입니다.
구조적으로 보장. 시스템이 유효한 객체만 만들 수 있게 해 잘못된 상태를 표현할 수 없게 합니다. factory method(smart constructor), type-level programming(예: refined type), database constraint 등 다양한 기법으로 할 수 있습니다.
런타임 검사. 로직을 실행할 때 invariant가 성립하는지 확인합니다. 운영 코드의 assertion이나 test가 될 수 있습니다. property-based testing이 여기서 빛납니다. 예를 들어 "어떤 posting 시퀀스에서도 장부는 균형을 이룬다" 같은 검사입니다.
사후 검증. 시스템이 저장한 데이터를 분석해 위반이 있는지 찾습니다. 예를 들어 reconciliation job이나 원장 잔액이 여전히 회계 등식을 만족하는지 확인하는 nightly check입니다.
중요한 점은 이 방법들이 서로 보완적이며, 원하는 신뢰 수준을 얻기 위해 보통 나란히 모두 사용한다는 것입니다. 구조적 보장이 가장 강하지만 모든 것을 표현할 수는 없습니다. 특히 cross-aggregate나 cross-system invariant는 어렵습니다. 런타임 검사는 위반이 발생하는 지점에서 잡습니다. 사후 검증은 이미 배포된 버그를 잡는 유일한 방법이지만, 늦게 잡습니다.
관련 원칙:
신뢰하지 않는다. Invariant는 가정하지 않고 검증합니다. 당신 코드의 출력도 검사 대상입니다.
자금 예약
대부분의 경우 거래에는 외부 세계와의 상호작용이 필요합니다. 예를 들어 사용자가 자금을 인출하도록 허용하기 전에 컴플라이언스 검사를 실행해야 할 수도 있고, 외부 시스템에 인출을 등록해야 할 수도 있습니다.
이런 경우 race condition도 피해야 합니다. 같은 돈을 두 번 쓰거나, 외부 세계와 상호작용이 이미 끝난 뒤에야 "잔액 부족"을 발견하는 상황입니다.
이를 해결하기 위해 시스템은 funds reservation(hold-and-release라고도 함)을 구현합니다. 외부 상호작용이 시작되기 전에 먼저 특정 거래를 위해 자금을 예약합니다. 완료되면 예약을 정산하고 거래를 진행합니다. 문제가 생기면 예약을 해제하고 자금을 available balance로 돌려놓습니다.
이 패턴은 두 가지 잔액을 구분합니다. total balance(예약된 자금을 포함해 사용자가 가진 모든 것)와 available balance(available = total - reserved)입니다. 잔액 검사와 새 예약은 available balance를 기준으로 이뤄지며, 이것이 같은 자금이 두 거래를 뒷받침하지 못하게 막습니다.
몇 가지 실무 메모입니다.
최종 금액은 달라질 수 있습니다. 처음부터 항상 알 수 있는 것은 아닙니다. 수수료나 환율이 추정치와 다를 수 있습니다. 그런 경우 추정 금액을 예약하고, 실제 금액을 정산한 뒤 나머지를 해제합니다.
예약은 반드시 끝나야 합니다. 정산도 해제도 되지 않은 예약은 사용자 자금을 잠급니다. 따라서 예약을 만드는 모든 flow는 결국 이를 끝내는 것을 보장해야 합니다. 명시적인 만료/timeout은 안전망이 될 수 있지만 필수는 아닙니다. 내부 시스템 규율에 의존할 수도 있습니다. 특히 실패 모드는 보수적입니다. 고아 예약은 돈을 잠글 뿐, 돈을 잃거나 만들지는 않습니다.
강한 일관성이 필요합니다. 잔액을 확인하고 예약을 기록하는 작업은 linearizable해야 합니다. stale read에서는 두 거래가 모두 검사를 통과하고 같은 자금으로 각자의 지출을 뒷받침할 수 있습니다. 그러니 여기에는 eventual consistency를 쓰면 안 됩니다.
관련 원칙:
없는 데이터를 만들지 않는다. 같은 자금이 두 거래를 뒷받침할 수 없습니다. 예약은 race가 있는 잔액 검사에 기대는 대신 이를 명시적으로 만듭니다.
Overdraft 처리
Overdraft는 계정 잔액이 음수가 되는 일입니다. Overdraft는 두 종류가 있습니다.
의도된 overdraft. 비즈니스가 명시적으로 제공하는 신용 상품이며, 한도와 이자가 있습니다. 이는 예외가 아니라 비즈니스 기능이고, 여기서는 대체로 범위 밖입니다. 별도의 overdraft 계정으로 모델링될 가능성이 큽니다. 사용자에게는 부채, 운영자에게는 receivable이며 양수 잔액을 가집니다.
의도하지 않은 overdraft. 정책이 금지하는데도 잔액이 음수가 되는 경우입니다.
의도하지 않은 overdraft는 올바른 시스템에서도 발생합니다. 외부 세계는 허락을 구하지 않기 때문입니다. settlement가 예약 추정치보다 높게 들어오거나, 자금이 이미 나간 뒤 reversal이 들어올 수 있습니다. Funds reservation은 overdraft가 생길 창을 줄이지만 없앨 수는 없습니다.
금지된 것과 표현 불가능한 것은 다릅니다. "잔액은 절대 음수가 아니다"를 unsigned integer나 CHECK (balance >= 0) constraint 같은 타입 또는 저장소 수준에 인코딩하고 싶어집니다. 하지만 음수 잔액을 받아들여야 하는 상황이 오면, 이를 표현할 수 없는 시스템은 flow 중간에 crash하거나, 잔액을 조용히 0으로 잘라내 돈을 만들어내거나, 그와 비슷하게 잘못된 일을 하게 됩니다.
다르게 말하면 "balance >= 0"은 그저 invariant이고 일반적인 도구들이 적용됩니다. 거래를 승인할 때 런타임에서 강제하고, 모니터링과 reconciliation으로 사후 위반을 감지하세요. 하지만 구조적으로 강제하지는 마세요. Overdraft가 감지되면 조사 신호이지만 반드시 버그라는 뜻은 아닙니다.
Overdraft가 실제로 발생하면 이를 장부에 기록하고 명시적으로 회수해야 합니다. 예를 들어 미래 입금과 상계하거나, 상환을 요청하거나, 비용/손실 계정에 대한 명시적인 보정 엔트리로 write-off합니다.
관련 원칙:
없는 데이터를 만들지 않는다. 음수 잔액을 0으로 잘라내면 돈이 만들어집니다.
신뢰하지 않는다. 외부 세계는 당신의 검사가 무엇을 말하든 overdraft를 강제로 만들 수 있습니다.
멱등성
분산 시스템에서는 exactly-once delivery를 보장할 수 없습니다. 어떤 호출이든 중단될 수 있고, 그것이 상대편에 도달했는지 알 수 없습니다. 메시지가 전달되게 하려면 그런 호출을 모두 재시도해야 합니다. 하지만 그러면 한 번 넘게 전달될 위험이 있으므로 처리는 멱등적이어야 합니다. 같은 메시지가 두 번 전달되어도 처리는 한 번만 일어나야 합니다.
명시적 key를 선호하세요. Idempotency key와 비즈니스에서 도출한 멱등성, 예를 들어 payload 기반 중복 제거를 비교해보세요. 명시적 key가 보통 더 단순하고 좋은 해법입니다. 데이터에서 도출하는 방식은 취약합니다. 예를 들어 같은 금액의 두 거래가 중복인지 진짜 두 작업인지 구분하기 어렵습니다. Idempotency key를 쓸 때는 특정 작업과 client 범위로 한정해야 합니다.
오류를 어떻게 재생할지 정하세요. 호출이 처음 실패했을 때 재시도는 저장된 오류를 다시 던져야 할까요, 아니면 처리를 다시 시작해야 할까요? 오류를 멱등 결과로 취급하고 재생하는 편이 보통 더 단순하고 추론하기 쉽습니다. client는 언제든 새 key로 재시도할 수 있습니다. 오류의 성격에 따라 많이 달라집니다. validation 같은 영구 오류는 그대로 재생해야 하고, network failure 같은 일시 오류는 재처리할 수 있습니다.
반복 payload 검증. 반복 호출이 원래 호출과 같은 payload를 담고 있는지 확인하는 것은 좋은 관행입니다. 실제로는 비용이 들고, 구현이 더 복잡해지고 유연성이 줄어드는 대가에 비해 약간의 신뢰만 더 줍니다. 호출자가 정당한 이유로 요청을 바꿀 수도 있습니다.
규모가 커지면 어렵습니다. 신뢰할 수 있는 멱등성을 만드는 일은 복잡할 수 있으므로 충분한 노력을 들이세요. 수십억 건의 요청을 중복 제거해야 할 수 있을 뿐 아니라, 동시 접근에서도 동작을 올바르게 만들어야 합니다. 예를 들어 같은 millisecond에 중복 호출 두 개가 도착할 수 있습니다. 멱등성 장벽은 atomic해야 합니다.
시간 창을 조심하세요. 예를 들어 24h 안에서만 dedupe하는 idempotency time window에 의존하고 싶어질 수 있습니다. 이는 구현을 크게 단순화합니다. 그렇지 않으면 데이터 양이 영원히 커집니다. 하지만 정확성을 희생합니다. 정말 필요할 때만 이 tradeoff를 선택하세요. 나중에 계속 문제를 일으킬 것입니다.
재시도를 테스트하세요. 더 나은 접근 중 하나는 integration test나 system test에 모든 호출을 자동으로 반복하는 generic middleware를 넣는 것입니다.
순서가 뒤섞인 재시도를 처리하세요. 시스템이 이미 새 상태로 이동했더라도 멱등성을 유지해야 합니다. 예를 들어 자금이 이미 해제된 뒤에도 자금을 hold하는 작업은 멱등적으로 유지되어야 합니다.
멱등성은 양쪽 모두에서 중요합니다. 호출할 때도, 받을 때도 중요합니다. 작업을 소비하거나 노출할 때마다 염두에 두세요.
관련 원칙:
없는 데이터를 만들지 않는다. 재시도는 피할 수 없으므로, 처리는 중복 전달을 한 번의 효과로 접어야 합니다. 돈을 두 번 움직이면 안 됩니다.
완전한 재개 가능성
돈을 다루는 flow는 거의 한 단계로 끝나지 않습니다. 인출은 자금을 예약하고, 컴플라이언스 검사를 실행하고, 외부 시스템에 작업을 등록한 뒤, 마지막으로 정산할 수 있습니다. 이런 시퀀스는 시간에 걸쳐 늘어나고 어느 두 단계 사이에서도 죽을 수 있습니다. 그러므로 안전한 가정은 실제로 죽는다는 것입니다. 두 단계마다 실패한다고 가정하세요. 따라서 flow는 한 번에 끝까지 실행된다고 가정할 수 없고, 반쯤 끝난 flow는 항상 복구 가능한 상태에 있어야지 불일치 상태에 있어서는 안 됩니다.
진행 상황을 메모리에 두지 말고 저장하세요. Flow를 명시적인 state machine으로 모델링하고, 그 상태를 durable하게 저장하며, 다음 단계를 시작하기 전에 각 단계의 완료를 commit하세요. 재시작 후 flow가 정확히 어디에 있었는지 알 수 있어야 합니다.
멈춘 flow를 재개하는 무언가가 있어야 합니다. 독립적인 driver(scheduler, worker, poller)가 미완료 flow를 집어 앞으로 밀어야 합니다. orchestrator의 crash가 flow를 영원히 방치해서는 안 됩니다.
모든 단계는 다시 실행해도 안전해야 합니다. 재개할 때 이미 부분적으로 일어난 단계를 다시 실행할 수 있으므로, 각 단계는 멱등적이어야 합니다(멱등성 참고).
앞으로 진행하거나 보정하세요. 외부 효과는 rollback할 수 없습니다. 외부 세계를 호출한 뒤에는 호출하지 않은 것으로 되돌릴 수 없고, 데이터베이스 rollback도 이를 되돌리지 못합니다. 따라서 flow가 완료될 때까지 앞으로 재시도하거나, 이후 단계가 영구적으로 실패하면 앞선 작업을 되돌리는 보정 action을 게시해야 합니다(saga pattern).
Durable-execution engine(예: Temporal, Camunda, Workflows4s, AWS Step Functions)을 쓰거나 직접 persistent state machine을 만들 수 있습니다.
관련 원칙:
데이터를 잃지 않는다. Flow 중간의 crash가 진행 중인 돈을 놓치게 해서는 안 됩니다. 저장된 진행 상황이 있어야 flow를 다시 집어 완료할 수 있습니다.
없는 데이터를 만들지 않는다. 재개는 단계를 다시 실행하므로 중복 계산 없이 다시 적용되어야 합니다. Flow는 정확히 한 번 완료됩니다.
The external world
외부 세계와의 상호작용은 피할 수 없습니다. 결제, KYC, AML, 은행, custodian 같은 third party provider일 수도 있고 내부 서비스일 수도 있습니다. 우리의 일은 그 의존성이 아무리 불안정해져도 올바르게 유지되는 시스템을 만드는 것입니다.
API 소비
언젠가는 다른 사람의 API를 호출해야 합니다. 예를 들어 payment provider, custodian, blockchain node, KYC vendor일 수 있습니다. 당신은 그 코드, 품질, uptime을 제어하지 못하므로, 안전한 기본값은 그것이 잘못 동작한다고 가정하고 방어적으로 감싸는 것입니다.
스키마를 신뢰하지 마세요. 응답은 전달받은 contract와 항상 일치하지 않습니다. 필드가 사라질 수 있고, 타입이 바뀔 수 있고, null이 있어서는 안 되는 곳에 나타날 수 있습니다. 중요한 부분은 경계에서 검증하고 예상하지 못한 것은 크게 실패하게 하세요. 잘못된 데이터가 시스템 안으로 새지 못하게 해야 합니다. 동시에 필요하지 않은 부분은 절대 검증하지 마세요. third party가 contract를 깼을 때 불필요한 장애를 만들 수 있습니다. 그리고 그들은 결국 깹니다.
완벽하지 않은 엔지니어링을 예상하세요. 당신이 의심스러운 엔지니어링 관행이라고 여기는 모든 것이 충분한 시간이 지나면 나타납니다. URL에 담긴 token, 손실된 정밀도, 의미와 맞지 않는 HTTP code(error body를 담은
200), 일관되지 않은 pagination, custom date format 같은 것들입니다. 짜증내지 말고 예외가 아니라 업무로 받아들이세요.모든 호출은 실패합니다. 응답이 없는 상황을 처리할 수 있게 시스템을 설계하세요. Retry와 timeout은 필요한 보호 장치입니다.
Circuit breaker는 보통 선택 사항입니다. 주로 과부하 서버를 배려하는 장치이며, client 쪽 복잡도라는 비용은 당신이 냅니다. 서버가 자신의 load를 처리하고 처리할 수 없는 request를 drop한다고 기대하는 것도 합리적입니다. 그렇다고 해도 circuit breaker는 latency와 유한한 자원(thread, connection 등)도 보호하므로 정말 필요할 때 사용하세요.
Quota를 신경 쓰세요. Rate limit과 usage quota는 잊기 쉽지만, 주말에 터지는 고약한 장애의 원인이 될 수 있습니다. 문제를 일으키기 전에 알 수 있도록 미리 대략적인 계산을 해두는 것이 좋습니다. 예상 호출량과 provider limit을 비교하세요.
모든 request와 response를 저장하세요. 과해 보일 수 있지만, 외부 API가 절대 반환해서는 안 되는 것을 반환하기 시작했을 때 조사에서 생명줄이 될 수 있습니다. 보낸 것과 돌아온 것을 구조화되고 쿼리 가능한 형식으로 저장하세요. 예를 들어 Redshift table입니다. 이는 provider의 동작에 이견이 있을 때 audit trail이자 증거가 되고, 버그 이후 재처리할 재료가 됩니다.
Provider redundancy를 목표로 하세요. 가장 중요한 부분에서는 같은 목적에 provider를 둘 이상 쓰는 것을 고려하세요. Provider를 완전히 신뢰할 수는 없습니다. 따라서 stakes가 가장 높을 때는 여러 출처로 데이터를 검증하거나(예: 두 개의 blockchain node), backup bank partner, crypto custodian, KYC vendor를 두는 것을 의미할 수 있습니다. 이 접근은 개발, 수수료, 복잡도 측면에서 매우 비싸지만 원하는 신뢰성을 얻기 위해 필요할 수 있습니다.
Sandbox를 신뢰하지 마세요. Provider가 testing/sandbox access를 준다면 이미 좋은 신호입니다. 이런 환경은 기본 시나리오에는 괜찮지만, 보통 production setup과 크게 달라집니다. Production에서 테스트할 준비를 하세요. 예를 들어 canary release와 영향이 작은 controlled usage를 통해서입니다.
관련 원칙:
신뢰하지 않는다. Provider의 코드, 스키마, uptime은 모두 당신 통제 밖에 있으므로 독립적인 출처와 사실을 대조하고 모든 것을 경계에서 검증합니다.
데이터를 잃지 않는다. 모든 request와 response를 저장하면 reconciliation과 재처리에 쓸 수 있는 기록이 남습니다.
Webhook 처리
Webhook은 외부 시스템에서 신호를 받는 가장 흔한 방법이지만, 안전하게 처리하기는 쉽지 않습니다. 여기서는 webhook, 즉 당신이 노출한 HTTP endpoint를 외부 시스템이 자신이 정의한 payload로 호출하는 방식에 초점을 맞추지만, 많은 내용은 다른 transport method에도 적용됩니다.
순서를 가정하지 마세요. 메시지는 순서가 바뀌어 도착하거나 오래된 데이터를 담을 수 있습니다. 따라서 마지막으로 받은 webhook이 반드시 최신 진실은 아닙니다. 방금 도착한 것으로 상태를 맹목적으로 덮어쓰지 말고, 이미 알고 있는 것과 대사하세요. 예를 들어 API를 조회해 현재 상태를 확인합니다.
유효성을 가정하지 마세요. Webhook은 발행자 시스템의 부차적인 부분에서 오고, 오래되거나 잘못 변환된 데이터를 담을 수 있습니다. 좋은 관행은 webhook 내용은 무시하고, 권위 있는 상태를 얻기 위해 API를 조회하는 trigger로만 쓰는 것입니다. API가 eventual consistent할 수 있고 webhook보다 뒤처질 수 있다는 점도 주의하세요. trigger 직후의 조회가 여전히 오래된 상태를 반환할 수 있으므로 재시도할 준비가 필요합니다.
전달을 가정하지 마세요. 발행자가 아무리 강한 재전달 정책을 약속해도 webhook은 언젠가 유실됩니다. 누락된 webhook을 처리할 준비가 필요하며, 이는 보통 데이터의 완전성을 고치는 독립 프로세스를 뜻합니다. Reconciliation을 참고하세요.
단일 전달을 가정하지 마세요. 같은 webhook은 한 번 넘게 전달됩니다. 처리는 멱등적이어야 합니다. 멱등성을 참고하세요.
빠르게 acknowledge하고 비동기로 처리하세요. Raw event를 durable하게 저장하자마자 2xx를 반환하고, 실제 작업은 비동기로 하세요. Inline으로 처리하다 느리면 발행자가 timeout 후 재시도해 load가 증폭될 수 있습니다.
Raw payload를 저장하세요. 조치를 취하기 전에 받은 것을 그대로 저장하세요. 처리 신뢰성을 높일 뿐 아니라 provider가 실제로 무엇을 말했는지에 대한 audit trail 역할도 합니다. 버그 이후 provider에게 재전송을 요청하지 않고도 메시지를 재처리할 수 있습니다.
호출자를 검증하세요. 일반적인 메커니즘은 발행자가 payload signature를 붙이고, 당신이 메시지가 정말 그들에게서 왔는지 검증하는 것입니다. 가장 흔한 것은 shared secret으로 계산한 HMAC입니다. 덜 흔하게는 공개된 public half를 가진 asymmetric signature를 씁니다. 한 가지 주의점이 있습니다. signature는 재직렬화한 payload가 아니라 받은 raw bytes 위에서 검증하세요. 재직렬화는 bytes를 바꾸고 signature를 깨뜨립니다. 이것을 하더라도 content는 신뢰하지 않는 편이 좋습니다(2번 참고).
여기에는 반복되는 주제가 있습니다. Webhook을 신뢰하지 마세요. 그것을 무엇이 일어났는지에 대한 신뢰할 수 있는 설명이 아니라, 무언가 일어났다는 hint로 다루세요.
관련 원칙:
신뢰하지 않는다. Webhook은 인증되지 않았고, 순서가 없고, 유실될 수 있고, 중복될 수 있는 hint입니다. 출처를 검증하고 실제 상태는 API와 대조해 확인하세요.
데이터를 잃지 않는다. Raw event를 저장하고 delivery를 reconciliation으로 보완하면, webhook 하나가 떨어져도 fact 하나가 떨어지지 않습니다.
신뢰할 수 있는 알림(Outbox and CDC)
우리 시스템의 변경을 외부 세계에 신뢰할 수 있게 알려야 하는 요구는 자주 있습니다. Kafka event를 publish하거나, webhook call을 dispatch하거나, 그 밖의 다양한 방식이 있을 수 있습니다. 문제는 신뢰할 수 있게입니다. at-least-once delivery를 보장해야 하는데, 이런 channel은 우리가 의존하는 일반적인 transactionality model에 맞지 않습니다. Transactionality가 없으면 다음 중 하나가 발생할 위험이 있습니다.
Publish 후 rollback. Publish는 성공했지만 network issue로 response를 받지 못해 우리 시스템 상태를 rollback합니다.
상태 변경은 됐지만 publish는 없음. Publish가 실제로 실패했지만 rollback하지 못합니다.
교과서적 답은 2-phase commit/distributed transaction이지만, 복잡하고 표준화·재사용하기 좋은 방법이 없어 드물게 쓰입니다. 실용적인 선택지는 다음과 같습니다.
Outbox pattern. "Publishing" event를 상태 변경과 함께 transactionally하게 전용 저장소에 기록하고, 거기서 신뢰할 수 있게 처리합니다. row를 가져와 성공할 때까지 retry하는 방식입니다. 다시 말해 "publish 의도"를 신뢰할 수 있게 저장한 뒤 나중에 처리합니다.
Change Data Capture(CDC). 데이터베이스에 commit된 변경을 감지하고, 보통 write-ahead/replication log를 tailing해 event stream으로 바꾸는 자동 메커니즘입니다. Log에서 직접 읽기 때문에 application에 명시적인 publish code가 없어도 commit된 모든 변경을 포착하고 놓치지 않습니다. Debezium이나 AWS DMS 같은 도구가 이를 off the shelf로 구현합니다. Tradeoff는 coupling과 운영 부담입니다. Raw CDC는 table row 모양의 event를 내보내며, consumer에게 내부 schema가 새지 않게 하려면 후처리가 필요합니다.
Listen-to-yourself. 순서를 뒤집어 event를 먼저 publish한 뒤, 예를 들어 Kafka에 먼저 보내고, 우리 상태를 그 event에서 다시 만듭니다.
Event sourcing. Event log가 이미 데이터베이스에 있으므로 publish는 그것을 읽는 문제입니다(Event sourcing 참고).
어떤 메커니즘을 고르든 delivery는 at-least-once입니다. Relay나 connector가 publish한 뒤, 그 사실을 기록하기 전에 crash하면 restart 때 다시 보낼 수 있습니다. 따라서 consumer는 멱등적이어야 하고 stable event id로 deduplicate해야 합니다(멱등성 참고).
관련 원칙:
데이터를 잃지 않는다. Commit된 변경은 consumer에게 신뢰할 수 있게 도달해야 합니다. Outbox 또는 log는 별도의 publish 단계가 실패했다는 이유로 알림이 유실되지 않게 보장합니다.
없는 데이터를 만들지 않는다. Commit되지 않은 변경에 대한 알림을 publish하지 않고, 중복 전달은 한 번의 효과로 접습니다.
Reconciliation
외부 데이터에 의존하는 시스템은 data drift에 취약합니다. 한 시스템이 다른 시스템과 맞지 않는 상태입니다. 예를 들어 webhook을 놓치거나, ledger에는 거래가 게시됐지만 외부 provider 시스템에는 반영되지 않았을 수 있습니다. 이런 모든 경우에 reconciliation이 필요합니다. 두 시스템을 맞추는 프로세스입니다. 여기서는 "두"라고 말하지만 실제로는 ledger, payment processor, bank처럼 더 많을 수 있습니다. 그래도 접근 방식은 달라지지 않습니다.
주기. 정확한 맥락과 제약에 따라 reconciliation은 매시간, 매일, 매월, 심지어 매년 이뤄질 수 있습니다.
Drift의 성격. 데이터가 누락된 경우도 있고, 이는 쉬운 편입니다. 서로 다른 경우도 있습니다. 예를 들어 같은 거래의 금액이 다르면 해결이 훨씬 복잡합니다. Timing도 매우 중요합니다. Settlement가 T+3에 일어나면 기록은 3일 동안 unreconciled 상태로 남습니다. 이런 경우에 alert하지 않도록 그 로직을 프로세스에 포함해야 합니다.
Matching algorithm. 두 시스템 사이에서 무엇을 비교할지 아는 것이 어려운 부분입니다. 보통은 외부 provider id를 시스템 안에 저장해 matching이 단순해지게 합니다. 그렇지 않다면 amount와 time으로 matching하는 식의 heuristic algorithm이 등장합니다.
One-to-many. 한쪽의 여러 record를 다른 쪽의 하나와 reconcile해야 할 때가 있습니다. 예를 들어 하나의 settlement transfer가 여러 거래를 포함할 수 있습니다.
맞추는 일은 단순하지 않습니다. Reconciliation을 만족시키기 위해 데이터를 그냥 덮어쓸 수는 없습니다. 발견된 각 discrepancy는 이해되어야 하고, correction record, webhook data reprocessing 같은 first-class support를 통해 고쳐야 합니다.
관련 원칙:
신뢰하지 않는다. Reconciliation은 단일 출처가 맞다고 믿는 대신 독립 출처들을 가로질러 검증하는 방법입니다.
데이터를 잃지 않는다. 누락된 fact, 놓친 webhook, 정산되지 않은 transfer가 영영 사라지기 전에 잡아내는 안전망입니다.
Controls and access
지금까지의 패턴은 데이터를 올바르게 유지합니다. 하지만 돈 시스템은 누가 그 위에서 행동할 수 있는지도 제한해야 하고, 사후에 절차가 지켜졌음을 증명해야 합니다. 여기서 신뢰하지 않는다 원칙은 안쪽으로 향합니다. 당신의 운영자와 엔지니어도 외부 provider나 내부 component와 마찬가지로 trust boundary입니다. 감사자는 장부 자체와 함께 이런 control도 검토합니다.
직무 분리와 four-eyes
어떤 action은 아무리 신뢰받는 사람이라도 한 사람에게 맡기기에는 너무 민감합니다. 이를 나누는 것은 금융에서 가장 오래된 control이며, 두 가지 관련 형태가 있습니다. segregation of duties(한 사람이 전체 프로세스를 소유하지 않음)와 four-eyes / maker-checker**(특정 action이 효력을 갖기 전에 두 번째 사람의 승인이 필요함, dual control이라고도 함)입니다.
돈 작업에 적용됩니다. 큰 금액 또는 수동 인출, 수동 ledger correction, treasury와 cold-wallet 이동, 수수료표나 한도 변경처럼 자금을 움직이거나 잘못 표시할 수 있는 모든 것이 second approver 후보입니다.
엔지니어링에도 적용됩니다. Code merge, production deploy, infrastructure change는 돈 시스템에서 민감한 action입니다. 그래서 보통 review와 approval을 요구합니다.
Approval은 trail의 일부입니다. 누가 요청했고, 누가 승인했으며, 둘이 서로 다른 사람이라는 것을 기록하세요. 그렇지 않으면 control을 증명할 수 없습니다(감사와 감사 추적 참고).
Break-glass 경로가 필요합니다. 긴급상황은 발생하고, rigid control은 사람들이 우회하도록 만듭니다. Backdoor를 강제하기보다 명시적이고 강하게 감사되는 override를 제공하세요.
관련 원칙:
신뢰하지 않는다. 단일 내부 행위자는, 신뢰받는 사람이라도, 민감하거나 되돌릴 수 없는 action에 충분한 권한이 아닙니다.
Access control
누가 무엇을 할 수 있는지는 그 자체로 시스템 상태의 일부이며, 사람들이 합류하고 팀을 옮기고 떠나면서 시간이 지나며 바뀝니다. 오늘 누가 자금을 만질 수 있는지만 아는 것으로는 충분하지 않습니다. 감사자는 그들이 어떻게 그 access를 갖게 되었는지도 묻습니다.
Least privilege. 각 actor, 즉 사람이나 service에 필요한 최소 권한만 부여하고, access가 검토 가능하게 유지되도록 개인별 grant보다 role(RBAC)을 선호하세요.
Authorization change에는 trail이 필요합니다. Capability를 부여하거나 회수하는 일은 돈 이동과 똑같이 민감한 event입니다. 무엇이 바뀌었고, 누가 바꿨고, 왜 바꿨는지 기록하세요. Ledger의 audit-trail discipline이 여기에도 적용됩니다(감사와 감사 추적 참고).
Access를 주기적으로 review하세요. Permission은 오래되거나 부정확해집니다. Scheduled access review(recertification)는 access에 적용된 사후 검사입니다(Invariants 참고). Drift를 잡기 위해 필요합니다.
관련 원칙:
신뢰하지 않는다. Standing access는 조용히 쌓입니다. Least privilege와 주기적 review가 이를 통제합니다.
변경 추적(SDLC)
규제 환경에서는 보통 코드가 어떻게 production에 도달했는지 감사해야 합니다. 누가 변경을 review했고, 누가 approve했으며, 언제 shipped됐는지 등을 알아야 합니다. Version control과 CI/CD system은 제대로 운영하면 여기서 큰 도움이 됩니다.
Source control이 기록입니다. Commit history는 모든 변경을 author에게 귀속하고, review와 linked ticket을 통해 변경 이유와 연결합니다. 감사 추적이 요구하는 보통의 what / who / why입니다. Signed commit, protected branch, shared history force-push 금지로 이를 보호하세요.
Review와 pipeline은 강제되어야 합니다. Required(non-optional) review, status check, "main direct push 금지"는 중요합니다. 감사에서는 규율만으로 통과할 수 없습니다.
Deployment는 추적 가능해야 합니다. 어떤 version이 실행 중인지, 누가 언제 release했는지 재구성할 수 있어야 합니다. 이것이 incident를 원인이 된 변경과 연결할 수 있게 합니다.
관련 원칙:
데이터를 잃지 않는다. 시스템 자체가 어떻게 만들어졌는지의 이력은 시스템이 보유한 돈의 이력만큼 trail의 일부입니다.
신뢰하지 않는다. 시스템이 delivery control을 강제합니다. 사람들이 기억해서 지키는 것에 의존하지 않습니다.
Testing
Test는 어디서나 중요하지만, 돈 시스템에서는 더 중요합니다. 어려운 점은 보통 기대 출력을 전부 열거할 수 없다는 것입니다. Operation sequence의 공간이 너무 크고, 흥미로운 실패는 조합 속에 있습니다. 아래 접근들은 시스템의 정확성에 대한 신뢰를 얻는 방법입니다. 식당 메뉴처럼 보고, 당신 시스템에 가장 큰 영향을 줄 기법을 고르세요.
Property-based testing. 특정 출력을 assert하는 대신, 생성된 어떤 입력에 대해서도 property가 성립한다고 assert합니다. Invariant나 money math에 자연스럽게 맞습니다. Framework가 손으로 작성하리라 생각하지 못한 까다로운 case를 생성합니다.
단계 사이 invariant check. Operation sequence를 생성할 때 끝에서만 invariant를 assert하지 말고 모든 단일 단계 뒤에 assert하세요. 이를 수동으로 scale 있게 하는 것은 불가능하므로, assertion을 자동으로 주입하는 더 정교한 testing harness가 필요합니다.
Generative idempotency testing. 외부 세계를 건드리는 모든 operation은 멱등적이어야 하므로(멱등성 참고), 이를 시스템 property로 만들 수 있습니다. 위와 비슷한 접근으로 선언된 모든 operation을 자동 반복하고, 두 번째 호출이 시스템에 영향을 주지 않는다고 assert할 수 있습니다.
Crash and resume injection. 긴 flow는 어느 두 단계 사이에서 죽어도 살아남아야 합니다(완전한 재개 가능성 참고). 일반적인 접근을 따라 모든 단계에 failure를 주입해 정확히 이를 테스트할 수 있습니다.
Round-trip testing. Encode 후 decode, serialize 후 deserialize, convert 후 convert back을 하고, 시작 지점으로 돌아왔는지 또는 알려진 tolerance 안에 있는지 assert합니다. Money와 currency type의 boundary에서 precision loss와 serialization bug를 빠르게 잡는 방법입니다. 자동 데이터 생성과 매우 잘 어울립니다.
Golden testing. 계산이나 projection의 출력, 예를 들어 fee breakdown, statement, report를 저장된 expected result에 고정해, 의도하지 않은 변경이 diff로 드러나게 합니다. 한 번 review된 결과를 새로 작성한 assertion보다 더 신뢰할 수 있는, 복잡하고 추론하기 어려운 계산에 유용합니다.
Backward-compatibility testing. Event와 저장된 record는 수년 동안 살아 있고, 오늘의 코드는 오래된 코드가 쓴 것을 계속 읽을 수 있어야 합니다(event sourcing 참고). 실제 오래된 format의 payload corpus를 유지하고, 현재 코드가 여전히 이를 올바르게 deserialize하고 project한다고 assert하세요. 이것이 schema change가 조용히 이력을 깨뜨리지 못하게 막습니다.
Production에서의 testing. 어떤 신뢰는 실제 대상에 대해서만 얻을 수 있습니다. Provider sandbox는 production과 크게 달라집니다(API 소비 참고). 따라서 integration이 동작한다는 최종 증거는 canary release, blast radius가 작은 controlled rollout, 또는 작은 실제 금액을 시스템에 지속적으로 흘려보내는 synthetic transaction 같은 live 환경에서 얻어야 하는 경우가 많습니다. 돈에 특화된 주의점은 이것들이 실제 이동이라는 점입니다. Production test는 실제 돈을 움직입니다. 따라서 다른 모든 것과 같은 ledger, reconciliation, audit trail을 거쳐야 하고, 명확히 tagged되어야 하며, 정상 correction/reversal 절차로 정리되어야 합니다. 장부를 우회하는 backdoor여서는 안 됩니다.
관련 원칙:
신뢰하지 않는다. Test는 패턴이 실제로 성립한다고 가정하는 대신 검증하는 방법입니다. Oracle은 우연히 기대한 값이 아니라 invariant입니다.
없는 데이터를 만들지 않는다. Operation 재생과 failure 주입은 retry와 recovery가 중복 계산하거나 돈을 만들어내지 않음을 증명합니다.
데이터를 잃지 않는다. Round-trip과 backward-compatibility test는 precision과 history가 boundary와 시간의 흐름을 견딘다는 것을 증명합니다.
Appendix A: Know your domain
핀테크에 합류할 때 가장 어려운 부분은 코드가 아니라 그 뒤의 어휘와 개념인 경우가 많습니다. 이 분야에는 평범하게 들리지만 정확한 의미를 가진 단어와, 주변 모두가 풀어 쓰지 않은 채 사용하는 약어가 가득합니다.
주의할 점: 일반인이 이미 아는 용어(deposit, withdrawal, transfer, currency)는 건너뜁니다. 도착해서 배울 수 있는 특수한 구석도 제외합니다. 가장 중요한 용어에 집중하려고 합니다. 핸드북이 이미 어떤 개념을 제대로 다룬 경우, 항목은 반복 설명 대신 해당 섹션으로 연결합니다.
Accounting & ledgers
Ledger - 돈의 이동에 대한 system of record입니다. 잔액이 도출되는 source of truth입니다(복식부기 참고).
General ledger vs sub-ledger - 단일 통합 장부와, 그 안으로 roll up되는 한 도메인의 상세 장부입니다. 예를 들어 사용자별 또는 제품별 장부입니다.
Debit / credit - 모든 엔트리의 양쪽입니다. 어느 쪽이 계정을 증가시키는지는 "돈이 들어옴 vs 돈이 나감"이 아니라 계정 타입에 따라 달라집니다(복식부기 참고).
Posting - 엔트리를 ledger에 commit하는 일입니다. "posted"는 기록되었고, 관례상 불변이라는 뜻입니다.
Chart of accounts - posting할 수 있는 계정의 catalogue입니다. 하나의 시스템이 여러 개를 가질 수 있습니다. 예를 들어 legal entity별, book별, reporting standard별입니다.
Account type - asset / liability / equity(그리고 revenue / expense)입니다. 그래서 accounting equation이 성립하고 각 계정은 어느 쪽에서 증가하는지 정의됩니다(복식부기 참고).
Receivable / payable - 당신에게 지급되어야 하는 돈 / 당신이 지급해야 하는 돈입니다.
IOU - 비공식적으로는 liability입니다. 누군가에게 돈을 빚지고 있다는 기록입니다. Custodial platform에서 사용자의 balance는 platform이 사용자에게 진 IOU이므로 장부의 liability 쪽에 놓입니다.
Accrual vs cash basis - 돈이 벌렸거나 owed된 때 인식하는 방식과, 돈이 실제로 움직인 때 인식하는 방식입니다.
Trial balance - 장부 전체에서 total debits와 total credits가 같은지 확인하는 검사입니다.
Suspense / clearing account - 이동 중이거나 아직 귀속할 수 없는 돈을 임시로 담는 계정입니다.
Write-off - 더 이상 회수할 것으로 기대하지 않는 balance를 loss로 장부에 기록하는 일입니다(Overdraft 처리 참고).
Commingling - 회사 자금과 사용자 자금을 섞는 일입니다. 규제상 위험 신호입니다(감사와 감사 추적 참고).
Reconciliation break - reconciliation으로 드러난 단일 unmatched discrepancy입니다.
Money & FX
Money(as a type) - 통화와 짝지어진 금액입니다(통화 처리 참고).
Minor units - 통화의 가장 작은 분할 불가능 단위입니다. 금액은 종종 이 단위의 정수로 저장됩니다(€12.34 →
1234)(정밀도 처리 참고).Basis point(bp / "bip") - 1퍼센트의 100분의 1(0.01%)입니다. 수수료와 rate는 자주 이 단위로 quoted됩니다.
Notional - 계산의 기준이 되는 face value입니다. 실제로 오가는 현금보다 훨씬 클 수 있습니다.
Fiat vs crypto - 국가가 발행한 통화와 blockchain-native asset입니다.
Stablecoin - 보통 USD 같은 법정화폐인 reference asset에 pegged된 token입니다.
Pegged / wrapped / bridged - 기초 자산과 연결된 representation이지만 기초 자산과 동등하지는 않습니다(통화 처리 참고).
Bid / ask / spread - 매수 가격, 매도 가격, 그리고 둘 사이의 차이입니다.
Mid-market rate - bid와 ask의 중간점입니다. 기준점이지 실제로 거래할 수 있는 가격은 아닙니다(FX Rates 참고).
Reference rate - valuation과 equivalence에 쓰이는 rate입니다. 보유분 가치나 tax base에 쓰이며 실제 거래에는 쓰이지 않습니다(FX Rates 참고).
Mark-to-market - holding을 취득 가격이 아니라 현재 market rate로 재평가하는 일입니다.
Transactions, timing & settlement
Value date / booking date / settlement date - 일이 일어난 시점 / 우리가 기록한 시점 / 돈이 실제로 움직인 시점입니다(Value time vs booking time vs settlement time 참고).
T+X - settlement 같은 일이 value date 이후 X business days 뒤에 일어나는 것입니다. 예를 들어 T+2입니다.
Clearing vs settlement - 누가 무엇을 owed하는지 합의하는 것과 실제로 돈을 이전하는 것입니다.
Cut-off time - 그 이후의 거래가 다음 settlement window로 넘어가는 일일 deadline입니다.
Float - transfer 중간에 두 시스템에 동시에 존재하는 것처럼 보이거나, 어느 쪽에도 없는 것처럼 보이는 돈입니다.
Netting - 많은 obligation을 각각 gross로 settlement하는 대신 하나의 net transfer로 offset하는 일입니다.
Backdating - booking date보다 이른 value date를 부여하는 일입니다(Value time vs booking time vs settlement time 참고).
Reversal / correction - posting을 경제적으로 없었던 것처럼 전액 상쇄하는 것과, 기록된 값과 기록됐어야 하는 값의 차이를 장부에 반영하는 것입니다(Reversals and corrections 참고).
Payments, rails & cards
Payment rail - 결제가 이동하는 기반 network입니다. SEPA, SWIFT, ACH, card network, blockchain 등이 있습니다.
IBAN / SWIFT / SEPA / ACH / FPS / CHAPS / wire - 은행 사이에서 fiat를 옮기기 위한 identifier와 network입니다.
Originator / beneficiary - transfer를 보내는 사람 / 받는 사람입니다. remitter / payee, payer / payee라고도 합니다.
PSP(payment service provider) - 하나 이상의 rail에 연결해주는 vendor입니다.
Nostro / vostro account - "그들의 은행에 보관된 우리의 돈" / "우리에게 보관된 그들의 돈"입니다. 은행 간 transfer를 가능하게 하는 account입니다.
Omnibus account - 많은 사용자의 자금을 함께 보유하는 하나의 pooled account이며, 사용자별 balance는 내부에서 추적합니다. Commingling과 달리 정당한 pooling입니다.
FBO("for benefit of") account - 회사가 사용자들을 대신해 보유하는 account입니다.
Sweep - 일정에 따라 balance를 계정 사이에서 자동 이동하는 일입니다. 예를 들어 cold storage나 interest-bearing account로 옮깁니다.
Chargeback - cardholder의 bank가 시작하는 card payment의 강제 reversal입니다.
Issuer / acquirer - cardholder의 bank / merchant의 bank입니다.
Interchange - 각 card transaction에서 acquirer가 issuer에게 내는 fee입니다. Card-processing cost의 대부분을 차지합니다.
Authorization vs capture - 자금에 hold를 거는 것과 실제로 charge하는 것입니다. 카드 세계의 funds reservation입니다.
Dunning - 실패한 recurring payment를 회수하기 위한 retry-and-notify process입니다.
Trading & markets
Order book - 각 price level의 미체결 buy(bid)와 sell(ask) order live list입니다.
Market vs limit order - 지금 best available price로 실행하는 것(liquidity를 가져감)과, 정한 가격 또는 더 나은 가격에서만 실행하는 것(book에 남아 liquidity를 제공함)입니다.
Maker / taker - maker는 resting order를 book에 추가하고, taker는 spread를 가로질러 하나를 제거합니다. Fee는 보통 다릅니다.
Slippage - 예상 가격과 실제 체결 가격의 차이입니다.
Liquidity / depth - 가격이 움직이기 전에 얼마나 많이 거래할 수 있는지입니다. 얇은 book은 더 많이 움직입니다.
Spot - 자산 자체를 즉시 delivery로 사고파는 것입니다.
Derivative - 자산 자체가 아니라 underlying asset에서 가치가 파생되는 contract입니다.
Futures / perpetual(perp) - 미래 날짜에 거래하는 contract / expiry가 없고 funding rate로 spot 근처에 유지되는 contract입니다.
Funding rate - perp 가격을 index에 묶어두는 long과 short 사이의 periodic payment입니다.
Long / short - 가격이 오를 때 / 내릴 때 이익을 내는 position입니다.
Leverage / margin - 자본보다 큰 position을 제어하는 것 / 이를 열고 유지하기 위해 posted된 collateral입니다.
Liquidation - margin이 maintenance requirement 아래로 떨어졌을 때 position을 강제로 닫는 일입니다.
Haircut - 가격 변동에 대비하기 위해 collateral value에 적용하는 discount입니다.
Counterparty - trade나 contract의 상대편입니다. "counterparty risk"는 그들이 이행하지 못할 위험입니다.
AUM / AUC - assets under management(플랫폼이 적극적으로 운용하는 client asset, 보통 fee-bearing mandate 아래 있음)와 assets under custody(플랫폼이 단지 보관하는 client asset)입니다. 같은 holding이 둘 중 하나일 수도, 둘 다일 수도 있습니다.
Custody & crypto
Custody - 누가 asset을 control하는지입니다. self-custody는 사용자가 key를 보유하고, custodial은 platform이나 custodian이 보유합니다.
Hot / cold wallet - 빠른 access를 위해 online에 보관된 key와 security를 위해 offline에 보관된 key입니다.
Private key / public key / address - spending을 authorize하는 secret / 여기서 파생된 public identifier / funds를 받는 곳입니다.
Seed phrase - wallet key를 재구성하는 human-readable backup입니다.
Multisig / MPC - transfer를 authorize하기 위해 여러 key 또는 key-share를 요구하는 방식입니다. 단일 device만으로 funds를 움직일 수 없게 합니다.
Gas / network fee - transaction이 blockchain에 포함되도록 지불하는 fee입니다.
Confirmation / finality - 당신의 transaction이 포함된 block 위에 block이 쌓이는 것 / 더 이상 되돌릴 수 없게 되는 지점입니다. Confirmation이 많을수록 finality가 커집니다.
Reorg - 최근 confirmed transaction을 되돌릴 수 있는 blockchain reorganisation입니다. Finality가 즉시 오지 않는 이유입니다.
Mempool - block에 포함되기를 기다리는 pending transaction pool입니다.
UTXO vs account model - Bitcoin식 "coin 전체를 쓰고 change를 받는" 방식과 Ethereum식 running balance 방식입니다. 둘은 서로 다른 accounting을 요구합니다.
Token vs coin - chain 위에서 발행된 asset, 예를 들어 ERC-20과 chain의 native asset(BTC, ETH)입니다.
Dust - 이동시키는 network fee가 value를 초과할 만큼 작은 amount입니다.
Address whitelisting(allow-listing) - withdrawal을 미리 승인된 address 집합으로 제한하는 것입니다.
Compliance & regulation
KYC - Know Your Customer. 사용자의 identity를 검증하는 것입니다.
AML / CFT - Anti-Money-Laundering / Countering the Financing of Terrorism. 불법 자금을 감지하고 방지하는 control입니다.
Sanctions screening - 당사자를 sanctioned-entity list와 대조하는 것입니다.
PEP - politically exposed person. 추가 diligence가 필요한 더 높은 risk의 customer category입니다.
SoF / SoW - source of funds / source of wealth. customer의 돈이 어디서 왔는지에 대한 evidence입니다.
Travel Rule - threshold를 넘는 transfer에서 originator와 beneficiary 정보를 공유해야 하는 요구사항입니다.
VASP - Virtual Asset Service Provider. exchange나 custodian 같은 crypto business에 대한 regulatory label입니다.
MiCA - EU의 Markets in Crypto-Assets regulation입니다.
Segregation of duties - 민감한 action을 여러 사람에게 나누어 한 사람이 혼자 완료할 수 없게 하는 것입니다(직무 분리와 four-eyes 참고).
Four-eyes / maker-checker / dual control - 민감한 action이 효력을 갖기 전에 두 번째 사람의 approval을 요구하는 것입니다(직무 분리와 four-eyes 참고).
Least privilege / RBAC - 각 actor에게 필요한 최소 access만 부여하는 것 / 개인별 grant가 아니라 role로 관리하는 것입니다(Access control 참고).
Change management - code와 config가 production에 도달하는 controlled, traceable process(review, approval, deployment)입니다(변경 추적(SDLC) 참고).
Audit / audit trail - 장부와 control이 현실을 반영하는지에 대한 external scrutiny / 어떤 balance나 decision도 재현할 수 있게 하는 recorded history입니다(감사와 감사 추적 참고).
Resources
돈 시스템을 end to end로 다루는 단일 책은 없으므로, 아래 목록은 layer별로 묶었습니다. 각 항목에는 무엇을 다루는지, 누구에게 맞춰져 있는지 적어두었으니 부족한 부분에 따라 고를 수 있습니다.
Accounting & ledgers
Accounting for Computer Scientists(essay, free online) - 복식부기를 graph/data model에 매핑합니다. 회계사가 아니라 엔지니어를 위해 쓰였습니다.
The Accounting Game: Basic Accounting Fresh from the Lemonade Stand - 계속 이어지는 lemonade-stand 예제로 first principles부터 회계를 설명합니다. finance 배경을 전제하지 않습니다.
Modern Treasury, How to Scale a Ledger(article series, free online) - software-engineering 관점에서 production ledger를 만드는 법을 다룹니다.
Payments & cards
Payments Systems in the U.S. - 은행 사이에서 돈이 어떻게 움직이는지(cards, ACH, wires, checks)를 reference style로 둘러봅니다. US-centric이고, 철저하지만 다소 건조합니다.
The Anatomy of the Swipe - card를 tap한 뒤 돈이 도착하기까지 어떤 일이 일어나는지 다룹니다. builder와 beginner를 대상으로 합니다.
Markets & trading
Trading and Exchanges: Market Microstructure for Practitioners - order book, maker/taker, spread가 어디서 나오는지 다룹니다. 길고 자세한 practitioner reference입니다.
Crypto
Mastering Bitcoin과 Mastering Ethereum - fintech engineer가 계속 마주치는 두 model(UTXO와 account)에 대한 engineering-level reference입니다. 기술적이며 developer를 대상으로 합니다.
The engineering half
Designing Data-Intensive Applications - 이 핸드북이 계속 돌아오는 idempotency, log, consistency, failure mode를 system 관점에서 다룹니다.
KYC & AML
이 자료들은 engineer가 아니라 compliance professional을 위해 쓰였습니다. Integration이 아니라 domain 자체가 필요할 때만 참고하세요.
Anti-Money Laundering in a Nutshell - laundering이 무엇이고 detection과 reporting이 어떻게 작동하는지에 대한 짧은 awareness-level introduction입니다.
Mastering Anti-Money Laundering and Counter-Terrorist Financing - AML/CTF framework를 만드는 더 무거운 practitioner guide이며, checklist와 example document를 포함합니다.
Appendix B: End to end examples
이 핸드북의 본문은 각 패턴을 따로 다루므로 전체적인 이해와 직관을 만들기는 어렵습니다. 이 appendix는 실제 시스템에서 보게 될 것들을 단순화했지만 대표적인 예로 보여주는 흔한 flow를 따라갑니다. Production 구현에는 더 많은 단계, 더 많은 failure branch, 더 많은 bookkeeping이 있겠지만, 단순화한 버전만으로도 전반적인 아이디어를 얻기에는 충분합니다. 세 가지 방향을 다룹니다. 시스템 밖으로 나가는 돈(withdrawal), 시스템 안으로 들어오는 돈(card deposit), 그리고 시스템 안에서 움직이는 돈(conversion)입니다.
Flow 1: Crypto withdrawal
사용자가 외부 address로 0.5 ETH를 인출해 달라고 요청합니다. 세 가지 중 가장 풍부한 예입니다. 되돌릴 수 없는 외부 효과를 통해 돈이 시스템을 떠나기 때문입니다. Chain이 send를 confirm하면 되돌릴 수 없습니다.
요청은 idempotency key와 함께 도착합니다. Client는 submit을 재시도할 수 있습니다. Network가 response를 먹었거나, 사용자가 double-click했을 수 있습니다. 그래서 가장 먼저 신경 쓸 것은 같은 요청이 두 번 전달되어도 withdrawal이 하나만 만들어지고 둘이 만들어지지 않게 하는 것입니다(멱등성 참고). Key는 이 사용자와 이 operation 범위로 한정됩니다.
되돌릴 수 없는 일이 일어나기 전에 자금을 예약합니다. 사용자의 available balance에서 0.5 ETH와 추정 network fee를 예약합니다(자금 예약 참고). Balance check와 reservation은 하나의 linearizable step입니다. 그렇지 않으면 concurrent withdrawal 두 개가 모두 검사를 통과하고 같은 coin으로 각자의 spend를 뒷받침할 수 있습니다(Invariants 참고). 이 시점부터 사용자의 total balance는 여전히 예약 금액을 포함하지만, available balance는 포함하지 않습니다.
Compliance gate가 실행되고, flow는 여기서 며칠 동안 잠들 수 있습니다. Broadcast 전에 transaction을 screen합니다(sanctions, AML, destination address). 여기서 여러 패턴이 함께 묶입니다.
이는 external call이므로 느리거나, 실패하거나, 거짓말할 수 있고, 그 주위에 방어적으로 만들어야 합니다(API 소비 참고).
Manual review로 escalate되어 몇 시간 또는 며칠이 걸릴 수 있으므로, flow는 이 단계와 다음 단계 사이에서 죽어도 살아남아야 합니다(완전한 재개 가능성 참고). State는 persisted되고, independent driver가 이를 resume하며, 그동안 reservation은 그대로 유지됩니다.
여기서 daily withdrawal limit이 강제되며, 이는 invariant일 뿐입니다(Invariants 참고). Time-windowed이고 stateful한 invariant를 concurrency 아래에서 평가하므로, reservation과 같은 atomicity 문제가 있습니다. Limit을 두고 race하는 withdrawal 두 개가 모두 통과해서는 안 됩니다.
모든 결정, 즉 passed, blocked, 누가 override했는지가 audit trail에 남습니다(감사와 감사 추적 참고).
Transaction을 on-chain에 broadcast합니다. 통과되면 node를 통해 sign하고 broadcast합니다. 이는 같은 주의가 필요한 또 다른 external call입니다. 이 단계는 멱등적이어야 합니다. Crash 이후 resume할 때 맹목적으로 두 번째 broadcast를 하지 말고 chain을 다시 확인해야 합니다(멱등성, out-of-order retry에 대한 7번 참고). 실제 network fee는 처음부터 알 수 없고, 그래서 estimate를 예약했습니다. 실제 금액을 정산하고 나머지를 해제할 것입니다.
Finality를 기다린 뒤 ledger에 게시합니다. Confirmation 하나가 끝이 아닙니다. Reorg는 너무 일찍 "done"이라고 선언한 send를 되돌릴 수 있으므로 충분한 confirmation을 기다립니다. 그때서야 double-entry movement를 게시합니다. 사용자의 account를 debit하고, external on-chain account를 credit합니다. 외부 세계도 account를 받습니다. Network fee는 expense account에, service fee는 revenue account에 기록합니다(복식부기 참고).
Nightly job이 chain과 reconcile합니다. 위 flow와 독립적으로, job이 ledger를 on-chain reality와 node가 보는 transaction과 비교합니다(Reconciliation 참고). Confirm되지 않은 broadcast나, 장부에 기록한 것과 다르게 나온 fee를 잡아내는 safety net입니다.
흥미로워지는 지점: 실제 network fee가 예약한 estimate보다 높게 들어온다고 해봅시다. Settlement는 account를 음수로 밀 수 있습니다. Overdraft를 장부에 기록하고 명시적으로 회수합니다(Overdraft 처리 참고). 그리고 process가 broadcast(step 4)와 confirmation(step 5) 사이에서 crash하면, resumability와 idempotency가 chain을 조회해 이어가게 해줍니다. 자금을 두 번 보내지 않습니다.
Flow 2: Card deposit
사용자가 payment service provider(PSP)를 통해 card payment로 계정을 충전합니다. 돈이 안으로 들어옵니다. 어려운 부분은 "두 번 보내지 않기"에서 "외부 세계가 말하는 것을 신뢰하지 않기, 실제로 도착하지 않은 돈을 credit하지 않기"로 바뀝니다.
사용자가 deposit을 시작합니다. 금액을 입력하고 card detail을 제출하면, PSP에 deposit transaction을 엽니다. Submit은 재시도될 수 있으므로 여기에도 idempotency key를 씁니다.
Authorization이 hold를 겁니다. PSP는 card를 authorize해, 아직 돈을 capture하지 않은 채 hold를 겁니다. 이는 card 세계의 funds reservation입니다(authorization vs capture). 아직 사용자의 balance를 credit하지 않습니다. 그 돈은 아직 당신 것이 아닙니다.
Webhook이 "captured"라고 말하지만, 아무것도 믿지 않습니다. PSP가 당신의 webhook endpoint를 호출합니다(Webhook 처리 참고). Raw bytes 위에서 signature를 검증하고, raw payload를 저장하고, 2xx로 빠르게 acknowledge한 뒤, 비동기로 처리합니다. 중요한 점은 webhook을 truth가 아니라 무언가 일어났다는 hint로 다룬다는 것입니다. Webhook은 순서가 뒤섞여 도착하고, 오래된 데이터를 담고, 중복되고, 유실되므로 PSP API를 조회해 authoritative state를 확인합니다. 처리는 멱등적이어서(멱등성 참고), 재전달된 "captured"가 사용자를 한 번만 credit합니다.
Credit은 clearing account를 거칩니다. 돈은 in flight(float) 상태입니다. PSP가 capture했지만 아직 당신의 bank로 settle되지 않았습니다. 그래서 도착한 척하지 않고 suspense/clearing account를 통해 게시합니다. 사용자의 balance를 credit합니다. 이는 liability입니다. 사용자의 balance는 당신이 사용자에게 진 IOU입니다. 그리고 PSP receivable을 debit합니다. Interchange/processing fee는 expense로 기록합니다. Booking time은 지금이고, settlement time은 나중인 T+X입니다(Value time vs booking time vs settlement time 참고).
Settlement가 batch로 도착하고, reconciliation은 one-to-many입니다. 며칠 뒤 PSP가 많은 deposit을 한꺼번에 포함한 single transfer를 bank에 settle합니다. 이 batch를 clearing account와 reconcile합니다(Reconciliation 참고). 하나의 settlement를 여러 transaction과 matching합니다. T+X delay는 process에 포함되어 있어, 단지 아직 settled되지 않은 transaction에 alert하지 않습니다. 그리고 이 같은 job이 전혀 도착하지 않은 webhook도 잡아냅니다.
몇 주 뒤 chargeback이 발생합니다. Cardholder가 payment에 이의를 제기하고 bank가 reversal을 강제합니다. 원래 posting을 수정하지 않습니다. 연결된 보정 엔트리를 게시합니다(Reversals and corrections 참고). 이는 보통 deposit보다 늦은 reporting period에 들어가며, 사용자가 이미 자금을 쓴 경우 balance를 음수로 밀 수 있습니다(다시 Overdraft 처리로 돌아갑니다).
흥미로워지는 지점: 전체 flow는 happy-path signal을 믿지 않는 것 위에 세워져 있습니다. Webhook은 fact가 아니라 trigger입니다. Clearing account는 돈이 실제로 움직이기 전까지 돈을 인식하지 않게 합니다. Reconciliation은 PSP를 당신의 장부와 대조해 검증하지, 그 반대로 하지 않습니다. 모든 단계가 신뢰하지 않는다의 적용입니다.
Flow 3: Cashback이 있는 in-app conversion
사용자가 1,000 EUR를 USDC로 전환하고 거래에 대한 작은 promotional cashback을 받습니다. 돈은 완전히 시스템 안에서 움직입니다. 따라서 신뢰할 수 없는 외부 rail은 없습니다. 대신 이 flow는 표현 계층(precision, rounding, currencies, rates)과 없는 데이터를 만들지 않는다 원칙의 가장 날카로운 형태를 압박합니다.
방향이 있는 quote와 reservation. EUR→USDC 환율을 quote합니다. 그 rate는 자체 가격이며, USDC→EUR의 inverse가 아닙니다. 매수와 매도는 bid/ask spread의 반대편에 있습니다(FX Rates 참고). 1,000 EUR를 예약하고(자금 예약 참고), 요청은 다른 작업처럼 idempotency key를 가집니다.
두 쪽을 절대 더하지 않습니다. EUR와 USDC는 서로 다른 currency입니다. 실제로 USDC는 bare code가 아니라
(network, contract address)로 식별되며, pegged된 fiat와 교환 가능하지 않습니다(통화 처리 참고). 시스템은 cross-currency arithmetic을 금지합니다. 두 amount 사이의 유일한 bridge는 통제된 rate에 의한 명시적 conversion입니다.계산은 전체 정밀도로 하고, edge에서만 반올림합니다. Conversion은 전체 정밀도를 유지해 계산하고, 의도적으로 선택한 전략으로 boundary에서 정확히 한 번만 round합니다(정밀도 처리와 반올림 전략 참고). 당신이 얻는 spread는 revenue입니다. 이는 double-entry를 통해 revenue account에 명시적으로 기록되어야 하며, rounding residual 속으로 사라지게 두면 안 됩니다(복식부기 참고). Transactional rate는 별도 필드로 저장하지 않습니다. 두 amount에서 도출됩니다. 나중에 holding을 valuation할 때 쓰는 것은 별도의 reference rate입니다.
Cashback은 "없는 데이터를 만들지 않는다"의 가장 어려운 테스트입니다. Bonus를 free balance bump처럼 다루고 싶어지지만, 이는 무에서 돈을 만들어내는 일입니다. Cashback은 실제 돈입니다. 반드시 funded되어야 합니다. 회사 promotional/expense account에서 사용자의 balance로 proper double-entry posting을 통해 이동해야 합니다. 이를 정의하는 percentage도 다른 모든 것과 마찬가지로 명시적인 rounding decision이 필요합니다(반올림 전략 참고).
Settle한 뒤 신뢰할 수 있게 알립니다. Reservation을 settle하고, 모든 movement를 timestamp와 audit trail과 함께 게시하고, 시스템의 나머지 부분, 즉 statement, notification, analytics가 알 수 있게 outcome을 publish합니다. 이 publish는 별도 channel을 가로지르더라도 신뢰할 수 있어야 합니다. 이를 위해 outbox, CDC, 또는 event log가 필요합니다(신뢰할 수 있는 알림(Outbox and CDC) 참고). Downstream consumer는 delivery가 at-least-once이므로 stable event id로 dedupe합니다.
흥미로워지는 지점: Cashback과 spread는 같은 posting에서 반대 방향으로 작용합니다. Spread는 사용자가 당신에게 잃는 돈(revenue)이고, cashback은 당신이 사용자에게 주는 돈(expense)입니다. 둘 다 실제이고, 둘 다 장부를 통과하며, 둘 다 round됩니다. 따라서 하나의 transaction은 없는 데이터를 만들지 않는다(장부가 계속 균형을 이루고 아무것도 만들어내지 않음)와 데이터를 잃지 않는다(모든 residual을 추적함)를 동시에 만족해야 합니다.
