# 운영 장애 알림 — 손으로 도는 테스트 flow

이 문서는 사람이 로컬에서 **직접** 운영 장애 알림을 발화시켜 보고, 눈으로 확인하고, 흔적 없이
되돌리는 순서다. 도구는 `scripts/alert-inject.ts` 이고 이 문서는 그 도구의 사용 순서다.

PRD 는 `.claude/prds/ops-alert-system.prd.md`, 지표 번호(M1~M13)는 그 문서의 Success Metrics 다.

> **이 flow 는 배포 승인이 아니다.** 마일스톤 2.5 가 발급한 판정은 `APPROVED_WITH_CONDITIONS`
> 이며 미충족 잔여조건 11건이 리포트에 이름으로 열거돼 있다. 아래 시나리오가 전부 PASS 해도
> 그 11건은 그대로다 — 이 flow 가 재는 것은 **로컬에서 재현 가능한 축**뿐이다(§6).

---

## 0. 전제 — 이 하네스가 무엇을 하는가

- Nest 컨텍스트를 열어 **실제 `AlertService` 인스턴스**를 구동한다. 로직을 복제하지 않는다.
- 임계·창·표본 하한을 하네스가 갖지 않는다. 전부 `AlertSettingsService` 에서 읽는다 —
  **규칙이 보는 값과 같은 소스**다.
- 실제 HTTP 웹훅이 **그대로 나간다**. `AlertService.send` 를 pass-through 로 감싸 계측만 얹는다.
- 심은 행·바꾼 설정은 전부 **대장**(`tn_alert_setting` 의 `alert-inject:ledger` 행 1개)에 기록되고
  `cleanup` 이 되돌린다.

---

## 1. 사전 준비

### 1-1. 대상 DB 확인 — 가장 중요한 한 줄

```bash
cd insuniverse-server
grep -E '^DB_DATABASE|^DB_HOST|^DB_PORT|^NODE_ENV' .env
```

`DB_DATABASE` 가 **`-dev` 로 끝나야 한다.** 하네스는 실패 결제 행과 진료 실패 행을 실제로
넣으므로, 이름이 다르면 `assertWritableDatabase` 가 부팅 전에 막는다.

```
대상 DB 'insuniverse' 는 dev 가 아니다. 이 하네스는 실패 결제 행을 실제로 넣는다 — 진행하지 않는다.
```

이 메시지를 봤다면 **터널이 stg 를 물고 있는지 먼저 의심하라.** dev(13306)와 stg 포트는 인접해
설정 하나로 뒤바뀐다. 의도적으로 다른 DB 를 쓰려면 `ALERT_INJECT_ALLOW_DB` 에 이름을 **정확히**
적어야 한다(접두·와일드카드 불가).

### 1-2. Redis · API 기동

```bash
grep -E '^REDIS_URL|^ALERT_' .env       # ALERT_HEALTH_URL 이 가리키는 포트가 API 포트여야 한다
npm run start:dev                        # API 는 7200 에서 뜬다 (apps/api/src/main.ts:44)
curl -s http://localhost:7200/health/detail
```

`/health/detail` 은 항상 200 이고 판정은 본문 `ok` 에 있다. `/health` 는 같은 본문이되 실패 시
503 이다. `/live` 는 상수 `'OK'` 로 분리돼 있다 — 이 셋의 차이가 A6 신호원의 구조다.

### 1-3. 마이그레이션 2건

```bash
ls migrations/alert-history migrations/alert-vendor-rule
# tn_alert_history 테이블과 alert_rule_enabled:treat-vendor='0' 시드가 적용돼 있어야 한다
```

벤더 규칙 시드 확인 — **행이 없으면 규칙은 켜진 것으로 읽힌다**(`isRuleEnabled` 는 행 부재를
켜짐으로 돌려준다). 그래서 OFF 는 코드가 아니라 행이다:

```sql
SELECT als_value FROM tn_alert_setting WHERE als_key = 'alert_rule_enabled:treat-vendor';
-- '0' 이어야 한다
```

