
관측 스택을 테스트 오라클로 쓰기
ORBIT을 만들며 E2E 합격을 HTTP 응답이 아니라 메트릭·로그·트레이스에 대고 수치 계약으로 단언하는 방법을 정리했습니다.
Photo from Rawpixel
초록불이 거짓말할 때
스모크 테스트가 201 Created를 돌려줬습니다. 화면은 초록불이고 CI는 통과입니다. 그런데 그 뒤에서 정산 워커가 잔액을 잘못 옮겨 자산별로 더해보면 Σ(잔액)이 Σ(주입)과 어긋나 있었습니다. 주문은 분명히 접수됐고 HTTP 응답도 정상인데 원장은 조용히 틀어진 상태였죠. 응답코드는 "요청이 접수됐다"만 말할 뿐, "원장이 정합하다"는 한마디도 하지 않습니다.
ORBIT(포트폴리오용 spot exchange)을 만들면서 저는 이 초록불에 몇 번 속았습니다. 그러다 한 가지로 마음을 정했습니다. E2E의 합격 판정을 응답 대신 신호로 내리기로 했습니다. 신호를 오라클(oracle, 합격과 실패를 가르는 판정 근거)로 삼는 거죠. 부하를 흘리거나 장애를 주입한 뒤에 Prometheus·Loki·Tempo가 내보내는 신호를 수치 계약과 대조해서 통과와 실패를 가르는 것입니다. 한 줄로 줄이면 이렇습니다. 관측 스택이 곧 테스트 오라클이다. HTTP 200이 아니라 신호에 단언한다.
위 그림처럼 드라이버가 시스템을 흔들면 합격 판정은 세 개의 신호 평면에서 나옵니다. 신호를 계약과 대조하고 그 결과를 exit code로 게이트하는 거죠. 이게 즉흥 자작 같지만 실은 업계에 이미 이름이 붙은 세 갈래의 합류점입니다. 계보가 궁금하면 아래를 펼쳐보세요.
세 갈래가 합쳐진 자리입니다.
- Observability-Driven Development(Honeycomb · Charity Majors). 규율 한 줄은 "이게 깨지면 내가 어떻게 알지?에 답 못 하는 PR은 머지하지 않는다"입니다. "일단 배포하면 너는 코드가 아니라 시스템을 테스트하는 것"이라는 말이 HTTP 코드가 아니라 신호에 단언하는 이 글의 전제 그 자체입니다. 다만 Honeycomb 스스로도 관측성은 개발을 돕는 것이지 몰아가는 게 아니라고 못 박습니다. 과신은 금물이고 이 결은 글 끝의 "정직이 신뢰의 근거"와 같습니다.
- Continuous Verification(Casey Rosenthal, Chaos Engineering 16장). CI에서 CD를 거쳐 CV로 가는 마지막 칸입니다. 알려진 내부 속성을 확인하는 validation이 아니라 시스템의 창발적 출력을 능동적으로 확인하는 verification입니다. 뒤에 나올 custody 불변식은 어느 워커의 내부 로직을 검증하지 않습니다. 돈 보존이라는 창발적 사실을 검증합니다.
- 불변식 / 결정적 시뮬레이션 테스팅(TigerBeetle). 마스터 게이트의 한 줄 Σ(잔액)+Σ(in-flight)==Σ(주입)을 TigerBeetle은 "돈은 생성·소멸하지 않고 오직 이전된다"로 거의 그대로 적습니다. 모든 연산 뒤 불변식을 단언하는 그들의 VOPR이 황금률이고 아래 머니패스 하네스가 시나리오 수준에서 그걸 흉내 냅니다.
정상을 숫자로 정하는 SLI/SLO 계약
신호에 단언하려면 단언할 대상인 숫자로 적힌 계약이 먼저 있어야 합니다. 그 계약의 단위가 SLI(Service Level Indicator, 서비스 수준 지표)입니다. ORBIT에서는 각 SLI를 비율 + 백분위 + 윈도우 세 조각으로 정의했습니다. 예를 들어 주문 제출 지연은 "1분 윈도우에서 측정한 p99가 N ms 미만, 단 정상 피크의 몇 배 부하에서"처럼 적는 거죠. 표를 다 외울 필요는 없고 핵심 몇 줄만 봐도 패턴이 보입니다.
| SLI / 불변식 | 출처 신호 | 계약(SLO) | 역할 |
|---|---|---|---|
| 주문 제출 지연 | orbit_order_submit_duration_ms (Histogram) | p99 < N ms @ 부하배수 | baseline + gate |
| 제출 에러율 | orbit_orders_submitted_total{result} | 5xx 비율 < ε | gate |
| outbox 적체 | orbit_outbox_pending_count | 부하 후 0으로 수렴 | abort/recovery |
| custody 불변식 | orbit_reconciliation_custody_drift_total | == 0 (항상) | gate + abort |
| 음수 잔액 / dangling | orbit_reconciliation_negative_rows | == 0 (항상) | gate |
| in-flight 항 | orbit_transfers_in_flight{asset} (Gauge) | 정산 후 0 수렴 | baseline |
표에서 한 가지가 눈에 띕니다. custody 불변식 게이지 하나가 baseline·gate·abort 세 역할을 동시에 한다는 점입니다. 평소엔 그냥 관찰하다가, CI에선 빌드를 막고, 부하나 카오스 도중엔 실험을 중단시키는 조건이 됩니다. 이 한 신호가 글 전체의 중심축이라 뒤에서 따로 자세히 다룹니다.
평균이 아니라 백분위, 그리고 반드시 부하 배수와 함께
SLI에는 평균을 쓰지 않고 백분위만 씁니다. 평균 지연은 꼬리(tail)를 숨깁니다. 그리고 백분위는 반드시 부하 배수와 함께 말합니다. "p99 1.2ms"는 반쪽짜리 사실이고 "정상 피크의 5배 부하에서 p99 1.2ms"가 온전한 사실입니다.
오라클이 되는 메트릭·로그·트레이스 스택
신호 평면이 셋인 데는 이유가 있습니다. 셋은 서로 다른 질문에 답합니다. 메트릭은 "얼마나 자주, 얼마나 느린가"를, 로그는 "정확히 무슨 일이 있었나"를, 트레이스는 "그 요청이 어느 경로를 거쳤나"를 답하죠. 그래서 세 평면은 각자 다른 단언 양식을 맡습니다.
분담은 이렇습니다. Prometheus는 지연 히스토그램(orbit_*_duration_ms)과 처리량 카운터, 포화·불변식 게이지를 내보내고 SLI의 출처가 됩니다. Loki는 pino 구조화 JSON 로그를 받는데 service/env 같은 저카디널리티 라벨에 본문의 trace_id를 더해 검색합니다. Tempo는 분산 트레이스와 서비스 그래프를 맡아서 한 요청이 거친 서비스와 각 span의 지연을 보여줍니다. 이 셋을 한 화면에서 잇는 게 Grafana의 7개 대시보드와 로그↔트레이스 양방향 딥링크입니다.
open-loop 부하와 백분위로 성능 단언하기
성능부터 단언해 봅니다. 부하는 scripts/order-gen.ts가 만드는데 여기서 핵심은 부하 모델이 open-loop라는 점입니다. 응답을 기다리지 않고 고정 cadence로 요청을 발사하면서 in-flight(아직 응답 안 온 요청)에 상한만 두는 방식이죠. 보통의 closed-loop 부하는 시스템이 느려지면 응답을 기다리느라 발사 속도도 같이 떨어져서 포화점을 숨깁니다. open-loop는 시스템이 느려져도 부하가 줄지 않으니 포화점을 정직하게 드러냅니다.
부하 값은 절대값보다 정상 피크의 배수로 표현하고 비교가 가능하도록 하나의 고정 프로파일을 반복합니다. 아래는 정상 피크의 약 5배를 거는 예시입니다.
RATE=200 DURATION=30 BOTS=20 pnpm orders:genp50/p95/p99는 스크립트 자체의 ring-buffer 집계에서 나옵니다. 서버를 거치지 않는 클라이언트 측 진실입니다. 아래는 이 글을 쓰며 실제로 돌린 RATE=80 DURATION=120 BOTS=15 실행의 요약 블록입니다. 성능 파트의 헤드라인 산출물입니다.
────── summary ──────
duration: 120.0s
sent: 9601 (80.0 /s effective)
ok 2xx: 9365
http 4xx: 236
http 5xx: 0
errors: 0
dropped: 0 (in-flight cap)
latency: p50=5.7ms p95=18.5ms p99=70.3ms4xx 236건은 장애가 아닙니다. 랜덤워크 가격이 tickSize·minNotional·post-only 규칙에 걸린 검증 거절이라 부하 생성기 특성상 시드나 시장 상태마다 건수가 변합니다. 헤드라인은 5xx=0, errors=0, drop=0이고 80 orders/s에서도 p50/p95는 약 6/19ms로 견고했습니다. 다만 p99는 70ms로, 지속 부하 막판 꼬리에서 올라간 값입니다.
그다음엔 같은 시간 윈도우를 Grafana에서 교차 확인합니다. 클라이언트 측 숫자와 서버 측 히스토그램이 맞는지 보는 것입니다. 서버 측 p99는 PromQL로 직접 뽑습니다.
histogram_quantile(0.99, sum by (le) (rate(orbit_order_submit_duration_ms_bucket[1m])))
그림 1이 딱 그 장면입니다. 지속 5배 부하에서 median과 p95는 끝까지 견고한데 극단 꼬리인 p99만 막판에 ~20ms에서 ~117ms로 자랍니다. 클라이언트 측 p99(70ms)는 같은 윈도우의 평균이라 그 사이값으로 잡힌 것입니다. 해석은 한 숫자에 집중하는 게 좋습니다. 예를 들어 matcher의 p-queue가 포화되기 전까지 p99가 평평하다는 걸 orbit-matcher 대시보드의 p-queue depth 패널과 짝지어 결과와 원인을 같이 읽는 식입니다.
카오스를 반증 가능한 가설로 세우기
성능을 봤으면 안정성으로 넘어갑니다. 안정성 드라이버 scripts/chaos-reliability.ts는 "이제 kafka 죽여" 같은 수동 프롬프트 없이 docker stop과 start까지 스스로 합니다. 제가 카오스를 짜면서 가장 도움이 됐던 프레이밍은 이것입니다. 모든 카오스 테스트는 정상상태 메트릭에 대한 반증 가능한 가설이라는 것. 막연히 "kafka 죽였는데 괜찮더라"가 아니라 깨질 수 있는 가설을 세우고 메트릭 델타로 확증하거나 반증하는 것입니다.
그래서 각 시나리오를 위 그림처럼 정상상태 → 장애 주입 → 복구 관찰 → 불변식 단언 → 확증/반증의 카드로 적었습니다. 필드는 Hypothesis / Attack / Abort / Conclusion 넷입니다. 기본 pnpm reliability:chaos는 A·B만 도는데 A·B만 봐도 이 루프가 어떻게 돌아가는지 충분히 드러납니다.
- A. kafka-kill → outbox 따라잡기. 가설은 이렇습니다. redpanda를 멈추면 발사한 N건이
processedAt=NULL로 쌓이고 재기동 후 전량 drain되며 주문은 하나도 안 잃는다. 확증의 증거는orbit_outbox_pending_count가 N까지 올랐다가 0으로 수렴하고 Order 행 수가 발사 수와 같다는 것입니다. - B. dedupe 재전송. 같은
eventId를 두 번 흘리면 두 번째는withDedupe에 걸린다는 가설입니다.orbit_dedupe_hit_total{worker="notification"}이 올라가면 확증입니다.
C·D 시나리오는 외부 컨테이너를 더 흔들어서 SCENARIOS=A,B,C,D pnpm reliability:chaos로 명시 실행합니다. C는 SNS(localstack)를 죽여 실패→retry→DLQ 경로가 DlqEvent 행으로 남는지 보고 D는 잔액을 정확히 한도에 맞춰 동시 SUBMIT해서 과예약·음수 잔액이 없는지 봅니다. 아래는 시나리오 A의 실제 출력입니다.
A. Kafka kill — outbox catch-up
✓ baseline reaches relay
✓ outbox accumulates while broker down (delta ≥ 5) — before=1 after=6 delta=5
✓ broker container healthy
✓ all 5 stranded orders processed within 60000ms
✓ no Order rows lost (5/5 present) — expected=5 found=5
──────────────────────────────────────────
PASS 11/11브로커가 죽은 동안에도 api는 주문을 받아 outbox에 쌓고(before=1에서 after=6) 재기동 후 전량 drain되며 5건 중 5건이 그대로 남았습니다. 무손실이 메트릭 델타로 확증된 것입니다.

