- 준비: Lab 01의 `my-work/intake.json` (없으면 `cp reference/lab01-example2-intake.json my-work/intake.json` 후 push. 그 파일은 Lab 01 예제 2까지)
- 목표: 다시 하면 될 실패는 **잃지 않게 모아 두고**(DLQ), 예상하지 못한 오류는 **n8n이 대신 알리게**(Error Workflow) 한다. 어느 실패가 어느 그물에 걸리는지 보고 그물 사이의 구멍을 막고, 처리한 실패도 사람에게 알리되 같은 알림이 쏟아지지 않게 한다.
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py ops --reset`.
## 예제 1 · 실패한 요청은 DLQ에 (12분)
### 이 실습의 의도
**한 줄로:** 데이터 API가 멈춘 동안 들어온 문의를 **잃지 않게 따로 모아 두는 곳**(DLQ)을 만듭니다.
> **생활 속 예 · 택배 보관소.** 집에 아무도 없으면 기사는 상자를 버리지 않고 보관소에 상자 그대로 · 못 전한 까닭과 함께 둡니다. 지금 `my-intake`는 "배달 실패"라고 말만 하고 상자를 길에 두고 가는 기사와 같습니다. 예제 1은 보관소(DLQ)를 만드는 실습입니다.
**상황.** Lab 01까지 고친 `my-intake`는 API가 멈추면 그 문의를 `failed`로 표시하고 200으로 답합니다. 정직한 답이지만, 그 답을 받은 화면이 닫히면 끝입니다. 문의 내용은 데이터 API에도, n8n에도, 어디에도 남지 않습니다. 원인을 고친 뒤 "아까 실패한 것 다시 해 줘"라고 할 대상이 없습니다.
uv run tools/flow.py test cases/lab02.json -v # 고치기 전
```
API가 멈춘 동안 들어온 두 문의는 결과에 `failed`로만 남았습니다. 사용자 화면을 닫으면 사라집니다.
**만들 것**: 실패한(`failed`) 요청을 **원래 요청 그대로** 데이터 API의 DLQ(`POST /dlq`)에 넣기.
| 조건 | 값 |
| --- | --- |
| 넣는 것 | 본문 `{items: [...]}`, 실패한 항목마다 `{item: 원래 요청 {id, user, text}, reason, workflow: "intake"}` |
| 거절(`rejected`)은 | 넣지 않는다 (다시 해도 같으니까) |
| 실패가 없으면 | DLQ를 부르지 않는다 |
| 기대 결과 | `cases/lab02.json` 앞 두 케이스 |
### 이해하기
**DLQ(dead letter queue)는 "배달하지 못한 편지 보관함"입니다.** 우체국은 주소가 틀린 편지를 버리지 않고 따로 모아 둡니다. 메시지 시스템(Amazon SQS · RabbitMQ · Kafka)도 처리하지 못한 메시지를 버리지 않고 DLQ로 옮깁니다. 나중에 원인을 고친 사람이 **같은 메시지를 다시** 처리할 수 있게요.
```mermaid
flowchart LR
subgraph 지금
S1["Summarize<br/>failed 2"] --> O1(["응답 · 그리고 끝"])
- **응답은 정직합니다.** 200 · `failed: 2`, 항목마다 이유(`tickets service is failing`)까지 있습니다. Lab 01에서 이미 이렇게 고쳤습니다. 그래서 "실패로 표시" 케이스는 ✔입니다.
- **그런데 `ops`에는 아무것도 없습니다.** 접수 건 0, DLQ 0, 알림 0. 결과를 받은 화면(사용자의 브라우저, 부른 프로그램)이 닫히면 R-301 · R-302 두 문의는 **어디에도 남지 않습니다.** 고장 스위치를 `ok`로 되돌려도 다시 보낼 것이 없습니다.
- **DLQ 케이스 ✘ `size = 0`:** DLQ에 넣는 갈래가 아직 없으니 당연합니다.
- **Error Workflow 케이스 ✘:** 예제 2에서 고칠 것입니다. 지금은 신경 쓰지 않습니다.
| DLQ | 0 | 2 (D-1 · D-2) | `failed` 항목을 DLQ에 넣는 갈래 |
| 다시 돌릴 수 있나 | 없음 | 원래 요청 그대로 있음 | DLQ에 요청 전체를 저장 |
- 응답은 똑같이 `failed: 2`입니다. 달라진 것은 **실패가 남았느냐**입니다. DLQ 한 줄마다 `R-301`(원래 요청 id), `attempts 1`(DLQ에 들어간 횟수), `parked False`(아직 사람이 볼 것으로 멈추지 않음), 실패 이유가 있습니다.
- 원래 요청이 그대로 있으니, 원인(데이터 API 장애)을 고친 뒤 **같은 요청을 다시 돌릴 수 있습니다.** 그것이 Lab 03의 재처리입니다. 다시 돌려도 키가 요청 id라 중복은 생기지 않습니다.
- 남은 ✘ 하나는 `nightly-report`의 오류를 아무도 알리지 않아서입니다. 예제 2에서 고칩니다.
**한 줄로:** 내가 **예상하지 못한** 오류로 워크플로우가 멈췄을 때, n8n이 대신 알림을 남기게 합니다.
> **생활 속 예 · 자동차 경고등.** 운전자는 모든 고장을 미리 알 수 없지만, 무슨 일이 생기면 계기판에 경고등이 켜져 정비소에 가게 됩니다. 경고등이 고장을 고치지는 않습니다. 예제 2는 `nightly-report`의 버그(고장)를 고치지 않고, 버그가 나면 켜지는 경고등(Error Workflow → 알림)을 다는 실습입니다.
**상황.** 예제 1의 DLQ는 "API가 멈출 수 있다"를 **예상하고** 미리 길을 만들어 둔 것입니다. 하지만 버그, 모르는 응답 모양, 누가 바꾼 설정 같은 오류는 미리 길을 만들 수 없습니다. 그런 오류로 워크플로우가 멈추면 부른 쪽에 500 하나가 갈 뿐, **운영하는 사람은 아무도 모릅니다.**
**연습 대상.**`nightly-report`는 일부러 버그를 넣어 둔 제공 워크플로우입니다. 버그는 **고치지 않습니다.** 오늘의 목표는 버그를 고치는 것이 아니라 "버그가 나면 누가 아느냐"를 바꾸는 것입니다.
| 순서 | 명령 | 볼 것 |
| --- | --- | --- |
| 1 | `call nightly-report` | 500 |
| 2 | `ops` | `alerts: 0` — 아무도 모름 |
| 3 | 에러 처리기를 만들어 push → `test cases/lab02.json` | 3초 뒤 critical 알림: 어느 워크플로우 · 어느 노드 · 무슨 오류 |
**n8n이 해 주는 것과 내가 할 것.** 워크플로우 설정에 `errorWorkflow`(처리기 id)가 있으면, 그 워크플로우가 멈출 때 n8n이 처리기를 부릅니다. 나는 처리기(Error Trigger로 시작 → 알림 남기기)를 만들어 **정해진 id로 게시**하고, 내 `my-intake`에도 같은 설정을 넣습니다.
**끝나면 알게 되는 것.** "처리한 실패"(DLQ)와 "처리하지 못한 실패"(Error Workflow)는 받는 곳이 다르고 둘 다 있어야 한다는 것, 알림 한 줄에 무엇이 있어야 고칠 곳을 찾을 수 있는지.
이제 확인합니다. `nightly-report`는 일부러 버그가 있는 워크플로우입니다 (고치지 않습니다).
```bash
uv run tools/flow.py call nightly-report # HTTP 500 — 누가 알까?
uv run tools/flow.py ops # alerts: 0
```
**만들 것**: 처리하지 못한 오류로 워크플로우가 멈추면 n8n이 부르는 **에러 처리기**. 알림을 데이터 API에 남긴다. 그리고 내 접수 워크플로우도 이 처리기를 쓰게.
| 조건 | 값 |
| --- | --- |
| 파일 | `my-work/error-handler.json`, 맨 위에 `"id": "errHandler76001"` ([AGENTS.md](AGENTS.md) 3 · 4절) |
| 내 접수 워크플로우 | `settings.errorWorkflow: "errHandler76001"` |
| 기대 결과 | `cases/lab02.json` 전체, `lab01`도 그대로 |
### 이해하기
**처리한 실패와 처리하지 못한 실패.** 예제 1의 DLQ는 내가 **예상한** 실패(API가 멈춤)를 그 자리에서 처리한 것입니다. 하지만 모든 실패를 예상할 수는 없습니다. 버그, 모르는 응답 모양, 누가 바꾼 설정. 그런 오류로 워크플로우가 멈추면, 응답 500 하나만 남고 아무도 모릅니다.
| | 처리한 실패 | 처리하지 못한 실패 |
| --- | --- | --- |
| 예 | API가 멈춤 (예상함) | `nightly-report`의 버그 |
| 워크플로우 | 계속, 200 | **멈춤**, 500 |
| 받는 곳 | DLQ + 알림 (내가 만듦, 과제 1) | **Error Workflow** (n8n이 부름) |
**Error Workflow는 마지막 그물입니다.** n8n은 워크플로우 설정에 `errorWorkflow`가 있으면, 그 워크플로우가 처리하지 못한 오류로 멈출 때 지정한 워크플로우를 부릅니다. 받는 쪽은 **Error Trigger** 노드로 시작하고, 어느 워크플로우 · 어느 실행 · 어느 노드 · 무슨 오류인지를 받습니다.
```mermaid
flowchart LR
W["nightly-report<br/>settings.errorWorkflow"] -->|처리 못 한 오류로 멈춤| E["On error<br/>(Error Trigger)"] --> C["Make alert"] --> A[("POST /alerts<br/>critical · unexpected")]
W -.->|응답 500| U(["부른 사람"])
```
| Error Trigger가 넘기는 것 | 알림에 |
| --- | --- |
| `workflow.name` · `workflow.id` | 어느 워크플로우 |
| `execution.lastNodeExecuted` | 어느 노드 |
| `execution.error.message` | 무슨 오류 |
| `execution.id` | n8n 화면 Executions에서 그 실행을 연다 |
**처리기의 id는 고정합니다.** 다른 워크플로우가 처리기를 **id로** 부르므로, 파일 맨 위에 `"id": "errHandler76001"`을 씁니다 (70일차 모듈과 같음). 처리기도 **게시(push)**해야 합니다. 게시를 내린 처리기는 n8n이 부르지 않습니다 (수업 준비 때 확인).
**알림은 늦게 생깁니다.** Error Workflow는 웹훅 응답(500)이 나간 **뒤에** 돕니다. 테스트는 3초를 기다린 뒤(`{"sleep": 3}`) 알림을 봅니다.
**테스트 케이스 (예제 2 몫).**
| 케이스 | 하는 것 | 기대 |
| --- | --- | --- |
| 예상 못 한 오류 | `nightly-report` | 500 |
| 알림 | 3초 뒤 `/alerts/latest` | `severity: critical, reason: unexpected`, 워크플로우 이름, 노드 `Count by category` |
**기대 결과.**`lab02` 4/4. `ops`에 critical 알림 하나.
<details><summary>▶ 1단계 · 둘 다 있어야 하는 이유</summary>
DLQ만 있으면? 예상 못 한 버그는 아무도 모릅니다. Error Workflow만 있으면? API가 멈출 때마다 워크플로우가 멈춰야 알림이 오고, 멈추면 그 실행의 다른 항목도 함께 잃습니다.
</details>
<details><summary>▶ 2단계 · 알림에 무엇을</summary>
받는 사람이 "어느 워크플로우의 어느 노드에서 무슨 오류"를 바로 알아야 합니다. 실행 번호가 있으면 n8n 화면 Executions에서 그 실행을 엽니다. Error Trigger가 넘기는 값은 AGENTS.md 5절.
-`nightly-report`는 **여전히 500**입니다. 버그를 고친 것이 아니라 **누가 아느냐**를 바꿨습니다.
-`… before: wait 3s`: 알림은 응답이 나간 **뒤에** 생깁니다. n8n이 워크플로우가 멈춘 것을 보고 에러 처리기를 따로 부르기 때문입니다. 그래서 테스트가 3초 기다린 뒤 확인했습니다.
- 알림 한 줄에 **어느 워크플로우**(`76 Nightly report`) · **어떤 종류**(`unexpected`, 예상 못 한 오류) · **어느 노드**(`Count by category`) · **무슨 오류**(`rows is not iterable [line 4]`)가 다 있습니다. 응답의 `tickets`를 `items`로 잘못 읽어 `rows`가 비어 있었던 것입니다. 알림만 보고 고칠 곳을 찾을 수 있습니다.
-`nightly-report`의 설정에는 처음부터 `errorWorkflow: errHandler76001`이 있었습니다. 그래서 처리기를 그 id로 **게시하는 것만으로** 알림이 시작되었습니다.
- 내 `my-intake`도 같은 처리기를 씁니다. 하지만 오늘의 실패를 모두 스스로 처리하고 200으로 끝나므로 처리기가 불리지 않습니다. `lab01` 6/6도 그대로입니다.
**한 줄로:** 지금까지 만든 그물 셋이 각각 어떤 실패를 잡는지 확인하고, **그물 사이로 빠지는 구멍**을 찾습니다. 고치지 않고(에이전트 없이) 재기만 합니다.
> **생활 속 예 · 건전지가 다 된 화재경보기.** 경보기가 있어도 건전지가 다 되면 불이 나도 조용합니다. 예제 3은 그물(거절 · 보관소 · 경고등)을 하나씩 시험하다가, 보관소(DLQ) 문까지 잠겼을 때 아무 소리도 나지 않는 것을 직접 확인하는 실습입니다.
**지금 있는 그물 셋.**
| 그물 | 언제 만들었나 | 잡는 것 |
| --- | --- | --- |
| 결과의 `rejected` | Lab 01 예제 2 · 과제 2 | 잘못된 항목 |
| DLQ | 예제 1 | API가 멈춰 처리 못 한 항목 |
| Error Workflow | 예제 2 | 워크플로우를 멈추게 한 예상 못 한 오류 |
**실패 넷을 일으킵니다.** 빈 글 · API 오류 · 버그, 그리고 마지막으로 **API와 DLQ가 함께 멈춘** 경우. 앞의 셋은 각자 그물에 걸립니다. 네 번째는 어디에 걸릴까요? DLQ에 넣지 못했고, 워크플로우는 멈추지 않았으니(`neverError`) Error Workflow도 불리지 않습니다.
**`lab02-nets.json`의 6 · 7번은 지금 ✘가 맞습니다.** 이 예제는 구멍을 **보여 주는** 것이고, 막는 것은 과제 1입니다. 5/7이 기대 결과입니다.
**끝나면 알게 되는 것.** 실패를 처리하는 장치(DLQ)도 실패할 수 있고, 그때 요청이 소리 없이 사라진다는 것 ("누가 감시자를 감시하나"). 그물 지도를 표로 그려 두면 구멍이 보인다는 것.
**적을 곳.**`my-work/RUNBOOK.md` 1.3 그물 지도 (실패 · 어떻게 알아채나 · 자동으로 하는 일 · 사람이 할 일).
```bash
uv run tools/flow.py test cases/lab02-nets.json -v
uv run tools/flow.py ops
```
### 이해하기
**그물 지도.**
| 실패 | 걸리는 그물 | 사람이 아는 방법 |
| --- | --- | --- |
| 잘못된 항목 (빈 글) | 결과의 `rejected` + 이유 | 부른 쪽이 결과를 보고 고쳐 보냄 |
**구멍은 그물 사이에 있습니다.** 예제 1에서 DLQ 저장이 실패해도 응답은 나가게 `neverError`를 두었습니다. 그래서 DLQ가 멈추면 워크플로우는 **멈추지 않고(→ Error Workflow도 안 불림)**, DLQ에도 없고, 결과는 `failed`만 말합니다. 실패를 처리하는 장치 자체가 실패할 때를 생각해야 합니다. 이것을 "누가 감시자를 감시하나"라고 부르기도 합니다.
**테스트 케이스.** 지금 워크플로우를 바꾸지 않고 재기만 합니다. 6 · 7번은 **지금은 ✘가 맞습니다** (과제 1에서 고침).
| 6 · 7 | 데이터 API **와** DLQ 모두 오류 (`fault dlq error`) | **없음** | **없음** |
- 1~5: 그물 셋이 각자 맡은 실패를 잡았습니다. `ops`의 접수 건 2개(R-201 · R-203)는 1번 요청의 정상 항목, DLQ 2건은 2번, 알림은 4번의 것입니다.
- **6 · 7이 오늘의 구멍입니다.** 6번에서 `batch-ok`의 세 문의(R-101~103)는 접수 건 만들기도 DLQ 저장도 500으로 실패했습니다. 응답은 200 · `failed: 3`이지만, DLQ는 **2건 그대로**입니다. DLQ 저장 노드가 실패해도 멈추지 않게(`neverError`) 해 두었기 때문에 워크플로우는 아무 일 없다는 듯 끝났고, 결과에도 "DLQ에 못 넣었다"(`dlq_saved`)는 말이 없습니다.
- 7번의 `reason = unexpected`: 마지막 알림이 여전히 5번의 버그 알림이라는 뜻입니다. DLQ가 멈췄다는 알림(`dlq_down`)은 없습니다. **세 문의를 잃었는데 아무도 모릅니다.**
✔ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s
Passed 7/7
```
**고친 뒤 이렇게 나온 까닭:** 과제 1에서 DLQ 저장 결과를 확인해 결과에 `dlq_saved: false`를 넣고, 저장에 실패하면 `dlq_down` critical 알림에 잃어버린 id를 적게 했습니다. 그래서 6 · 7번이 ✔가 되었습니다. 1~5는 그대로입니다. 자세한 것은 과제 1의 실행 결과에 있습니다.
| 결과에 | `dlq_saved` (실패가 있을 때 true/false), `alert` (`sent` · `none`) |
| 기대 결과 | `cases/lab02-challenge.json`, `lab02-nets` 7/7, `lab02` 그대로 |
### 이해하기
**알림 등급.** 모든 알림이 같은 무게면 받는 사람은 무엇부터 볼지 모릅니다.
| 등급 | 오늘 | 받으면 |
| --- | --- | --- |
| `critical` | 예상 못 한 오류(버그), **DLQ가 멈춤 (요청을 잃을 수 있음)** | 바로 본다 |
| `high` | DLQ에 쌓임, parked (Lab 03) | 원인(데이터 API 등)을 확인하고 재처리 |
| (알림 없음) | 거절, 미분류 | 결과 · 보고서로 셈 |
**요청 하나에 알림 하나.** 실패한 항목마다 알림을 보내면 50건 실패에 알림 50개입니다. 요청 하나에 알림 하나, 개수(`count`)를 담습니다.
**구멍 막기.** DLQ의 응답에 `stored`가 있으면 저장된 것, 없으면(500의 `error`) 저장되지 않은 것입니다. 저장되지 않았다면 **잃을 뻔한 요청의 id를 알림에 직접 적습니다.** 그러면 DLQ가 멈춰도 사람이 알림만 보고 그 요청들을 살릴 수 있습니다. 결과의 `dlq_saved: false`는 부른 쪽에게도 "이건 아직 아무 데도 없다"고 알려 줍니다.
```mermaid
flowchart LR
F{"failed > 0?"} -->|아니오| N(["alert: none"])
F -->|예| D[("POST /dlq")] --> S{"stored?"}
S -->|예| H["high · dlq · count"] --> A[("POST /alerts")]
S -->|아니오| C["critical · dlq_down<br/>잃을 뻔한 id들"] --> A
- **첫 줄 ✘:** DLQ에는 실제로 넣었습니다. 하지만 결과에 `dlq_saved` · `alert` 칸이 없어 `null`입니다. 보낸 쪽은 실패한 것이 안전하게 보관됐는지 알 수 없습니다.
- **처리한 실패 알림 ✘:** 예제 2의 Error Workflow는 **워크플로우가 멈춘 실패**만 알립니다. `my-intake`는 실패를 스스로 처리하고 200으로 끝나니 처리기가 불리지 않습니다. 그래서 DLQ에 2건이 쌓여도 알림은 0개입니다. DLQ를 누가 들여다보지 않으면 아무도 모릅니다.
- **실패가 없으면 ✘ · 알림 1개 ✘:** 결과에 `alert: "none"`이 없고, 앞 케이스에서 알림이 0개라 "그대로 1개"도 맞지 않습니다.
- **DLQ도 멈추면 ✘ · 잃어버린 id ✘:** 예제 3의 구멍 그대로입니다.
- 0/6은 **예상한 결과**입니다. 결과와 알림의 새 칸을 아직 만들지 않았으니 모두 ✘입니다.
#### 고친 뒤
실행한 명령:
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab02-challenge.json -v
- **실패 2건 → 알림 하나:** 실패마다 하나씩이 아니라 요청 하나에 하나, 개수(`count 2`)를 담았습니다. 알림을 받은 사람은 "DLQ에 2건이 기다린다"를 압니다. 이 알림은 high입니다. 문의는 DLQ에 안전하게 있고, 원인을 고친 뒤 재처리하면 되기 때문입니다.
- **실패가 없으면 알림 없음:** 정상 요청은 알림을 만들지 않습니다. 알림이 "무슨 일이 있다"는 뜻을 잃지 않게 하는 규칙입니다.
- **DLQ도 멈추면 critical:** 이번에는 문의가 DLQ에도 없습니다. 그래서 등급이 critical이고, 메시지에 **잃어버린 id**(R-201 · R-203)를 적었습니다. 사람이 이 알림만 보고 보낸 쪽에 연락하거나 손으로 다시 넣을 수 있습니다.
**한 줄로:** 같은 원인의 `high` 알림은 **10분에 한 번만** 보내, 사람이 알림을 꺼 버리지 않게 합니다. 급한 것(`critical`)과 실패의 기록(DLQ)은 줄이지 않습니다.
> **생활 속 예 · 알림을 꺼 버린 단톡방.** 같은 공지가 백 번 오면 사람들은 단톡방 알림을 끄고, 정말 중요한 공지도 못 봅니다. 그래도 대화 기록은 남아 있습니다. 과제 2는 같은 원인의 알림은 10분에 한 번만 보내되(알림 줄이기), DLQ(대화 기록)는 그대로 두고, 정말 급한 것(critical)은 늘 보내는 실습입니다.
**상황.** 과제 1로 "DLQ에 넣으면 알림"이 생겼습니다. 그런데 API가 30분 멈추면, 그동안 들어온 요청마다 알림이 하나씩 갑니다. 같은 원인을 백 번 알리는 셈이고, 받는 사람은 결국 알림을 끕니다. 그러면 정말 급한 알림도 못 봅니다 (알림 피로).
| 줄이는 것 | 줄이지 않는 것 |
| --- | --- |
| 10분 안에 같은 `reason`의 `high` 알림이 이미 있으면 보내지 않음 (`alert: suppressed`) | DLQ에는 모두 남음 |
| | `critical`은 늘 보냄 (요청을 잃을 수 있으니) |
**어떻게 아나.** 알림을 보내기 **바로 전에** 데이터 API에 "10분 안에 같은 알림이 있었나"를 묻습니다 (`/alerts/list`의 `within`).
**시험하는 것.** 첫 실패 → 보냄, 10분 안에 또 실패 → 보내지 않음, 그래도 DLQ에는 모두, DLQ가 멈춘 critical은 그래도 보냄.
**끝나면 알게 되는 것.** 알림이 많은 것이 안전한 것은 아니라는 것, 운영 도구의 묶기 · 반복 간격 · 중복 없애기가 무엇을 하는지 (그 가장 작은 판), 줄이는 것은 알림이지 기록이 아니라는 것.
> "어제 API가 30분 멈췄는데, 그동안 요청마다 알림이 와서 휴대폰이 백 번 울렸어요. 그다음부터는 다들 알림을 꺼 버렸고요. 같은 문제면 10분에 한 번만 알려 주세요. 단, 정말 급한 건(critical)은 늘 보내 주세요."
```bash
uv run tools/flow.py test cases/lab02-challenge2.json -v # 고치기 전
```
| 조건 | 값 |
| --- | --- |
| 같은 알림 | `workflow: intake`이고 같은 `reason`의 알림이 10분(600초) 안에 이미 있으면 |
| 그때 high는 | 보내지 않고 결과에 `alert: "suppressed"` |
| critical은 | 늘 보냄 |
| 10분 안의 알림 수 | `POST /alerts/list` 본문 `{workflow, reason, within: 600}` (AGENTS.md 2절) |
| 기대 결과 | `cases/lab02-challenge2.json`, `lab02-challenge` 그대로 |
### 이해하기
**알림 피로 (alert fatigue).** 알림이 너무 많으면 사람은 알림을 보지 않게 됩니다. 그러면 정말 급한 알림도 묻힙니다. 알림이 많다는 것은 안전하다는 뜻이 아니라, **아무도 보지 않는다**는 뜻이 되기 쉽습니다.
**같은 문제는 한 번, 대신 숨기지 않기.**
| 하는 것 | 하지 않는 것 |
| --- | --- |
| 같은 문제의 high 알림은 10분에 한 번 | 실패를 DLQ에서 빼기 (DLQ에는 모두 남음) |
| 결과에 `alert: suppressed` (보내지 않은 것도 기록) | critical 줄이기 (급한 것은 늘 보냄) |
운영 도구들(Prometheus Alertmanager · PagerDuty 등)은 이것을 묶기(grouping) · 반복 간격(repeat interval) · 중복 없애기(deduplication)라고 부릅니다. 오늘은 그 가장 작은 판입니다: "**같은 이유의 알림이 10분 안에 있었나?**"