### 1-4. 웹훅 수신처 — **로컬 HTTP 서버는 쓸 수 없다**

`alert-url.ts` 의 목적지 검증이 다섯 가지를 요구한다: **https 만 · 자격증명 금지 · 443 포트만 ·
허용목록 접미사 일치 · IP 리터럴 금지**. `http://localhost:3001/webhook` 은 세 가지를 동시에 어긴다.
이것은 관리자 입력이 그대로 서버 아웃바운드 목적지가 되는 신뢰 경계라 완화하지 않는다.

허용목록의 **기본값은 코드에 있고 항상 포함된다**(`DEFAULT_ALLOW_SUFFIXES`):

| 수신처 | 준비 |
|---|---|
| **Google Chat** (권장) | 스페이스 → 앱 및 통합 → Webhook 추가 → URL 복사 |
| **Slack** | Incoming Webhook 생성 → URL 복사 |

`ALERT_WEBHOOK_HOST_ALLOW` 에 도메인을 **더** 넣을 수는 있지만, 위 둘은 넣지 않아도 통과한다.

> **네트워크 없이 보고 싶다면** 채널을 등록하지 않아도 된다. 하네스가 발송 시도를 전부 계측해
> `spy` 블록으로 찍는다(§3-3). 다만 그때 `delivered` 는 0 이므로 "수신 확인"(M10)은 못 재고,
> **발화 여부**만 잰다.

### 1-5. 채널 등록

관리자 API 는 로그인 토큰이 필요하다. 로컬 테스트는 DB 직접 삽입이 빠르다:

```sql
INSERT INTO tn_alert_channel (alc_name, alc_url, alc_types, alc_enabled, alc_created_at)
VALUES ('local-test-1', 'https://chat.googleapis.com/v1/spaces/…', NULL, 1, NOW());
-- alc_types NULL = 전 종류 수신. 다중 채널(M10)을 재려면 서로 다른 URL 로 2행을 넣는다
```

도달성 확인:

```bash
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts test
```

활성 채널마다 `🔔 [테스트]` 메시지가 한 건씩 나간다. **여기서 안 오면 아래 시나리오는 전부
의미가 없다** — URL·방화벽·허용목록 순으로 확인하라.

---

## 2. 지금 상태 보기

```bash
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts status
```

읽는 순서:

| 필드 | 무엇 |
|---|---|
| `status.enabled` | 규칙별 ON/OFF. `treat-vendor` 는 기본 `false` 가 정상이다 |
| `suppress` | 억제창에 물려 있는 경고 수. **여기가 비어 있지 않으면 같은 경고는 다시 안 나간다** |
| `digestQueue` | warn 이 몇 건 쌓여 있는가. warn 은 즉시 발송하지 않는다 |
| `ledger.active` | 회수되지 않은 주입이 있는가. `true` 면 새 주입이 거부된다 |
| `ledger.orderDetails` | `a5` 가 심어 둔 진료 행 수 |

`status` 는 **읽기 전용**이라 대상 DB 가드를 면제받는다. 어떤 DB 든 안전하게 볼 수 있다.

---

## 3. 시나리오 — 한 명령으로 끝까지

### 3-1. 목록

```bash
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts scenario list
```

부팅도 DB 도 필요 없다.

### 3-2. 한 건 돌리기

```bash
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts scenario payment-fire
```

한 시나리오가 하는 일 — **억제 상태 초기화 → 더미데이터 주입 → poll → 판정 → 무조건 cleanup**.
cleanup 이 `finally` 에 있으므로 중간에 실패해도 회수된다.

억제 초기화가 핵심이다. 이것이 없으면 같은 시나리오가 **30분에 한 번만 참**이고, 두 번째 실행의
"발송 0건"이 억제 때문인지 발화하지 않아서인지 구분되지 않는다.

### 3-3. 출력 읽는 법