그림 2를 보면 현재값은 0인데 sparkline에 적체 봉우리가 남아 있어서 한때 쌓였다가 0으로 빠졌다는 사실이 그래프에 그대로 찍혀 있죠.
거짓말 못 하는 custody reconciliation 불변식
부하든 카오스든, 마지막 단언은 항상 같습니다. 자산별로 Σ(잔액) + Σ(in-flight) == Σ(주입). 돈이 어디로 흘렀든 시스템 전체로 보면 주입한 양과 정확히 같아야 한다는 것입니다. pnpm reconcile은 이 식에 드리프트가 있으면 non-zero로 종료합니다. 실제 출력은 이렇습니다.
=== ORBIT reconciliation (trading working-balance model) ===
scanned: 12 balances, 2 open orders
-- custody invariant (ADR-0017): Σ(balances)+Σ(in-flight) == Σ(injections) --
BTC: balances=5 + inflight=0 vs injections=5 => ok
KRW: balances=300000000 + inflight=0 vs injections=300000000 => ok
USDT: balances=110000 + inflight=0 vs injections=110000 => ok
PASS: 0 critical finding(s)같은 사실을 Prometheus에서 boolean SLO로도 봅니다. 이 글을 쓰며 실제로 0을 확인했습니다.
orbit_reconciliation_custody_drift_total # => 0 (드리프트 한 푼이라도 있으면 critical)이 게이지가 한 몸으로 세 역할을 합니다. 평소엔 baseline, CI나 운영에선 gate, 부하나 카오스 도중엔 abort 조건입니다.

