LedgerCore
돈이 새로 생기거나 사라지지 않고, 어떤 이체도 두 번 적용되지 않는 PostgreSQL 복식부기 원장
이 프로젝트가 증명하는 것
- 동시성 정확성: 순서가 정해진 행 락으로 부하 테스트 12/12에서 deadlock 0, fault 0. reversal race를 찾아 테스트로 고쳤습니다
- 불변식은 DB에: 차변 = 대변 규칙을 COMMIT 시점 트리거로 검사하고, 부하 중에도 백업에서 복원한 DB에서도 imbalance 0
- 측정 기반 최적화: 잔액 조회를 단일 statement로 줄여 고정 속도 A/B에서 p95 24.9 → 13.4ms
- 증거로 원인 찾기: 부하 중 10초 정지를 Postgres 로그와 API 로그의 시각을 맞춰 추적했고, 원인은 동기 로그 쓰기였습니다
- 운영: SHA 태그 배포, 약 1초 rollback, 매일 백업과 복원 검증
- 역할
- 단독 개발: 설계, 백엔드, DB, 테스트, 부하 테스트, 배포 (커밋 53개)
- 기간
- 2026.08 – 2026.10
- 기술 스택
- NestJS
- TypeScript
- PostgreSQL
- node-pg-migrate
- zod
- React
- Vite
- Vitest
- k6
- Docker
- Caddy
- GitHub Actions
핵심 수치
| 항목 | 값 | 구분 | 증빙 |
|---|---|---|---|
| 부하 테스트 유효 실행 (deadlock 0, fault 0) | 12 / 12 | 측정값 | 증빙 보기 |
| 일반 이체 처리량 (50 VU) | 249 req/s | 측정값 | 증빙 보기 |
| 일반 이체 p95 | 228 ms | 측정값 | 증빙 보기 |
| hot 계좌 이체 p95 (40 VU → 계좌 1개, 409·deadlock 0) | 896 ms | 측정값 | 증빙 보기 |
| 혼합 부하 처리량 (읽기 30 + 쓰기 10) | 884 req/s | 측정값 | 증빙 보기 |
| 잔액 조회 p95 (고정 속도 A/B, 개선 후) | 13.4 ms | 측정값 | 증빙 보기 |
| 백업에서 복원 (14/14 테이블, imbalance 0) | 0.68 s | 측정값 | 증빙 보기 |
| 자동 테스트 | 392 | 측정값 | 증빙 보기 |
해결하려는 문제
계좌 간 자금 이동을 기록하는 시스템의 유일한 요구사항은 절대 틀리지 않는 것입니다. 요청이 같은 순간에 도착하든, 클라이언트가 재시도하든, 처리 도중 프로세스가 죽든 돈이 새로 생기거나 사라지면 안 되고, 같은 이체가 두 번 적용되어서도 안 됩니다.
아키텍처
API(NestJS 10 모듈러 모놀리스)가 사용자와 계좌, 입금과 출금, 이체, 취소(reversal), 잔액, 내역, 대사(reconciliation)를 처리합니다. 같은 코드베이스의 별도 워커 프로세스가 outbox 이벤트를 FOR UPDATE SKIP LOCKED로 가져와 HMAC 서명 웹훅으로 보냅니다. 불변식은 PostgreSQL 16의 deferred constraint trigger가 검사하므로 애플리케이션 코드의 실수로 우회할 수 없습니다. 운영 콘솔(React + Vite)은 계좌, 이체, outbox 전달 상태를 보여 줍니다.
데이터 모델
핵심 제약 조건과 인덱스
- ledger_entries: UPDATE / DELETE 금지 (트리거), append-only
- covering index (account_id, id) INCLUDE (direction, amount): 잔액 합계를 index-only scan으로
- accounts: 통화마다 SYSTEM 계좌 1개 (unique index)
- transactions.reverses_id: 원 거래 하나에 reversal 하나 (unique index)
- idempotency_keys: 원장 엔트리와 같은 트랜잭션에 저장
- 모든 FK ON DELETE RESTRICT: 돈과 관련된 행은 cascade로 지울 수 없음
- outbox_events: created_at 기준 월별 파티션 + DEFAULT 파티션
핵심 설계 결정
ADR-0001 금액은 BIGINT 최소 단위, API에서는 문자열
- 상황
- JSON 숫자는 IEEE 754라 큰 금액이 조용히 손상될 수 있습니다.
- 결정
- BIGINT 최소 단위로 저장하고 bigint 기반 Money로 감싸, 모든 경계에서 문자열로 직렬화합니다.
- 트레이드오프
- 클라이언트가 금액을 문자열로 읽어야 하지만, 부동소수점 오류가 없습니다.
ADR-0002 잔액 컬럼 없음, 엔트리에서 계산
- 상황
- 저장된 잔액은 읽기가 빠르지만 엔트리와 값이 다를 때 무엇이 맞는지 판단할 수 없습니다.
- 결정
- 잔액은 항상 ledger_entries 합계로 계산하고 covering index로 비용을 제한합니다.
- 트레이드오프
- 읽기 비용이 내역과 함께 늘어납니다. 측정이 필요해지면 스냅샷을 추가합니다.
ADR-0003 ORM 대신 raw SQL
- 상황
- 원장에서는 모든 쿼리와 락이 눈에 보여야 하고, ORM이 숨기는 것이 위험합니다.
- 결정
- pg 드라이버와 raw SQL, 마이그레이션은 node-pg-migrate.
- 트레이드오프
- 타입 안전성을 컴파일러가 아니라 통합 테스트가 보장합니다.
ADR-0004 모듈러 모놀리스
- 상황
- 원장과 outbox를 분리하면 모든 것이 의존하는 원자성이 깨집니다.
- 결정
- accounts, ledger, idempotency, outbox 모듈을 한 프로세스와 한 DB에 둡니다.
- 트레이드오프
- 모듈은 다른 모듈의 테이블에 직접 접근하지 않습니다.
ADR-0005 불변식은 deferred constraint trigger로
- 상황
- 유효한 트랜잭션의 규칙은 엔트리 집합의 속성이라 CHECK로 표현할 수 없고, 앱 검사는 우회될 수 있습니다.
- 결정
- COMMIT 시점 트리거가 엔트리 2개 이상, 차변 = 대변, 합계 = 거래 금액, 통화 일치를 검사합니다.
- 트레이드오프
- 오류가 statement가 아니라 COMMIT에서 나옵니다.
ADR-0006 READ COMMITTED + id 순서 FOR UPDATE
- 상황
- 요청 순서대로 계좌를 잠그면 A→B와 B→A 이체가 즉시 데드락이 납니다.
- 결정
- 계좌 행 락을 항상 id 오름차순으로 잡습니다.
- 트레이드오프
- 새로운 쓰기 경로도 반드시 이 순서를 지켜야 합니다.
ADR-0007 outbox만 월별 파티션, 원장은 파티션 없음
- 상황
- transactions를 파티셔닝하면 이중 환불을 막는 unique 장치가 조용히 깨집니다.
- 결정
- created_at 기준 월별 파티션 + DEFAULT 파티션을 outbox_events에만 적용했습니다.
- 트레이드오프
- 파티션을 미리 만드는 것이 운영 작업이 됩니다.
개선 전후 측정
- 무엇을 바꿨나
- 잔액 엔드포인트가 BEGIN READ ONLY → 계좌 확인 → SUM → COMMIT으로 DB를 4번 오가며 연결을 잡고 있었습니다(HTTP p95 141ms, SQL 자체는 약 0.3ms). READ COMMITTED에서는 statement마다 snapshot을 따로 잡아 이 트랜잭션은 일관성도 보장하지 않았습니다. pool을 20에서 40으로 늘려도 7%만 빨라져 원인은 pool이 아니었고, 20만 행 DB의 EXPLAIN ANALYZE는 두 방식 모두 같은 55 buffer와 같은 인덱스를 보였습니다. accounts + CROSS JOIN LATERAL(SUM …) 단일 statement로 바꾸고(404 유지), DB 호출이 한 번인지 확인하는 회귀 테스트를 추가했습니다. 고정 속도 100 iter/s, 3+3회 측정이며 내역과 이체 p95 차이는 잡음 범위입니다.
장애 대응 기록
- 증상
- 전체 벤치마크 중 500 3건과 409 3건이 나왔습니다. 원장은 균형을 유지했습니다.
- 원인
- Postgres는 04:00:52.7에 lock_timeout을 냈는데 API는 그 응답을 10초 뒤에 기록했고, 같은 순간 40ms 안에 500이 몰려 나왔습니다. Node 프로세스 자체가 멈춰 있었던 것입니다. 원인은 모든 요청을 info 수준으로 로깅한 것으로, Windows에서 stdout 파이프 쓰기는 동기이고 PowerShell 리다이렉션이 따라가지 못했습니다.
- 해결
- LOG_LEVEL=warn으로 같은 시나리오에서 fault 0, 409 0, 처리량 +37%. lock_timeout 3s, statement_timeout 5s, idle_in_transaction_session_timeout 10s가 락을 풀어 주었고, 그때 500 INTERNAL_ERROR를 돌려주던 timeout을 503 DATABASE_TIMEOUT(retryable: true)으로 바꿨습니다 (8c30dba).
- 재발 방지
- 실제 Postgres와 pg-pool로 timeout → 503 테스트 3개를 작성했습니다. 프로덕션에는 비동기 logger나 sampling이 필요하다는 점을 제약에 기록했습니다.
테스트와 품질
- 테스트 수
- 392
- 커버리지
- —
- CI
- GitHub Actions (커밋 메시지, 포맷, lint, typecheck, Postgres 서비스로 테스트, 빌드; billing 문제로 현재 중단)
- 테스트 도구
- Vitest, supertest, 실제 PostgreSQL 16 (mock 없음), k6 + bench.ps1
배포와 운영
- 배포 환경
- iwinv VPS, Docker Compose. web(Caddy)만 외부 proxy 네트워크에 있고 /v1/*을 API로 넘깁니다. Postgres와 Redis 포트는 열지 않습니다. deploy.sh: 충돌 표시 검사 → git merge --ff-only → migrate, api, web 이미지를 SHA 태그로 빌드(worker는 api 이미지 사용) → migrate 성공 뒤 API와 worker 기동 → /v1/health/ready를 최대 60초 대기 → 준비된 버전만 APP_TAG와 .deploy-history에 기록, 실패하면 rollback 명령을 출력.
- 백업
- cron이 매일 03:00에 서버의 모든 Postgres 컨테이너를 pg_dump -Fc로 백업하고 7일 보관합니다. restore-test.sh가 최신 백업을 임시 DB에 복원해 테이블 수를 비교하고, LedgerCore는 VERIFY_SQL로 복식부기 불변식까지 확인합니다: 68K 덤프, 14/14 테이블, 복원 0.68초, 20 entries imbalance=0.
- 롤백
- rollback.sh는 빌드 없이 api, worker, web을 이전(또는 지정한) SHA로 되돌리고 health를 확인합니다. Postgres와 Redis는 건드리지 않으며 마이그레이션은 되돌리지 않습니다. 프로덕션에서 2066318 → d10826f → 2066318로 시험해 매번 ready였습니다(약 1초).
- 모니터링
- postgresql.prod.conf: lock_timeout 3s, statement_timeout 5s, idle_in_transaction_session_timeout 10s, 500ms 넘는 쿼리 로깅, pg_stat_statements 활성화, 모니터링 네트워크로 PG Insight에 연결. /v1/health/ready
스크린샷
한계와 회고
현재 한계
- 모든 수치는 노트북 한 대(i7-8565U, 4C/8T, 15W)에서 k6, API, Postgres를 함께 실행한 결과로 운영 용량을 뜻하지 않고 비교만 신뢰할 수 있습니다
- 일반 이체 p95 228ms로 스크립트 목표 150ms에 미달했습니다
- 잔액 비용이 내역과 함께 늘어납니다(약 240건 계좌 0.42ms, 약 850건 1.68ms). 다음 단계는 엔트리에서 다시 만들 수 있는 캐시인 스냅샷 테이블입니다
- assert_transaction_balanced가 이체마다 3번(행마다) 실행되어 baseline DB 시간의 두 번째로 큰 비중입니다: 이체 42,421건에 104초
- 요청마다 동기 로그를 쓰면 부하 중 Node가 멈출 수 있어 운영에는 비동기 logger나 sampling이 필요합니다
- Postgres primary 하나: 쓰기 수평 확장, 복제본, failover가 없습니다
- Redis는 compose에 있지만 쓰지 않습니다. outbox 큐는 Postgres(SKIP LOCKED)에 있습니다
- GitHub Actions가 billing 문제로 멈춰 테스트를 로컬에서 수동으로 실행했습니다
핵심 코드
db/migrations/1737000200000_enforce-double-entry-invariant.sql차변 = 대변을 COMMIT에서 검사하는 deferred constraint trigger
db/migrations/1737000300000_make-ledger-immutable.sql엔트리 수정과 삭제를 DB에서 금지
apps/api/src/modules/ledger/infrastructure/transaction.repository.ts계좌를 id 순서로 FOR UPDATE
apps/api/src/modules/ledger/application/transfer.service.ts락, 잔액 확인, 엔트리, outbox를 한 트랜잭션에서
apps/api/src/modules/ledger/application/reversal.service.ts락을 잡은 뒤 상태를 다시 읽는 reversal (race 수정)
apps/api/src/modules/ledger/infrastructure/balance.repository.ts단일 statement 잔액 조회
apps/api/src/shared/database/transaction.manager.tsAsyncLocalStorage 트랜잭션, 40001/40P01 재시도
apps/api/src/shared/errors/postgres-error.translator.tsSQLSTATE → 도메인 오류, timeout → 503
apps/api/test/concurrency/transfer-concurrency.test.ts동시성 테스트 10개
load-tests/bench.ps1warm-up, 3회, 중앙값, 유효성 규칙
현재 상태와 다음 단계
최종 코드의 부하 테스트 12회가 모두 유효했고 fault 0, deadlock 0, contention 0, 매 실행 뒤 원장 균형이 맞았습니다. 잔액 조회를 단일 statement로 줄여 고정 속도 A/B에서 p95를 24.9ms에서 13.4ms로 낮췄습니다. 그 밖에 동시 reversal 두 건 중 두 번째가 409 대신 422를 돌려주던 race(ccee39b), .env.test가 gitignore에 있어 깨끗한 클론에서 테스트가 아예 돌지 않던 문제(e0e6884), k6 스크립트의 __ITER와 teardown 오류(0f76291)를 숫자를 믿기 전에 찾아 고쳤습니다.