```json
{
  "scenario": "payment-fire",
  "observations": [
    { "delivered": 2, "critical": 2, "recovery": 0, "unclassified": 0,
      "criticalDestinations": 2, "invalid": 0 }
  ],
  "verdict": "PASS"
}
```

- `invalid > 0` — 그 회차는 **평가되지 않았다**(lease 미획득 등). 그 구간의 "0건"은 무발송의
  증거가 아니므로 판정식에 닿기 전에 FAIL 로 떨어진다.
- `unclassified > 0` — 메시지 형식이 분류 어휘와 어긋났다. 이 측정은 무효다.
- `criticalDestinations` — 같은 critical 이 몇 개의 **서로 다른** 목적지로 갔는가. M10 의 N/N.

끝에 붙는 `spy` 블록에는 실제 발송 본문 표본 3건과 마스킹된 목적지가 들어 있다.

### 3-4. 시나리오 카탈로그

| id | 무엇을 만드는가(더미데이터) | 기대 | 지표 |
|---|---|---|---|
| `payment-fire` | `tn_payment`+`tn_order` 실패 결제 행 N건(표본 하한 이상) | critical ≥1 | M1·M2 |
| `payment-under` | 같은 행을 **하한 미만**으로 | 발송 0건 | M7 |
| `payment-suppress` | 실패 결제 행 + 3회차 연속 폴링 | critical **정확히 1** | M4 |
| `payment-recover` | 주입 → 폴링 → 행 삭제 → 폴링 | recovery **정확히 1** | M3 |
| `multi-channel` | 실패 결제 행 (채널 2개 이상 필요) | 목적지 = 활성 채널 수 | M10 |
| `heartbeat-fire` | `alert:hb:*` 를 기대주기×grace 밖으로 되돌림 | critical ≥1 | M1·M2 |
| `heartbeat-recover` | 위 + 하트비트 갱신 | recovery 정확히 1 | M3 |
| `api5xx-fire` | Redis 5xx/요청 카운터 bump | critical ≥1 | M1 |
| `vendor-fire` | `tn_order_detail` 벤더 실패 행 + 규칙 ON + 산출 서명 | critical ≥1 | M1·M6 |
| `vendor-under` | 같은 행을 하한 미만으로 | 발송 0건 | M7 |
| `quiet` | 아무것도 안 심고 3회차(하트비트 모사 ON) | 발송 0건 | M9 |
| `channel-test` | 없음 — 수동 테스트 발송 | 채널 수만큼 수신 | M10 |

`payment-suppress` 는 **0건도 실패**다. 0건은 억제가 아니라 애초에 발화하지 않았다는 뜻이고
그것으로 M4 를 주장할 수 없다.

### 3-5. 전부 돌리기

```bash
for s in payment-fire payment-under payment-suppress payment-recover multi-channel \
         heartbeat-fire heartbeat-recover api5xx-fire vendor-fire vendor-under \
         quiet channel-test; do
  echo "=== $s ==="
  npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts scenario "$s" || echo "FAILED: $s"
done
```

각 시나리오가 자기 뒤를 치우므로 순서 의존이 없다. 다만 **채널이 실제 Google Chat 이면 알림이
꽤 온다** — 테스트 전용 스페이스를 쓰라.

---

## 4. 손으로 한 단계씩

시나리오는 primitive 의 조합일 뿐이다. 중간 상태를 들여다보고 싶으면 직접 친다.

```bash
I="npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts"

$I inject a1 --sample over          # 실패 결제 행 시딩. 발화 예상 여부를 되읽어 확인하고 어긋나면 멈춘다
$I status                            # 대장에 몇 행이 올라갔는가
$I poll 1                            # 1회차 평가 — 여기서 웹훅이 온다
$I poll 3                            # 억제창 확인 (추가 발송 없어야 함)
$I clear a1                          # 행 삭제 = 해소
$I poll 1                            # 복구 알림
$I cleanup                           # 대장 기반 원상 복구. 몇 번 돌려도 안전하다
```

### 축별 주입

