설계 사례: 정확성이 중요한 시스템
호텔 예약, 결제, 전자 지갑, 증권 거래소처럼 한 번의 실수가 돈이 되는 시스템이다. 멱등성, 이중 기입, 이벤트 소싱이 핵심이다.
앞의 사례들은 가끔 틀려도 다음 갱신이 바로잡았다. 이 노트의 사례는 한 번 틀리면 돈이 사라지거나 두 번 빠진다. 처리량은 오히려 작은 경우가 많고, 설계의 무게는 동시에 들어온 요청, 재시도, 부분 실패를 어떻게 다루느냐에 있다. 사례마다 요구와 규모를 직접 어림하고, 핵심 설계, 어려운 점과 선택지, 실제 사례 순서로 본다. 트랜잭션과 격리 수준의 기초는 동시성 제어 노트에 있다.
호텔 예약
요구와 규모
호텔과 객실 유형, 날짜를 골라 예약한다. 가장 중요한 요구는 초과 예약을 하지 않는 것과 같은 예약을 두 번 만들지 않는 것이다. 호텔 2만 곳, 호텔당 객실 유형 다섯, 1년 치 날짜를 판다고 하자.
- 재고 행: 2만 × 5 × 365 = 3,650만 행. 행 하나가 "이 호텔, 이 유형, 이날 총 몇 개 중 몇 개 예약됨"이다. 관계형 DB 한 대에 넉넉히 들어간다.
- 예약: 하루 30만 건이면 초당 3.5건으로 작다. 문제는 평균이 아니라 몰림이다. 특가가 열리는 순간 1분 안에 1만 명이 같은 날짜의 방 50개를 노리면, 초당 170건의 요청이 같은 몇 행을 고치려 한다.
핵심 설계
객실 하나하나가 아니라 (호텔, 객실 유형, 날짜)마다 재고 수를 둔다. 손님은 "디럭스 하나"를 사지 "1204호"를 사지 않으므로, 방 번호는 체크인 때 정해도 된다. 2박 예약은 두 날짜의 재고 행을 한 트랜잭션에서 함께 고친다. 남은 일은 두 요청이 동시에 마지막 방을 노릴 때 하나만 성공하게 하는 것이다. 방법은 셋이다.
- 비관적 잠금: 읽을 때
SELECT … FOR UPDATE로 행을 잠그고, 다른 요청은 앞의 트랜잭션이 끝날 때까지 기다린다. 이해하기 쉽고 충돌이 잦아도 틀리지 않지만, 잠금을 쥔 채 오래 머물면 줄이 길어지고 여러 행을 다른 순서로 잠그면 교착 상태가 난다. - 낙관적 동시성: 잠그지 않고 읽은 뒤, 쓸 때 "버전이 그대로이면"이라는 조건을 건다. 바뀐 행이 0이면 다시 시도한다. 충돌이 드물면 빠르고, 몰릴 때는 재시도가 늘어 낭비가 커진다.
- DB 제약:
CHECK (reserved <= total)같은 제약을 걸어 두면 어떤 코드 경로로 들어와도 DB가 초과를 거절한다. 방 번호까지 파는 경우에는 날짜 범위가 겹치는 예약을 막는 배제 제약(exclusion constraint)을 쓸 수 있다. 앞의 두 방법과 함께 거는 마지막 안전망이다.
두 사람이 같은 날 같은 객실 유형을 동시에 예약한다. 둘 다 재고 행을 읽어 남은 방이 1개, 버전이 5임을 본다. 이때는 잠금을 걸지 않는다.
어려운 점과 선택지
- 몰리는 날짜: 특가 날짜 몇 행에 요청이 몰리면 어떤 방법이든 그 행이 병목이다. 앞단에서 대기열을 두어 줄을 세우거나, 재고를 미리 작은 묶음으로 나눠 여러 행에 흩어 두는 방법이 있다.
- 같은 예약 두 번: 예약 버튼을 두 번 누르거나 앱이 응답을 못 받아 다시 보내면 같은 예약이 둘 생긴다. 예약 화면을 열 때 서버가 예약 ID(멱등 키)를 미리 발급하고, 예약 표의 기본 키로 써서 두 번째 삽입이 실패하게 한다. 아래 결제 절의 멱등 키와 같은 생각이다.
- 결제와 묶기: 방을 잡고 결제가 실패하면 방을 돌려놔야 한다. 결제 전에 재고를 "보류"로 몇 분간 잡아 두고, 결제가 끝나면 "확정", 시간이 지나면 자동으로 풀리게 상태를 나눈다.
- 읽기 확장: 검색은 예약보다 수백 배 많다. 검색 결과의 남은 방 수는 캐시나 복제본에서 조금 늦게 보여 줘도 되고, 예약하는 순간에만 원본 DB에서 확인한다.
실제 사례
세 방법 모두 관계형 DB의 기본 기능이다. PostgreSQL 문서는 행 잠금의 종류와 FOR UPDATE가 무엇을 막는지, INSERT … ON CONFLICT로 "이미 있으면 아무것도 하지 않기"를 원자적으로 하는 법을 설명한다. 예약 ID를 기본 키로 둔 삽입에 이것을 쓰면 중복 요청이 같은 행으로 모인다. 격리 수준에 따라 무엇이 보장되는지는 동시성 제어 노트에 있다.
결제 시스템
요구와 규모
쇼핑몰의 결제를 받아 판매자에게 정산한다. 카드사와 직접 연결하지 않고 PSP(결제 대행사)를 거친다. 하루 500만 건을 처리한다고 하자.
- 평균 500만 ÷ 86,400 ≈ 초당 58건, 대목에 열 배여도 초당 600건이다. DB 한 대가 감당할 만한 양이라, 결제 시스템의 어려움은 처리량이 아니다.
- 원장: 결제 하나가 적어도 두 줄, 수수료와 정산까지 넣으면 다섯 줄 남짓이다. 하루 2,500만 줄, 1년이면 90억 줄이 쌓이고 지우지 않는다.
- 틀릴 수 있는 자리: 앱과 우리 서버 사이, 우리 서버와 PSP 사이, PSP와 카드사 사이 어디서든 응답을 잃을 수 있다. 하루 500만 건이면 0.01%만 해도 500건이다.
핵심 설계
결제 서비스는 결제 주문을 상태 기계로 기록한다(생성 → PSP 요청 → 승인 또는 실패 → 정산). 결제 실행기가 PSP를 부르고, PSP는 응답과 함께 나중에 웹훅으로 최종 결과를 다시 알린다. 응답을 잃어도 웹훅이, 웹훅을 잃어도 PSP 조회 API가 있으니 결과는 결국 알게 된다. 확정된 결과는 원장에 기입한다.
재시도는 피할 수 없으니 멱등 키로 받는다. 클라이언트가 요청마다 고유한 키를 붙이고, 서버는 키와 결과를 함께 저장했다가 같은 키가 다시 오면 처리하지 않고 저장된 응답을 돌려준다. 우리 서버가 PSP를 부를 때도 같은 원리로 PSP에 멱등 키를 넘긴다. 네트워크는 "정확히 한 번 전달"을 해 주지 않지만, 최소 한 번 전달 + 받는 쪽의 중복 제거로 "정확히 한 번의 효과"를 만들 수 있다.
앱이 결제 요청에 멱등 키 K-7f3a를 붙여 보낸다. 서버는 키를 고유 제약이 걸린 표에 "처리 중"으로 먼저 넣는다. 같은 키가 동시에 두 번 와도 하나만 넣어진다.
원장은 이중 기입(double-entry)으로 쓴다. 돈의 움직임마다 어느 계정에서 나가(대변) 어느 계정으로 들어갔는지(차변)를 함께 적어, 모든 기입의 차변 합과 대변 합이 늘 같다. 합이 어긋나면 어딘가 잘못 적힌 것이다. 원장은 고치지 않고 덧붙이기만 한다. 틀린 기입은 지우지 않고 반대 기입으로 상쇄한다.
| 사건 | 계정 | 차변 | 대변 |
|---|---|---|---|
| 결제 승인 | PSP 미수금 | 10,000 | |
| 결제 승인 | 판매자 미지급금 | 9,700 | |
| 결제 승인 | 수수료 수익 | 300 | |
| PSP 정산 입금 | 은행 예금 | 10,000 | |
| PSP 정산 입금 | PSP 미수금 | 10,000 | |
| 합계 | 20,000 | 20,000 |
1만 원 결제에 수수료 3%(300원)인 예다. 승인 때 PSP에서 받을 돈(미수금)이 생기고, 판매자에게 줄 돈과 우리 수익으로 나뉜다. PSP가 실제로 입금하면 미수금이 은행 예금으로 바뀐다. 정산이 끝나면 PSP 미수금 계정의 잔액은 0이 된다. 이런 중간 계정은 결국 0이 되어야 하므로, 0이 아닌 채 오래 남은 중간 계정이 곧 "어딘가 사건이 빠졌다"는 경보다.
어려운 점과 선택지
- 모르는 상태: PSP 호출이 시간 초과되면 승인됐는지 모른다. 실패로 처리하면 돈만 빠지고 주문은 취소되고, 성공으로 처리하면 돈 없이 물건이 나간다. 결제를 "확인 필요" 상태로 두고, 같은 멱등 키로 재시도하거나 PSP 조회 API와 웹훅으로 결과를 확정한다.
- 재시도의 모양: 즉시 재시도는 장애를 키운다. 지수 백오프와 무작위 흔들림, 재시도 횟수 상한, 끝내 실패한 건을 사람이 보는 대기열(dead letter queue)이 필요하다.
- 대사: 우리 원장과 PSP의 정산 파일, 은행 입금 내역을 매일 맞춰 본다. 실시간 경로가 아무리 조심해도 외부 시스템의 버그와 수동 조정이 있어서, 독립된 두 번째 계산이 마지막 방어선이다.
- 카드 정보: 카드 번호를 우리 서버가 만지면 PCI DSS 감사 범위가 시스템 전체로 넓어진다. PSP가 제공하는 결제창이나 토큰화를 써서 우리 서버는 토큰만 다룬다.
실제 사례
Stripe의 멱등 요청 문서는 같은 키로 온 재시도에는 처음 결과를 상태 코드까지 그대로 돌려주고, 같은 키에 다른 파라미터가 오면 오류를 내며, 키는 24시간쯤 지나면 지울 수 있다고 정한다. HTTP 헤더로서의 Idempotency-Key는 IETF의 초안으로 표준화가 진행 중이다. 결제 하나를 상태 기계로 다루는 모습은 Stripe의 PaymentIntent가 잘 보여 준다. 원장 쪽은 Stripe의 Ledger 글이 하루 50억 건의 사건을 고치지 않는 이중 기입 로그로 쌓고, 0이 되어야 할 중간 계정에 남은 잔액으로 빠진 사건을 찾는 방법을 설명한다. 이중 기입이 처음이라면 Modern Treasury의 Accounting for Developers가 개발자 눈높이로 풀어 준다.
전자 지갑
요구와 규모
사용자끼리 잔액을 주고받는다. 잔액은 음수가 되면 안 되고, 이체는 양쪽에 다 반영되거나 둘 다 반영되지 않아야 하며, 감사할 때 어떤 잔액이 어떻게 만들어졌는지 다시 계산할 수 있어야 한다. 목표는 초당 5만 건의 이체다.
- 이체 하나는 잔액 둘을 고친다. DB 한 대가 초당 5천 건의 쓰기 트랜잭션을 버틴다고 보면, 5만 건에는 적어도 열 대가 필요하고, 여유를 두어 20개 샤드로 나눈다.
- 샤드가 20개면 두 계좌가 같은 샤드에 있을 확률은 1/20이다. 이체의 95%가 샤드를 건넌다. 한 DB 안의 트랜잭션으로는 풀리지 않는다.
핵심 설계
샤드를 건너는 이체에는 길이 둘이다. 하나는 두 샤드가 함께 커밋하는 2단계 커밋이다. 결과가 단순하지만 조정자가 멈추면 참가자들이 잠금을 쥔 채 기다리고, 모든 이체가 두 샤드의 왕복을 기다린다(복제·샤딩·합의 노트). 다른 하나는 이체를 단계로 쪼개는 사가다. 샤드 1에서 A의 출금을 로컬 트랜잭션으로 확정하고, 그 사건을 받아 샤드 2에서 B에 입금한다. 입금이 끝내 실패하면 A에 돌려주는 보상 단계를 실행한다. 중간에 "A에서는 빠졌지만 B에는 아직 안 들어간" 순간이 보이지만, 돈은 어디에도 새거나 생기지 않는다.
이벤트 소싱이 이 구조와 잘 맞는다. 잔액을 직접 고치는 대신 "A에서 100 출금", "B에 100 입금" 같은 이벤트를 고치지 않는 로그에 덧붙이고, 잔액은 이벤트를 차례로 적용한 결과로 둔다. 명령은 검증(잔액이 충분한가)을 통과해야 이벤트가 되고, 이벤트는 이미 일어난 사실이라 거절하지 않는다. 같은 이벤트를 같은 순서로 적용하면 늘 같은 잔액이 나오므로, 감사할 때 어느 시점의 잔액이든 다시 계산할 수 있고, 장애 뒤에는 스냅샷과 그 뒤의 이벤트로 상태를 복원한다. 로그와 파생 상태의 관계는 배치와 스트림 처리 노트에 있다.
어려운 점과 선택지
- 단계의 중복: 사가의 각 단계는 메시지로 이어지므로 최소 한 번 전달된다. 입금 단계가 두 번 실행되지 않게 이체 ID로 멱등하게 만든다. 출금을 확정하는 로컬 트랜잭션과 "다음 단계로 보낼 사건"을 같은 트랜잭션에 쓰는 아웃박스 패턴을 쓰면, 출금은 됐는데 사건은 안 나가는 틈이 없다.
- 중간 상태가 보인다: 사가는 격리를 포기한다. A의 잔액이 줄었는데 B의 잔액은 아직인 순간을 사용자나 다른 이체가 볼 수 있다. 이체를 "진행 중"으로 보이게 하고, 잔액 확인은 출금이 일어나는 샤드에서 한다.
- 뜨거운 계좌: 가맹점처럼 초당 수천 건을 받는 계좌는 이벤트 순서를 매기는 곳이 하나라 병목이 된다. 받는 쪽 잔액을 여러 하위 계좌로 나눠 받고 주기적으로 합친다.
- 스냅샷: 이벤트가 수십억 개면 처음부터 다시 적용하는 데 오래 걸린다. 주기적으로 잔액 스냅샷을 찍어 그 뒤만 적용한다.
실제 사례
이벤트 소싱이라는 이름은 Martin Fowler의 Event Sourcing이 정리했고, 사가와 보상 트랜잭션은 microservices.io의 Saga 패턴 문서에 있다. 금융 거래 전용 데이터베이스인 TigerBeetle은 계좌와 이체를 이중 기입 모델로 직접 다룬다. 이체마다 고유 ID가 있어 같은 이체를 두 번 넣으면 거절되고, 여러 이체를 묶어 모두 되거나 모두 안 되게 하며, 잔액을 먼저 보류했다가 나중에 확정하거나 취소하는 2단계 이체를 기본 기능으로 둔다. 위에서 따로 설계한 멱등성, 원자성, 보류가 저장소의 기본 연산이 된 모습이다.
증권 거래소
요구와 규모
사려는 주문과 팔려는 주문을 맞춰 체결한다. 규칙이 공정해야 하고(같은 조건이면 먼저 온 주문이 먼저), 모든 참가자에게 같은 정보가 같은 순서로 가야 하며, 지연이 짧고 고르게 짧아야 한다. 종목 3,000개, 하루 신규·정정·취소 주문 4억 건이 장중 6시간 30분에 몰린다고 하자.
- 평균 4억 ÷ 23,400초 ≈ 초당 1만 7천 건. 장 시작과 마감 몇 분에는 열 배인 초당 17만 건이 온다.
- 지연: 증권사 입장에서 주문이 호가창에 닿기까지가 수십 마이크로초여야 경쟁이 된다. 디스크 쓰기 한 번이나 DB 왕복 한 번이 예산을 다 쓴다.
핵심 설계
중심은 종목마다 하나인 호가창(order book)과 그것을 고치는 매칭 엔진이다. 매수 주문은 높은 가격순, 매도 주문은 낮은 가격순으로 줄을 서고, 같은 가격 안에서는 들어온 순서로 선다. 새 주문이 맞은편 최우선 가격과 맞물리면 체결하고, 남으면 자기 쪽 줄에 선다. 이 규칙을 가격 우선·시간 우선이라 한다.
매수 호가는 높은 가격이 위, 매도 호가는 낮은 가격이 위에서 기다린다. 가장 비싼 매수(100)와 가장 싼 매도(101)의 차이가 스프레드다. 같은 가격에서는 먼저 온 주문이 가격 칸에 가깝다.
"먼저 온 주문"을 정하는 것이 시퀀서다. 게이트웨이 여러 곳으로 들어온 주문에 시퀀서가 하나의 순번을 매기고, 매칭 엔진은 그 순서대로만 처리한다. 매칭 엔진은 결정적(deterministic)이라 같은 입력을 같은 순서로 넣으면 같은 체결이 나온다. 그래서 순번 매긴 입력 로그만 디스크와 다른 서버에 복제해 두면, 예비 엔진이 같은 로그를 재생해 같은 상태를 유지하고, 주 엔진이 죽으면 그 자리를 넘겨받는다. 매칭 엔진의 출력(체결, 호가 변화)에도 순번을 매겨 증권사 보고와 시장 데이터로 나눠 보낸다.
어려운 점과 선택지
- DB 없이 메모리로: 호가창은 메모리 안의 자료구조(가격별 대기열)로 둔다. 영속성은 상태가 아니라 입력 로그의 몫이다. 상태를 DB에 쓰지 않으니 빠르고, 로그를 재생하면 상태가 돌아온다.
- 한 스레드: 종목 하나의 호가창은 스레드 하나가 처리한다. 잠금이 없으니 지연이 고르고, 순서가 곧 처리 순서다. 대신 처리량은 종목 단위로만 늘릴 수 있어, 종목을 여러 엔진에 나눈다. 스레드 사이 전달은 잠금 없는 링 버퍼로 한다.
- 꼬리 지연: 평균보다 가장 느린 1%가 문제다. 가비지 컬렉션 멈춤, 메모리 할당, 운영체제 스케줄링, 네트워크 스택이 모두 지연의 원인이다. 객체를 미리 만들어 재사용하고, 스레드를 CPU 코어에 고정하고, 커널을 우회하는 네트워크를 쓴다.
- 공정한 배포: 시장 데이터를 참가자마다 다른 시각에 받으면 불공정하다. 멀티캐스트로 한 번에 보내고, 순번으로 빠진 메시지를 알아채 따로 다시 받게 한다.
- 사전 위험 검사: 증권사의 한도를 넘는 주문이나 잘못 입력된 큰 주문을 엔진 앞에서 막는다. 이 검사도 지연 경로 위에 있으니 메모리 안에서 끝나야 한다.
실제 사례
Martin Fowler의 The LMAX Architecture는 금융 거래소 LMAX가 업무 로직 전체를 메모리 안 한 스레드로 돌려 초당 600만 건의 주문을 처리하고, 입력 이벤트를 먼저 기록·복제한 뒤 처리해 재시작 때 재생으로 상태를 되찾는 구조를 설명한다. 스레드 사이를 잇는 잠금 없는 링 버퍼는 LMAX Disruptor로 공개됐다. 거래소가 내보내는 순번 매긴 출력의 실제 모양은 Nasdaq의 TotalView-ITCH 명세에서 볼 수 있다. 주문 추가, 체결, 취소 같은 메시지가 주문 단위로 나가고, 받는 쪽은 그것을 차례로 적용해 호가창을 스스로 다시 만든다. 위의 "로그를 재생해 상태를 만든다"가 거래소 바깥에서도 그대로 쓰이는 셈이다.
- Designing robust and predictable APIs with idempotency — Stripe
- Idempotent requests — Stripe API
- The Idempotency-Key HTTP Header Field — IETF draft
- How PaymentIntents work — Stripe Docs
- Ledger: Stripe's system for tracking and validating money movement — Stripe
- Accounting for Developers, Part I — Modern Treasury
- Explicit Locking — PostgreSQL
- Constraints — PostgreSQL
- INSERT (ON CONFLICT) — PostgreSQL
- TigerBeetle Docs — TigerBeetle
- The LMAX Architecture — Martin Fowler
- LMAX Disruptor — LMAX
- Nasdaq TotalView-ITCH Specification — Nasdaq