그림 3의 큰 초록 0이 바로 이 글의 한 문장을 그림으로 옮긴 것입니다. 불변식이 성립한다는 사실이 이제 한눈에 보이죠. 이 게이지가 읽는 신호는 알람 룰이 읽는 신호와 같아서 사람용 패널과 게이트가 한 출처를 봅니다.
저는 이 custody 게이트가 이 시스템에서 제일 드문 부분이라고 생각합니다. 성능 부하 하네스나 카오스 하네스는 흔하지만 "끝나고 원장이 한 푼도 안 틀렸나"를 exit code로 막는 곳은 드뭅니다. Rosenthal의 분류로는 데이터 산출물(data-artifact) 검증에 해당하는데 이게 ORBIT의 차별점입니다. in-flight 항이 식에 들어간 이유는 동시성 보정입니다. 2단계 이체에서 돈이 출발지를 떠나 목적지에 닿기 전에는 잔액 합만 보면 항상 깨진 것처럼 보이는데 in-flight 항이 그 중간 상태를 메워서 식을 열린 시스템에서도 항상 참으로 유지해줍니다.
마스터 게이트는 끝에 한 번보다 매 연산마다 돌릴 때 더 강해집니다. scripts/e2e-moneypath.ts(pnpm e2e:moneypath)는 머니패스를 S0~S9 단계로 흘립니다. 입금, 입금 멱등, 예약/취소, 체결+수수료, fee-sweep, reconcile, 부하를 거치면서 각 머니 연산 직후에 reconciliation을 돌려 criticalCount==0을 단언합니다. 한 실행에서 236개 단언이 전부 통과하고 in-flight 도중에도 custody가 정합한지를 repeatable-read 스냅샷으로 따로 단언해서 거짓 드리프트를 배제합니다. 이건 TigerBeetle VOPR의 "모든 연산 뒤 불변식 단언"을 시나리오 수준으로 축소한 셈입니다.
metric → trace → log로 이상 하나를 추적하기
신호가 셋이라 좋은 진짜 이유는 하나의 이상을 셋으로 이어 추적할 수 있다는 데 있습니다. 메트릭에서 이상을 발견하고, 트레이스로 경로를 좁히고, 로그로 정확한 한 줄을 찍는 것입니다.
위 동선에서 조인 키는 trace_id 하나입니다. pino mixin이 모든 로그 라인에 이 값을 박아주니까 Loki에서 한 요청의 로그만 정확히 뽑을 수 있습니다. 아래는 이 글을 쓰며 실제로 적재된 로그 한 줄입니다.
{"level":40,"time":1782049461723,"service":"orbit-api","env":"development",
"req":{"id":1,"method":"POST","url":"/api/v1/auth/login"},
"trace_id":"5f586133b4c37d22cf055849b63cc79b","res":{"statusCode":401},"responseTime":25,"msg":"request completed"}이 trace_id로 LogQL을 걸면 그 요청과 묶인 로그만 걸러집니다.
{service="orbit-api"} | json | trace_id="5f586133b4c37d22cf055849b63cc79b"
그림 4는 Tempo가 span에서 자동으로 그려준 서비스 그래프입니다. user에서 matcher·api·workers로 화살표가 뻗고 각 노드에 평균 지연과 RPS가 붙습니다. 테두리가 전부 초록이라 모든 경로가 성공이라는 뜻입니다. 어느 요청이 느렸는지 메트릭에서 보고 이 그래프의 느린 edge를 따라 트레이스로 들어간 다음, 그 span의 trace_id로 로그 한 줄까지 내려가는 게 제가 ORBIT을 디버깅하는 기본 동선입니다.
그래프는 사람용, exit code가 빌드를 막는다
대시보드는 사람이 보라고 있는 것입니다. 게이트는 exit code입니다. 이 둘을 섞으면 안 됩니다. 사람이 그래프를 안 보고 있어도 빌드가 깨져야 하니까요. 그래서 게이트(빌드 실패)와 관찰(정보성)을 구분했습니다.
- 매 PR(CI,
architecture.yml).pnpm check:architecture,pnpm test(unit+arch),pnpm build,pnpm test:integration이 돕니다. custody 불변식은 통합 테스트(itest)로 매 PR마다 강제됩니다. - 로컬/스테이징 게이트(non-zero exit).
pnpm reliability:smoke(해피패스),pnpm reliability:chaos(AD),S9, 매 연산 뒤 custody==0)가 여기 묶입니다.pnpm reconcile(드리프트 시 exit 1),pnpm orders:gen(부하 + p50/p95/p99 요약),pnpm e2e:moneypath(머니패스 S0
한 가지 정밀한 구분이 있습니다. pnpm reconcile CLI는 exit code로 빌드를 막지만 Prometheus 게이지를 내보내지는 않습니다. orbit_reconciliation_* 게이지는 주기적으로 도는 reconciliation 워커가 같은 순수 코어를 호출해서 set합니다. 그러니까 CLI는 빌드를 막고 워커는 대시보드와 알람을 먹입니다. 한 로직의 두 소비자인 셈이죠. reconcile과 chaos는 지금 로컬·스테이징 운영 게이트입니다. ephemeral CI DB에선 flaky해서 매 PR엔 미배선이고 대신 불변식 자체는 itest로 매 PR 강제됩니다.
이게 말해주지 않는 것
정직이 신뢰의 근거라고 생각합니다. 그래서 이 방식이 못 잡는 것들을 적어둡니다.
로컬 단일 호스트에서 잰 수치는 SLA가 아닙니다. redpanda 한 대에 워밍업과 노이즈도 통제하지 않은 환경이라 이 측정은 회귀 탐지나 상대 비교, 불변식 검증에 쓰는 거지 성능 보증서가 아닙니다.
보존은 일관성과 다릅니다. 저는 이게 이 글에서 가장 정직하게 말해야 하는 한계라고 느껴요. Σ==Σ는 돈이 생성·소멸했나는 잡지만 누가 소유하나의 순서나 선형성은 못 잡습니다. lost update나 stale read 같은 비선형 인터리빙은 합을 보존한 채로 소유권을 망가뜨릴 수 있습니다. Jepsen의 TigerBeetle 분석이 바로 그 축을 보는데 그 잣대로 보면 custody 불변식은 필요조건이지 충분조건은 아닙니다.
카오스는 아직 순차입니다. 머니패스 하네스가 매 연산 뒤 custody 단언과 순차 카오스까지는 닫았지만 부하를 흘리는 도중에 장애를 주입하는 통합은 아직 남아 있습니다. 결정적 재현을 하려면 wall-clock에 의존하는 docker stop 대신 시드 기반 결정적 시뮬레이션 테스팅으로 가야 합니다.
다음 한 걸음은 이것들을 하나의 시나리오로 엮는 것입니다. 부하를 흘리는 도중에 broker를 죽이고 rolling restart까지 하면서 끝나고 custody_drift == 0이고 outbox가 drain됐고 p99가 계약 안에 있는지를 한 번에 단언하는 하네스입니다. 머니패스가 그 절반은 이미 닫았습니다. 신호는 다 있고 남은 건 동시성과 결정적 재현을 한 줄의 exit code로 엮는 일입니다.
본문 그림 3은 custody drift 단일 패널만 떼어 보였습니다. 아래는 orbit-money-path 대시보드의 전체 보드입니다.