```bash
$I inject a1 --sample over|under                 # 정기결제
$I inject a3 --name fax-status                   # 스케줄러 하트비트 (이름은 감시 목록에 있어야 한다)
$I inject a4 --redis --ok 20 --bad 20            # API 5xx — Redis 카운터 직접
$I inject a4 --base http://localhost:7200 --path /없는경로 --bad 20   # 실제 HTTP 로 500 유발
$I inject a5 --vendor octover --sample over      # 진료 벤더
```

`inject a5` 는 셋을 함께 만든다 — **규칙 ON** + **§D10 산출 서명** + **관측 행**. 서명이 없으면
규칙이 매 회차 throw 하기 때문이다(fail-closed).

> **`inject a5` 가 쓰는 서명은 배선 시험용이지 실측 임계가 아니다.** 실측 서명은
> `scripts/alert-threshold-derive.ts` 만 낸다. `cleanup` 이 규칙 ON 과 서명을 CAS 로 되돌리므로,
> **시험 뒤에 `cleanup` 을 거르면 벤더 규칙이 근거 없는 임계를 들고 켜진 채 남는다.**

### 시간 전진 (노이즈 상한 압축 측정)

```bash
$I poll 288 --advance 5m
```

시계를 미는 대신 저장된 시각을 폴링 주기만큼 **역행**시킨다. 비교식이 전부 `now - stored >= 임계`
형태라 판정상 동치다. `--advance` 는 `--sim-heartbeat` 를 함의한다 — 짝으로 묶이지 않으면 유예
만료와 함께 A3 가 떠서 하네스가 장애를 만든다.

### A6 (DB/Redis) — 이것만 손으로 해야 한다

인프라 축은 시딩 대상이 아니다. 실제로 끊는다:

```bash
# Redis 축
docker stop <redis>            # 또는 REDIS_URL 을 죽은 포트로
$I poll 1                      # → 🔴 Redis 연결 실패

# 헬스체크 축 — API 프로세스 밖에서 인지하는 경로
# .env 의 ALERT_HEALTH_URL 을 죽은 포트로 바꾸고 재기동 후 poll

# DB 전면 장애 — Redis last-known-good 폴백을 재는 전용 모드
# (DB 를 실제로 끊은 뒤에만 통과한다. 살아 있으면 거부된다)
$I poll 1 --expect-db-down
```

---

## 4-b. 발송 문구 템플릿

무엇이 오는지 알고 봐야 이상한 것이 보인다. 정본은 `apps/api/src/modules/alert/alert-noise.ts`
의 `groupFindings` 주석이고, 아래는 요약이다.

일반적인 알림 도구(Alertmanager · Grafana · Datadog)가 공유하는 3층 구조를 쓴다 —
**머리(무슨 일이 몇 건) → 그룹(같은 원인끼리) → 항목(사실 한 줄)**.

```
[dev] 🔴 [위험] 운영 알림 15건

■ 스케줄러 하트비트 (13건)
· 'alimtalk-status' 하트비트 없음 (기대 5분 · 유예 2배)
· 'fax-status' 하트비트 없음 (기대 1분 · 유예 2배)

■ API 5xx 급증 (1건)
· 🟠 [주의] API 요청 카운터 0건 (최근 5분) — 무트래픽이거나 카운터 미기록

■ 인프라 헬스 (Redis · API) (1건)
· API 헬스 조회 실패 — ECONNREFUSED
```

| 층 | 형식 | 규칙 |
|---|---|---|
| 라벨 | `[dev] ` | 환경이 첫 토큰. 운영/스테이징 오인 방지 |
| 머리 | `{마커} 운영 알림 {N}건` | 마커는 그 메시지의 **최고 심각도**. 시각은 안 넣는다 — 메신저가 이미 보여 준다 |
| 그룹 | `■ {규칙 표시명} ({K}건)` | 같은 규칙끼리 접는다. 스케줄러 13건이 13줄이 아니라 블록 1개 |
| 항목 | `· {사실}` | **마커를 반복하지 않는다.** 머리와 다른 심각도일 때만 줄에 남긴다 |

