운영 보안 모니터링 runbook
도입
audit report는 배포 전의 사진이고, monitoring runbook은 배포 후의 운전 절차다. 스테이블코인 결제 시스템은 정상일 때도 mint, burn, transfer, freeze, pause, upgrade, cross-chain message, indexer sync가 계속 일어난다. 사고는 이 흐름 중 하나가 멈추거나, 예상하지 못한 주소가 권한을 행사하거나, 온체인 상태와 DB 상태가 갈라질 때 발생한다.
이번 강의에서는 알림 목록을 만드는 데서 멈추지 않는다. 알림이 울렸을 때 누가 확인하고, 어느 화면과 transaction을 보고, 몇 분 안에 어떤 결정을 내리고, 사용자와 merchant에게 어떤 상태를 보여줄지까지 runbook으로 연결한다.
학습 목표
- 온체인 이벤트, off-chain job, 사용자 결제 상태를 하나의 incident timeline으로 연결한다.
- alert마다 severity, owner, SLA, 1차 확인 링크, escalation 조건을 지정한다.
- 결제 정산 불일치 incident를 탐지부터 postmortem까지 처리한다.
- monitoring 자체가 실패했을 때의 indexer lag, RPC 장애, notification 실패를 다룬다.
개념 설명
온체인 이벤트와 운영 알림을 runbook으로 연결한다.
권한 경계가 테스트되는가
핵심 알림 10개를 정의했다.
1. 모니터링은 세 계층을 동시에 본다
스마트컨트랙트 이벤트만 보면 결제 서비스의 절반만 본 것이다. 실제 운영에서는 온체인 이벤트, 내부 DB 상태, 외부 provider 상태가 맞물린다.
| 계층 | 관찰 대상 | 대표 실패 |
|---|---|---|
| On-chain | token event, role event, pause, upgrade, bridge message | unexpected mint, role takeover, stuck transfer |
| Off-chain | indexer, relayer, reconciliation job, notification worker | lag, duplicate processing, failed webhook |
| Product state | invoice, delivery, refund, merchant settlement | paid but not delivered, delivered but not settled |
runbook은 이 세 계층을 같은 incident ID로 묶어야 한다. 예를 들어 결제가 Paid로 보이지만 merchant settlement가 멈춘 경우, 원인은 token transfer가 아니라 indexer lag일 수 있고, CCTP attestation 지연일 수도 있으며, reconciliation job의 nonce 중복 처리일 수도 있다.
2. Alert catalog는 이벤트명이 아니라 행동 단위로 쓴다
원본 문서의 모니터링 항목을 실무 catalog로 바꾸면 다음과 같다.
| Alert | 조건 예시 | Severity | Owner | SLA | 1차 대응 |
|---|---|---|---|---|---|
| Unexpected mint | 허용된 minter가 아니거나 일일 한도 초과 | Critical | Security on-call | 5분 | issuer 확인, pause 검토 |
| Role change | production multisig 외 주소가 role 변경 | Critical | Protocol owner | 5분 | incident channel, signer 확인 |
| Freeze spike | 15분 동안 freeze 수가 기준치 초과 | High | Compliance + Support | 15분 | case batch 확인, 사용자 안내 준비 |
| Pause event | production contract가 paused | Critical | Incident commander | 5분 | route disable, status page 검토 |
| Upgrade executed | timelock 없는 implementation 변경 | Critical | Security on-call | 5분 | emergency pause, admin slot 확인 |
| Settlement mismatch | Paid 후 N분 내 settled/refunded 없음 | High | Payments on-call | 30분 | reconciliation job 재실행 |
| CCTP stuck | source burn 후 attestation 또는 mint 지연 | High | Cross-chain owner | 30분 | attestation 조회, retry/manual review |
| Indexer lag | chain head 대비 N block 이상 지연 | Medium | Data platform | 30분 | RPC 전환, backfill 시작 |
| Relayer failure | submit 실패율 또는 pending queue 증가 | High | Infrastructure | 15분 | key balance, nonce, RPC 확인 |
| Notification failure | alert webhook/email/slack 전송 실패 | Medium | SRE | 30분 | fallback channel, dead-letter 확인 |
OpenZeppelin Monitor와 Defender Monitor 문서는 event/function 조건, severity, notification channel, confirmation block, checkpoint persistence 같은 운영 요소를 제공한다. 강의에서는 특정 도구 사용법보다 "어떤 조건이 울리면 어떤 결정을 내릴 것인가"를 먼저 작성한다.
3. Incident 상태는 선형 보고서가 아니라 반복 루프다
심각한 mint, role, upgrade alert에서는 원인 분석보다 확산 방지가 먼저일 수 있다. 반대로 indexer lag alert에서는 pause가 아니라 backfill과 사용자 상태 메시지가 우선이다. runbook은 모든 alert를 같은 대응으로 몰지 않고, "돈이 더 움직이는가", "사용자에게 잘못된 완료 상태를 보여주는가", "수동 조치가 필요한가"를 기준으로 갈라야 한다.
4. 증거는 transaction hash만으로 부족하다
incident ticket에는 다음 증거가 같이 있어야 한다.
| 증거 | 왜 필요한가 |
|---|---|
| chain ID, contract address, transaction hash | 다른 네트워크와 같은 주소를 혼동하지 않기 위해 |
| event name과 decoded args | 알림이 실제 어떤 조건으로 울렸는지 확인하기 위해 |
| related payment/invoice ID | 사용자와 merchant 영향 범위를 찾기 위해 |
| DB row version과 job run ID | off-chain 중복 처리와 누락을 구분하기 위해 |
| indexer head와 chain head | 데이터 지연인지 계약 사고인지 나누기 위해 |
| operator action log | 누가 pause, retry, refund, manual settle을 실행했는지 남기기 위해 |
| user-visible status | 고객에게 보여준 상태와 실제 상태가 일치하는지 확인하기 위해 |
이 증거가 없으면 postmortem이 감상문이 된다. runbook은 "무엇을 했는가"보다 "어떤 근거로 그 결정을 했는가"를 남기도록 설계한다.
코드로 확인하기
앞의 보안 기준을 코드와 테스트로 확인한다. 함수가 어떤 전제를 세우고, 테스트가 어떤 실패 조건을 고정하는지 함께 읽는다.
인덱서온체인 이벤트 watcher — viem 기반
Transfer/Mint/Burn/RoleGranted/Paused 같은 신호를 한 프로세스에서 잡아 alert로 전송한다. 멱등성을 위해
lastSeenBlock을 DB에 기록한다.
import { createPublicClient, http, parseAbi } from "viem";import { mainnet } from "viem/chains";import { prisma } from "./db";import { pageOncall } from "./alerts";const client = createPublicClient({ chain: mainnet, transport: http(process.env.RPC_URL) });const abi = parseAbi([ "event Transfer(address indexed from, address indexed to, uint256 value)", "event Paused(address account)", "event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender)"]);async function watchBlock() { const watermark = await prisma.indexerState.findUnique({ where: { id: "stablecoin" } }); const fromBlock = (watermark?.lastBlock ?? 0n) + 1n; const toBlock = await client.getBlockNumber(); if (toBlock < fromBlock) return; const logs = await client.getContractEvents({ address: STABLECOIN_ADDRESS, abi, fromBlock, toBlock }); for (const log of logs) { if (log.eventName === "Paused") { await pageOncall({ severity: "critical", reason: "Stablecoin paused", log }); } if (log.eventName === "Transfer" && log.args.value! > 100_000_000_000n) { await pageOncall({ severity: "warning", reason: "Large transfer", log }); } } await prisma.indexerState.upsert({ where: { id: "stablecoin" }, update: { lastBlock: toBlock }, create: { id: "stablecoin", lastBlock: toBlock } });}운영PagerDuty / Slack 알림 라우팅
severity에 따라 어디로 보낼지 분리. critical은 oncall 핸드폰, warning은 Slack 채널, info는 대시보드에만.
import { WebClient } from "@slack/web-api";import { PagerDuty } from "@pagerduty/pdjs";const slack = new WebClient(process.env.SLACK_BOT_TOKEN);const pd = new PagerDuty({ token: process.env.PAGERDUTY_TOKEN });export async function pageOncall(event: { severity: "critical" | "warning" | "info"; reason: string; log: { transactionHash?: string; blockNumber?: bigint };}) { const baseText = `${event.reason} — tx ${event.log.transactionHash} block ${event.log.blockNumber}`; if (event.severity === "critical") { await pd.post("/incidents", { data: { incident: { type: "incident", title: event.reason, urgency: "high" } } }); } await slack.chat.postMessage({ channel: event.severity === "info" ? "#stablecoin-monitoring" : "#stablecoin-oncall", text: `[${event.severity.toUpperCase()}] ${baseText}` });}인덱서정산 불일치 (reconciliation) — SQL invariant
매 시간 도는 job 이 onchain balance 합과 internal ledger의 settled amount를 비교한다. drift가 임계값을 넘으면 alert.
-- 1) 정산 완료된 invoice 의 amount 합WITH settled AS ( SELECT SUM(amount) AS total_settled FROM invoices WHERE status = 'Settled' AND settled_at >= NOW() - INTERVAL '24 hours'),-- 2) 같은 기간의 merchant 지갑 onchain inflowonchain AS ( SELECT SUM(value) AS total_onchain FROM transfers WHERE to_address = ANY (SELECT wallet FROM merchants) AND block_timestamp >= NOW() - INTERVAL '24 hours')SELECT settled.total_settled, onchain.total_onchain, (onchain.total_onchain - settled.total_settled) AS drift, CASE WHEN ABS(onchain.total_onchain - settled.total_settled) > 1000000 THEN 'critical' WHEN ABS(onchain.total_onchain - settled.total_settled) > 100000 THEN 'warning' ELSE 'ok' END AS severityFROM settled, onchain;운영Incident 증거 템플릿 (YAML)
ticket에 박는 표준 양식. transaction hash 단독으로는 부족하다는 사실을 코드로 강제.
incident: id: INC-2026-05-15-001 severity: critical summary: "Stablecoin merchant settlement drift > 1k USDC" evidence: onchain: chain_id: 1 contract: "0xUSDC..." tx_hashes: ["0xabc...", "0xdef..."] block_range: [19_840_010, 19_840_120] product: payment_ids: ["pay_01HX...", "pay_01HY..."] merchants: ["merch_acme", "merch_globex"] job: reconciler_run_id: "rec_2026-05-15T10:00Z" indexer_head_block: 19_840_120 chain_head_block: 19_840_125 user_visible: status_page_state: "operational" support_macro: SUP-PAYMENT-DELAY-v3 operator_actions: - at: "2026-05-15T10:14Z" by: alice action: "pause settlement job" - at: "2026-05-15T10:32Z" by: bob action: "manual refund pay_01HX..."강의 포인트
| 관점 | 수업 중 확인할 질문 | 산출물 |
|---|---|---|
| 탐지 | 어떤 이벤트와 job metric을 alert로 만들 것인가 | alert catalog 10개 |
| 우선순위 | 어떤 alert가 pause나 route disable로 이어지는가 | severity/SLA matrix |
| 상태 연결 | 온체인 tx와 payment state가 어떻게 매칭되는가 | incident evidence template |
| 사용자 영향 | 사용자에게 pending, delayed, refunded 중 무엇을 보여줄 것인가 | status copy와 support macro |
| 개선 루프 | 한 번 처리한 incident가 다음 alert rule을 어떻게 바꾸는가 | postmortem action list |
실무 예시
운영[INDEXER] 결제 정산 불일치 runbook
상황: 사용자는 USDC 결제를 완료했고 UI는 Paid로 보인다. merchant에는 배송 요청이 전달됐지만 settlement ledger에는 Settled 또는 Refunded가 없다.
| 단계 | 질문 | 실행 |
|---|---|---|
| Detect | 어떤 alert가 울렸는가 | Settlement mismatch alert의 payment ID, tx hash, chain ID를 ticket에 붙인다 |
| Triage | 사용자 돈이 묶였는가, merchant가 서비스 제공을 했는가 | invoice, delivery, ledger row를 비교한다 |
| Contain | 추가 피해를 막아야 하는가 | 같은 route에서 mismatch가 반복되면 해당 route를 임시 비활성화한다 |
| Diagnose | 온체인 문제인가 DB/indexer 문제인가 | transfer event, indexer head, reconciliation job log, nonce 사용 여부를 확인한다 |
| Recover | 어떤 최종 상태로 닫을 것인가 | retry settlement, manual settle, refund 중 하나를 선택하고 operator log를 남긴다 |
| Communicate | 사용자와 merchant에게 무엇을 말할 것인가 | delay notice, refund notice, merchant settlement notice 중 맞는 템플릿을 사용한다 |
| Review | 재발 방지 항목은 무엇인가 | alert threshold, idempotency key, backfill script, test fixture를 업데이트한다 |
이 예시는 capstone의 checkout 시스템과 직접 연결된다. 결제 도메인에서 "완료"는 token transfer 성공이 아니라 상품 제공, merchant 정산, refund 가능성, ledger 일치까지 닫힌 상태다.
Cross-chain stuck transfer runbook
CCTP나 bridge 기반 전송에서는 source chain의 burn과 destination chain의 mint 사이에 시간이 생긴다. runbook은 이 대기 상태를 장애와 구분해야 한다.
| 상태 | 정상/비정상 판단 | 사용자 표시 | 운영 행동 |
|---|---|---|---|
| Source confirmed | source burn 또는 lock 확인 | Processing | attestation 또는 message 상태 조회 |
| Waiting attestation | 예상 window 안의 대기 | Processing | retry 없이 관찰 |
| Attestation delayed | SLA 초과 | Delayed | provider status 확인, support note 생성 |
| Destination submit failed | relayer tx 실패 | Delayed | nonce, gas, RPC, relayer balance 확인 |
| Destination minted | destination transfer 완료 | Completed | reconciliation close |
| Manual review | 중복, reorg, amount mismatch | Under review | refund 또는 수동 정산 결정 |
여기서 핵심은 사용자의 UI 상태다. 내부적으로는 여러 단계가 있어도 사용자가 볼 수 있는 상태는 Processing, Delayed, Completed, Refunded, Under review처럼 제한해야 혼란이 줄어든다.
알림 피로도 줄이기
alert가 너무 많으면 운영팀은 중요한 사고를 놓친다. threshold와 dedupe는 보안 약화가 아니라 실질 대응력을 높이는 장치다.
| 문제 | 개선 방법 |
|---|---|
| 같은 tx가 여러 channel로 반복됨 | incident key를 chain ID + tx hash + alert name으로 묶는다 |
| reorg 때문에 false positive가 많음 | confirmation block 또는 finalized tag를 적용한다 |
| indexer lag가 매분 울림 | 첫 alert 후 N분 동안 상태 업데이트로 병합한다 |
| 낮은 금액 mismatch가 너무 많음 | 금액, merchant tier, user impact별 severity를 나눈다 |
| notification channel 장애 | Slack 실패 시 email/PagerDuty/webhook fallback을 둔다 |
흔한 오해와 실패 시나리오
| 오해 | 실제 기준 |
|---|---|
| 이벤트를 모두 수집하면 모니터링이 끝난다 | owner, SLA, decision path가 없으면 alert는 로그일 뿐이다 |
| Critical alert는 무조건 pause다 | 추가 피해가 있는지, route disable로 충분한지 먼저 판단한다 |
| indexer lag는 데이터팀 문제다 | 결제 UI, merchant fulfillment, refund 판단에 직접 영향을 준다 |
| postmortem은 사고 후 보고서다 | 다음 alert rule, test, dashboard를 바꾸는 작업 목록이어야 한다 |
| 사용자 공지는 원인 분석 후에 한다 | 사용자 영향이 확인되면 원인 확정 전에도 지연 상태를 알려야 한다 |
실습 과제
- 운영Alert catalog 10개 작성: mint, role, freeze, pause, upgrade, payment, cross-chain, indexer, relayer, oracle 관련 알림을 severity, owner, SLA, 1차 확인 링크와 함께 정리한다.
- 운영[INDEXER] 결제 정산 불일치 incident runbook 작성: paid 상태인데 settled/refunded가 되지 않은 결제를 기준으로 탐지, 격리, 진단, 복구, 사후 개선 절차를 작성한다.
- 운영알림 피로도 개선안 만들기: 반복 alert, false positive, indexer lag alert가 운영팀을 압도하지 않도록 threshold, confirmation, dedupe, escalation 정책을 설계한다.
완료 기준
- 핵심 alert 10개에 severity, owner, SLA, 1차 확인 링크를 붙였다.
- 결제 정산 불일치 incident를 Detect, Triage, Contain, Diagnose, Recover, Communicate, Review 단계로 작성했다.
- cross-chain stuck transfer의 사용자 표시 상태와 운영 상태를 분리했다.
- 반복 alert를 줄이는 threshold, confirmation, dedupe 정책을 설계했다.
- postmortem action이 test, dashboard, runbook, support template 중 최소 하나를 바꾸도록 작성했다.
근거 자료
- 운영보안 모니터링 런북: 02-보안-테스트/09-운영보안-모니터링-런북.md
- OpenZeppelin Monitor: https://docs.openzeppelin.com/monitor/1.3.x
- OpenZeppelin Defender Monitor: https://docs.openzeppelin.com/defender/module/monitor
- The Graph Indexing Overview: https://thegraph.com/docs/en/indexing/overview/
- Circle CCTP Technical Guide: https://developers.circle.com/cctp/references/technical-guide
- Safe Guards: https://docs.safe.global/advanced/smart-account-guards