- 목표: 여러 건을 한 번에 처리할 때 **한 건의 실패가 전체를 망치지 않게** 한다. 잘못된 항목은 그 항목만 거절하고, 잠깐의 실패는 다시 해 보고, 다시 해도 **접수 건이 두 번 생기지 않게** 한다. 고장 종류마다 재시도가 무엇을 하는지 재 보고, LLM이 멈춰도 접수는 하고, 요청 안의 규칙(같은 id · 10건 상한)을 지킨다.
```mermaid
flowchart LR
E1["예제 1<br/>기준을 망가뜨려 보기"] --> E2["예제 2<br/>항목 확인 · 재시도 ·<br/>멱등 키"] --> E3["예제 3<br/>고장 종류마다<br/>재시도가 하는 일"] --> T1["과제 1<br/>LLM이 멈춰도 접수"] --> T2["과제 2<br/>요청 안의 규칙"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py ops --reset`.
## 시작하기 · 터미널 두 개
| 터미널 | 하는 일 |
| --- | --- |
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/76` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 |
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/76` 에서 `call` · `push` · `test` · `ops` · `fault`를 **내가** 실행한다 |
`cp templates/RUNBOOK-TEMPLATE.md my-work/RUNBOOK.md` 후 표를 채워 갑니다. 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다.
## 예제 1 · 기준을 망가뜨려 보기 (10분, 에이전트 없이)
### 이 실습의 의도
**한 줄로:** 오늘 고칠 기준 워크플로우(`base-intake`)가 실패할 때 **무엇을 숨기는지** 직접 봅니다. 고치는 것은 예제 2부터이고, 여기서는 **망가뜨려 보고 적기만** 합니다.
> **생활 속 예 · 마트 계산대.** 계산원이 "결제 실패"라고만 했는데 영수증을 보니 앞의 물건은 이미 결제돼 있다면, 다시 계산하면 두 번 냅니다. 예제 1은 일부러 이런 상황을 만들어, **들은 말**(워크플로우의 답, `call`)과 **실제로 일어난 일**(영수증, `ops`)을 나란히 보는 실습입니다.
**상황.**`base-intake`는 문의 여러 건(`requests[]`)을 한 번에 받아, 한 건씩 LLM으로 분류하고 데이터 API에 접수 건을 만듭니다. 정상 요청이면 잘 됩니다. 그런데 운영하다 보면 이런 일이 생깁니다.
- 보낸 문의 중 하나가 잘못됨 (글이 비어 있음)
- 데이터 API가 잠깐 끊김
- 사용자가 답을 못 받았다며 같은 요청을 다시 보냄
이 셋을 하나씩 일부러 일으켜, **사용자가 받은 답**과 **데이터 API에 실제로 생긴 접수 건**을 나란히 봅니다. 둘이 다르면 그것이 오늘 고칠 문제입니다.
**실험 셋.**
| 실험 | 일으키는 것 | 보낼 요청 | 볼 것 |
| --- | --- | --- | --- |
| ① 잘못된 항목 | 3건 중 가운데 글이 빔 | `batch-bad-item` (R-201 · 빈 R-202 · R-203) | 답은 성공인가 실패인가? 그런데 `ops`에 접수 건이 생겼나, 몇 개? |
| ② 잠깐의 끊김 | `fault tickets flaky`: 접수 건 만들기가 키마다 첫 시도만 실패 | `batch-two` (R-301 · R-302) | 한 번만 더 했으면 됐을 실패를 기준은 어떻게 끝내나? |
| ③ 다시 보내기 | 고장을 끄고 정상 3건을 한 번 더 | `batch-ok` (R-101~103) 두 번째 | 같은 문의의 접수 건이 몇 개가 되나? `ops`의 `key` 칸이 첫 번째와 같나? |
`call`은 웹훅을 한 번 불러 **워크플로우의 답**을 보여 주고, `ops`는 데이터 API에 지금 있는 접수 건 · DLQ · 알림 · 고장 스위치를 보여 줍니다. `ops`는 답이 아니라 **실제로 생긴 것**을 보는 창입니다. 두 화면을 번갈아 봅니다.
**끝나면 알게 되는 것.** 기준은 ① 한 건 때문에 전체를 실패라고 하면서 다른 건은 이미 만들었고, ② 잠깐의 끊김도 바로 실패로 끝내고, ③ 같은 문의를 다시 보내면 또 만듭니다. 예제 2에서 이 셋을 **항목 확인 · 재시도 · 멱등 키**로 하나씩 고칩니다.
**적을 곳.**`my-work/RUNBOOK.md` 1.1 표 (한 일 · 사용자가 본 것 · 실제로 생긴 것), 실험마다 한 줄.
```bash
uv run tools/flow.py ops --reset # 접수 건 · DLQ · 알림 비우기, 고장 스위치 모두 ok
# 0. 정상일 때 (비교 기준)
uv run tools/flow.py call base-intake requests/batch-ok.json # 정상 3건
# ① 잘못된 항목
uv run tools/flow.py call base-intake requests/batch-bad-item.json # 가운데 항목의 text가 비어 있음
uv run tools/flow.py ops # 답은 실패였는데, 접수 건은 몇 개?
# ② 잠깐의 끊김
uv run tools/flow.py fault tickets flaky # 접수 건 만들기가 키마다 첫 시도에 실패
uv run tools/flow.py call base-intake requests/batch-two.json
uv run tools/flow.py fault tickets ok # 고장 끄기
# ③ 다시 보내기
uv run tools/flow.py call base-intake requests/batch-ok.json # 0번과 같은 요청을 다시
uv run tools/flow.py ops # VPN 문의(R-101)의 접수 건은 몇 개? key는?
```
### 이해하기
**여러 건을 한 번에 처리하는 일(일괄 처리, batch)에는 "반쯤 된" 상태가 있습니다.** 한 건만 처리하면 결과는 됐다 · 안 됐다 둘 중 하나입니다. 세 건을 처리하다 두 번째에서 멈추면 첫 번째는 이미 끝났고, 세 번째는 했는지 안 했는지 모릅니다. 그런데 사용자에게는 `500` 하나만 갑니다.
```mermaid
flowchart LR
Q(["requests 3건"]) --> S["하나씩 나누기"] --> C{{"LLM 분류"}} --> T["접수 건 만들기<br/>key = 실행 번호"]
T -->|1번째 · 3번째| OK["만들어짐"]
T -->|2번째: 빈 글 → 400| X["노드 오류 → 전체 500"]
```
**오늘 기준이 가진 문제 셋.**
| 문제 | 겉으로 | 실제로 |
| --- | --- | --- |
| 한 건의 실패가 전체를 멈춤 | HTTP 500 · `Error in workflow` | 다른 건은 이미 접수됨 (부분 처리) |
| 잠깐의 실패도 바로 실패 | 500 | 1초 뒤에 다시 했으면 됐을 것 |
| 다시 보내면 또 만듦 | 200 · 접수 건 번호 3개 | 같은 문의의 접수 건이 둘 (중복) |
**왜 중복이 생기나.** 기준은 접수 건을 만들 때 키를 `$execution.id + '-' + $itemIndex`(이번 실행 번호와 순서)로 정합니다. 같은 문의를 다시 보내면 실행 번호가 다르니 데이터 API는 다른 요청으로 봅니다. "같은 문의"를 알아보게 하려면 **실행마다 바뀌지 않는 것**(요청의 `id`)이 키여야 합니다 (예제 2).
**고장 스위치.** 오늘 데이터 API에는 접수 건 만들기(`tickets`)와 DLQ(`dlq`)에 고장 스위치가 있습니다. `flaky`는 키마다 첫 시도만 실패시킵니다 (잠깐의 끊김), `error`는 늘 500, `slow`는 8초를 기다린 뒤 답합니다. 연습이 끝나면 `ops --reset`이 접수 건 · DLQ · 알림을 비우고 스위치를 모두 `ok`로 돌립니다.
**기대 결과.** 빈 글 하나가 섞인 3건 → 500인데 접수 건 2개가 생깁니다. `flaky`에서는 500. 같은 정상 3건을 두 번 보내면 VPN 문의의 접수 건이 두 개가 됩니다. `RUNBOOK.md` 1절에 "사용자가 본 것 / 실제로 생긴 것"을 나란히 적습니다.
<details><summary>▶ 1단계 · 실패를 나누기</summary>
| 실패 | 다시 하면 되나 |
| --- | --- |
| 입력이 잘못됨 (빈 글) | 아니오 — 몇 번을 해도 같음 |
| API가 잠깐 실패 | 예 |
| API가 오래 멈춤 | 지금은 아니오, 나중에는 예 |
이 셋을 똑같이 "500"으로 끝내면 무엇을 잃을까요? 사용자는 무엇을 할까요?
</details>
<details><summary>▶ 2단계 · 중복은 왜 생기나</summary>
`ops`의 `key` 칸을 봅니다. 두 번째 `batch-ok`의 키는 첫 번째와 같나요? 같은 요청을 알아보게 하려면 무엇을 키로 써야 할까요?
</details>
### 실행 결과 · 기준을 망가뜨려 보기
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다 (줄임). LLM 분류 · 시간 · 접수 건 번호의 순서는 실행마다 조금씩 다를 수 있고, 500 · 접수 건 수는 같아야 합니다.
- 응답은 500, 곧 "실패"입니다. 그런데 접수 건이 3개에서 **5개**로 늘었습니다 (0번 3개 + 이번 2개).
- 키를 보면 까닭이 보입니다. `10058-0`은 실행 번호 10058의 0번째 항목이고, `10058-2`는 2번째 항목입니다. **1번째(빈 글)만 없습니다.** 접수 건 만들기 노드는 항목을 하나씩 처리하다가 빈 글에서 데이터 API의 400(`title`이 비었음)을 받았고, 거기서 워크플로우 전체가 멈췄습니다. 앞뒤 항목은 이미 처리된 뒤였습니다.
- 그래서 생긴 문제는 **부분 성공을 실패라고 말한 것**입니다. 사용자는 "실패"를 믿고 다시 보낼 것이고, 그러면 프린터 · 메일 문의가 한 번 더 생깁니다.
#### ② 잠깐 끊김 (`flaky`) → 다시 해 보면 될 것을 바로 포기
실행한 명령:
```bash
uv run tools/flow.py fault tickets flaky
uv run tools/flow.py call base-intake requests/batch-two.json
uv run tools/flow.py fault tickets ok
```
출력:
```text
HTTP 500 · 2.5s {"message": "Error in workflow"}
```
**왜 이렇게 나왔나:**`flaky`는 키마다 **첫 시도만** 500을 주고, 두 번째부터는 성공하는 고장입니다. 한 번만 더 보냈으면 됐을 텐데, 기준에는 재시도가 없어 첫 500에서 바로 멈췄습니다. 이번 요청으로는 접수 건이 하나도 생기지 않았습니다.
#### ③ 0번과 같은 요청을 다시 → 같은 문의가 두 번
실행한 명령:
```bash
uv run tools/flow.py call base-intake requests/batch-ok.json
**한 줄로:** 예제 1에서 본 세 문제를 내 워크플로우(`my-intake`)에서 고칩니다. Lab 01의 중심 실습입니다.
> **생활 속 예 · 택배 배송 조회 + 송장 번호.** 좋은 택배 회사는 ① 상자마다 "배달 완료 / 주소 불명 반송"을 따로 보여 주고(부분 성공), ② 벨을 눌러 대답이 없으면 한 번 더 눌러 보고(재시도), ③ 같은 송장 번호의 상자는 두 번 배달하지 않습니다(멱등 키). 예제 2는 `my-intake`를 이런 택배 회사로 만드는 실습입니다.
| 예제 1의 문제 | 고치는 방법 | 확인하는 테스트 케이스 |
| --- | --- | --- |
| ① 한 건이 잘못되면 전체 500, 다른 건은 이미 접수 | 항목마다 확인해 잘못된 것만 `rejected`, 결과는 늘 200에 항목마다 상태 (부분 성공) | 잘못된 항목 · 목록이 아님 |
| ② 잠깐의 끊김도 바로 실패 | 접수 건 만들기 노드에 재시도 3번, 그래도 실패면 `failed`로 적고 계속 | 가끔 실패 (`flaky`) |
| ③ 다시 보내면 또 만듦 | 접수 건의 `key`를 실행 번호 대신 요청 `id`(R-101)로 | 같은 요청을 다시 · 접수 건 수 |
**왜 셋을 한 번에.** 셋은 서로 기대고 있습니다. 재시도(②)를 넣으면 n8n은 노드에 들어온 항목을 **모두** 다시 보냅니다. 이미 만든 접수 건도 또 보내므로, 멱등 키(③) 없이는 재시도가 중복을 만듭니다. 그리고 재시도 뒤에도 실패한 항목을 `failed`로 남기려면 항목마다 상태를 담는 결과 모양(①)이 먼저 있어야 합니다.
**순서.** 기준을 복사 → 같은 테스트를 **기준에 먼저** 돌려 0/6을 봄 (무엇이 틀렸는지 케이스마다 확인) → 요청문을 써서 에이전트에게 고치게 함 → push → 6/6.
**끝나면 알게 되는 것.** "일부가 실패해도 200 + 항목마다 상태"가 왜 500보다 정직한지, n8n 재시도가 노드의 어디에 붙고 무엇을 다시 보내는지, 멱등 키가 재시도를 어떻게 안전하게 만드는지.
**만들 것**: 기준을 복사해, 항목마다 확인하고, 접수 건 만들기를 다시 해 보게 하고, 요청 `id`를 멱등 키로. 결과는 늘 200 (부분 성공).
| 결과 · 400 | [AGENTS.md](AGENTS.md) 3절 (`requests`가 목록이 아니면 400) |
| 기대 결과 | `cases/lab01.json` |
### 이해하기
**지금 무엇이 문제인가.** 예제 1의 세 문제(한 건이 전체를 멈춤 · 잠깐의 실패도 실패 · 다시 보내면 중복)를 한 번에 고칩니다. 셋은 따로 보이지만 서로 이어져 있습니다. 재시도를 넣으면 **이미 만든 건을 또 보내게** 되므로, 재시도와 멱등 키는 늘 함께 갑니다.
**부분 성공.** 결과는 늘 HTTP 200이고, 항목마다 `created` · `failed` · `rejected`와 이유를 담습니다. "모두 되거나 500" 대신 "무엇이 되고 무엇이 안 됐는지"를 알려 줍니다. 받는 쪽은 `rejected`는 고쳐서, `failed`는 나중에 다시 보내면 됩니다.
**n8n의 재시도는 노드 단위입니다.**`retryOnFail` · `maxTries` · `waitBetweenTries`는 노드 **바로 아래**(`parameters` 밖)에 둡니다. 그리고 그 노드에 들어온 **항목을 모두** 다시 보냅니다. 세 건 중 하나만 실패해도 세 건을 다시 보냅니다. 그래서 키가 필요합니다.
**멱등 키(idempotency key).** 같은 요청을 몇 번 보내도 결과가 한 번 한 것과 같게 하는 표시입니다. 오늘 데이터 API는 `POST /tickets`의 `key`가 같으면 새로 만들지 않고 처음 번호를 돌려줍니다 (`duplicate: true`).
| 키 | 같은 요청을 다시 보내면 |
| --- | --- |
| 실행 번호 (`$execution.id`) | 매번 새 키 → 중복 |
| **요청 `id`** (`R-101`) | "이미 만든 건" → 같은 번호, `duplicate: true` |
**실패해도 다음으로.** 재시도 뒤에도 실패한 항목에서 멈추면 안 됩니다. `onError: continueRegularOutput`이면 실패한 항목이 `error`를 가진 채 다음 Code로 갑니다. 다음 Code는 앞 노드(`Parse`)의 항목과 **같은 순서**로 맞춰 `created` / `failed`를 정합니다.
**테스트 케이스.**
| 케이스 | 보내는 것 | 기대 |
| --- | --- | --- |
| 정상 3건 | `batch-ok` | `created: 3` |
| 같은 요청을 다시 | `batch-ok` 또 | `created: 3, duplicates: 3` |
| 접수 건 수 | `/tickets-created/list` | `count: 3` (중복 없음) |
| 잘못된 항목 | `batch-bad-item` | `created: 2, rejected: 1`, 이유 `text is required` |
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 항목 확인과 거절 이유 ③ 재시도 설정과 실패해도 계속 ④ 멱등 키 ⑤ 두 갈래 모으기 ⑥ 결과 모양 · 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/intake.json (baseline 을 복사한 것) 의 웹훅 경로를 my-intake 로, 이름을 "76 Intake (my)" 로 바꿔 줘.
requests 가 목록이 아니면 400 {"error": "requests must be a list"}.
항목마다 id · user · text 를 확인해서, 빠진 것이 있으면 LLM 과 API 로 보내지 말고 status rejected 와 이유("text is required" 등)로.
Create ticket 노드에 timeout 3000ms, 재시도 ____① 번, 간격 300ms, 그리고 실패해도 다음으로 가게 onError ____② .
접수 건의 key 는 ____③ (실행 번호가 아님).
처리한 갈래와 거절한 갈래를 Merge 로 모아 AGENTS.md 3절 결과 모양으로, HTTP ____④ .
기대 결과 cases/lab01.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 위 조건 표의 "재시도"입니다. 처음 한 번을 포함한 수입니다.
- ② AGENTS.md 5절 접수 건 만들기 노드의 설정입니다. 멈추지 않고 실패한 항목이 다음으로 가는 값.
- ③ 같은 문의를 다시 보내도 **바뀌지 않는** 것입니다. 예제 1의 `ops`에서 키가 왜 바뀌었는지 떠올립니다.
- ④ 부분 성공의 상태 코드입니다. 일부가 실패해도 이것.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab01.json -v
uv run tools/flow.py ops
```
### 실행 결과 · 항목 확인 · 재시도 · 멱등 키
수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다.
- **정상 3건 ✘ `created = null`:** 기준은 200을 주고 접수 건도 만들었습니다. 하지만 결과가 `{processed, tickets}` 모양이라 테스트가 찾는 `created` · `failed` · `rejected` 칸(AGENTS.md 3절의 결과 모양)이 없습니다. `null`은 "그런 칸이 없음"이라는 뜻입니다.
- **같은 요청 ✘ · 접수 건 ✘ `count = 6`:** 같은 3건을 두 번 보냈더니 6개가 되었습니다. 예제 1 ③과 같은 까닭으로, 키가 실행 번호라서입니다.
- **잘못된 항목 ✘ 500:** 예제 1 ①과 같습니다. 빈 글 하나 때문에 요청 전체가 500으로 끝났습니다.
- **목록이 아니면 ✘ 500 (400 기대):** 본문이 목록인지 보지 않고 바로 나누다가 n8n이 오류를 냈습니다. 400은 "보낸 쪽이 잘못"이고 500은 "받는 쪽이 고장"이라, 보낸 사람에게 틀린 신호를 줍니다.
| 정상 3건 | `created = null` | `created: 3` | 결과를 오늘의 결과 모양으로 |
| 같은 요청을 다시 | 새로 3건 | `created: 3, duplicates: 3` | 키 = 요청 `id` |
| 접수 건 수 | 6 | 3 | 같은 까닭 |
| 잘못된 항목 | 500 | 200 · 그 항목만 `rejected` | 항목마다 확인 |
| 목록이 아님 | 500 | 400 (`requests must be a list`) | 입구에서 본문 확인 |
| 가끔 실패 | 500 | 200 · `created: 2` | 재시도 + 키 |
- **두 번째 `batch-ok`:** 응답에 `duplicates: 3`이 있습니다. 키가 `R-101`처럼 요청 `id`라, 데이터 API가 "이미 있는 건"으로 알아보고 새로 만들지 않고 처음 번호를 돌려줬습니다. `created: 3`은 "세 문의 모두 접수 건이 있다"는 뜻이고, 새로 생긴 것은 없습니다.
- **잘못된 항목:** 빈 글 항목만 `rejected`(`text is required`)이고 나머지 2건(R-201 · R-203)은 접수되었습니다. 1.7초로 빨라진 것은 빈 글을 LLM에 보내지 않고 거절했기 때문입니다.
- **가끔 실패:** 두 건 모두 첫 시도가 500이었지만 0.3초 뒤 두 번째 시도에서 성공했습니다. 재시도는 두 건을 모두 다시 보냈지만, 키가 같아 중복은 생기지 않았습니다.
- **`ops`의 7개** = 정상 3(R-101~103) + 잘못된 항목 요청의 2(R-201 · R-203) + 가끔 실패의 2(R-301 · R-302). 같은 요청을 다시 보낸 것은 하나도 늘리지 않았습니다. 화면에는 마지막 5개만 보입니다. 키가 모두 `R-…`인 것을 확인합니다.
**한 줄로:** 예제 2에서 넣은 재시도가 **어떤 고장에는 도움이 되고, 어떤 고장에는 시간만 늘리는지** 재 봅니다. 워크플로우는 고치지 않고(에이전트 없이), 데이터 API의 고장 종류만 바꿔 가며 같은 `my-intake`를 부릅니다.
> **생활 속 예 · 전화 다시 걸기.** 통화 중이면 다시 걸면 됩니다(`flaky`). 상대 전화국이 고장이면 열 번 걸어도 시간만 갑니다(`error`). 상대가 받았는데 말소리가 늦게 들려 내가 먼저 끊었다면, 상대는 사실 내 말을 다 들었을 수 있습니다(`slow`). 예제 3은 세 경우에 "다시 걸기"가 각각 무엇을 하는지 재 봅니다.
**왜 하나.** 재시도를 넣으면 "이제 실패는 해결"이라고 생각하기 쉽습니다. 하지만 고장은 종류가 다릅니다.
| 고장 스위치 | 현실에서는 | 물어볼 것 |
| --- | --- | --- |
| `flaky` | 네트워크가 잠깐 끊김 · 서버가 한 번 버벅임 | 재시도가 구해 주나? |
| `error` | 데이터 API가 죽음 (몇 분 · 몇 시간) | 세 번 다시 하면 달라지나? 무엇이 늘어나나? |
| `slow` | 데이터 API가 느림 (8초) — 우리는 3초만 기다림 | 우리가 "실패"라고 포기한 요청은 정말 실패했나? |
`cases/lab01-faults.json`이 이 셋을 차례로 켜고, 마지막에 8초를 기다린 뒤 접수 건 목록을 봅니다. **`slow`에서 포기한 3건이 나중에 생겼는지**가 이 예제의 핵심 관찰입니다.
**끝나면 알게 되는 것.** 재시도는 잠깐의 실패에만 약이고, 오래 멈춘 고장에는 다른 그물(Lab 02의 DLQ)이 필요하며, 시간 초과는 "실패"가 아니라 "모름"이라서 멱등 키가 꼭 있어야 한다는 것.
**적을 곳.**`my-work/RUNBOOK.md` 1.2 표 (고장 · 걸린 시간 · 결과 · 실제로 생긴 접수 건).
```bash
uv run tools/flow.py test cases/lab01-faults.json -v
uv run tools/flow.py ops
```
### 이해하기
**재시도는 잠깐의 실패에만 약입니다.**
| 고장 | 무엇 | 재시도를 하면 |
| --- | --- | --- |
| `flaky` (잠깐 끊김) | 키마다 첫 시도만 500 | 두 번째에 성공 → **좋아짐** |
| `error` (멈춤) | 늘 500 | 세 번 다 실패 → 결과는 같고 **시간만 더** |
| `slow` (느림) | 8초 뒤에 답 | 3초마다 끊고 다시 → 세 번 × 3초 + 간격, 그래도 실패 |
```mermaid
sequenceDiagram
participant W as my-intake
participant A as 데이터 API (slow)
W->>A: 1번째 (key R-101)
Note over W: 3초 timeout → 포기
W->>A: 2번째 (같은 key)
Note over W: 3초 timeout → 포기
W->>A: 3번째 (같은 key)
Note over W: 3초 timeout → failed
A-->>A: 8초 뒤: 접수 건 R-101 만들기 (늦게)
```
**시간 초과(timeout)는 "실패"가 아니라 "모름"입니다.** 3초 안에 답이 오지 않아 포기했지만, 데이터 API는 요청을 받았고 8초 뒤에 **접수 건을 만들었습니다.** 워크플로우는 `failed`라고 말하지만 실제로는 만들어져 있습니다. 이때 같은 키로 다시 보내면 `duplicate: true`로 처음 번호를 받습니다. 멱등 키가 없었다면 두 번째 접수 건이 생깁니다. 이것이 **"시간 초과 뒤 재시도"에 멱등 키가 꼭 필요한** 까닭입니다 (Lab 03 예제 3에서 재처리로 다시 봄).
**재시도는 사용자를 기다리게 합니다.**`slow`에서 사용자는 3초 × 3번 + 간격 + LLM 분류 = 12초쯤 기다리고 `failed`를 받습니다. "재시도를 늘리면 더 잘 된다"가 아니라, **기다릴 수 있는 시간 안에서** 몇 번 할지 정합니다 (바이브 코딩 `tretry`의 마감 `--deadline`).
**테스트 케이스.** 이 파일은 내 워크플로우를 바꾸지 않고 **고장만 바꿔 가며** 잽니다.
| flaky | 키마다 첫 시도만 500 | 2.4s | `created: 2` | 첫 시도 500 → 0.3초 뒤 두 번째 성공. 재시도가 **도운** 경우 |
| error | 계속 500 | 3.4s | `failed: 3` | 세 번 모두 500. 재시도가 결과는 못 바꾸고 시간만 더함 (분류 약 2.5초 + 시도 셋과 간격 둘) |
| slow | 응답이 8초 늦음 | 12.4s | `failed: 3` | 시도마다 3초에 끊김(`timeout of 3000ms exceeded`). 사용자는 12초를 기다리고 실패를 받음 |
| 늦게 만들어짐 | (8초 기다림) | 0.0s | 접수 건 5개 | 실패라던 R-101~103이 사실은 만들어짐 |
| 같은 요청을 다시 | ok | 2.9s | `duplicates: 3` | 이미 있는 것을 알아보고 새로 만들지 않음 |
- **가장 중요한 줄은 "늦게 만들어짐"입니다.** `slow`에서 n8n은 3초를 기다리다 끊었지만, 데이터 API는 요청을 이미 받았고 8초 뒤에 처리했습니다. 그래서 "실패"라던 R-101 · R-102 · R-103이 `ops`에 있습니다. **시간 초과는 "안 됨"이 아니라 "모름"** 입니다.
- 시도는 3건 × 3번 = 9번 갔지만, 키가 같아 접수 건은 3개만 생겼습니다. 키가 실행 번호였다면 9개가 될 수도 있었습니다.
-`ops`의 5개 = flaky의 2(R-301 · R-302) + 늦게 만들어진 3(R-101~103). 마지막 "같은 요청을 다시"는 늘리지 않았습니다.
- 이 표의 시간 · 결과 · 실제로 생긴 접수 건을 `my-work/RUNBOOK.md` 1.2 표에 적습니다. `failed`가 된 요청을 어디에 모을지는 Lab 02에서 다룹니다.
> **생활 속 예 · 카드 단말기가 고장 난 편의점.** 단말기가 안 된다고 문을 닫지 않고 "오늘은 현금만"으로 판매를 계속합니다. 과제 1은 LLM(단말기)이 멈춰도 접수(판매)는 "미분류"로 계속하게 하는 실습입니다.
**상황.** 지금 `my-intake`는 분류(LLM) → 접수 순서입니다. 키가 잘못되거나 OpenAI에 장애가 나면 분류 노드에서 멈추고 500이 나며, 접수 건은 하나도 생기지 않습니다. 그 사이 들어온 문의는 모두 사라집니다. 분류는 나중에 사람이 해도 되지만, 접수되지 않은 문의는 아무도 모릅니다.
**어떻게 시험하나.** 실제 OpenAI 장애를 기다릴 수는 없으니 "LLM이 멈춘 상태"를 만듭니다. `cases/lab01-challenge.json`의 한 케이스가 `llm down`으로 n8n의 OpenAI Credential을 일부러 틀린 키로 바꾸고, 끝나면 `llm up`으로 진짜 키를 되돌립니다.
| 볼 것 | 고치기 전 | 고친 뒤 기대 |
| --- | --- | --- |
| LLM이 멈췄을 때 답 | 직접 확인 | 200, `created: 3, unclassified: 3` |
| 접수 건의 분류 | 직접 확인 | `미분류` |
| 평소 요청 | 분류됨 | 그대로 분류됨 |
**예제 2와의 관계.** 예제 2에서 접수 건 만들기 노드에 쓴 "실패해도 계속"을 이번에는 **분류 노드**에 씁니다. 같은 설정, 다른 노드입니다. 그리고 분류 결과를 읽는 Code가 "실패한 분류"를 알아보고 대신 넣을 값을 정합니다.
**끝나면 알게 되는 것.** 핵심 일(접수)과 덧붙인 일(분류)을 나누고, 덧붙인 일이 실패하면 줄여서라도 핵심 일을 끝내는 법(우아한 성능 저하), 그리고 줄인 양(`unclassified`)을 숨기지 않는 법.
팀장이 요청했습니다.
> "AI 분류가 안 된다고 문의 접수까지 막히면 안 돼요. 분류는 나중에 사람이 해도 되니, 일단 '미분류'로 받아 주세요."
```bash
uv run tools/flow.py test cases/lab01-challenge.json -v # 고치기 전
| 기대 결과 | `cases/lab01-challenge.json` (케이스 하나가 `llm down`, 끝나면 `llm up`) |
### 이해하기
**핵심 일과 덧붙인 일.** 접수는 이 워크플로우의 **핵심 일**입니다. 분류는 접수를 돕는 **덧붙인 일**입니다. 덧붙인 일이 실패했다고 핵심 일까지 멈추면, LLM이 멈춘 동안 들어온 문의가 모두 사라집니다. 덧붙인 일은 **줄여서라도** 핵심 일을 끝내게 합니다 (graceful degradation, 우아한 성능 저하).
**72일차와 무엇이 다른가.** 72일차에 LLM이 멈췄을 때는 FAQ 원문으로 "간단 답변"을 했습니다. 오늘은 "미분류로 접수"입니다. 둘 다 같은 생각입니다: LLM 없이도 할 수 있는 몫은 한다. 그리고 **얼마나 줄였는지를 숨기지 않습니다** — `unclassified` 수가 있어야 나중에 사람이 몇 건을 분류해야 하는지 압니다.
**테스트 케이스.**
| 케이스 | 하는 것 | 기대 |
| --- | --- | --- |
| 평소 | `batch-two` | `created: 2, unclassified: 0` |
- 가운데 `OpenAI credential` 두 줄은 케이스가 아니라 **테스트가 대신 해 준 준비와 뒷정리**입니다. 두 번째 케이스 전에 `llm down`(일부러 틀린 키), 끝난 뒤 `llm up`(`.env`의 진짜 키로 되돌림)을 했습니다.
- **LLM이 멈추면 ✘ 0.8초에 500:** 키가 틀려 OpenAI가 호출을 거절하자 분류 노드가 오류로 멈췄고, n8n은 `Error in workflow`를 돌려줬습니다. 분류 뒤에 있는 접수 건 만들기까지 가지 못했으니 세 문의 모두 **사라졌습니다.** 0.8초로 빠른 것은 LLM이 답을 만들기 전에 거절했기 때문입니다.
- **미분류 접수 건 ✘:** 접수 건 목록에 `미분류`가 하나도 없습니다. 위 케이스에서 아무것도 만들지 못했으니 당연합니다.
- 1/3은 **예상한 결과**입니다. 예제 2의 재시도 · 키는 데이터 API의 실패만 다뤘고, LLM의 실패는 아직 아무도 다루지 않습니다.
#### 고친 뒤
실행한 명령:
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab01-challenge.json -v
| LLM이 멈춤 | 500 · 접수 0 | 200 · `created: 3, unclassified: 3` | 분류 노드가 멈추지 않고 오류를 다음으로 넘김 |
| 미분류 접수 건 | 없음 | `미분류` 3건 | `Parse`가 오류를 보고 미분류 · 보통으로 정함 |
- 분류는 여전히 실패했습니다 (키가 틀렸으니까요). 달라진 것은 **실패를 받은 뒤**입니다. 분류 노드가 워크플로우를 멈추지 않고 `error`를 넘겼고, `Parse`가 그것을 보고 분류를 `미분류`, 우선순위를 `보통`으로 정해 접수했습니다. 문의는 잃지 않고, 사람이 나중에 분류하면 됩니다.
- 0.9초로 **오히려 빨랐습니다.** LLM이 거절한 뒤 기다리지 않고 바로 접수로 갔기 때문입니다. 이것이 **우아한 저하**(전부 멈추는 대신 덜 좋은 결과라도 내기)입니다.
-`unclassified: 3`이 결과에 따로 있으니, 사용자나 운영자는 "분류가 안 된 것이 3건"이라는 것을 압니다. 조용히 넘어간 것이 아닙니다.
-`llm up`으로 진짜 키를 되돌린 뒤 `lab01` 6/6: 평소 분류와 예제 2의 규칙은 그대로입니다.
**한 줄로:** 비싼 일(LLM 분류 · 접수)을 하기 **전에** 요청 전체를 보고, 규칙에 맞지 않는 것을 입구에서 막습니다.
> **생활 속 예 · 접수 창구 직원.** 같은 서류를 두 번 내밀면 "방금 접수하셨는데요" 하고 그 자리에서 돌려주고, 서류를 수백 장 한꺼번에 내밀면 "한 번에 10장까지만 받아요"라고 합니다. 안쪽 사무실까지 들고 가서 처리하지 않습니다. 과제 2는 워크플로우 입구에 이 창구 직원을 세우는 실습입니다.
**상황 둘.**
| 상황 | 지금은 | 바라는 것 |
| --- | --- | --- |
| 엑셀에서 복사하다 같은 줄(같은 `id`)이 두 번 | 둘 다 분류 · 접수 시도. 키 덕분에 접수 건은 하나지만 답은 "둘 다 접수" | 두 번째 줄만 `rejected` (`duplicate id in this request`) |
| 한 번에 수백 건 | 모두 처리, 그동안 n8n이 붙잡힘 | 10건이 넘으면 요청 전체를 400으로 거절, 하나도 처리하지 않음 |
**예제 2와의 차이.** 예제 2의 항목 확인은 "이 한 줄에 id · user · text가 있나"였습니다. 이번 규칙은 **다른 줄과 비교해야**(같은 id) 또는 **요청 전체를 봐야**(몇 건인가) 알 수 있습니다.
**왜 멱등 키로 충분하지 않나.** [교안 1단계 8절](../../Lecture/76/01-STAGE1-FAILURE-TYPES.md)의 "멱등 키가 있어도 입구 규칙이 필요한 까닭"을 먼저 읽습니다. 키는 중복 접수 건이라는 **피해**를 막고, 입구 규칙은 쓸데없는 분류와 틀린 숫자라는 **일**을 막습니다.
**시험하는 것.**`batch-dup-id`(R-401 두 번 · R-402), `batch-eleven`(11건), 11건이 400일 때 접수 건이 정말 하나도 안 생겼는지, 평소 요청은 그대로인지.
**끝나면 알게 되는 것.** 항목 규칙과 요청 전체 규칙의 차이, 상한을 넘으면 일부가 아니라 전체를 거절하는 까닭, 입구에서 막으면 얼마나 빨라지는지 (11건을 다 처리할 때와 비교).
> "어떤 팀은 엑셀에서 복사하다 같은 줄을 두 번 넣어 보내요. 그리고 한 번에 수백 건을 보내서 몇 분씩 걸린 적도 있어요. 같은 요청 안에 같은 번호가 또 있으면 두 번째는 받지 말고, 한 번에는 10건까지만 받아 주세요."
```bash
uv run tools/flow.py test cases/lab01-challenge2.json -v # 고치기 전
```
| 조건 | 값 |
| --- | --- |
| 같은 `id`가 또 | 두 번째부터 `rejected`, 이유 `duplicate id in this request` |
| 10건 넘음 | HTTP 400 `{"error": "too many requests (max 10)"}`, 하나도 처리하지 않음 |
| 기대 결과 | `cases/lab01-challenge2.json`, `lab01` · `lab01-challenge`도 그대로 |
### 이해하기
**입구에서 막기 (validate at the edge).** 잘못된 요청은 비싼 일(LLM · API)을 하기 **전에** 막는 것이 가장 쌉니다. 예제 2의 항목 확인이 "항목 하나"의 규칙이었다면, 이번에는 **요청 전체**의 규칙입니다.
| 규칙 | 어느 수준 | 어기면 |
| --- | --- | --- |
| id · user · text가 있어야 | 항목 | 그 항목만 거절 (나머지는 처리) |
| 같은 요청 안에 같은 id가 또 | 항목 (두 번째) | 두 번째만 거절 |
| 한 번에 10건까지 | 요청 전체 | 요청 전체 400 (하나도 처리하지 않음) |
**왜 같은 id는 멱등 키로 충분하지 않나.** 같은 id 두 개는 키가 같아 접수 건은 하나만 생깁니다. 하지만 결과에는 `created` 2건(하나는 `duplicate: true`)으로 나와, 사용자는 두 건이 접수된 줄 압니다. 그리고 LLM 분류를 두 번 합니다. **입구에서 두 번째를 거절**하면 결과가 정직하고 비용도 덜 듭니다.
**왜 상한인가.** 상한이 없으면 한 요청이 몇 분씩 n8n을 붙잡고, 그동안 다른 요청이 기다립니다. 많은 API가 한 번에 받을 수 있는 수를 정해 둡니다 (예: AWS SQS의 묶음 보내기는 한 번에 10개). 상한을 넘으면 일부만 처리하지 않고 **전체를 거절**합니다. 일부만 처리하면 어디까지 됐는지 사용자가 알기 어렵기 때문입니다.
```mermaid
flowchart LR
Q(["requests"]) --> L{"목록이고<br/>10건 이하?"}
L -->|아니오| E(["400 · 이유"])
L -->|예| V{"항목마다:<br/>빠진 것 · 앞에 나온 id?"}
V -->|맞음| P["분류 · 접수"]
V -->|아님| R["rejected · 이유"]
```
**테스트 케이스.**
| 케이스 | 보내는 것 | 기대 |
| --- | --- | --- |
| 같은 id 두 번 | `batch-dup-id` (R-401 둘 · R-402) | `created: 2, rejected: 1, duplicates: 0`, 이유 `duplicate id` |
✘ 같은 id가 두 번이면 두 번째는 거절 · 2.8s · created = 3 (expected 2); rejected = 0 (expected 1); duplicates = 1 (expected 0); results has none of ['duplicate id']
✘ 11건은 400 (한 번에 10건까지) · 11.0s · status 200 (expected 400); error has none of ['too many']
- **같은 id 두 번 ✘ `created = 3, duplicates = 1`:** 요청 안에 R-401이 두 번 있습니다. 멱등 키 덕분에 접수 건은 둘(R-401 · R-402)만 생겼습니다. 하지만 결과는 "세 건을 접수했다"고 말하고, 두 번째 R-401도 LLM 분류를 한 번 더 했습니다. 보낸 쪽의 실수(같은 id를 두 번 넣음)를 아무도 알려 주지 않습니다.
- **11건 ✘ 200 · 11.0초:** 한 번에 받을 수 있는 수의 상한이 없어 11건을 모두 분류 · 접수했습니다. 11초 동안 n8n이 이 요청 하나에 붙잡혀 있었습니다. 100건, 1000건이 와도 그대로 받습니다.
- **접수 건 ✘ `count = 13`:** 2(같은 id 케이스) + 11(이번 케이스). 막아야 할 요청이 접수 건을 11개 만들었습니다.
- **평소 요청 ✔:** 정상 3건은 원래 잘 됩니다. 이 케이스는 고친 뒤에도 ✔여야 합니다.
- 1/4는 **예상한 결과**입니다. 키는 "같은 건을 두 번 만들기"는 막지만, "이상한 요청을 받기"는 막지 않습니다 (교안 1단계 8절).
#### 고친 뒤
실행한 명령:
```bash
uv run tools/flow.py push my-work/intake.json
uv run tools/flow.py test cases/lab01-challenge2.json -v
uv run tools/flow.py test cases/lab01.json
uv run tools/flow.py test cases/lab01-challenge.json