항목 1건이면 머리·블록 없이 `{마커} {사실}` 한 줄이다. 다이제스트는 `🟠 [요약] 누적 경고 N종`
머리에 `• 사실 (N회)` 항목이다.

**항목 문구 작성 규칙** (새 규칙을 추가할 때)

1. 식별자를 앞에 — `'fax-status' 하트비트 없음`. 블록 제목이 주어를 이미 말한다.
2. 사실 → 근거 순, 근거는 괄호 — `실패 10/10건 (최근 120분)`.
3. 명사 종결로 통일 — `…없음`, `…실패`. `…입니다` 는 쓰지 않는다.
4. 원인 후보는 `— ` 뒤에 — `API 헬스 조회 실패 — ECONNREFUSED`. **값이 없으면 절을 통째로 뺀다.**
   빈 괄호(`(-, )`)는 잘린 메시지처럼 보인다.
5. `text` 에 `🔴 [위험]` 을 박아도 되지만 **중복되지 않는다** — `formatFindingLine` 이 걷어내고
   `level` 기준으로 다시 붙인다. `level` 이 사실 소스다.
6. **`🔴 [위험]` · `🟠 [주의]` · `✅ [복구]` · `🟠 [요약]` 리터럴은 바꾸지 않는다.** 하네스
   `classifySeverity` 와 자기감시 화면이 이 토큰으로 분류한다. 바꾸면 발송은 정상인데 계측이
   전부 `unclassified` 로 떨어져 측정이 통째로 버려진다.

---

## 5. 관리자 화면 축

하네스가 못 재는 것이 여기 있다 — 사람이 화면/API 로 확인한다. 전부 `admin/alert` 하위이고
`admin` 역할 + `ALERT_ADMIN_IDS` 허용 목록이 필요하다(목록이 비면 fail-closed).

| 엔드포인트 | 확인할 것 | 지표 |
|---|---|---|
| `GET/POST/PATCH/DELETE /admin/alert/channels` | 채널 등록·삭제·종류 지정. URL 은 마스킹돼 돌아온다 | — |
| `GET/PATCH /admin/alert/config` | 임계·주기·억제창 변경이 **재배포 없이** 다음 주기에 반영되는가 | M11 |
| `GET /admin/alert/rules` | 규칙별 ON/OFF. `treat-vendor` 가 `false` 인가 | — |
| `GET /admin/alert/status` · `/monitor` | 타입별 마지막 점검·발송 시각, 최근 이력, 억제 중 경고, 채널별 실패율 | M3 마일스톤 |
| `POST /admin/alert/snooze` | 스누즈 걸고 해제 시 `🟠 [스누즈 중 경과]` 요약 1건 | — |
| `POST /admin/alert/test` | 수동 테스트 발송 | M10 |

**접근 통제(M12)** — 허용 목록 밖 `admin`, `sales`, `corp` 계정으로 위 경로에 붙어 403 을 확인한다.
이것은 계정이 필요해 하네스가 대신할 수 없다.

---

## 6. 이 flow 가 증명하지 못하는 것

숨기지 않는다. 아래는 로컬에서 재현되지 않으므로 **미측정**이다.

| 축 | 왜 |
|---|---|
| EventBridge 실주기 · ECS 태스크 순환 | 저장소 밖 인프라. `poll` 은 스케줄러가 아니라 평가만 부른다 |
| 스케줄러 15종 실제 실행 | 2.5 가 `APPROVED` 대신 `APPROVED_WITH_CONDITIONS` 를 낸 직접 원인 |
| CDN/WAF 경로 · 실환경 설정 | local 에 없다 |
| 진료 규칙(`treat-monitor`)의 실발화 동등성 | 실데이터가 있는 환경에서만 잰다. `a5` 는 **벤더 비교 축**이지 진료 경고 축이 아니다 |
| 벤더 임계의 타당성 | `a5` 서명은 배선 시험용이다. 실측 임계는 prod 표본이 쌓여야 나온다(PRD Q3, 열린 채) |
| Redis 키 TTL 만료 · 실시간 스케줄러 노후화 | `--advance` 의 시각 역행으로 재현되지 않는다 |
| 24시간 실경과 노이즈 | `quiet` 는 3회차 압축본이다. 288회차 등가는 `poll 288 --advance 5m` |

