diff --git a/Lecture/65/01-STAGE1-TRIGGER-CONDITION-ACTION.md b/Lecture/65/01-STAGE1-TRIGGER-CONDITION-ACTION.md
new file mode 100644
index 0000000..4699b05
--- /dev/null
+++ b/Lecture/65/01-STAGE1-TRIGGER-CONDITION-ACTION.md
@@ -0,0 +1,349 @@
+# 1단계 · Trigger·Condition·Action으로 워크플로우 설계하기
+
+- 시간: 1~2교시 (1교시 앞부분은 [오늘의 개요](00-DAY65-OVERVIEW.md))
+- 학습 목표
+ - 워크플로우를 Trigger(언제)·Condition(어느 길)·Action(무엇을)으로 나누어 설명할 수 있다.
+ - 업무 상황에 맞는 Trigger 종류와 Condition 종류를 고를 수 있다.
+ - 비교하는 값의 형식 차이 때문에 생기는 분기 오류를 찾고 막을 수 있다.
+ - "결과가 없거나 예상과 다른 경우"를 받는 길까지 포함해 흐름을 설계할 수 있다.
+- 실습: [Lab 01 · Condition으로 "결과 없음" 경로 만들기](../../Last3Mon/65/LAB-01-CONDITION-IN-MINIFLOW.md)
+
+```mermaid
+flowchart LR
+ A["1. 어제의 질문"] --> B["2. Trigger"] --> C["3. Condition"] --> D["4. Action"] --> E["5. 설계 연습"] --> F["6. Lab 01"]
+```
+
+---
+
+## 1. 어제의 질문에서 출발하기
+
+64일차 마지막에 IT 문의 워크플로우에 "점심 메뉴 추천해줘"를 넣었더니, FAQ 검색 결과가 없는데도 다음 단계가 첫 번째 FAQ를 꺼내려다 멈췄습니다.
+
+```mermaid
+flowchart LR
+ A[/"문의 입력"/] --> B["FAQ 검색
결과 0건"] --> C{{"답변 초안
FAQ 첫 항목 사용"}} --> X(["멈춤 ✕"])
+```
+
+실제 업무에서는 "결과가 있을 때"와 "없을 때"를 **다른 길**로 보내야 합니다. 이렇게 흐름을 설계할 때 쓰는 세 가지 질문이 오늘의 주제입니다.
+
+| 질문 | 구성 요소 | 예 |
+| --- | --- | --- |
+| 언제 시작하나? | **Trigger** | 직원이 문의 양식을 제출했을 때 |
+| 어느 길로 갈까? | **Condition** | FAQ 검색 결과가 있는가? |
+| 무엇을 하나? | **Action** | 답변 초안 작성 / 담당자에게 전달 |
+
+```mermaid
+flowchart LR
+ T(["Trigger
문의 접수"]) --> S["FAQ 검색"] --> C{"Condition
결과가 있나?"}
+ C -->|"있음"| A1["Action
답변 초안 작성"]
+ C -->|"없음"| A2["Action
담당자에게 전달"]
+```
+
+### 1.1 코드로 만들 때와 비교하면
+
+TCA는 새로운 개념이 아닙니다. 지난 3개월 동안 FastAPI로 만든 서비스도 같은 세 부분으로 되어 있었습니다.
+
+| TCA | 코드에서 | 예 |
+| --- | --- | --- |
+| Trigger | 엔드포인트, 스케줄러, 메시지 큐 소비자 | `@app.post("/requests")` |
+| Condition | `if` / `elif` / `else`, `match` | `if not faq: return hand_off()` |
+| Action | 함수 호출, DB 저장, 응답 반환 | `draft = llm.complete(...)` |
+
+차이는 **분기가 그림으로 드러난다**는 점입니다. 코드에서는 `else`가 빠져 있어도 눈에 잘 띄지 않지만, 그림에서는 마름모에서 나가는 선이 하나뿐이면 바로 보입니다. 어제 워크플로우가 멈춘 것도 "결과 없음"이라는 선이 그림에 없었기 때문입니다.
+
+### 1.2 TCA로 생각하면 좋은 점
+
+| 좋은 점 | 설명 |
+| --- | --- |
+| 빠진 경우가 보인다 | Condition마다 "참일 때·거짓일 때·그 밖"을 적다 보면 빠진 길이 드러남 |
+| 업무 담당자와 이야기하기 쉽다 | "언제 시작하나요? 어떤 경우에 다른 사람에게 넘기나요?"는 코드 지식 없이 답할 수 있음 |
+| 도구가 바뀌어도 그대로 | n8n, miniflow, 다른 도구 모두 Trigger·분기·Action 노드가 있음 |
+| 비용을 줄이는 위치를 찾기 쉽다 | 비싼 Action(LLM)을 어느 길에만 둘지 정할 수 있음 (3단계 측정) |
+
+---
+
+## 2. Trigger: 워크플로우를 시작시키는 사건
+
+| 종류 | 언제 쓰나 | 예 | 지난 실습에서 비슷한 것 |
+| --- | --- | --- | --- |
+| 수동 실행 | 개발·테스트할 때, 사람이 직접 시작할 때 | 실행 버튼 | Streamlit 버튼 |
+| 웹훅·폼 | 다른 시스템이나 화면이 요청을 보낼 때 | 문의 양식 제출 → `POST /webhook/...` | FastAPI 엔드포인트 |
+| 일정 | 정해진 시간마다 | 매일 09:00 미처리 문의 요약 | (cron) |
+| 이벤트 | 외부에서 무언가 바뀌었을 때 | 메일 도착, 파일 업로드, DB 행 추가 | Redis Queue에 작업 등록 |
+| 다른 워크플로우 | 워크플로우를 부품처럼 나눌 때 | 공통 "알림 보내기" 워크플로우 호출 | 함수 호출 (70일차 모듈화) |
+
+Trigger를 정할 때는 **시작할 때 어떤 데이터를 받는지**도 함께 정합니다. 웹훅이라면 `{"user": "kim", "text": "..."}`처럼 받을 필드를 미리 약속해 둡니다. 받은 데이터가 비어 있거나 형식이 틀리면 바로 거절하는 Condition을 맨 앞에 두는 것이 좋습니다.
+
+### 2.1 Trigger를 고르는 네 가지 질문
+
+| 질문 | 답에 따라 |
+| --- | --- |
+| 누가 시작하나? | 사람이 직접 → 폼·웹훅 / 시스템이 → 이벤트 / 시계가 → 일정 |
+| 얼마나 자주? | 요청마다 → 웹훅 / 하루 한 번 묶어서 → 일정 |
+| 요청한 쪽이 결과를 기다리나? | 기다린다 → 웹훅 + 응답 노드 / 안 기다린다 → 처리 후 알림 |
+| 한 번에 몇 건이 들어오나? | 1건 → 웹훅 / 여러 건 → 일정 Trigger가 목록을 읽어 항목별 처리 (66일차) |
+
+예를 들어 "직원이 IT 문의를 남기면 바로 답을 받는다"는 웹훅 + 응답, "매일 아침 어제 들어온 문의를 팀장에게 요약해 보낸다"는 일정 Trigger가 맞습니다. 같은 IT 문의 업무라도 질문에 대한 답이 다르면 Trigger가 달라집니다.
+
+### 2.2 시작 데이터의 약속
+
+웹훅 Trigger라면 받을 데이터를 문서로 정해 둡니다. 오늘 실습의 IT 문의 웹훅은 다음 약속을 씁니다.
+
+```json
+{"user": "kim", "text": "VPN 접속이 계속 끊겨요"}
+```
+
+| 필드 | 필수 | 비었을 때 |
+| --- | --- | --- |
+| `user` | 예 | 이력을 남길 수 없으므로 거절하거나 `anonymous`로 처리 |
+| `text` | 예 | 처리할 내용이 없으므로 **400으로 거절** (오늘 실습의 `Has text?`) |
+
+약속을 정해 두면 Trigger를 바꿔도(폼 → 메일 → 메신저) 뒤의 흐름은 그대로 쓸 수 있습니다. 3단계에서 n8n과 miniflow가 **같은 입력 약속**을 지켜서 서로 대신할 수 있는 것도 이 때문입니다.
+
+---
+
+## 3. Condition: 데이터를 보고 길을 고르는 단계
+
+| 종류 | 길의 수 | 예 | n8n 노드 |
+| --- | --- | --- | --- |
+| 참/거짓 분기 | 2 | 긴급 문의인가? | IF |
+| 여러 갈래 분기 | 여러 개 + 그 밖의 값 | 분류가 account / network / device / software / 그 밖 | Switch |
+| 걸러내기 | 1 (조건에 맞는 항목만 통과) | 금액 10만 원 이상 주문만 | Filter |
+| 입력 검증 | 2 | 필수 필드가 있는가? | IF |
+| 오류 경로 | 2 | 외부 API 호출이 실패했는가? | 노드의 오류 출력 (76일차) |
+
+```mermaid
+flowchart LR
+ subgraph IF["IF · 두 길"]
+ direction LR
+ i1["데이터"] --> i2{"긴급?"}
+ i2 -->|true| i3["즉시 연결"]
+ i2 -->|false| i4["일반 접수"]
+ end
+ subgraph SW["Switch · 여러 길"]
+ direction LR
+ s1["분류값"] --> s2{"category"}
+ s2 -->|account| s3["계정 담당"]
+ s2 -->|network| s4["네트워크 담당"]
+ s2 -->|"그 밖"| s5["접수 창구"]
+ end
+```
+
+### 3.1 LLM의 판단으로 길을 고를 때
+
+"이 문의는 어떤 종류인가?"처럼 규칙으로 정하기 어려운 판단은 LLM에게 맡기고, 그 결과로 Switch 분기를 만들 수 있습니다. 이때 꼭 지킬 것이 있습니다.
+
+```mermaid
+flowchart LR
+ L{{"LLM 분류"}} -->|"'Software', ' network\n' ..."| N["값 정리·검증
소문자, 공백 제거,
허용 목록 확인"] -->|"정해진 값"| S{"Switch"}
+```
+
+- LLM의 답은 매번 형식이 조금씩 다를 수 있습니다(대문자, 공백, 설명 문장 추가). 64일차에 작은 모델은 `Software`처럼 대문자로 답하기도 했습니다.
+- 그래서 LLM 출력은 **정해진 값으로 정리하고 검증**한 뒤 Condition에 넘깁니다. 허용 목록에 없으면 `other`로 보냅니다.
+
+### 3.2 좋은 Condition의 조건
+
+| 원칙 | 이유 |
+| --- | --- |
+| 모든 길의 끝에 결과가 있다 | 어떤 입력이 와도 응답·저장·알림 중 하나로 끝나야 합니다 |
+| "그 밖의 값" 길(default)이 있다 | 예상하지 못한 값이 와도 멈추지 않습니다 |
+| 조건은 하나의 값만 본다 | 여러 조건을 한 번에 섞으면 왜 그 길로 갔는지 찾기 어렵습니다 |
+| 비교하는 값의 형식을 맞춘다 | `"30"`(문자)과 `30`(숫자), `"Network"`와 `"network"`는 다릅니다 |
+
+### 3.3 비교할 때 자주 걸리는 형식 함정
+
+Condition이 "이상하게" 동작할 때는 대부분 비교하는 두 값의 모양이 다릅니다.
+
+| 함정 | 왼쪽 값 | 오른쪽 값 | 결과 | 막는 방법 |
+| --- | --- | --- | --- | --- |
+| 대소문자 | `Network` | `network` | 같지 않음 | 비교 전에 소문자로 |
+| 앞뒤 공백·줄바꿈 | `" network\n"` | `network` | 같지 않음 | 비교 전에 `trim` |
+| 문장으로 답함 | `"It is network."` | `network` | 같지 않음 | 허용 목록에서 포함된 값 찾기 |
+| 문자와 숫자 | `"30"` | `30` | 연산자에 따라 다름 | 형식(String·Number)을 먼저 고름 |
+| 빈 값과 없는 값 | `""` 또는 `[]` | (필드 없음) | 검사 방법에 따라 다름 | "비어 있음(empty)" 연산자 사용 |
+
+오늘 실습의 `miniflow` Condition 노드는 `equals`, `not_equals`, `contains`, `empty`, `not_empty`, `in`, `gt`, `lt` 연산자를 씁니다. FAQ 검색 결과가 있는지는 `not_empty`로 봅니다.
+
+```json
+{"id": "has_faq", "type": "condition", "check": {"left": "{{nodes.faq.output}}", "op": "not_empty"}}
+```
+
+### 3.4 Condition의 순서: 싼 검사부터
+
+Condition을 여러 개 둘 때는 **비용이 적고 확실한 검사를 앞에** 둡니다.
+
+```mermaid
+flowchart LR
+ T(["요청"]) --> V{"입력 검증
비었나?
(0원 · 즉시)"}
+ V -->|"통과"| K{"규칙 검사
'긴급' 포함?
(0원 · 즉시)"}
+ K -->|"아니요"| L{{"LLM 분류
(비용 · 1~2초)"}} --> S{"Switch"}
+ V -->|"비었음"| R0(["거절"])
+ K -->|"예"| U(["담당자 즉시 연결"])
+```
+
+빈 요청이나 "긴급" 문의는 LLM을 부르기 전에 걸러지므로, 시간과 비용이 들지 않습니다. 75일차(비용 관리)에서 이 원칙을 숫자로 확인합니다.
+
+---
+
+## 4. Action: 각 길에서 실제로 하는 일
+
+64일차에 배운 노드들이 모두 Action이 됩니다.
+
+| Action 종류 | 예 |
+| --- | --- |
+| 데이터 처리 | 필드 추가·이름 바꾸기, 형식 변환 |
+| LLM | 분류, 요약, 답변 초안 작성 |
+| Tool·HTTP | 외부 API·DB 조회, 다른 시스템에 등록 |
+| 저장 | 처리 이력 기록 |
+| 응답·알림 | 요청한 쪽에 결과 반환, 메신저·메일 알림 |
+| 사람 확인 | 담당자에게 넘기고 승인 대기 |
+
+### 4.1 모든 길이 같은 모양으로 끝나게
+
+길이 여러 개여도 요청한 쪽이 받는 결과는 **같은 모양**인 것이 좋습니다. 오늘 실습 `06`의 두 끝 노드는 같은 필드를 가집니다.
+
+| 필드 | 자동 답변 길 (`auto_reply`) | 담당자 확인 길 (`hand_off`) |
+| --- | --- | --- |
+| `category` | LLM 분류값 | LLM 분류값 |
+| `faq_id` | `FAQ-003` 같은 번호 | 빈 값 |
+| `route` | `자동 답변` | `담당자 확인` |
+| `reply` | LLM이 쓴 답변 | "관련 안내를 찾지 못해 담당자가 확인 후 답변드리겠습니다." |
+
+요청한 쪽은 `route`만 보고 다음 일을 정하면 됩니다. 길마다 모양이 다르면 받는 쪽 코드가 길마다 따로 처리해야 합니다.
+
+### 4.2 Action에서 조심할 것
+
+| 조심할 것 | 예 | 방법 |
+| --- | --- | --- |
+| 되돌리기 어려운 Action | 메일 발송, 결제, 삭제 | 앞에 사람 확인 또는 검증 Condition |
+| 같은 요청이 두 번 오면 | 웹훅이 재시도해 같은 문의가 두 번 등록 | 요청 번호로 중복 확인 (76일차) |
+| 느린 Action | LLM 호출 2회 | 꼭 필요한 길에만 (3단계 측정: 담당자 확인 길은 LLM 1회) |
+
+---
+
+## 5. 설계 연습: IT 문의 처리를 TCA로 나누기
+
+| 구분 | 내용 |
+| --- | --- |
+| Trigger | 문의 양식 제출 (웹훅, 필드: `user`, `text`) |
+| Condition C1 | `text`가 비어 있나? → 비었으면 거절(400) |
+| Action A1 | LLM으로 분류 → 값 정리 |
+| Action A2 | FAQ 검색 |
+| Condition C2 | FAQ가 있나? |
+| Action A3 (있음) | LLM 답변 초안 → 결과 응답 (`route`: 자동 답변) |
+| Action A4 (없음) | 담당자 전달 안내 → 결과 응답 (`route`: 담당자 확인) |
+
+```mermaid
+flowchart LR
+ T(["Trigger
문의 제출"]) --> C1{"C1 내용 있음?"}
+ C1 -->|"없음"| R0(["거절 400"])
+ C1 -->|"있음"| A1{{"A1 LLM 분류
+ 값 정리"}} --> A2["A2 FAQ 검색"] --> C2{"C2 FAQ 있음?"}
+ C2 -->|"있음"| A3{{"A3 답변 초안"}} --> R1(["응답: 자동 답변"])
+ C2 -->|"없음"| A4["A4 담당자 전달"] --> R2(["응답: 담당자 확인"])
+```
+
+이 설계는 오늘 3단계에서 n8n으로 그대로 만들어 실행합니다.
+
+### 5.1 같은 설계를 확장하기: 긴급 문의
+
+Lab 01 4절에서 직접 만드는 확장입니다. "긴급"이라는 단어가 있으면 FAQ와 상관없이 바로 담당자에게 연결합니다.
+
+```mermaid
+flowchart LR
+ T(["문의"]) --> U{"is_urgent
'긴급' 포함?"}
+ U -->|"true"| UC(["urgent_call
담당자 즉시 연결"])
+ U -->|"false"| H{"has_faq"}
+ H -->|"true"| D{{"draft"}} --> AR(["자동 답변"])
+ H -->|"false"| HO(["담당자 확인"])
+```
+
+| 질문 | 답 |
+| --- | --- |
+| 새로 생긴 Condition은? | `is_urgent` (`contains` "긴급") |
+| 왜 맨 앞에 두나? | 긴급 문의는 FAQ 검색·LLM 결과를 기다릴 필요가 없음 (3.4절 싼 검사부터) |
+| 기존 흐름은 어떻게 이어지나? | `is_urgent`의 `false` 길이 `has_faq`로 이어짐 |
+
+### 5.2 설계표로 정리하기
+
+설계는 [TCA 설계 템플릿](../../Last3Mon/65/templates/TCA-DESIGN-TEMPLATE.md)에 적습니다. 64일차 Lab 03에서 분석한 시나리오를 이 표로 다시 정리하면, 오늘 배운 Condition과 예외 경로가 자연스럽게 들어갑니다.
+
+| 템플릿 칸 | 채울 내용 |
+| --- | --- |
+| Trigger | 시작 방식, 받는 필드, 비었을 때 처리 |
+| Condition | 번호·검사할 값·조건·참일 때·거짓일 때, Switch라면 default 길 |
+| Action | 길마다 할 일과 노드 종류 |
+| 흐름 그림 | mermaid로 전체 흐름 |
+
+---
+
+## 6. Lab 01 안내
+
+- 실습 안내: [Last3Mon/65/LAB-01-CONDITION-IN-MINIFLOW.md](../../Last3Mon/65/LAB-01-CONDITION-IN-MINIFLOW.md)
+- 할 일
+ 1. 어제 멈춘 워크플로우(`05`)와 Condition이 들어간 워크플로우(`06`)를 비교 실행
+ 2. Switch(`07`)로 여러 갈래 분기, `default` 길 확인
+ 3. "긴급" 문의를 바로 담당자에게 보내는 Condition 직접 추가
+
+`miniflow`에서는 분기 노드에서 나가는 연결에 길 이름을 적습니다. 선택되지 않은 길의 노드는 실행 기록에 `skipped`로 남습니다.
+
+```json
+"edges": [["has_faq", "draft", "true"], ["has_faq", "hand_off", "false"]]
+```
+
+Switch 노드는 `cases`에 정한 값과 같으면 그 이름의 길로, 아니면 `default` 길로 보냅니다.
+
+```json
+{"id": "route", "type": "switch", "value": "{{nodes.classify.output}}", "cases": ["account", "network", "device", "software"]}
+```
+
+실행 기록에서 확인할 것: 선택된 길의 이름(`branch`), `skipped` 노드, 마지막 결과의 `route`.
+
+---
+
+## 7. 자주 하는 설계 실수
+
+| 실수 | 증상 | 고치는 법 |
+| --- | --- | --- |
+| 거짓 길을 안 그림 | 결과가 없을 때 멈춤 (64일차 점심 메뉴) | Condition마다 참·거짓 길을 모두 연결 |
+| Switch에 default가 없음 | 예상 밖 값(`Software`)이 오면 아무 길도 안 감 | default(Fallback) 길 추가 |
+| LLM 답을 그대로 비교 | 대문자·공백·문장 때문에 분기 실패 | 정리·검증 단계 추가 |
+| 입력 검증을 뒤에 둠 | 빈 요청에도 LLM을 불러 비용 발생 | 입력 검증을 맨 앞에 |
+| 길마다 결과 모양이 다름 | 받는 쪽 처리가 복잡해짐 | 같은 필드로 끝나게 (`route`로 구분) |
+
+---
+
+## 8. 스스로 확인하기
+
+Q1. "매일 아침 어제 들어온 문의를 팀장에게 요약해 보낸다"에 맞는 Trigger는?
+
+일정(Schedule) Trigger입니다. 요청마다가 아니라 정해진 시간에 여러 건을 묶어 처리하고, 요청한 사람이 결과를 기다리지 않습니다.
+
+
+Q2. Switch 노드에 default(그 밖) 길이 꼭 필요한 이유는?
+
+LLM이 `Software`, `It is network.`처럼 예상 밖의 값을 내면 어떤 규칙에도 맞지 않아 흐름이 멈춥니다. default 길이 있으면 그런 값도 접수 창구 같은 곳으로 보내 결과가 남습니다.
+
+
+Q3. LLM 분류 결과를 Switch에 바로 넣지 않고 정리 단계를 두는 이유는?
+
+LLM 답은 대소문자·공백·설명 문장이 섞일 수 있습니다. 소문자 변환, 공백 제거, 허용 목록 확인을 거쳐 정해진 값으로 만든 뒤 분기해야 안전합니다.
+
+
+Q4. 빈 요청 검사를 LLM 분류보다 앞에 두는 이유는?
+
+비용이 들지 않고 즉시 끝나는 검사이기 때문입니다. 뒤에 두면 처리할 수 없는 요청에도 LLM을 불러 시간과 비용을 씁니다.
+
+
+Q5. 자동 답변 길과 담당자 확인 길이 같은 필드로 끝나야 하는 이유는?
+
+요청한 쪽이 `route` 값만 보고 처리할 수 있어 단순해집니다. 3단계의 플랫폼 전환도 모든 플랫폼이 같은 출력 약속을 지켜야 가능합니다.
+
+
+## 정리
+
+- 워크플로우는 Trigger(언제 시작)·Condition(어느 길)·Action(무엇을)으로 설계합니다. 코드의 엔드포인트·`if`·함수와 같은 구조이고, 그림이 빠진 길을 드러내 줍니다.
+- Trigger는 누가·얼마나 자주·결과를 기다리는지·몇 건인지로 고르고, 시작 데이터의 약속을 함께 정합니다.
+- Condition은 IF(두 길), Switch(여러 길), 걸러내기, 입력 검증, 오류 경로로 나뉩니다. 싼 검사를 앞에 둡니다.
+- 모든 길의 끝에 결과가 있어야 하고, 예상하지 못한 값을 받는 default 길이 필요합니다.
+- LLM의 판단으로 분기할 때는 출력값을 정리·검증한 뒤 Condition에 넘깁니다. 비교하는 값의 형식(대소문자·공백·문자/숫자)을 맞춥니다.
+- 여러 길은 같은 모양의 결과로 끝나게 하고, `route` 같은 필드로 어느 길이었는지 알려 줍니다.
diff --git a/Lecture/65/02-STAGE2-N8N-ENVIRONMENT.md b/Lecture/65/02-STAGE2-N8N-ENVIRONMENT.md
new file mode 100644
index 0000000..151c4d5
--- /dev/null
+++ b/Lecture/65/02-STAGE2-N8N-ENVIRONMENT.md
@@ -0,0 +1,399 @@
+# 2단계 · n8n 실습 환경과 기본 설정
+
+- 시간: 3~4교시
+- 학습 목표
+ - 실습 환경(WSL·Docker·n8n·OpenAI API·Ollama 대체)의 구성과 서로 연결되는 주소를 설명할 수 있다.
+ - API 키를 워크플로우에 쓰지 않고 Credential로 관리하는 이유를 설명할 수 있다.
+ - n8n 화면의 캔버스·노드 설정·실행 기록을 사용할 수 있다.
+ - n8n의 데이터 단위(항목, item)와 표현식으로 앞 노드의 값을 꺼내 쓸 수 있다.
+ - Manual·Webhook Trigger와 IF·Switch 노드로 워크플로우를 만들고 실행할 수 있다.
+- 실습: [Lab 02A · 브라우저에서 n8n 첫 워크플로우 직접 만들기 (기본)](../../Last3Mon/65/LAB-02A-FIRST-WORKFLOW-IN-BROWSER.md) → [Lab 02 · n8n 화면 익히기와 첫 워크플로우](../../Last3Mon/65/LAB-02-N8N-FIRST-WORKFLOWS.md)
+
+```mermaid
+flowchart LR
+ A["1. n8n이란"] --> B["2. 실습 환경·주소"] --> C["3. 화면"] --> D["4. 항목·표현식"] --> E["5. Trigger"] --> F["6. IF·Switch"] --> G["7. 문제 해결"]
+```
+
+---
+
+## 1. n8n은 어떤 도구인가
+
+n8n은 **오픈소스 노코드 워크플로우 자동화 도구**입니다. 내 노트북이나 회사 서버에 직접 설치해서 쓸 수 있고, 화면에서 노드를 놓고 연결해 워크플로우를 만듭니다.
+
+| 특징 | 내용 |
+| --- | --- |
+| 노드 | 수백 개의 서비스 연동 노드 (Slack·Email·Jira·Notion·DB·HTTP 등) |
+| AI | AI Agent·LLM 노드 (OpenAI/Anthropic/Gemini, Ollama 등) |
+| 코드 | 필요하면 Code 노드에 JavaScript·Python을 직접 작성 |
+| 실행 기록 | 실행마다 노드별 입력·출력·시간 저장 |
+| 설치 | Docker 한 줄로 실행, 데이터는 내 PC에 저장 |
+
+64일차의 `miniflow`와 비교하면 다음과 같습니다.
+
+| 개념 | miniflow | n8n |
+| --- | --- | --- |
+| 워크플로우 정의 | JSON 파일 직접 작성 | 화면에서 노드 배치 (내보내면 JSON) |
+| 노드 연결 | `edges` 목록 | 캔버스의 선 |
+| 앞 노드 값 꺼내기 | `{{nodes.faq.output}}` | `{{ $json.faq }}`, `{{ $('FAQ').first().json }}` |
+| 조건 분기 | `condition`, `switch` 노드 | IF, Switch 노드 |
+| 실행 기록 | `runs/*.json` | Executions 화면 |
+
+### 1.1 직접 설치(self-hosted)해서 쓰는 이유
+
+| 이유 | 설명 |
+| --- | --- |
+| 데이터가 내 PC·회사 서버에 남음 | 워크플로우·실행 기록·Credential이 Docker 저장 공간(`n8n-data`)에 저장 |
+| 사내 시스템에 바로 접속 | 같은 네트워크의 DB·API를 부를 수 있음 (67일차 사내 데이터 API) |
+| 비용 | 실행 횟수에 따른 요금이 없음 (LLM API 비용은 따로) |
+| 수업 | 모든 학생이 같은 버전(`2.42.6`)을 같은 방법으로 실행 |
+
+대신 설치·업데이트·백업을 직접 해야 합니다. 74일차(버전 관리)와 76일차(운영)에서 이 부분을 다룹니다.
+
+---
+
+## 2. 오늘의 실습 환경
+
+```mermaid
+flowchart LR
+ B["Windows 브라우저
http://localhost:5678"] --> N
+ subgraph WSL["WSL2 (Ubuntu)"]
+ M["miniflow"]
+ subgraph DC["Docker Compose 프로젝트 day65-n8n"]
+ N["n8n :5678
(공식 한 줄 설치)"] -. "대체: http://ollama:11434" .-> O["Ollama :11434
(수업 Compose)"]
+ N --- R["runners
Code 노드 실행"]
+ end
+ M -->|"localhost:5678/webhook/..."| N
+ end
+ N -->|"Credential: OpenAI (.env)"| AI["OpenAI API
api.openai.com"]
+ M -->|"Last3Mon/.env의 키"| AI
+```
+
+| 누가 | 누구를 | 어떤 주소로 | 왜 이 주소인가 |
+| --- | --- | --- | --- |
+| 브라우저 | n8n | `http://localhost:5678` | n8n 컨테이너가 5678 포트를 공개 |
+| n8n | OpenAI API | `https://api.openai.com/v1` | 키는 Credential `OpenAI (.env)`에 저장 (워크플로우에 키를 쓰지 않음) |
+| miniflow (WSL) | OpenAI API | `https://api.openai.com/v1` | 키는 `Last3Mon/.env`에서 읽음 |
+| n8n (대체) | Ollama | `http://ollama:11434` | 같은 Compose 안의 컨테이너는 **서비스 이름**으로 서로 부름 |
+| miniflow (대체) | Ollama | `http://localhost:11434` | Ollama 컨테이너가 11434 포트를 공개 |
+| miniflow (WSL) | n8n | `http://localhost:5678/webhook/...` | 웹훅 주소로 워크플로우 호출 |
+
+> 컨테이너 안에서 `localhost`는 **그 컨테이너 자신**입니다. n8n 안에서 `localhost:11434`를 부르면 Ollama가 아니라 n8n 컨테이너를 찾습니다. 9월 Weather MCP 배포 실습에서 Backend가 `http://weather-mcp:8010`을 썼던 것과 같은 원리입니다.
+
+### 2.1 설치: n8n 공식 한 줄 설치
+
+n8n은 설치 스크립트 하나로 Docker Compose 환경을 **자동으로** 만들어 줍니다 ([공식 안내](https://docs.n8n.io/deploy/host-n8n/install-options/one-line-setup)). 수업에서는 설치 폴더와 버전만 정해 사전 준비에서 실행했습니다.
+
+```bash
+cd ~ && curl -fsSL https://get.n8n.io | N8N_DIR=./day65-n8n sh -s -- --version 2.42.6
+```
+
+| 스크립트가 하는 일 | 결과 |
+| --- | --- |
+| Docker·Compose·데몬 확인, 5678 포트가 비었는지 확인 | 문제가 있으면 이유를 알려 주고 멈춤 |
+| `~/day65-n8n/compose.yml`, `.env` 작성 | n8n, `runners`(Code 노드의 코드를 n8n과 분리해 실행), `sandbox-*`·`searxng`(n8n 화면 안 AI 도우미용) |
+| 이미지 받기, 시작, `/healthz` 확인 | http://localhost:5678 |
+
+9월에는 Compose 파일을 직접 썼습니다. 지금은 **도구가 권장 구성을 만들어 주고**, 우리는 필요한 것(Ollama)만 더합니다. 수업 Compose(`Last3Mon/65/n8n/compose.yml`)는 공식 파일을 `include`로 포함하고 Ollama를 더한 것이라, 둘이 **한 프로젝트**(`day65-n8n`)로 움직입니다.
+
+| 설치 옵션 | 이유 |
+| --- | --- |
+| `N8N_DIR=./day65-n8n` | 폴더 이름 = 프로젝트 이름 → 네트워크 `day65-n8n_default`. 67일차 데이터 API가 이 네트워크에 붙음 |
+| `--version 2.42.6` | 수업 자료의 버전과 맞춤. 빼면 그날의 최신 버전 |
+
+### 2.2 켜고 끄기
+
+실습 폴더 `Last3Mon/65/n8n`에서 Docker Compose로 다룹니다 (n8n과 Ollama가 함께).
+
+| 명령 | 하는 일 | 데이터 |
+| --- | --- | --- |
+| `docker compose up -d` | 공식 설치에 Ollama를 더해 함께 실행 | n8n 쪽은 그대로, Ollama 새로 |
+| `docker compose ps` | n8n·runners·ollama가 `running`인지 확인 | |
+| `docker compose stop` | 수업이 끝나면 멈춤 | 유지 |
+| `docker compose start` | 다음 날 다시 켬 | 유지 |
+| `docker compose down` | 컨테이너 삭제 | 워크플로우·계정·Credential은 `n8n-data`에 유지 |
+| `docker compose logs n8n` | 화면이 안 열릴 때 오류 확인 | |
+
+65일차 이후 모든 n8n 실습은 오늘 띄운 이 n8n을 계속 씁니다. 매일 새로 설치하지 않습니다.
+
+### 2.3 API 키는 Credential로
+
+n8n에서 OpenAI를 부르려면 키가 필요합니다. 키를 HTTP Request 노드의 헤더에 직접 적으면 워크플로우를 내보내거나 화면을 공유할 때 키가 함께 새어 나갑니다. 그래서 키는 **Credential**(자격 증명)이라는 별도 저장소에 두고, 노드는 그 이름만 가리킵니다.
+
+```mermaid
+flowchart LR
+ E[".env
OPENAI_API_KEY
(git 제외)"] -->|"register-credentials.sh"| C["n8n Credential
'OpenAI (.env)'"]
+ C -.->|"이름으로 참조"| H["HTTP Request 노드
Authentication: OpenAI"]
+ H --> AI["OpenAI API"]
+```
+
+| 방식 | 워크플로우 JSON에 키가 들어가나 | 키를 바꿀 때 |
+| --- | --- | --- |
+| 노드에 직접 적기 | 들어감 (공유·내보내기 위험) | 모든 노드를 찾아 고침 |
+| Credential 참조 | 들어가지 않음 (이름·ID만) | Credential 하나만 고침 (`.env` 수정 후 스크립트 다시 실행) |
+
+사전 준비에서 `register-credentials.sh`가 `.env`의 키로 `OpenAI (.env)`와 `Ollama local` 두 Credential을 만들어 두었습니다. 수업 워크플로우는 이 이름을 그대로 씁니다.
+
+---
+
+## 3. n8n 화면 둘러보기
+
+```mermaid
+flowchart LR
+ A["워크플로우 목록"] --> B["캔버스
노드 배치·연결"]
+ B --> C["노드 설정 창
Parameters · INPUT · OUTPUT"]
+ B --> D["실행 버튼
Execute workflow"]
+ D --> E["Executions
실행 기록"]
+ B --> F["Publish
게시(항상 동작)"]
+```
+
+| 화면 | 하는 일 |
+| --- | --- |
+| 캔버스 | 노드를 놓고 선으로 연결. `+`를 누르거나 노드 출력 점을 끌어 다음 노드 추가 |
+| 노드 설정 창 | 왼쪽 INPUT(들어온 데이터), 가운데 Parameters(설정), 오른쪽 OUTPUT(나간 데이터) |
+| Execute workflow | 지금 화면의 워크플로우를 한 번 실행 (테스트) |
+| Executions | 지난 실행 목록. 열면 실행 당시의 노드별 데이터를 볼 수 있음 |
+| Publish | 게시하면 웹훅·일정 Trigger가 화면을 닫아도 계속 동작 |
+| Import from File | 다른 사람이 만든 워크플로우 JSON 가져오기 |
+
+### 3.1 노드 설정 창 읽는 법
+
+노드를 더블클릭하면 세 칸이 나옵니다. 64일차 실행 기록의 `args·prompt → output` 순서와 같습니다.
+
+| 칸 | 64일차 실행 기록에서 | 보는 것 |
+| --- | --- | --- |
+| INPUT (왼쪽) | 앞 노드의 `output` | 이 노드에 들어온 항목. 필드 이름을 끌어다 Parameters에 놓으면 표현식이 자동으로 만들어짐 |
+| Parameters (가운데) | `args`, `prompt` | 이 노드의 설정. 표현식은 실제 값으로 미리 보기가 나옴 |
+| OUTPUT (오른쪽) | `output` | 이 노드가 내보낸 항목. IF·Switch는 출력마다 탭이 나뉨 |
+
+OUTPUT은 **Table · JSON · Schema** 세 가지로 볼 수 있습니다. 필드 이름과 모양을 정확히 볼 때는 JSON, 여러 항목을 한눈에 볼 때는 Table이 편합니다.
+
+### 3.2 워크플로우 가져오기와 저장
+
+수업 워크플로우는 `Last3Mon/NN/n8n/workflows/*.json`으로 배포합니다. Windows 파일 선택 창에서 WSL 폴더로 가려면 주소창에 `\\wsl$\Ubuntu\home\<사용자>\aio-01d4-stu\...`를 입력합니다.
+
+- 같은 파일을 여러 번 가져오면 같은 웹훅 경로를 가진 워크플로우가 여러 개 생깁니다. 게시할 때 충돌하므로 **한 번만 가져오고, 다시 가져올 때는 기존 것을 지웁니다.**
+- 고친 뒤에는 오른쪽 위 **Save**를 누릅니다. 저장하지 않고 다른 화면으로 가면 바뀐 내용이 사라집니다.
+
+---
+
+## 4. 데이터 단위: 항목(item)
+
+n8n에서 노드 사이를 흐르는 데이터는 **항목(item)의 목록**입니다. 항목 하나는 JSON 객체 하나입니다.
+
+```json
+[
+ {"json": {"id": "T-1", "priority": "urgent"}},
+ {"json": {"id": "T-2", "priority": "normal"}},
+ {"json": {"id": "T-3", "priority": "urgent"}}
+]
+```
+
+- 대부분의 노드는 **항목마다 한 번씩** 동작합니다. 항목이 3개면 IF 노드는 3번 검사해서 true 쪽에 2개, false 쪽에 1개를 내보냅니다.
+- 64일차 `miniflow`는 한 번에 값 하나만 흘렸습니다. n8n은 여러 건을 한꺼번에 처리할 수 있다는 점이 다릅니다.
+
+```mermaid
+flowchart LR
+ S["Sample requests
항목 3개"] --> I{"Is urgent?"}
+ I -->|"true: T-1, T-3"| U["Urgent route
항목 2개"]
+ I -->|"false: T-2"| N["Normal route
항목 1개"]
+```
+
+### 4.1 표현식으로 값 꺼내기
+
+노드 설정에서 `{{ }}` 안에 JavaScript 식을 쓰면 데이터를 꺼낼 수 있습니다.
+
+| 표현식 | 의미 |
+| --- | --- |
+| `{{ $json.priority }}` | 지금 처리 중인 항목의 `priority` |
+| `{{ $json.body.text }}` | 웹훅으로 받은 요청 본문의 `text` |
+| `{{ $('Settings').first().json.model }}` | `Settings` 노드 출력의 첫 항목에서 `model` |
+| `{{ $json.text.length > 0 }}` | 식 계산도 가능 |
+
+### 4.2 `$json`과 `$('노드 이름')`의 차이
+
+| 표현식 | 어느 데이터 | 언제 쓰나 |
+| --- | --- | --- |
+| `$json` | **바로 앞 노드**가 넘겨 준 지금 항목 | 대부분의 경우 |
+| `$('Settings').first().json` | 이름으로 지정한 노드의 첫 항목 | 몇 단계 앞 노드의 값이 필요할 때 (설정값, 원래 요청) |
+| `$('Request webhook').item.json` | 이름으로 지정한 노드에서 **지금 항목과 짝인** 항목 | 여러 항목을 처리하며 원래 요청 값을 다시 볼 때 |
+
+64일차 `miniflow`의 `{{nodes.weather.output}}`이 `$('weather')`에 해당합니다. 노드 이름을 바꾸면 그 이름을 쓰는 표현식도 함께 고쳐야 하므로, **노드 이름은 처음부터 하는 일로** 짓습니다 (64일차 노드 설계 원칙).
+
+### 4.3 웹훅 데이터는 `body` 안에
+
+Webhook 노드가 내보내는 항목에는 요청 정보가 함께 들어 있습니다. 요청 본문은 `body` 안에 있습니다.
+
+```json
+{
+ "headers": {"content-type": "application/json", "...": "..."},
+ "params": {},
+ "query": {},
+ "body": {"user": "kim", "text": "VPN 접속이 계속 끊겨요"}
+}
+```
+
+그래서 웹훅 바로 뒤에서는 `{{ $json.text }}`가 아니라 `{{ $json.body.text }}`로 꺼냅니다. 처음 만들 때 가장 자주 틀리는 부분입니다. 3단계 워크플로우는 `Settings` 노드에서 `user`, `text`를 꺼내 두고 뒤에서는 `Settings` 값을 씁니다.
+
+---
+
+## 5. Trigger 노드: Manual과 Webhook
+
+| 노드 | 시작 방법 | 주소 |
+| --- | --- | --- |
+| Manual Trigger | 화면에서 Execute workflow 클릭 | 없음 |
+| Webhook | 다른 프로그램이 HTTP 요청을 보냄 | 테스트: `/webhook-test/` · 게시 후: `/webhook/` |
+
+```mermaid
+sequenceDiagram
+ participant C as curl / Python
+ participant N as n8n
+ C->>N: POST /webhook/request-route {"user":"kim","text":"..."}
+ N->>N: Has text? → Keyword classify → Switch → Team
+ N-->>C: {"category":"network","team":"네트워크 담당"}
+```
+
+- **Test URL**은 화면에서 "Listen for test event"를 누른 동안 한 번만 받습니다. 실행 과정이 캔버스에 바로 보여서 만들 때 편합니다.
+- **Production URL**은 워크플로우를 **Publish**한 뒤 언제든 받습니다. 실행 과정은 Executions에서 확인합니다.
+- 요청에 답을 돌려주려면 Webhook 노드의 응답 방식을 "Respond to Webhook 노드 사용"으로 두고, 흐름 끝에 **Respond to Webhook** 노드를 둡니다.
+
+### 5.1 Test URL과 Production URL 비교
+
+| | Test URL | Production URL |
+| --- | --- | --- |
+| 주소 | `/webhook-test/request-route` | `/webhook/request-route` |
+| 받을 수 있는 때 | "Listen for test event"를 누른 동안 1회 | Publish한 뒤 언제든 |
+| 실행 모습 | 캔버스에 지나간 길이 바로 표시 | Executions에서 열어 봄 |
+| 쓰는 때 | 만들면서 확인 | 다른 프로그램(miniflow, 업무 코드)이 부를 때 |
+| 자주 나는 오류 | 대기 중이 아니면 404 | Publish하지 않았거나 같은 경로가 둘이면 404 (`not registered`) |
+
+### 5.2 응답 상태 코드도 결과의 일부
+
+Respond to Webhook 노드는 본문뿐 아니라 HTTP 상태 코드도 정할 수 있습니다. Lab 02의 `65-02`는 빈 요청에 **400**을 돌려줍니다.
+
+| 상황 | 상태 코드 | 받는 쪽이 할 일 |
+| --- | --- | --- |
+| 정상 처리 | 200 | 결과 사용 |
+| 입력이 잘못됨 (빈 `text`) | 400 | 사용자에게 다시 입력 요청 |
+| 워크플로우가 없음·게시 안 됨 | 404 | 주소·Publish 확인 |
+
+상태 코드로 실패를 알려야 3단계의 플랫폼 전환 코드가 "이 플랫폼은 실패"를 판단할 수 있습니다.
+
+---
+
+## 6. Condition 노드: IF와 Switch
+
+| 설정 | IF | Switch |
+| --- | --- | --- |
+| 출력 | true, false 두 개 | 규칙마다 하나 + Fallback(그 밖) |
+| 조건 | 왼쪽 값 · 연산자 · 오른쪽 값 | 규칙마다 같은 형식, 위에서부터 검사 |
+| 자주 쓰는 연산자 | is equal to, contains, is empty, is not empty, is true | 같음 |
+
+- 연산자를 고를 때 값의 **형식(String·Number·Boolean)** 을 먼저 고릅니다. 숫자를 문자로 비교하면 원하는 결과가 나오지 않습니다.
+- Switch의 **Fallback Output**을 켜 두면, 어떤 규칙에도 맞지 않는 값이 그 밖의 길로 갑니다. 1단계의 "default 길이 있다" 원칙입니다.
+
+### 6.1 Lab 02의 두 워크플로우 미리 보기
+
+```mermaid
+flowchart LR
+ subgraph A["65-01 Condition basics"]
+ direction LR
+ t1(["Manual"]) --> c1["Sample requests
Code"] --> i1{"Is urgent?"}
+ i1 -->|true| u1["Urgent route"]
+ i1 -->|false| n1["Normal route"]
+ end
+ subgraph B["65-02 Webhook request router"]
+ direction LR
+ w2(["Webhook"]) --> h2{"Has text?"}
+ h2 -->|false| r0["Reject 400"]
+ h2 -->|true| k2["Keyword classify
Code"] --> s2{"Switch"} --> t2["Team ..."] --> r2["Respond"]
+ end
+ A ~~~ B
+```
+
+| | `65-01` | `65-02` |
+| --- | --- | --- |
+| 배우는 것 | 항목 여러 개가 IF에서 나뉘는 모습 | 웹훅으로 받고 분기해 응답하기 |
+| Trigger | Manual | Webhook (`/request-route`) |
+| 분류 방식 | `priority` 값 비교 | 키워드 규칙(Code) → Switch |
+| 직접 바꿔 볼 것 | 조건을 `text` contains `계정`으로 | `security`(보안) 분류 추가 |
+
+`65-02`의 분류는 LLM이 아니라 **키워드 규칙**입니다. 3단계에서 같은 자리를 LLM 분류로 바꾼 워크플로우를 실행해 보고, 무엇이 달라지는지 비교합니다.
+
+---
+
+## 7. 처음 n8n을 쓸 때 자주 막히는 곳
+
+| 증상 | 원인 | 해결 |
+| --- | --- | --- |
+| 웹훅 호출이 404 (`not registered`) | Publish 안 함, 또는 같은 워크플로우를 여러 번 가져옴 | Publish 확인, 중복 워크플로우 삭제 |
+| Test URL 호출이 404 | "Listen for test event"를 누르지 않음 | 누른 뒤 다시 호출 |
+| 표현식 결과가 비어 있음 | 웹훅 데이터를 `$json.text`로 꺼냄 | `$json.body.text` |
+| n8n에서 Ollama 연결 실패 | 주소를 `localhost:11434`로 씀 | `http://ollama:11434` (서비스 이름) |
+| OpenAI 호출 401 | Credential의 키가 틀림 | `.env` 수정 후 `register-credentials.sh` 다시 실행 |
+| 고친 내용이 사라짐 | Save를 누르지 않음 | 오른쪽 위 Save |
+| `localhost:5678`이 열리지 않음 | 컨테이너가 꺼져 있음 | `docker compose ps`, `docker compose logs n8n` |
+
+---
+
+## 8. Lab 02A · Lab 02 안내
+
+### 8.1 Lab 02A (기본): 브라우저에서 직접 만들기
+
+- 실습 안내: [Last3Mon/65/LAB-02A-FIRST-WORKFLOW-IN-BROWSER.md](../../Last3Mon/65/LAB-02A-FIRST-WORKFLOW-IN-BROWSER.md)
+- **WSL의 Docker에서 도는 n8n을 Windows 브라우저(`localhost:5678`)로 열고**, 파일을 가져오지 않고 노드를 하나씩 놓아 "긴급 문의 나누기"를 만듭니다.
+ 1. Manual Trigger → Edit Fields(`Sample request`) → If(`Is urgent?`) → 두 Edit Fields, 두 길을 모두 실행
+ 2. Webhook을 더해 WSL 터미널의 `curl`로 부르기 (Test URL → Publish 후 Production URL)
+
+```mermaid
+flowchart LR
+ C["WSL 터미널
curl"] -->|"localhost:5678/webhook/..."| N["n8n 컨테이너
(WSL Docker)"]
+ B["Windows 브라우저
캔버스 · Executions"] -->|"localhost:5678"| N
+```
+
+요청을 보내는 곳(WSL), 실행되는 곳(WSL의 컨테이너), 화면을 보는 곳(Windows)이 다르다는 것을 직접 확인하는 것이 목표입니다. 이 구조가 이해되면 Lab 02·03의 `curl`과 `miniflow call`이 무엇을 하는지 바로 보입니다.
+
+### 8.2 Lab 02: 준비된 워크플로우 가져와 다루기
+
+- 실습 안내: [Last3Mon/65/LAB-02-N8N-FIRST-WORKFLOWS.md](../../Last3Mon/65/LAB-02-N8N-FIRST-WORKFLOWS.md)
+- 할 일
+ 1. 워크플로우 JSON 가져오기 (`Import from File`)
+ 2. `65-01`: Manual Trigger → Code → IF → Set, 항목별 분기 확인
+ 3. `65-02`: Webhook → IF → Code → Switch → Respond, Test URL과 Production URL 비교
+ 4. Switch에 새 분류(보안) 추가
+
+---
+
+## 9. 스스로 확인하기
+
+Q1. n8n 컨테이너 안에서 Ollama를 부를 때 `localhost:11434`가 안 되는 이유는?
+
+컨테이너 안의 `localhost`는 n8n 컨테이너 자신입니다. 같은 Compose 안의 다른 컨테이너는 서비스 이름(`http://ollama:11434`)으로 부릅니다.
+
+
+Q2. API 키를 HTTP Request 노드 헤더에 직접 쓰지 않고 Credential로 두는 이유는?
+
+워크플로우를 내보내거나 공유해도 키가 따라가지 않고, 키를 바꿀 때 Credential 하나만 고치면 되기 때문입니다.
+
+
+Q3. 항목 3개가 IF 노드를 지나 true 2개, false 1개가 되었습니다. IF 노드는 몇 번 검사했나요?
+
+3번입니다. 대부분의 n8n 노드는 항목마다 한 번씩 동작합니다.
+
+
+Q4. 웹훅 바로 뒤 노드에서 `{{ $json.text }}`가 비어 있습니다. 무엇을 고쳐야 하나요?
+
+웹훅 요청 본문은 `body` 안에 있으므로 `{{ $json.body.text }}`로 꺼냅니다.
+
+
+Q5. miniflow 같은 다른 프로그램이 부를 주소는 Test URL과 Production URL 중 어느 것인가요?
+
+Production URL(`/webhook/...`)입니다. 워크플로우를 Publish한 뒤 언제든 받을 수 있고, Test URL은 화면에서 대기 중일 때 한 번만 받습니다.
+
+
+## 정리
+
+- n8n은 오픈소스 노코드 워크플로우 도구로, Docker로 내 PC에 실행합니다. 오늘 띄운 n8n을 79일차까지 계속 씁니다.
+- LLM은 OpenAI API이고 키는 `.env`(git 제외)와 n8n Credential에만 둡니다. 대체용 Ollama처럼 같은 Compose 안의 컨테이너는 서비스 이름(`ollama`)으로, WSL에서는 `localhost`로 접속합니다.
+- 노드 설정 창은 INPUT·Parameters·OUTPUT 세 칸이고, 64일차 실행 기록과 같은 순서로 읽습니다.
+- n8n 데이터는 항목의 목록이고, 표현식 `{{ $json.필드 }}`로 값을 꺼냅니다. 웹훅 데이터는 `body` 안에 있습니다.
+- Webhook은 테스트 주소와 게시 후 주소가 다르며, 결과를 돌려주려면 Respond to Webhook 노드가 필요합니다. 상태 코드로 실패를 알립니다.
diff --git a/Lecture/65/03-STAGE3-SAMPLE-AI-WORKFLOW.md b/Lecture/65/03-STAGE3-SAMPLE-AI-WORKFLOW.md
new file mode 100644
index 0000000..5155f1a
--- /dev/null
+++ b/Lecture/65/03-STAGE3-SAMPLE-AI-WORKFLOW.md
@@ -0,0 +1,349 @@
+# 3단계 · 샘플 AI 워크플로우와 실행 흐름
+
+- 시간: 5~6교시
+- 학습 목표
+ - LLM이 들어간 Trigger·Condition·Action 워크플로우를 노드 단위로 설명할 수 있다.
+ - 실행 기록으로 각 노드의 입력·출력과 지나간 길을 추적할 수 있다.
+ - 키워드 규칙 분류와 LLM 분류를 비교하고, 어느 길에 LLM을 둘지 시간·비용으로 판단할 수 있다.
+ - 같은 업무를 다른 플랫폼에서도 실행할 수 있도록 입력·출력 약속과 전환(Failover) 구조를 설명할 수 있다.
+ - 플랫폼 전환과 LLM 대체(OpenAI → Ollama)라는 두 층의 대체 경로를 구분할 수 있다.
+- 실습: [Lab 03 · AI 문의 처리 워크플로우와 플랫폼 전환](../../Last3Mon/65/LAB-03-AI-WORKFLOW-FAILOVER.md)
+
+```mermaid
+flowchart LR
+ A["1. 설계를 n8n으로"] --> B["2. 노드별 자세히"] --> C["3. 실행 흐름 추적"] --> D["4. 플랫폼 전환"] --> E["5. 두 층의 대체"] --> F["6. Lab 03"]
+```
+
+---
+
+## 1. 1단계 설계를 n8n으로 옮기기
+
+1단계에서 설계한 IT 문의 처리 흐름을 n8n 노드로 바꾸면 다음과 같습니다.
+
+```mermaid
+flowchart LR
+ W(["AI request webhook
Trigger"]) --> S["Settings
설정값"] --> H{"Has text?
Condition"}
+ H -->|false| R0(["Reject empty
400"])
+ H -->|true| L1{{"LLM classify
HTTP → OpenAI"}} --> N["Normalize and
search FAQ
Code"] --> F{"FAQ found?
Condition"}
+ F -->|true| L2{{"LLM draft reply
HTTP → OpenAI"}} --> A["Auto reply
Set"] --> R(["Send result
Respond"])
+ F -->|false| P["Hand off to staff
Set"] --> R
+```
+
+| 설계 (1단계) | n8n 노드 | 역할 |
+| --- | --- | --- |
+| Trigger 문의 제출 | AI request webhook | `POST /webhook/ai-request`로 `{user, text}` 받기 |
+| (설정) | Settings | LLM API 주소·모델 이름을 한곳에 모아 둠 |
+| C1 내용 있음? | Has text? (IF) | 비었으면 400 응답 |
+| A1 LLM 분류 | LLM classify (HTTP Request) | OpenAI `/chat/completions` 호출 (Credential `OpenAI (.env)`) |
+| A1 값 정리 + A2 FAQ 검색 | Normalize and search FAQ (Code) | 분류값을 허용 목록으로 정리, FAQ 검색 |
+| C2 FAQ 있음? | FAQ found? (IF) | `found`가 true인가 |
+| A3 답변 초안 | LLM draft reply (HTTP Request) | FAQ 내용으로 답변 작성 |
+| A4 담당자 전달 | Hand off to staff (Set) | 안내 문구와 `route: 담당자 확인` |
+| 응답 | Send result (Respond to Webhook) | `{category, faq_id, route, reply}` 반환 |
+
+---
+
+## 2. 노드별로 자세히 보기
+
+### 2.1 Settings: 바뀔 수 있는 값은 한곳에
+
+```text
+llm_url = https://api.openai.com/v1
+model = gpt-4.1-mini
+user = {{ $json.body.user }}
+text = {{ $json.body.text }}
+```
+
+모델이나 주소를 바꿀 때 여러 노드를 고치지 않고 이 노드만 고치면 됩니다. 뒤의 노드는 `{{ $('Settings').first().json.model }}`처럼 값을 가져다 씁니다.
+
+### 2.2 LLM 호출: HTTP Request 노드로 OpenAI 부르기
+
+| 설정 | 값 |
+| --- | --- |
+| Method · URL | `POST` `{{ $('Settings').first().json.llm_url }}/chat/completions` |
+| Authentication | Predefined Credential Type → **OpenAI** → `OpenAI (.env)` |
+| Body | JSON (아래) |
+
+```json
+{
+ "model": "gpt-4.1-mini",
+ "temperature": 0,
+ "messages": [
+ {"role": "system", "content": "Classify the IT request into exactly one of: account, network, device, software, other. Reply with the label only."},
+ {"role": "user", "content": "VPN 접속이 계속 끊겨요"}
+ ]
+}
+```
+
+- 응답은 `{"choices": [{"message": {"content": "network"}}], "usage": {...}}` 형태입니다.
+- API 키는 노드에 쓰지 않고 **Credential**에서 가져옵니다. 워크플로우를 내보내거나 공유해도 키는 따라가지 않습니다.
+- n8n에는 OpenAI 같은 LLM 전용 노드(OpenAI Chat Model 등)도 있습니다. 오늘은 **요청과 응답이 그대로 보이도록** HTTP Request 노드로 직접 호출합니다. 64일차 `miniflow`의 LLM 호출과 같은 메시지 형식입니다.
+- 대체: Ollama도 같은 형식의 주소(`http://ollama:11434/v1/chat/completions`)를 제공합니다. Settings의 `llm_url`을 `http://ollama:11434/v1`, `model`을 `gemma3:4b`로 바꾸면 그대로 동작합니다.
+
+### 2.3 LLM 출력 정리: Code 노드
+
+```javascript
+const allowed = ['account', 'network', 'device', 'software', 'other'];
+const raw = String($input.first().json.choices?.[0]?.message?.content ?? '').trim().toLowerCase();
+const category = allowed.find(a => raw.includes(a)) ?? 'other';
+```
+
+- LLM이 `Network`, ` network\n`, `It is network.`처럼 답해도 `network`로 정리합니다.
+- 허용 목록에 없으면 `other`로 보냅니다. 1단계에서 배운 "LLM 출력은 정리·검증 후 Condition에 넘긴다"를 코드로 옮긴 것입니다.
+- 같은 노드에서 FAQ 목록을 검색해 `found`(true/false)와 `faq`를 만듭니다.
+
+### 2.4 키워드 규칙 분류와 LLM 분류
+
+Lab 02의 `65-02`는 키워드 규칙(Code)으로, 오늘 `03`은 LLM으로 분류합니다. 같은 자리의 두 방법을 비교하면 언제 LLM이 필요한지 보입니다.
+
+| | 키워드 규칙 (`65-02` Keyword classify) | LLM 분류 (`03` LLM classify) |
+| --- | --- | --- |
+| 방식 | 단어 목록에 있으면 그 분류 | 문장의 뜻을 보고 분류 |
+| 시간·비용 | 즉시 · 0원 | 1~2초 · 호출 비용 |
+| 같은 입력의 결과 | 항상 같음 | 거의 같음 (`temperature: 0`) |
+| 약한 곳 | 목록에 없는 표현 ("암호를 바꾸고 싶어요"는 "비밀번호"가 없어 못 찾음, 64일차) | 형식이 흔들림 → 정리 단계 필요 |
+| 고치는 방법 | 단어 추가 (끝이 없음) | 프롬프트·허용 목록 수정 |
+
+실무에서는 둘을 함께 쓰기도 합니다. 확실한 단어("긴급", "피싱")는 규칙으로 먼저 걸러 내고(싼 검사부터, 1단계 3.4절), 나머지만 LLM에 맡깁니다.
+
+### 2.5 대체 LLM(Ollama)로 바꾸는 방법
+
+OpenAI 키·인터넷에 문제가 생기면 같은 워크플로우를 Ollama로 돌릴 수 있습니다. 바꾸는 곳은 `Settings` 노드와 인증뿐입니다.
+
+| 바꿀 곳 | OpenAI (기본) | Ollama (대체) |
+| --- | --- | --- |
+| `Settings.llm_url` | `https://api.openai.com/v1` | `http://ollama:11434/v1` |
+| `Settings.model` | `gpt-4.1-mini` | `gemma3:4b` |
+| HTTP Request 인증 | Credential `OpenAI (.env)` | Credential `Ollama local` |
+| 요청·응답 형식 | `/chat/completions` | 같음 (Ollama도 이 형식 지원) |
+
+바뀔 수 있는 값을 Settings에 모아 둔 덕분에 노드 두 개(`LLM classify`, `LLM draft reply`)를 하나씩 고치지 않아도 됩니다. 대체 모델은 처음 부를 때 모델을 메모리에 올리느라 오래 걸리고(수업 전 측정 20초 이상), 64일차에 본 것처럼 품질도 다를 수 있습니다.
+
+### 2.6 두 길이 같은 곳에서 끝나기
+
+`Auto reply`와 `Hand off to staff`는 같은 형식의 필드를 만들고, 둘 다 `Send result`로 연결됩니다. 어느 길로 가든 **응답 형식이 같기 때문에** 요청한 쪽은 `route` 값만 보고 결과를 처리할 수 있습니다.
+
+| 필드 | 자동 답변 | 담당자 확인 |
+| --- | --- | --- |
+| `category` | `network` | `other` |
+| `faq_id` | `FAQ-003` | (빈 값) |
+| `route` | `자동 답변` | `담당자 확인` |
+| `reply` | LLM이 쓴 답변 | 고정 안내 문구 |
+
+---
+
+## 3. 실행 흐름 추적하기
+
+워크플로우를 게시(Publish)한 뒤 웹훅으로 호출하면, 실행 과정은 **Executions**에 남습니다.
+
+```mermaid
+sequenceDiagram
+ participant U as 요청 (curl·Python)
+ participant W as Webhook
+ participant L as LLM classify
+ participant C as Normalize·FAQ
+ participant F as FAQ found?
+ participant D as LLM draft
+ participant R as Respond
+ U->>W: {"user":"kim","text":"VPN 접속이 계속 끊겨요"}
+ W->>L: text
+ L->>C: {"message":{"content":"network"}}
+ C->>F: {"category":"network","found":true,"faq":{"id":"FAQ-003",...}}
+ F->>D: true 길
+ D->>R: {"choices":[{"message":{"content":"VPN 클라이언트를..."}}]}
+ R-->>U: {"category":"network","faq_id":"FAQ-003","route":"자동 답변","reply":"..."}
+```
+
+수업 전에 측정한 결과입니다. 내 노트북 결과와 비교해 보세요.
+
+| 입력 | 지나간 길 | 응답 시간 |
+| --- | --- | --- |
+| VPN 접속이 계속 끊겨요 | 자동 답변 | 약 3~4초 (LLM 2회) |
+| 비밀번호를 잊어버려서 로그인이 안 돼요 | 자동 답변 | 약 3초 (LLM 2회) |
+| 점심 메뉴 추천해줘 | 담당자 확인 | 약 0.6초 (LLM 1회만 호출) |
+
+담당자 확인 길은 LLM을 한 번만 부르기 때문에 빠릅니다. **어느 길에 LLM 노드를 두느냐가 처리 시간과 비용을 정합니다.** (75일차 비용 관리)
+
+### 3.1 Executions에서 볼 것
+
+| 확인할 것 | 어디서 | 왜 |
+| --- | --- | --- |
+| LLM이 돌려준 원래 분류값 | `Normalize and search FAQ` 출력의 `raw_label` | LLM이 실제로 어떤 형식으로 답했는지 |
+| 정리된 분류값 | 같은 출력의 `category` | 정리 단계가 제대로 동작했는지 |
+| 어느 길로 갔나 | `FAQ found?`의 true/false 출력 | 분기가 예상과 같은지 |
+| 가장 오래 걸린 노드 | 각 노드 오른쪽 위의 실행 시간 | 어디를 줄여야 빠른지 (72일차) |
+
+`raw_label`과 `category`를 나란히 두는 것은 **정리 전 값을 버리지 않는** 습관입니다. 정리 단계가 값을 잘못 바꿨을 때 원래 값을 보고 원인을 찾을 수 있습니다.
+
+### 3.2 시간은 어디서 드나
+
+```mermaid
+flowchart LR
+ W["Webhook·Settings·IF
밀리초"] --> L1["LLM classify
약 1초대"] --> C["Code
밀리초"] --> L2["LLM draft reply
약 1~2초"] --> R["Respond
밀리초"]
+```
+
+64일차에 본 것과 같습니다. **전체 시간은 거의 LLM 호출 횟수가 정합니다.** 자동 답변 길(LLM 2회)이 약 3초, 담당자 확인 길(LLM 1회)이 1초 안쪽인 이유입니다. 속도를 높이려면 노드 수보다 LLM 호출을 줄이거나 한꺼번에 처리하는 방법을 먼저 봅니다.
+
+---
+
+## 4. 플랫폼에 묶이지 않는 워크플로우 실행
+
+같은 업무를 여러 플랫폼에서 실행할 수 있게 만들어 두면, 한 플랫폼에 장애가 나도 업무가 멈추지 않습니다. 오늘은 n8n과 `miniflow` 두 플랫폼이 같은 업무 `request`를 처리합니다.
+
+```mermaid
+flowchart LR
+ APP["업무 코드
call('request', inputs)"] --> FR["FailoverRunner
순서대로 시도"]
+ FR -->|"1순위"| N8["n8n 어댑터
POST /webhook/ai-request"]
+ FR -->|"1순위 실패 시"| MF["miniflow 어댑터
flows/06-...json"]
+ N8 --> OUT["같은 형식의 결과
category · faq_id · route · reply"]
+ MF --> OUT
+```
+
+```python
+class FailoverRunner:
+ def run(self, workflow, inputs):
+ attempts = []
+ for platform in self.platforms: # 예: [n8n, miniflow]
+ try:
+ result = platform.run(workflow, inputs)
+ except PlatformError as error: # 꺼짐, 시간 초과, 404 ...
+ attempts.append({"platform": platform.name, "error": str(error)})
+ continue # 다음 플랫폼으로
+ return {"served_by": platform.name, "result": result, "attempts": attempts}
+ raise PlatformError("All platforms failed")
+```
+
+수업 전 측정에서는 정상일 때 n8n이 약 2.5초(`2506.8ms`)에 처리했고, n8n을 멈추자 n8n 시도는 약 12ms 만에 실패한 뒤 miniflow가 같은 업무를 처리했습니다.
+
+| 구성 | 하는 일 |
+| --- | --- |
+| 어댑터 | 플랫폼마다 "업무 이름과 입력을 받아 결과를 돌려주는" 같은 모양의 `run()` 제공 |
+| 설정 (`platforms.json`) | 업무 이름 → 플랫폼별 실행 방법 (n8n 웹훅 경로, miniflow 파일) |
+| 실행 순서 (`WORKFLOW_PLATFORMS`) | `n8n,miniflow`처럼 환경 변수로 지정 |
+| FailoverRunner | 앞 플랫폼이 실패하면 다음 플랫폼으로 자동 전환, 시도 기록 남김 |
+
+### 4.1 무엇이 바뀌지 않고, 무엇을 다시 만들어야 하나
+
+| 플랫폼을 바꿔도 그대로인 것 | 플랫폼마다 따로 만들어야 하는 것 |
+| --- | --- |
+| 업무를 부르는 코드 (`call("request", inputs)`) | 워크플로우 자체 (n8n 화면, miniflow JSON) |
+| 입력 형식 `{user, text}` | 각 플랫폼의 설정 (주소, 인증) |
+| 출력 형식 `{category, faq_id, route, reply}` | 어댑터 클래스 (플랫폼당 한 번) |
+
+"코드를 바꾸지 않고 플랫폼을 바꾼다"는 말은 **업무를 부르는 쪽**의 이야기입니다. 이를 위해 모든 플랫폼이 **같은 입력·출력 약속**을 지켜야 합니다. 이 구조는 과정 제목의 Auto-Healing(장애 자동 복구)과 76일차 예외 대응에서 다시 다룹니다.
+
+---
+
+### 4.2 무엇을 "실패"로 볼까
+
+FailoverRunner가 다음 플랫폼으로 넘어가려면 실패를 알아차려야 합니다.
+
+| 실패 | 예 | 알아차리는 방법 |
+| --- | --- | --- |
+| 접속 불가 | n8n 컨테이너가 꺼짐 | 연결 거부 (`n8n is not reachable ...`) |
+| 시간 초과 | 응답이 너무 늦음 | 정해 둔 시간 안에 답이 없음 |
+| 워크플로우 없음 | 게시 안 됨, 경로 오타 | HTTP 404 |
+| 플랫폼 내부 오류 | 노드 실행 실패 | HTTP 500 |
+
+반대로 **입력이 잘못된 경우(400)** 는 다른 플랫폼으로 넘겨도 입력이 그대로라 올바른 결과가 나오지 않습니다. 이런 요청은 넘기지 않고 바로 돌려보내는 것이 맞습니다.
+
+그런데 오늘의 FailoverRunner는 아직 이 둘을 나누지 않습니다. 빈 문의로 직접 확인해 보세요.
+
+```bash
+uv run miniflow call request -i user=kim -i text=""
+```
+
+```text
+ n8n failed 108.5ms n8n returned HTTP 400 for http://localhost:5678/webhook/ai-request
+ miniflow ok 1465.4ms
+Served by: miniflow → route: 담당자 확인
+```
+
+n8n은 빈 문의를 400으로 거절했는데, 실행기가 이것도 "플랫폼 실패"로 보고 miniflow에 넘겼고, `06`에는 입력 검증이 없어 빈 문의를 담당자에게 보내 버렸습니다. 두 플랫폼이 **"빈 입력은 거절"이라는 약속까지 같아야** 진짜 같은 업무이고, 실행기는 400을 넘기지 않아야 합니다. "어떤 실패는 다시 시도하거나 넘기고, 어떤 실패는 바로 돌려보낼지"는 72일차(안정성)와 76일차(예외 대응)에서 자세히 나눕니다.
+
+---
+
+## 5. 두 층의 대체 경로
+
+오늘 실습에는 대체 경로가 두 층 있습니다.
+
+```mermaid
+flowchart TB
+ subgraph P["플랫폼 층 (FailoverRunner)"]
+ direction LR
+ p1["n8n"] -->|"실패하면"| p2["miniflow"]
+ end
+ subgraph L["LLM 층 (MINIFLOW_LLM_FALLBACK)"]
+ direction LR
+ l1["OpenAI gpt-4.1-mini"] -->|"실패하면"| l2["Ollama gemma3:4b"]
+ end
+ P ~~~ L
+```
+
+| 층 | 무엇이 실패할 때 | 무엇으로 넘어가나 | 오늘 확인 |
+| --- | --- | --- | --- |
+| 플랫폼 | n8n이 꺼짐·응답 없음 | miniflow | `docker compose stop n8n` 후 `miniflow call request` |
+| LLM | OpenAI 키 오류·한도·인터넷 | Ollama | 잘못된 키로 `miniflow run` (이 명령에서만, `.env`는 그대로) |
+
+수업 전 측정에서 잘못된 키로 실행하자 `openai failed (...); trying ollama`가 찍히고 Ollama(`gemma3:4b`)가 답해 같은 `자동 답변`이 나왔습니다. 두 층을 함께 쓰면 "n8n도 꺼지고 OpenAI도 안 되는" 상황에서도 miniflow + Ollama로 업무가 이어집니다. 단, 대체 쪽은 느리고 품질이 다를 수 있으므로 **어느 쪽이 처리했는지 기록**(`served_by`, `provider`)을 꼭 남깁니다.
+
+---
+
+## 6. 자주 생기는 문제
+
+| 증상 | 원인 | 해결 |
+| --- | --- | --- |
+| 모든 요청이 담당자 확인으로 감 | `category`가 늘 `other` → `raw_label` 확인 필요 | LLM 응답 형식·정리 코드 확인 |
+| `LLM classify`가 401 | Credential 키 오류 | `.env` 확인 후 `register-credentials.sh` |
+| Ollama로 바꿨더니 연결 실패 | `llm_url`을 `localhost`로 씀 | `http://ollama:11434/v1` |
+| `miniflow call`이 n8n에서 404 | `03` 워크플로우를 Publish하지 않음 | Publish 토글 확인 |
+| 전환이 일어나지 않음 | n8n이 켜져 있음 | `docker compose stop n8n` 후 다시 |
+
+---
+
+## 7. Lab 03 안내
+
+- 실습 안내: [Last3Mon/65/LAB-03-AI-WORKFLOW-FAILOVER.md](../../Last3Mon/65/LAB-03-AI-WORKFLOW-FAILOVER.md)
+- 할 일
+ 1. `03-ai-request-flow.json` 가져오기, Settings 확인, Publish
+ 2. 자동 답변·담당자 확인 두 길을 실행하고 Executions에서 `raw_label`과 `category` 비교
+ 3. `uv run miniflow call request ...`로 n8n 호출, n8n을 멈추고 miniflow로 전환되는지 확인
+ 4. 정리 질문 작성
+
+---
+
+## 8. 스스로 확인하기
+
+Q1. "점심 메뉴 추천해줘"가 다른 문의보다 훨씬 빨리 끝나는 이유는?
+
+FAQ가 없어 담당자 확인 길로 가므로 LLM을 분류에 한 번만 부르기 때문입니다. 자동 답변 길은 분류와 답변 초안에 두 번 부릅니다.
+
+
+Q2. Executions에서 `raw_label`이 `Network`, `category`가 `network`였습니다. 무엇을 알 수 있나요?
+
+LLM이 대문자로 답했지만 정리 단계가 정해진 값으로 바꿔 분기가 제대로 동작했다는 것입니다. 원래 값을 남겨 두었기 때문에 이런 확인이 가능합니다.
+
+
+Q3. 빈 요청(400)을 받았을 때 FailoverRunner가 다음 플랫폼으로 넘기지 않아야 하는 이유는?
+
+입력이 잘못된 요청은 어느 플랫폼으로 넘겨도 올바른 결과가 나오지 않습니다. 오늘 실행기는 아직 400도 넘기기 때문에, 빈 문의가 miniflow에서 `담당자 확인`으로 처리되어 버립니다(miniflow `06`에는 입력 검증이 없음). 넘기면 시간만 더 들고, 잘못된 결과가 나올 수도 있습니다.
+
+
+Q4. 플랫폼을 n8n에서 miniflow로 바꿔도 업무를 부르는 코드를 고치지 않아도 되는 조건은?
+
+두 플랫폼이 같은 입력 약속(`user`, `text`)과 같은 출력 약속(`category`, `faq_id`, `route`, `reply`)을 지키는 것입니다.
+
+
+Q5. OpenAI 키가 틀렸을 때와 n8n이 꺼졌을 때, 각각 어느 층의 대체 경로가 동작하나요?
+
+키가 틀리면 LLM 층(OpenAI → Ollama), n8n이 꺼지면 플랫폼 층(n8n → miniflow)이 동작합니다.
+
+
+## 정리
+
+- 1단계의 TCA 설계는 n8n의 Webhook·IF·HTTP Request·Code·Set·Respond 노드로 그대로 옮길 수 있습니다.
+- 바뀔 수 있는 값은 Settings 노드에 모으고, LLM 출력은 Code 노드에서 정리·검증합니다.
+- 여러 길이 같은 응답 형식으로 끝나면 요청하는 쪽의 처리가 단순해집니다.
+- 입력·출력 약속을 지키면 업무 코드를 바꾸지 않고 플랫폼을 바꾸거나, 장애 시 다른 플랫폼으로 자동 전환할 수 있습니다.
+- 키워드 규칙은 빠르고 일정하지만 표현이 달라지면 놓치고, LLM 분류는 뜻을 알지만 형식 정리가 필요합니다. 전체 시간은 거의 LLM 호출 횟수가 정합니다.
+- 대체 경로는 플랫폼 층(n8n → miniflow)과 LLM 층(OpenAI → Ollama) 두 층이며, 어느 쪽이 처리했는지 기록을 남깁니다.