# 2단계 · 데이터 파싱과 변환 노드
- 시간: 3~4교시 (3교시 끝에 Lab 02 예제 1, 4교시에 예제 2와 과제)
- 학습 목표
- LLM의 답은 JSON처럼 **보이는 글자**라는 것을 OUTPUT에서 구별할 수 있다.
- Code 노드로 글자를 목록으로 파싱하고, 빈 값을 채우고, 실패 이유를 남길 수 있다.
- 지시문과 JSON 스키마로 LLM에게 답의 모양을 알려 줄 수 있다.
- 뽑을 정보를 하나 늘릴 때 고칠 곳(알려 주기 · 받기 · 꺼내기)을 찾을 수 있다.
- Edit Fields와 표현식으로 간단한 변환을 할 수 있다.
- 실습: [Lab 02 · LLM 답을 데이터로 바꾸기 (예제 2개 + 과제)](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md)
```mermaid
flowchart LR
A["1. LLM 답은 글자
(예제 1)"] --> B["2. 파싱
(예제 1)"] --> C["3. 모양 알려 주기
(예제 2)"] --> D["4. 하나 더 뽑기
(과제)"] --> E["5. Edit Fields"] --> F["6. 만들고 확인하는 순서"]
```
---
## 1. LLM의 답은 "글자"다 · Lab 02 예제 1
LLM에게 "JSON으로 답해"라고 하면 이런 답이 옵니다.
```text
{"actions": [{"task": "신규 고객 문의 응답 템플릿 정리", "owner": "이준호", "due": "금요일"}, {"task": "회의실 예약 규칙 개선", "owner": "", "due": ""}]}
```
사람 눈에는 목록처럼 보이지만, n8n에게는 **따옴표로 둘러싸인 긴 글자 한 줄**입니다. 글자 상태로는 `actions`를 꺼낼 수도, 할 일 개수를 셀 수도 없습니다.
| 상태 | n8n OUTPUT에서 보이는 모습 | 할 수 있는 것 |
| --- | --- | --- |
| **글자** (문자열) | `content` 한 칸에 긴 글자 | 읽기만 |
| **데이터** (객체·목록) | Table 보기에서 `actions`가 줄줄이 펼쳐짐 | `$json.actions`, `count`, Split Out |
글자를 데이터로 바꾸는 일을 **파싱(parsing)** 이라고 합니다. 27일차 Structured Output에서 Pydantic으로 하던 일을 오늘은 Code 노드가 합니다.
예제 1에서는 LLM을 부르지 않고 위 글자를 `Fake LLM answer` 노드에 **직접 적어 넣고** 파싱부터 연습합니다. 돈도 시간도 들지 않고 매번 같은 결과가 나와서, 파싱만 따로 이해하기 좋습니다.
```mermaid
flowchart LR
T(["Start"]) --> F["Fake LLM answer
content = 글자"] --> P["Parse actions
글자 → 목록"]
```
---
## 2. 파싱: 글자를 목록으로 · Lab 02 예제 1
`Parse actions`의 코드는 네 단계입니다. 한 줄씩 외울 필요는 없고 **번호의 뜻**만 알면 됩니다.
```javascript
const raw = String($input.first().json.content ?? '').trim().replace(/^```(json)?\s*|```$/g, ''); // ① 글자 꺼내기
let data;
try {
data = JSON.parse(raw); // ② 글자 → 데이터
} catch (error) {
return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }]; // 실패해도 멈추지 않고 이유를 남김
}
const items = Array.isArray(data) ? data : (data.actions ?? []);
const actions = items
.filter(item => item && String(item.task ?? '').trim()) // ③ 빈 할 일 버리기
.map(item => ({
task: String(item.task).trim(),
owner: String(item.owner || '미정').trim() || '미정', // ④ 빈 값은 '미정'
due: String(item.due || '미정').trim() || '미정',
}));
return [{ json: { actions, count: actions.length } }]; // 항상 같은 모양으로 돌려줌
```
| 번호 | 하는 일 | 예제 1에서 확인 |
| --- | --- | --- |
| ① | 앞 노드에서 글자를 꺼냄 (모델이 ```json 으로 감싸면 벗김) | `content` |
| ② | 글자 → 데이터. 실패하면 빈 목록 + `error` | 끝 `}`를 지우면 `error: answer is not valid JSON` |
| ③ | 할 일이 빈 칸인 것은 버림 | |
| ④ | 담당자·기한이 비면 `미정` | 두 번째 할 일의 `owner`·`due`가 `미정` |
예제 2에서는 ①만 바뀝니다. 가짜 답 대신 진짜 LLM 답이 있는 곳 `choices[0].message.content`를 읽습니다.
### 2.1 어떤 답이 와도 같은 모양으로
| LLM 답 | 그대로 `JSON.parse` | 오늘 코드의 결과 |
| --- | --- | --- |
| `{"actions": [...]}` | 성공 | 그대로 |
| ```` ```json {"actions": [...]} ``` ```` | 실패 | ①에서 감싼 표시를 벗기고 성공 |
| `[{"task": ...}]` (목록만) | 성공 | 목록 그대로 사용 |
| `할 일은 다음과 같습니다 ...` (문장) | 실패 | `{actions: [], count: 0, error: ...}` |
| `{"actions": [{"task": "", ...}]}` | 성공 | 빈 할 일 버림 |
| `{"owner": null}` | 성공 | `owner: "미정"` |
마지막 열이 모두 `{actions, count}` 모양입니다. 다음 노드는 어떤 답이 와도 같은 필드를 받습니다.
### 2.2 실패를 숨기지 않기
| 결과 | `count` | `error` | 뜻 |
| --- | --- | --- | --- |
| 할 일 없는 회의 (`no-action`) | 0 | 없음 | 정상 |
| LLM 답이 깨짐 | 0 | `answer is not valid JSON` | 문제 → 실행 기록 확인 |
`error` 한 칸이 "정말 할 일이 없는 회의"와 "답이 깨진 경우"를 구별합니다. 76일차(예외 대응)에서 이런 경우를 모아 알림을 보내는 Error Workflow를 만듭니다.
---
## 3. LLM에게 답의 모양 알려 주기 · Lab 02 예제 2
예제 2는 예제 1의 앞에 **Webhook과 진짜 LLM**이 붙은 워크플로우를 가져와 실행합니다.
```mermaid
flowchart LR
W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"}
H -->|false| R0["Reject empty
400"]
H -->|true| L{{"LLM extract actions"}} --> P["Parse actions"] --> R["Send result"]
```
`LLM extract actions` 노드의 JSON 칸은 세 부분입니다. (실습 파일은 읽기 쉽게 여러 줄로 적혀 있습니다.)
```text
{{ JSON.stringify({
model: $('Settings').first().json.model,
temperature: 0,
response_format: { type: 'json_schema', json_schema: { name: 'reply', schema: { ← ① 스키마: 답의 모양
type: 'object',
properties: {
actions: { type: 'array', items: { type: 'object',
properties: { task: { type: 'string' }, owner: { type: 'string' }, due: { type: 'string' } },
required: ['task', 'owner', 'due'] } },
},
required: ['actions'],
} } },
messages: [
{ role: 'system', content: "Extract action items ... Use \"미정\" when ..." }, ← ② 지시문: 무엇을 찾을지
{ role: 'user', content: $('Settings').first().json.notes }, ← ③ 회의록 원문
],
}) }}
```
| 부분 | 하는 일 | 비유 |
| --- | --- | --- |
| ① 스키마 (`response_format`) | "이 칸들로만 답해라" — 답의 **모양**을 정함 | 빈칸이 정해진 답안지 |
| ② 지시문 (`system`) | 무엇을 찾고, 없으면 무엇을 쓸지 | 문제 설명 |
| ③ 회의록 (`user`) | 읽을 글 | 지문 |
### 3.1 스키마는 모양만 지킨다
| | 지시문만 | 지시문 + 스키마 |
| --- | --- | --- |
| 모양 | 가끔 어긋남 (설명 문장, ```json) | 정한 모양으로만 |
| 필드 이름 | 가끔 `assignee`, `deadline` | 스키마 이름 그대로 |
| **내용이 맞나** | 보장 안 함 | **보장 안 함** |
스키마를 써도 담당자를 잘못 찾거나 할 일을 빠뜨리는 것은 막지 못합니다. 4번 할 일의 기한 `10월 23일`(회의록에 직접 적히지 않은 추론)이 그 예입니다. 그래서 스키마를 써도 2절의 파싱·검증은 그대로 둡니다. 그리고 답은 여전히 **글자**(`choices[0].message.content`)로 옵니다.
Anthropic·Gemini에도 비슷한 구조화 출력 기능이 있고, 대체용 Ollama도 같은 `response_format`을 받습니다.
> **표현식 주의**: `{{ }}` 안에서 `}`와 `}`가 **붙으면** 표현식이 거기서 끝나 `invalid syntax`가 납니다. 실습 파일에서 `} }`처럼 띄어 쓴 곳은 일부러 띄운 것입니다.
---
## 4. 뽑을 것을 하나 늘리려면 · Lab 02 과제
새 정보(예: 참석자)가 결과까지 오려면 데이터가 지나가는 길을 따라 **세 곳**이 모두 알아야 합니다.
```mermaid
flowchart LR
A["① 알려 주기
스키마에 칸 추가"] --> B["② 부탁하기
지시문에 한 문장"] --> L{{"LLM 답
(글자)"}} --> C["③ 꺼내기
파싱 코드의 return"] --> R["결과"]
```
| 곳 | 빠뜨리면 |
| --- | --- |
| ① 스키마 | LLM이 그 칸을 만들지 않을 수 있음 |
| ② 지시문 | 무엇을 넣을지 몰라 비우거나 엉뚱한 값 |
| ③ 파싱 코드 | LLM 답에는 있어도 **새 상자를 만들며 버려짐** → 결과에 없음 |
③이 가장 잘 빠집니다. 파싱 코드는 `return [{ json: { actions, count } }]`처럼 **필요한 필드만 골라 새 상자를 만들기** 때문입니다. 어디서 사라졌는지 모를 때는 노드를 하나씩 열어 OUTPUT에 그 필드가 있는지 봅니다.
뽑을 것을 늘린 뒤에는 **원래 기대 결과(할 일 4건)도 다시** 확인합니다. 요청이 바뀌면 원래 뽑던 결과도 흔들릴 수 있습니다.
---
## 5. 변환: Edit Fields와 표현식
코드 없이 필드를 만들거나 값을 계산할 때는 Edit Fields(Set)를 씁니다. 예제 2의 `Settings`가 그 예입니다.
| 하고 싶은 일 | 설정 예 |
| --- | --- |
| 입력값 꺼내기 | `notes` = `{{ $json.body.notes }}` (웹훅 입력은 `body` 안에) |
| 비었으면 기본값 | `title` = `{{ $json.body.title \|\| '제목 없음' }}` |
| 공백 다듬기 | `summary` = `{{ $json.choices[0].message.content.trim() }}` |
| 조건으로 값 정하기 | `priority` = `{{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}` |
| 빈 목록 | `actions` (Array) = `{{ [] }}` |
- `{{ }}`가 들어간 값은 입력칸이 **Expression** 모드(초록색)여야 합니다. Fixed 모드면 글자 그대로 들어갑니다.
- 필드 형식(String·Number·Array·Boolean)을 맞춥니다. 예를 들어 `count`가 글자 `"0"`이면 숫자 비교가 원하는 대로 되지 않을 수 있습니다.
| Edit Fields | Code |
| --- | --- |
| 필드 몇 개를 만들거나 바꿀 때 | 파싱, 여러 줄 검증, 실패 처리 (`try / catch`) |
| 누구나 읽기 쉬움 | 자유롭지만 실수하기 쉬움 |
---
## 6. 만들고 확인하는 순서
```mermaid
flowchart LR
A["노드 하나 추가"] --> B["이름 짓기
(표현식이 이름을 씀)"] --> C["설정"] --> D["실행"] --> E["OUTPUT 확인"]
E -->|"다음 노드"| A
```
- **한 번에 다 만들지 않습니다.** 노드 하나를 붙일 때마다 실행해 OUTPUT을 봅니다.
- 노드 이름을 안내와 똑같이 짓습니다. `$('Settings')`처럼 표현식이 이름을 씁니다.
- 같은 요청 파일(`weekly`·`no-action`·`empty`)로 매번 확인해야 고친 것의 효과를 비교할 수 있습니다.
- Test URL은 **한 번에 한 요청**만 받습니다. 요청마다 `Listen for test event`를 다시 누릅니다.
---
## 7. 막혔을 때
| 증상 | 원인 | 해결 |
| --- | --- | --- |
| `invalid syntax` | 표현식 안에서 `}}`가 붙음 | `} }`로 띄어 쓰기 |
| `401` | Credential이 `OpenAI (.env)`가 아님 | Authentication 확인 |
| 값에 `{{ $json.body.notes }}` 글자가 그대로 | Fixed 모드 | Expression 모드 |
| `actions`가 비고 `error`가 있음 | LLM 답이 JSON이 아님 | 앞 노드 OUTPUT의 `content` 확인 |
| 새 필드가 결과에 없음 | 파싱 코드의 `return`에 안 넣음 | 4절 ③ |
| 고치다 망가짐 | | 예제 2 파일을 다시 가져오기 |
---
## 8. Lab 02 안내
- 실습 안내: [Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md)
| 순서 | 시간 | 할 일 | 교안 |
| --- | --- | --- | --- |
| 예제 1 | 3교시 끝 15분 (함께) | `Fake LLM answer`(글자) → `Parse actions`(목록), 끝 `}`를 지워 `error` 보기 | 1~2절 |
| 예제 2 | 4교시 15분 | `lab02-example2-meeting-part1.json` 가져오기 → `weekly`·`empty`·`no-action`, JSON 칸의 ①②③ 찾기 | 3절 |
| 과제 | 4교시 25분 | 결과에 **참석자(`attendees`)** 추가 | 4절 |
---
## 9. 스스로 확인하기
Q1. `Fake LLM answer`의 `content`와 `Parse actions`의 `actions`는 무엇이 다른가요?
`content`는 글자 한 줄이고, `actions`는 파싱해서 만든 목록입니다. 목록이어야 개수를 세고 Split Out으로 펼칠 수 있습니다.
Q2. 스키마(`response_format`)를 지정했는데도 파싱·검증이 필요한 이유는?
스키마는 모양만 지켜 줍니다. 답은 여전히 글자로 오므로 파싱이 필요하고, 빈 값이나 잘못 찾은 내용은 검증으로 걸러야 합니다.
Q3. 파싱에 실패하면 빈 목록을 돌려주면서도 `error`를 남기는 이유는?
흐름이 멈추지 않게 하면서도, "정말 할 일이 없는 회의"와 "LLM 답이 깨진 경우"를 구별하기 위해서입니다.
Q4. 스키마와 지시문에 `attendees`를 넣었는데 결과에 없습니다. 어디를 보나요?
`LLM extract actions`의 OUTPUT에는 있는데 `Parse actions`의 OUTPUT에 없다면, 파싱 코드의 `return`이 `attendees`를 넣지 않은 것입니다. 노드를 앞에서부터 하나씩 열어 어디서 사라졌는지 찾습니다.
Q5. 기한을 스키마에서 날짜 형식이 아니라 글자(`string`)로 둔 이유는?
회의록에는 "금요일", "다음 주 월요일"처럼 날짜가 아닌 표현이 많습니다. 날짜로 바꾸려면 회의 날짜를 아는 계산이 필요하므로, 필요하면 규칙으로 따로 합니다.
## 정리
- LLM의 JSON 답은 **글자**입니다. Code 노드로 파싱해야 목록이 됩니다.
- 파싱은 꺼내기 → 바꾸기(실패하면 `error`) → 빈 할 일 버리기 → `미정` 채우기, 그리고 항상 같은 모양으로 돌려줍니다.
- 스키마는 답의 **모양**, 지시문은 **무엇을 찾을지**를 알려 줍니다. 스키마도 내용은 보장하지 않습니다.
- 뽑을 것을 늘리면 **알려 주기(스키마) · 부탁하기(지시문) · 꺼내기(파싱)** 세 곳을 고칩니다.
- 간단한 변환은 Edit Fields, 파싱·검증은 Code. 노드 하나마다 실행해 OUTPUT을 확인합니다.