---

## 7. 자주 걸리는 것

| 증상 | 원인 · 조치 |
|---|---|
| `활성 대장이 있다 — 먼저 cleanup 을 돌려라` | 이전 주입이 회수되지 않았다. `cleanup` |
| `대장 잠금 획득 실패` | 다른 `alert-inject` 가 돌고 있다. 끝난 뒤 재시도 |
| `무효 — 회차가 평가되지 않았다 (skipped='lease-not-acquired')` | 강제 종료된 이전 실행이 `alert:lease` 를 물고 죽었다. TTL 15분을 기다리거나 키를 지운다. **이 회차의 "발송 0건"은 무장애의 증거가 아니다** |
| 두 번째 실행부터 발송 0건 | 억제창(critical 30분). `scenario` 는 자동 초기화하고, 손으로 돌 때는 `status` 의 `suppress` 를 보라 |
| `주입 상태가 기대와 다르다` | 판정 창 안의 기존 행이 모집단을 바꿨다. 메시지가 지목하는 `*_window_ms` 를 좁히거나 창 안의 행을 비운다 |
| `심은 N행이 판정 창에 하나도 잡히지 않는다 — 집계 0/0` | **시계 편차다.** 관측 시각은 DB 시계(`NOW()`), 창 상한은 앱 시계(`Date.now()`)에서 온다. dev DB 는 터널 너머 원격이라 두 시계가 어긋난다. `a5` 는 60초 뒤로 심어 이를 흡수하지만 편차가 그보다 크면 뚫린다. `SELECT NOW();` 와 로컬 시각을 비교하라 |
| `산출된 것이 없다 — treat-vendor_derivation 가 비어 있어…` | 규칙만 켜고 서명을 안 심었다. `inject a5` 를 쓰거나 `cleanup` 으로 되돌린다 |
| 시나리오마다 스케줄러 13건이 딸려 온다 | **모사를 안 켠 수동 `poll` 이다.** dev·local 은 EventBridge 가 없어 15종이 한 번도 안 돌고, A3 가 회차마다 상시 발화한다. `scenario` 는 자동으로 켜지만 손으로 칠 때는 `poll 1 --sim-heartbeat` 를 붙여라. 운영에서는 스케줄러가 `markSchedule()` 을 호출하므로 이 발화 자체가 진짜 신호다 |
| warn 이 안 온다 | 정상이다. warn 은 즉시 발송하지 않고 다이제스트로 묶인다(억제창 24시간) |
| `미분류 메시지가 있다` | 메시지 형식이 바뀌었는데 `SEVERITY_VOCAB` 이 안 따라갔다. 계측기가 계측 대상을 놓친 것이므로 **그 측정은 버린다** |

---

## 8. 끝내기

```bash
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts cleanup
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts status   # ledger.active=false 확인
```

`cleanup` 은 idempotent 하고 중간에 죽어도 이어서 끝낸다. KV 복원은 **CAS** 라, 그 사이 다른
기록자가 값을 바꿨으면 덮지 않고 **보류**로 보고한다:

```
[cleanup] KV 복원 3건, CAS 불일치로 보류 1건
[cleanup] 보류 키(외부 기록자가 그 사이 갱신함 — 덮지 않는다): alert:suppress
```

억제 상태가 보류로 남는 것은 정상이다(폴링이 다시 썼다). **규칙 ON 과 산출 서명이 보류로
남았다면 그것은 정상이 아니다** — 값을 직접 확인하고 되돌려라:

```sql
SELECT als_key, als_value FROM tn_alert_setting
 WHERE als_key IN ('alert_rule_enabled:treat-vendor', 'treat-vendor_derivation');
```
