Files

918 lines
52 KiB
Markdown
Raw Permalink Normal View History

# Lab 02 · 실패를 모으고(DLQ), 알리기 (Error Workflow)
- 관련 강의: [2단계 · 모으고 알리기](../../Lecture/76/02-STAGE2-COLLECT-AND-ALERT.md)
- 소요 시간: 50분 (4교시 한 시간) · 예제 1 12분 · 예제 2 12분 · 예제 3 6분 · 과제 1 10분 · 과제 2 10분
- 준비: Lab 01의 `my-work/intake.json` (없으면 `cp reference/lab01-example2-intake.json my-work/intake.json` 후 push. 그 파일은 Lab 01 예제 2까지)
- 목표: 다시 하면 될 실패는 **잃지 않게 모아 두고**(DLQ), 예상하지 못한 오류는 **n8n이 대신 알리게**(Error Workflow) 한다. 어느 실패가 어느 그물에 걸리는지 보고 그물 사이의 구멍을 막고, 처리한 실패도 사람에게 알리되 같은 알림이 쏟아지지 않게 한다.
```mermaid
flowchart LR
E1["예제 1<br/>실패한 요청은 DLQ"] --> E2["예제 2<br/>Error Workflow"] --> E3["예제 3<br/>어느 실패가<br/>어느 그물에"] --> T1["과제 1<br/>처리한 실패도 알림<br/>DLQ가 멈추면?"] --> T2["과제 2<br/>알림 폭주 막기"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `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에도, 어디에도 남지 않습니다. 원인을 고친 뒤 "아까 실패한 것 다시 해 줘"라고 할 대상이 없습니다.
**먼저 볼 것 → 고칠 것.**
| 순서 | 명령 | 볼 것 |
| --- | --- | --- |
| 1 | `fault tickets error` → `call my-intake batch-two` | 답은 `failed: 2`. 여기까지는 Lab 01과 같음 |
| 2 | `fault tickets ok` → `ops` | **DLQ가 0** — 실패한 두 문의가 어디에도 없음 |
| 3 | `test cases/lab02.json` (고치기 전) | DLQ 케이스가 ✘ |
| 4 | 요청문 → push → 다시 test | DLQ에 R-301 · R-302가 원래 요청 그대로 |
**무엇을 넣고 무엇을 안 넣나.** 다시 하면 될 실패(`failed`)만, **처음 받은 요청 그대로** 넣습니다. 그래야 Lab 03에서 같은 요청을 `my-intake`에 다시 보낼 수 있습니다. 거절(`rejected`)은 다시 해도 같으니 넣지 않습니다.
**끝나면 알게 되는 것.** 실패를 "알리는 것"과 "잃지 않는 것"은 다르다는 것, DLQ에 무엇을 담아야 나중에 다시 돌릴 수 있는지. 이 DLQ는 Lab 03의 재처리가 읽어 갑니다.
```bash
uv run tools/flow.py ops --reset
uv run tools/flow.py fault tickets error
uv run tools/flow.py call my-intake requests/batch-two.json # failed 2 — 그다음은?
uv run tools/flow.py fault tickets ok
uv run tools/flow.py ops # DLQ는?
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(["응답 · 그리고 끝"])
end
subgraph 바꾼 뒤
S2["Summarize"] --> F2{"failed > 0?"}
F2 -->|예| D2[("POST /dlq<br/>원래 요청 · 이유")] --> O2(["응답"])
F2 -->|아니오| O2
end
```
**무엇을 넣나.**
| 넣는 것 | 까닭 |
| --- | --- |
| **원래 요청 그대로** (`id` · `user` · `text`) | 나중에 같은 요청으로 다시 돌림 (Lab 03). 분류 결과 같은 중간 값이 아니라 처음 받은 것 |
| 이유 (`reason`) | 사람이 원인을 압니다 (`500 - tickets service is failing`, `timeout`) |
| 어느 워크플로우 (`workflow`) | 여러 워크플로우가 같은 DLQ를 쓸 때 |
**거절은 넣지 않습니다.** 빈 글은 다시 해도 빈 글입니다. DLQ는 "**나중에 다시 하면 될 것**"만 모읍니다. 거절까지 넣으면 재처리할 때마다 같은 실패가 되풀이되고, 정말 살려야 할 것이 묻힙니다.
**같은 요청이 또 오면.** 오늘 데이터 API는 같은 `item.id`가 다시 들어오면 새 줄을 만들지 않고 `attempts`를 하나 올립니다. 재처리(Lab 03)에서 또 실패할 때 쓰입니다.
**테스트 케이스 (예제 1 몫).**
| 케이스 | 하는 것 | 기대 |
| --- | --- | --- |
| API가 멈추면 | `tickets error` · `batch-two` | 200 · `created: 0, failed: 2` |
| DLQ 확인 | `/dlq/list` | `size: 2`, `R-301` · `R-302` |
**기대 결과.** 고치기 전 `lab02` 2/4, 예제 1 뒤 3/4 (남은 ✘는 예제 2의 알림).
<details><summary>▶ 1단계 · 왜 원래 요청 그대로</summary>
나중에 다시 돌릴 때(3단계) 같은 요청을 그대로 `my-intake`에 보내야 합니다. Lab 01의 결과에서 실패한 항목은 `request`(원래 요청)를 가지고 있습니다. 그것을 모아 DLQ 본문으로.
</details>
<details><summary>▶ 2단계 · DLQ에 넣다가 실패하면</summary>
DLQ 저장마저 실패하면? 최소한 사용자 응답은 나가야 합니다. 어떤 설정을 둘까요? (그다음은 예제 3에서 직접 봅니다)
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 언제 DLQ로 가나 ② DLQ 주소와 본문 모양 ③ 넣지 않는 것 ④ DLQ가 실패해도 응답 ⑤ 기대 결과**. 빈칸 4개를 채웁니다.
```text
my-work/intake.json 의 Summarize 뒤에 Any failed?(If) 를 넣어 failed 가 1 이상일 때만 Send to DLQ(HTTP POST http://data-api:8800/ ____① ) 로.
본문은 {items: [...]}, 실패한 항목마다 { item: ____② {id, user, text}, reason, workflow: "intake" }. rejected 는 ____③ .
DLQ 저장이 실패해도 응답은 나가게 options.response 에 ____④ , 두 갈래 모두 Public result 로 가서 같은 결과를 응답.
기대 결과 cases/lab02.json 앞 두 케이스.
```
빈칸 힌트:
- ① AGENTS.md 2절 데이터 API 표에서 실패한 요청을 넣는 주소입니다.
- ② 나중에 다시 돌릴 때 그대로 보낼 것입니다 (▶ 1단계).
- ③ 다시 해도 같은 실패입니다.
- ④ AGENTS.md 5절 "DLQ · 알림" 노드의 응답 설정입니다. HTTP 오류를 노드 오류로 만들지 않는 값.
</details>
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py ops
```
### 실행 결과 · 실패한 요청은 DLQ에
수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다.
#### 고치기 전 · Lab 01 과제 2까지 고친 `my-intake`
실행한 명령:
```bash
uv run tools/flow.py ops --reset
uv run tools/flow.py fault tickets error
uv run tools/flow.py call my-intake requests/batch-two.json
uv run tools/flow.py fault tickets ok
uv run tools/flow.py ops
uv run tools/flow.py test cases/lab02.json -v
```
출력 (줄임):
```text
HTTP 200 · 2.8s
{ "total": 2, "created": 0, "failed": 2, "rejected": 0, "duplicates": 0, "unclassified": 0,
"results": [ { "id": "R-301", "status": "failed", "reason": "500 - … tickets service is failing (fault switch)" },
{ "id": "R-302", "status": "failed", "reason": "500 - … tickets service is failing (fault switch)" } ] }
fault switch: all ok
created tickets: 0
DLQ: 0 (parked 0)
alerts: 0
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.8s
✘ 예제 1 · 실패한 2건은 DLQ에 · 0.0s · size = 0 (expected 2); items has none of ['R-301', 'R-302']
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s
✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); ...
Passed 2/4
```
**왜 이렇게 나왔나 (고치기 전)**
- **응답은 정직합니다.** 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에서 고칠 것입니다. 지금은 신경 쓰지 않습니다.
- 2/4는 **예상한 결과**입니다.
#### 고친 뒤
실행한 명령:
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py ops
```
출력 (결과 줄과 `ops`):
```text
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.1s
✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s
✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); ...
Passed 3/4
DLQ: 2 (parked 0)
D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
```
**고친 뒤 이렇게 나온 까닭**
| 볼 것 | 고치기 전 | 고친 뒤 | 무엇이 바꿨나 |
| --- | --- | --- | --- |
| 응답 | `failed: 2` | `failed: 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에서 고칩니다.
## 예제 2 · Error Workflow (12분)
### 이 실습의 의도
**한 줄로:** 내가 **예상하지 못한** 오류로 워크플로우가 멈췄을 때, 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절) |
| 알림 | `POST /alerts` `{workflow, workflow_id, node, message, execution_id, severity: "critical", reason: "unexpected"}` |
| 내 접수 워크플로우 | `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절.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 파일과 고정 id ② 시작 노드 ③ 알림에 담을 값과 등급 ④ 부르는 쪽 설정 ⑤ 기대 결과**. 빈칸 4개를 채웁니다.
```text
my-work/error-handler.json 을 새로 만들어 줘. 파일 맨 위에 "id": "____①", 첫 노드는 ____② .
Make alert(Code) 가 workflow.name · workflow.id · execution.lastNodeExecuted · execution.error.message · execution.id 를
{workflow, workflow_id, node, message, execution_id, severity: "____③", reason: "unexpected"} 로 만들어 POST http://data-api:8800/alerts.
my-work/intake.json 의 settings 에 ____④ : "errHandler76001" 을 넣어 줘.
기대 결과 cases/lab02.json 전체.
```
빈칸 힌트:
- ① 위 조건 표의 파일 줄입니다. `nightly-report`의 설정에 이미 적혀 있는 값과 같아야 합니다.
- ② AGENTS.md 5절 "Error Workflow"의 노드 종류(`type`의 끝 부분)입니다.
- ③ 처리하지 못한 오류의 등급입니다. 강의 2단계 알림 등급 표.
- ④ AGENTS.md 4절 워크플로우 파일 규칙의 `settings` 안 이름입니다.
</details>
```bash
uv run tools/flow.py push my-work/error-handler.json my-work/intake.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py ops
uv run tools/flow.py test cases/lab01.json
```
### 실행 결과 · Error Workflow
수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다.
#### 고치기 전 · 에러 처리기 없음
실행한 명령:
```bash
uv run tools/flow.py call nightly-report
uv run tools/flow.py test cases/lab02.json -v # 예제 1을 고친 뒤의 결과
```
출력 (줄임):
```text
HTTP 500 · 0.1s
{ "message": "Error in workflow" }
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s
✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); ...
Passed 3/4
```
**왜 이렇게 나왔나 (고치기 전)**
- `nightly-report`에는 일부러 넣은 버그가 있어 0.1초 만에 500으로 끝납니다. 이것은 DLQ로 받을 수 없는 실패입니다. 항목 하나가 아니라 **워크플로우가 통째로 멈춘 것**이고, 오늘 만든 갈래들은 `my-intake` 안에만 있기 때문입니다.
- 500을 본 것은 `call`을 한 우리뿐입니다. 밤에 스케줄로 돌았다면 아무도 보지 못했을 것입니다. 알림 케이스의 `severity = null`은 "알림이 하나도 없음"이라는 뜻입니다.
- `ops`를 봐도 `alerts: 0`입니다. 실패는 했는데 **누구도 모르는** 상태입니다.
#### 고친 뒤
실행한 명령:
```bash
uv run tools/flow.py push my-work/error-handler.json my-work/intake.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py ops
uv run tools/flow.py test cases/lab01.json
```
출력 (결과 줄과 `ops`):
```text
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.8s
✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s
… before: wait 3s
✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s
Passed 4/4
alerts: 1
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
(lab01 6/6)
```
**고친 뒤 이렇게 나온 까닭**
| 볼 것 | 고치기 전 | 고친 뒤 | 무엇이 바꿨나 |
| --- | --- | --- | --- |
| `nightly-report` | 500 | 500 (그대로) | 버그는 고치지 않음 |
| 알림 | 0 | critical 1 | 에러 처리기 `errHandler76001`을 게시 |
| 누가 아나 | `call`한 사람만 | 알림을 보는 사람 모두 | |
- `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 · 어느 실패가 어느 그물에 (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` + 이유 | 부른 쪽이 결과를 보고 고쳐 보냄 |
| API 오류 | `failed` → **DLQ** | (아직 없음 → 과제 1) |
| 버그 (`nightly-report`) | **Error Workflow** → critical 알림 | 알림 |
| **DLQ도 멈춤** | ? | ? |
```mermaid
flowchart LR
F1["빈 글"] --> R["rejected"]
F2["API 오류"] --> D[("DLQ")]
F3["버그"] --> E["Error Workflow"] --> A[("알림")]
F4["API 오류 +<br/>DLQ 오류"] -.->|neverError로<br/>조용히 지나감| X(["200 · failed 3<br/>어디에도 없음"])
```
**구멍은 그물 사이에 있습니다.** 예제 1에서 DLQ 저장이 실패해도 응답은 나가게 `neverError`를 두었습니다. 그래서 DLQ가 멈추면 워크플로우는 **멈추지 않고(→ Error Workflow도 안 불림)**, DLQ에도 없고, 결과는 `failed`만 말합니다. 실패를 처리하는 장치 자체가 실패할 때를 생각해야 합니다. 이것을 "누가 감시자를 감시하나"라고 부르기도 합니다.
**테스트 케이스.** 지금 워크플로우를 바꾸지 않고 재기만 합니다. 6 · 7번은 **지금은 ✘가 맞습니다** (과제 1에서 고침).
| 케이스 | 하는 것 | 기대 |
| --- | --- | --- |
| 1 | `batch-bad-item` | `rejected: 1, created: 2` |
| 2 · 3 | `tickets error` · `batch-two`, `/dlq/list` | `failed: 2`, DLQ `size: 2` |
| 4 · 5 | `nightly-report`, 3초 뒤 알림 | 500, critical · unexpected |
| 6 | `tickets error` + `dlq error` · `batch-ok` | `failed: 3, dlq_saved: false` |
| 7 | `/alerts/latest` | critical · `dlq_down` |
**기대 결과.** 5/7. 6번은 결과에 `dlq_saved`가 없어서, 7번은 마지막 알림이 5번의 `unexpected`라서 ✘. `ops`의 DLQ는 2건 그대로 (3건이 사라짐).
<details><summary>▶ 1단계 · 사라진 3건을 찾을 수 있나</summary>
6번 뒤 `ops`를 봅니다. DLQ는 몇 건인가요? n8n 화면 Executions에서 그 실행을 열면 `Send to DLQ` 노드의 응답에 무엇이 있나요? 사람이 매번 Executions를 열어 볼 수 있을까요?
</details>
### 실행 결과 · 어느 실패가 어느 그물에
수업 준비 때(10/29) 강사 노트북에서 예제 2 정답으로 실제로 나온 출력입니다.
이 예제는 **고치지 않습니다.** 예제 2까지 만든 그물(결과 · DLQ · 알림)에 실패 넷을 던져 보고, 어디에 걸리는지 봅니다. 6 · 7번의 구멍은 과제 1에서 막습니다.
#### 지금 상태 · 예제 2까지
실행한 명령:
```bash
uv run tools/flow.py test cases/lab02-nets.json -v
uv run tools/flow.py ops
```
출력 (결과 줄과 `ops`):
```text
✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 1.7s
✔ 2 · API 오류 → failed · 2.8s
✔ 3 · failed는 DLQ에 · 0.0s
✔ 4 · 버그 → 500 · 0.0s
… before: wait 3s
✔ 5 · Error Workflow → critical 알림 · 0.0s
✘ 6 · DLQ마저 멈추면 결과가 알려야 · 4.1s · dlq_saved = null (expected false)
✘ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s · reason = unexpected (expected dlq_down)
Passed 5/7
created tickets: 2
T-2001 key R-201 장비/보통 프린터 용지가 걸려요
T-2002 key R-203 소프트웨어/보통 메일 첨부가 안 열려요
DLQ: 2 (parked 0)
D-1 R-301 … D-2 R-302 …
alerts: 1
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
```
**왜 이렇게 나왔나 (케이스마다)**
| 케이스 | 던진 실패 | 걸린 그물 | 남은 흔적 |
| --- | --- | --- | --- |
| 1 | 빈 글 항목 | 결과 (`rejected`) | 응답에만. 보낸 쪽이 고칠 일 |
| 2 · 3 | 데이터 API 오류 (`fault tickets error`) | 결과 (`failed`) + DLQ | D-1 · D-2 |
| 4 · 5 | `nightly-report`의 버그 | Error Workflow | critical 알림 |
| 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`)은 없습니다. **세 문의를 잃었는데 아무도 모릅니다.**
- 5/7은 **예상한 결과**입니다. 그물이 있어도 **그물이 고장 나면** 실패가 빠져나갑니다.
#### 과제 1을 고친 뒤 · 같은 테스트
실행한 명령 (과제 1 끝에서):
```bash
uv run tools/flow.py test cases/lab02-nets.json
```
출력:
```text
✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 2.1s
✔ 2 · API 오류 → failed · 2.9s
✔ 3 · failed는 DLQ에 · 0.0s
✔ 4 · 버그 → 500 · 0.1s
✔ 5 · Error Workflow → critical 알림 · 0.0s
✔ 6 · DLQ마저 멈추면 결과가 알려야 · 3.7s
✔ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s
Passed 7/7
```
**고친 뒤 이렇게 나온 까닭:** 과제 1에서 DLQ 저장 결과를 확인해 결과에 `dlq_saved: false`를 넣고, 저장에 실패하면 `dlq_down` critical 알림에 잃어버린 id를 적게 했습니다. 그래서 6 · 7번이 ✔가 되었습니다. 1~5는 그대로입니다. 자세한 것은 과제 1의 실행 결과에 있습니다.
## 과제 1 · 처리한 실패도 알리기, DLQ가 멈추면 (10분)
### 이 실습의 의도
**한 줄로:** DLQ에 쌓인 것을 사람이 **바로 알게** 하고, 예제 3에서 찾은 구멍(DLQ도 멈춤)을 **더 큰 알림**으로 막습니다.
> **생활 속 예 · 응급실과 "건전지 부족" 삑 소리.** 응급실은 급한 정도에 따라 환자를 나누고, 화재경보기는 건전지가 떨어지면 따로 삑 소리를 냅니다. 과제 1은 보관소에 넣었으면 "확인해 주세요"(high), 보관소에도 못 넣었으면 "지금 바로"(critical, 잃을 뻔한 송장 번호와 함께)로 등급을 나눠 알리는 실습입니다.
**문제 둘.**
| 문제 | 지금 | 바라는 것 |
| --- | --- | --- |
| DLQ에 쌓여도 아무도 모름 | 다음 날 아침에 `ops`를 쳐야 앎 | DLQ에 넣을 때 `high` 알림 하나 (몇 건인지) |
| DLQ도 멈추면 요청이 사라짐 | 결과는 `failed`만, 알림 없음 | `critical` 알림, 메시지에 **잃을 뻔한 요청 id** |
**왜 등급을 나누나.** DLQ에 넣었으면 요청은 안전합니다. 원인을 고친 뒤 재처리하면 됩니다(`high`). DLQ에도 못 넣었으면 요청은 지금 **어디에도 없습니다.** 알림에 적힌 id가 유일한 단서라 바로 봐야 합니다(`critical`).
**지킬 것.** 실패가 없으면 알림을 보내지 않습니다. 실패한 항목마다가 아니라 **요청 하나에 알림 하나**(개수를 담아)입니다. 그리고 결과에 `dlq_saved` · `alert`를 더해, 부른 쪽도 "DLQ에 들어갔나"를 알게 합니다.
**끝나면 알게 되는 것.** 알림 등급을 무엇으로 정하나(요청을 잃을 수 있나), DLQ에 저장됐는지를 상태 코드가 아니라 응답 내용으로 판단하는 법. 고치면 예제 3의 `lab02-nets`가 7/7이 됩니다.
> "DLQ에 쌓이는 걸 아무도 몰라서 다음 날 아침에야 봤어요. DLQ에 넣었으면 바로 알려 주세요. 그리고 아까처럼 DLQ에도 못 넣었으면 그건 정말 급한 거니 더 크게 알려 주세요. 대신 문제가 없을 때는 알림을 보내지 마세요."
```bash
uv run tools/flow.py test cases/lab02-challenge.json -v # 고치기 전
```
| 조건 | 값 |
| --- | --- |
| DLQ에 넣었으면 | `POST /alerts` `{workflow: "intake", severity: "high", reason: "dlq", count, message}` |
| DLQ에 못 넣었으면 | `{severity: "critical", reason: "dlq_down", count, message}` — message에 잃을 뻔한 요청 id들 |
| 실패가 없으면 | 알림 없음 |
| 결과에 | `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
A --> R(["dlq_saved · alert: sent"])
```
**테스트 케이스.**
| 케이스 | 하는 것 | 기대 |
| --- | --- | --- |
| API가 멈춤 | `tickets error` · `batch-two` | `failed: 2, dlq_saved: true, alert: sent` |
| high 알림 | `/alerts/latest` | `high · dlq · count 2 · intake` |
| 실패 없음 | `batch-ok` | `alert: none` |
| 알림은 그대로 | `/alerts/list` | `count: 1` |
| DLQ도 멈춤 | `tickets error` + `dlq error` · `batch-bad-item` | `failed: 2, dlq_saved: false` |
| critical 알림 | `/alerts/latest` | `critical · dlq_down · count 2`, message에 `R-201` |
**기대 결과.** 고치기 전 0/6, 고친 뒤 6/6. 예제 3의 `lab02-nets`도 7/7.
**먼저 생각해 보기 (2분)**: 알림이 너무 많으면 어떻게 될까요? `critical`과 `high`는 받는 사람에게 무엇이 달라야 할까요? (런북 1절)
<details><summary>▶ 1단계 · 한 번에 하나</summary>
DLQ 노드는 요청 하나에 한 번 돕니다 (`items` 목록 하나). 그 뒤에 알림을 하나 만들면 저절로 "요청 하나에 하나"가 됩니다.
</details>
<details><summary>▶ 2단계 · 저장됐는지 어떻게 아나</summary>
`curl -s -XPOST localhost:8800/dlq -d '{"items": [{"item": {"id": "X-1"}, "reason": "test"}]}'`과, `fault dlq error` 뒤 같은 명령을 해 봅니다. 응답에 무엇이 있고 무엇이 없나요? (끝나면 `ops --reset`)
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 어디에 붙이나 ② 저장됐을 때 알림 ③ 저장 못 했을 때 알림 ④ 알림 수 ⑤ 결과에 더할 칸 ⑥ 기대 결과**. 빈칸 4개를 채웁니다.
```text
my-work/intake.json 의 Send to DLQ 뒤에 Make alert(Code) 와 Send alert(HTTP POST http://data-api:8800/alerts) 를 넣어 줘.
DLQ 응답에 ____① 가 있으면 {workflow: "intake", severity: "____②", reason: "dlq", count: failed 수, message},
없으면(DLQ 저장 실패) severity "critical", reason " ____③ ", message 에 잃을 뻔한 요청 id 들.
알림은 요청 하나에 하나 (항목마다가 아님). 실패가 없으면 알림을 보내지 않는다.
결과에 dlq_saved(실패가 있을 때 true/false) 와 ____④ ("sent" / "none") 를 더해 줘.
기대 결과 cases/lab02-challenge.json, cases/lab02-nets.json, cases/lab02.json.
```
빈칸 힌트:
- ① ▶ 2단계에서 본 DLQ 응답의 칸입니다. 저장된 수.
- ② 팀장 말의 "바로 알려 주세요"는 critical보다 한 단계 아래입니다 (위 등급 표).
- ③ 위 조건 표의 reason 그대로입니다 (테스트가 찾음).
- ④ 위 조건 표의 결과 칸 이름입니다.
</details>
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab02-challenge.json -v
uv run tools/flow.py test cases/lab02-nets.json
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py ops
```
### 실행 결과 · 처리한 실패도 알리기
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다.
#### 고치기 전 · 예제 2까지 고친 `my-intake`
실행한 명령:
```bash
uv run tools/flow.py test cases/lab02-challenge.json -v
```
출력 (결과 줄만):
```text
✘ API가 멈춤 → DLQ · 2.7s · dlq_saved = null (expected true); alert = null (expected sent)
✘ 처리한 실패도 알림 (high · 개수) · 0.0s · severity = null (expected high); reason = null (expected dlq); ...
✘ 실패가 없으면 알림 없음 · 2.8s · alert = null (expected none)
✘ 알림은 그대로 1개 · 0.0s · count = 0 (expected 1)
✘ DLQ도 멈추면 critical · 2.4s · dlq_saved = null (expected false)
✘ 잃어버린 id를 알림에 · 0.0s · severity = null (expected critical); ... message has none of ['R-201']
Passed 0/6
```
**왜 이렇게 나왔나 (고치기 전)**
- **첫 줄 ✘:** 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
uv run tools/flow.py test cases/lab02-nets.json
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py ops
```
출력 (결과 줄과, 응답에 있던 알림 두 개):
```text
✔ API가 멈춤 → DLQ · 4.3s
✔ 처리한 실패도 알림 (high · 개수) · 0.0s
✔ 실패가 없으면 알림 없음 · 2.9s
✔ 알림은 그대로 1개 · 0.0s
✔ DLQ도 멈추면 critical · 2.4s
✔ 잃어버린 id를 알림에 · 0.0s
Passed 6/6
(lab02-nets 7/7 · lab02 4/4)
[high] intake · dlq · count 2 · "2 request(s) could not be created and wait in the DLQ"
[critical] intake · dlq_down · count 2 · "DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203"
```
**고친 뒤 이렇게 나온 까닭**
| 상황 | 고치기 전 | 고친 뒤 결과 | 알림 |
| --- | --- | --- | --- |
| API가 멈춤 (2건 실패) | DLQ에만 | `dlq_saved: true, alert: "sent"` | high `dlq` **하나** (`count 2`) |
| 실패 없음 | 칸 없음 | `alert: "none"` | 없음 (1개 그대로) |
| DLQ도 멈춤 | 아무 흔적 없음 | `dlq_saved: false` | critical `dlq_down` + `lost ids R-201, R-203` |
- **실패 2건 → 알림 하나:** 실패마다 하나씩이 아니라 요청 하나에 하나, 개수(`count 2`)를 담았습니다. 알림을 받은 사람은 "DLQ에 2건이 기다린다"를 압니다. 이 알림은 high입니다. 문의는 DLQ에 안전하게 있고, 원인을 고친 뒤 재처리하면 되기 때문입니다.
- **실패가 없으면 알림 없음:** 정상 요청은 알림을 만들지 않습니다. 알림이 "무슨 일이 있다"는 뜻을 잃지 않게 하는 규칙입니다.
- **DLQ도 멈추면 critical:** 이번에는 문의가 DLQ에도 없습니다. 그래서 등급이 critical이고, 메시지에 **잃어버린 id**(R-201 · R-203)를 적었습니다. 사람이 이 알림만 보고 보낸 쪽에 연락하거나 손으로 다시 넣을 수 있습니다.
- `lab02-nets` 7/7: 예제 3의 구멍(6 · 7번)이 막혔습니다. `lab02` 4/4: DLQ와 Error Workflow는 그대로입니다.
## 과제 2 · 알림 폭주 막기 (10분)
### 이 실습의 의도
**한 줄로:** 같은 원인의 `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분 안에 있었나?**"
```mermaid
flowchart LR
M["Make alert<br/>high · dlq"] --> L[("POST /alerts/list<br/>intake · dlq · within 600")] --> D{"high이고<br/>이미 있음?"}
D -->|예| S(["alert: suppressed"])
D -->|아니오| A[("POST /alerts")] --> O(["alert: sent"])
```
**테스트 케이스.**
| 케이스 | 하는 것 | 기대 |
| --- | --- | --- |
| 첫 실패 | `tickets error` · `batch-two` | `alert: sent` |
| 10분 안에 또 | `batch-ok` | `failed: 3, alert: suppressed` |
| dlq 알림 수 | `/alerts/list {reason: dlq}` | `count: 1` |
| DLQ에는 모두 | `/dlq/list` | `size: 5` |
| critical은 늘 | `dlq error` · `batch-bad-item` | `dlq_saved: false, alert: sent` |
| 고친 뒤 | `batch-dup-id` | `failed: 0, alert: none` |
**기대 결과.** 고치기 전 4/6 (두 번째에도 보내서), 고친 뒤 6/6.
<details><summary>▶ 1단계 · 어디서 세나</summary>
알림을 보내기 **바로 전에** 데이터 API에 "10분 안에 같은 알림이 있나"를 묻습니다. `curl -s -XPOST localhost:8800/alerts/list -d '{"workflow": "intake", "reason": "dlq", "within": 600}'`로 모양을 봅니다.
</details>
<details><summary>▶ 2단계 · 왜 critical은 예외인가</summary>
DLQ가 멈춘 알림을 10분 동안 한 번만 보내면, 그 10분 동안 들어온 다른 요청의 id는 어디에 남나요? (critical 메시지에 id를 적은 까닭)
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 묻는 곳과 본문 ② 보내지 않는 조건 ③ 예외 ④ 결과 값 ⑤ 기대 결과**. 빈칸 4개를 채웁니다.
```text
my-work/intake.json 의 Make alert 뒤에 Recent alerts(HTTP POST http://data-api:8800/alerts/list) 를 넣어
본문 {workflow: "intake", reason: 이번 알림의 reason, within: ____① } 로 10분 안의 같은 알림 수를 받아 줘.
Decide alert(Code): severity 가 ____② 이고 그 수가 1 이상이면 suppressed true. critical 은 ____③ .
Send it?(If) 로 suppressed 가 아닐 때만 Send alert, 결과의 alert 는 "sent" / "____④" / "none".
기대 결과 cases/lab02-challenge2.json, cases/lab02-challenge.json.
```
빈칸 힌트:
- ① 10분을 초로.
- ② 팀장이 줄이라고 한 등급입니다.
- ③ 팀장 말의 "단," 뒤입니다.
- ④ 위 조건 표의 값 그대로입니다.
</details>
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab02-challenge2.json -v
uv run tools/flow.py test cases/lab02-challenge.json
uv run tools/flow.py test cases/lab01-challenge2.json
uv run tools/flow.py ops --reset
```
### 실행 결과 · 알림 폭주 막기
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다.
#### 고치기 전 · 과제 1까지 고친 `my-intake`
실행한 명령:
```bash
uv run tools/flow.py test cases/lab02-challenge2.json -v
```
출력 (결과 줄만):
```text
✔ 첫 실패 → 알림 보냄 · 2.9s
✘ 10분 안에 또 실패 → 보내지 않음 · 3.7s · alert = sent (expected suppressed)
✘ dlq 알림은 1개 · 0.0s · count = 2 (expected 1)
✔ 그래도 DLQ에는 5건 모두 · 0.0s
✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.5s
✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.0s
Passed 4/6
```
**왜 이렇게 나왔나 (고치기 전)**
- **10분 안에 또 실패 ✘ `alert = sent`:** 과제 1의 규칙은 "실패가 있으면 보낸다" 하나뿐입니다. 같은 장애가 이어져도 요청마다 알림을 또 보냅니다. 그래서 dlq 알림이 **2개**입니다.
- 장애가 30분 이어지고 요청이 1분에 한 번 오면 같은 알림이 30개 쌓입니다. 사람은 곧 알림을 읽지 않게 되고(**알림 피로**), 정말 새로운 알림도 놓칩니다.
- **✔ 넷:** DLQ에 5건 모두 들어간 것, DLQ가 멈춘 critical을 보낸 것, 실패가 없을 때 알림이 없는 것은 과제 1에서 이미 됩니다. 고친 뒤에도 그대로여야 합니다.
- 4/6은 **예상한 결과**입니다.
#### 고친 뒤
실행한 명령:
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab02-challenge2.json -v
uv run tools/flow.py test cases/lab02-challenge.json
uv run tools/flow.py test cases/lab01-challenge2.json
uv run tools/flow.py ops --reset
```
출력 (결과 줄만):
```text
✔ 첫 실패 → 알림 보냄 · 4.5s
✔ 10분 안에 또 실패 → 보내지 않음 · 3.7s
✔ dlq 알림은 1개 · 0.0s
✔ 그래도 DLQ에는 5건 모두 · 0.0s
✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.4s
✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.8s
Passed 6/6
(lab02-challenge 6/6 · lab01-challenge2 4/4)
```
**고친 뒤 이렇게 나온 까닭**
| 케이스 | 고치기 전 | 고친 뒤 | 까닭 |
| --- | --- | --- | --- |
| 첫 실패 | `sent` | `sent` | 10분 안에 같은 이유의 알림이 없음 |
| 10분 안에 또 실패 | `sent` | `suppressed` | 같은 이유(`dlq`)의 알림이 이미 있음 |
| dlq 알림 수 | 2 | 1 | 같은 까닭 |
| DLQ | 5건 | 5건 (그대로) | 줄인 것은 알림뿐 |
| DLQ가 멈춤 | critical `sent` | critical `sent` | 급한 것은 줄이지 않음 |
- 보내기 전에 지난 10분 안에 같은 이유의 알림이 있었는지 보고, 있으면 `suppressed`로 정했습니다. 결과에 `suppressed`라고 쓰니 **보내지 않은 것도 기록에 남습니다.**
- **DLQ에는 5건 모두 있습니다.** 줄인 것은 사람에게 가는 알림이지, 실패의 기록이 아닙니다. 첫 알림을 본 사람이 DLQ를 열면 그 뒤에 쌓인 것까지 다 보입니다.
- DLQ가 멈춘 critical은 10분 안이라도 `sent`입니다. 문의를 잃고 있는 중이라, 줄이면 안 되는 알림입니다.
- 마지막 케이스(`batch-dup-id`)는 `failed: 0` · `alert: none`입니다. `lab01-challenge2` 4/4로 Lab 01 과제 2의 같은 id 거절도 그대로입니다.
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| DLQ가 비어 있음 | 실패가 있을 때만 가는 If의 true 쪽에 DLQ 노드가 있는지, 본문이 `{items: [...]}`인지 |
| 알림이 안 생김 | 에러 처리기를 push(게시)했는지, 파일의 `id`가 `errHandler76001`인지, 부르는 쪽 `settings.errorWorkflow` |
| 알림이 늦게 생김 | 정상. 응답이 나간 뒤에 돈다 (테스트는 `{"sleep": 3}`) |
| 알림이 두 개 | 테스트의 `url` 케이스가 `/alerts`에 POST하면 알림이 생김 — 읽기는 `/alerts/list` · `/alerts/latest` |
| `dlq_saved`가 늘 true | DLQ 응답의 `stored`를 보는지 (상태 코드는 `neverError`라 늘 성공처럼 보임) |
| 늘 `suppressed` | 앞 테스트의 알림이 남았는지 (`ops --reset`), `within`이 초 단위인지 |
| `fault dlq error` 뒤 다른 테스트가 이상함 | `uv run tools/flow.py fault dlq ok` 또는 `ops --reset` |
| 계속 안 됨 | `reference/lab02-example1-dlq.json`, `reference/lab02-example2-*.json` (예제까지) |
## 확인
- [ ] 예제 1: `cases/lab02.json` 앞 두 케이스, `ops`로 DLQ 확인.
- [ ] 예제 2: `cases/lab02.json` 4/4, `nightly-report`의 알림을 봤다.
- [ ] 예제 3: `cases/lab02-nets.json` 5/7, 그물 지도와 구멍을 `RUNBOOK.md`에 적었다.
- [ ] 과제 1: `cases/lab02-challenge.json` 6/6, `lab02-nets` 7/7, `lab02`도 통과.
- [ ] 과제 2: `cases/lab02-challenge2.json` 6/6, `lab02-challenge`도 통과.
- [ ] `RUNBOOK.md` 1절 (알아채는 법 · 자동으로 하는 일 · 알림 등급).