n8n로 AI 워크플로 자동화
Webhook → AI 노드 → 분기 → 외부 연동까지, 실제로 따라 만드는 프로덕션 워크플로 설계
n8n은 노드 기반 워크플로 자동화 도구다. Zapier·Make와 같은 카테고리지만 개발자 입장에서 결정적 차이가 셋 있다. (1) fair-code 라이선스로 셀프호스팅이 가능해서 데이터를 자기 인프라에 둘 수 있고, (2) 거의 모든 노드 사이에 **Code 노드(JavaScript/Python)**로 빠져나갈 구멍이 있어서 "이 자동화 도구로는 안 되는 일"이 거의 없으며, (3) 실행당 과금이 아니라 셀프호스팅이면 비용이 실질적으로 인프라 비용뿐이다. AI 에이전트·RAG·LLM 체이닝을 붙이기 시작하면 Zapier의 task 과금은 금방 비현실적이 되는데, n8n은 여기서 진가가 나온다.
개발자 입장에서 n8n을 제대로 쓰는 핵심은 "GUI로 클릭만 하는 도구"가 아니라 데이터 흐름을 코드처럼 다루는 도구로 보는 것이다. 모든 노드는 items 배열을 입력받아 items 배열을 출력하고, 노드 사이는 표현식(expression)으로 값을 참조한다. 이 멘탈 모델 하나만 정확히 잡으면 나머지는 전부 응용이다.
이 가이드는 설치(Docker 셀프호스팅)부터 데이터 모델(item/json/binary), 트리거(Webhook·Schedule·polling), LangChain 기반 AI 노드, 제어 흐름(IF/Switch·루프·에러 핸들링), 그리고 인입 문서 분류·RAG Q&A 봇·슬랙 알림 같은 실전 시나리오, 마지막으로 시크릿·버전 관리·로깅 같은 운영까지 다룬다. 각 섹션은 실제 노드 설정값과 동작하는 표현식/코드를 담는다. 읽고 나면 빈 캔버스에서 프로덕션급 AI 워크플로를 직접 만들 수 있는 수준을 목표로 한다.
n8n 멘탈 모델: 모든 것은 item 배열이다
n8n을 다루기 전에 단 하나만 머리에 박으면 나머지가 전부 쉬워진다. 모든 노드는 item의 배열을 입력받아 item의 배열을 출력한다.
하나의 item은 두 부분으로 구성된다.
{
"json": { "id": 42, "email": "a@b.com", "score": 0.91 },
"binary": {
"data": { "mimeType": "application/pdf", "fileName": "doc.pdf", "data": "<base64>" }
}
}
json— 구조화 데이터(객체). 표현식에서$json.email로 접근.binary— 파일/바이너리. 키 이름(보통data)으로 접근하며, 실제 바이트는 메모리/디스크에 따로 저장되고 여기엔 메타만 들어간다.
노드가 입력으로 item 3개를 받으면, 대부분의 노드는 각 item마다 한 번씩 실행된다. 즉 HTTP Request 노드 앞에 item이 3개 있으면 HTTP 호출이 3번 나간다. 이게 n8n의 가장 흔한 함정이자 가장 강력한 기능이다. 루프를 명시적으로 안 돌려도 item 배열이 곧 루프다.
입력 참조 표현식 (핵심 4개만 외워도 됨):
| 표현식 | 의미 |
|---|---|
{{ $json.fieldName }} | 현재 item의 json 필드 |
{{ $json["field name"] }} | 공백/특수문자 있는 키 |
{{ $node["Webhook"].json.body }} | 이름으로 특정 노드 출력 참조 |
{{ $items("Set")[0].json.x }} | 다른 노드의 전체 item 배열 인덱싱 |
표현식은 {{ }} 안에서 JavaScript가 동작한다. {{ $json.price * 1.1 }}, {{ $json.name.toUpperCase() }}, {{ new Date().toISOString() }} 전부 된다.
흔한 함정: "왜 노드가 0번 실행됐지?"의 90%는 입력 item이 0개여서다. 앞 노드가 빈 배열을 출력하면 뒤 노드는 아예 실행되지 않는다(에러가 아니라 skip). 디버깅할 때는 항상 앞 노드의 출력 item 개수부터 본다.
베스트 프랙티스: 데이터 형태를 바꾸는 로직은 Set 노드나 Code 노드로 명시적으로 정규화해두고 그 뒤 노드들은 깔끔한 $json만 보게 한다. 웹훅 raw body나 외부 API 응답을 그대로 10개 노드에 끌고 다니면 나중에 구조가 바뀔 때 전부 깨진다.
설치: Docker 셀프호스팅과 환경변수
개발자라면 npx로 띄우는 것보다 Docker Compose로 Postgres와 함께 올리는 걸 권한다. n8n 기본값은 SQLite인데, 실행 데이터가 쌓이면 SQLite는 금방 한계가 온다. 프로덕션은 처음부터 Postgres로 간다.
# docker-compose.yml
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: n8n
volumes:
- pgdata:/var/lib/postgresql/data
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: unless-stopped
ports:
- "5678:5678"
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${DB_PASSWORD}
# 외부에서 웹훅이 들어올 공개 URL — 매우 중요
WEBHOOK_URL: https://n8n.example.com/
N8N_HOST: n8n.example.com
N8N_PROTOCOL: https
# 자격증명 암호화 키 — 한 번 정하면 절대 바꾸지 말 것
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
GENERIC_TIMEZONE: Asia/Seoul
TZ: Asia/Seoul
# Code 노드에서 외부 npm 모듈 허용 (선택)
NODE_FUNCTION_ALLOW_EXTERNAL: ""
volumes:
- n8ndata:/home/node/.n8n
depends_on:
- postgres
volumes:
pgdata:
n8ndata:
반드시 짚어야 할 변수:
N8N_ENCRYPTION_KEY— 모든 자격증명(API 키 등)이 이 키로 암호화된다. 이 키를 잃으면 저장된 모든 자격증명이 복호화 불가가 되어 전부 다시 입력해야 한다. 처음에openssl rand -hex 24로 생성해서 시크릿 매니저에 보관하라. 마이그레이션할 때 이 키만 안 옮기면 새 인스턴스에서 자격증명이 다 깨진다.WEBHOOK_URL/N8N_HOST— 리버스 프록시(Nginx/Caddy/Traefik) 뒤에 두면 n8n이 자기 외부 주소를 모른다. 이걸 명시 안 하면 Webhook 노드가 보여주는 URL이localhost:5678로 나와서 외부 서비스가 콜백을 못 보낸다. 웹훅이 안 들어오는 사고의 1순위 원인이다.GENERIC_TIMEZONE— Schedule 트리거의 cron이 이 타임존 기준으로 돈다. 안 박으면 UTC라서 한국 시간 09:00에 돌린다고 했는데 18:00에 도는 일이 생긴다.
스케일 모드(선택): 트래픽이 커지면 EXECUTIONS_MODE=queue로 메인 프로세스와 워커를 분리하고 Redis를 큐로 붙인다. 단일 인스턴스로 시작하되, 이 분리가 가능하다는 것만 기억해두면 된다.
함정: n8n:latest 태그는 자동 업데이트가 아니다(이미지를 다시 pull해야 함). 그리고 마이너 업그레이드 시에도 DB 마이그레이션이 돈다 — 업그레이드 전 Postgres 볼륨 백업은 필수다.
트리거 1: Webhook 노드 — 외부에서 워크플로를 깨우기
Webhook 노드는 n8n을 HTTP 엔드포인트로 만든다. AI 워크플로의 가장 흔한 진입점이다(폼 제출, 결제 콜백, 슬랙 슬래시 커맨드, 다른 서비스의 webhook).
기본 설정:
- HTTP Method:
POST(보통) - Path:
lead-intake같은 고유 경로 → 최종 URL은https://n8n.example.com/webhook/lead-intake - Respond:
When Last Node Finishes/Immediately/Using Respond to Webhook Node중 선택
Test URL vs Production URL — 가장 헷갈리는 지점:
n8n에는 두 개의 웹훅 URL이 있다.
| 경로 | 활성 조건 | |
|---|---|---|
| Test | /webhook-test/lead-intake | 에디터에서 "Listen for test event" 누른 동안만, 1회성 |
| Production | /webhook/lead-intake | 워크플로가 Active 상태일 때 상시 |
외부 서비스에 등록할 땐 반드시 **production URL(/webhook/)**을 쓰고 워크플로를 Active로 켜야 한다. 테스트할 때 잘 되던 게 배포하면 404가 나는 사고의 대부분이 이것이다.
들어온 데이터 접근:
// 표현식에서
{{ $json.body }} // POST 바디 (파싱된 JSON)
{{ $json.headers }} // 헤더 객체
{{ $json.query }} // 쿼리 스트링
{{ $json.body.email }} // 바디 안의 필드
서명 검증(보안 필수): 공개 웹훅은 누구나 호출할 수 있다. 외부 서비스가 HMAC 서명을 보내면 Code 노드로 검증하라.
// Code 노드 (Run Once for All Items)
const crypto = require('crypto');
const secret = $env.WEBHOOK_SECRET; // 환경변수
const signature = $json.headers['x-signature'];
const rawBody = JSON.stringify($json.body);
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (signature !== expected) {
throw new Error('Invalid signature'); // 워크플로 중단
}
return $input.all();
주의: HMAC을 진짜로 정확히 하려면 파싱 후 재직렬화한 body가 아니라 원본 raw body 바이트로 검증해야 서명이 맞는다(키 순서·공백 차이로 깨질 수 있음). 엄격하게 가려면 Webhook 노드 옵션에서 raw body를 보존하는 설정을 켜고 그 바이트로 HMAC을 돌려야 한다.
응답 제어: 무거운 AI 처리를 하는데 호출자에게 빠른 200이 필요하면, Webhook 노드의 Respond를 Immediately로 두거나, 별도 Respond to Webhook 노드를 워크플로 앞쪽에 배치해 먼저 응답하고 뒤에서 무겁게 처리한다. 호출자가 타임아웃을 거는 경우(슬랙 3초, 결제 게이트웨이 등) 필수 패턴이다.
트리거 2: Schedule와 폴링 — 시간 기반 자동화
외부 이벤트가 아니라 주기적으로 도는 워크플로는 Schedule Trigger를 쓴다.
Schedule Trigger 설정:
- Trigger Interval:
Seconds / Minutes / Hours / Days / Weeks / Custom (Cron) - 정교하게는 Cron 표현식을 직접 쓴다.
# 평일 오전 9시 (GENERIC_TIMEZONE 기준)
0 9 * * 1-5
# 15분마다
*/15 * * * *
# 매일 자정과 정오
0 0,12 * * *
타임존 함정: cron은 컨테이너의 GENERIC_TIMEZONE을 따른다. Asia/Seoul로 안 박았으면 위 0 9는 UTC 09:00 = KST 18:00이 된다. 워크플로별로 타임존을 override하고 싶으면 워크플로 설정의 Timezone에서 바꿀 수 있다.
폴링 트리거: 많은 통합 노드(Gmail Trigger, Airtable Trigger 등)는 "새 항목"을 감지하기 위해 주기적 폴링을 한다. 내부적으로 마지막 처리 시점을 저장해두고 그 이후 항목만 가져온다. 폴링 간격이 너무 짧으면 외부 API rate limit에 걸리고, 너무 길면 반응이 느리다. 보통 1~5분이 균형점이다.
중복 처리 방지 — 폴링의 핵심 함정: 폴링은 "마지막 처리 시점"에 의존한다. 워크플로가 중간에 실패하거나 n8n이 재시작되면 같은 항목을 두 번 처리할 수 있다. AI 워크플로에서 이건 비싸다(LLM 호출 2배 + 중복 슬랙 알림). 멱등성(idempotency)을 직접 보장해야 한다.
// Code 노드: 이미 처리한 ID는 거른다
// (Postgres/Redis 같은 외부 스토어에 처리 기록을 두는 게 정석.
// 여기선 워크플로 스태틱 데이터로 간단 버전)
const data = $getWorkflowStaticData('global');
data.processed = data.processed || {};
const out = [];
for (const item of $input.all()) {
const id = item.json.id;
if (!data.processed[id]) {
data.processed[id] = true;
out.push(item);
}
}
return out;
$getWorkflowStaticData('global')는 실행 간에 유지되는 작은 영속 저장소다. 다만 대량 데이터엔 부적합하니, 진짜 중요한 멱등성은 Postgres에 unique 제약을 건 테이블이나 Redis SETNX로 처리하는 게 안전하다.
Manual Trigger: 개발 중에는 캔버스의 수동 트리거로 "Execute Workflow"를 눌러 테스트한다. 프로덕션 워크플로에는 Schedule이나 Webhook 트리거가 들어가야 하고, Manual은 디버깅 전용이다.
데이터 변환: Set·Edit Fields·Code 노드
노드 사이에서 데이터 형태를 다듬는 일이 실제 작업의 절반이다. 세 가지 도구를 상황에 맞게 쓴다.
Set / Edit Fields 노드 — 선언적 변환:
새 필드를 만들거나 필드를 골라내는 가벼운 변환. "Keep Only Set Fields"를 켜면 지정한 필드만 남기고 나머지는 버린다(다운스트림 노드에 깔끔한 페이로드를 주는 정석 패턴).
name = {{ $json.body.full_name }}
email = {{ $json.body.email.toLowerCase() }}
created = {{ $now.toISO() }}
source = webhook
$now는 n8n이 제공하는 Luxon DateTime이라 .toISO(), .plus({ days: 7 }) 같은 메서드가 바로 된다.
Code 노드 — 임의 로직:
Set으로 표현 못 하는 로직은 Code 노드로 간다. 모드가 두 개라는 게 핵심이다.
| 모드 | 의미 | 반환 |
|---|---|---|
| Run Once for All Items | 전체 item을 한 번에 처리 | item 배열 |
| Run Once for Each Item | item마다 한 번씩 실행 | 단일 item |
// Run Once for All Items — 집계/필터/재구조화에 적합
const items = $input.all();
const total = items.reduce((s, i) => s + (i.json.amount || 0), 0);
return [{ json: { count: items.length, total } }];
// Run Once for Each Item — item별 변환에 적합
const j = $input.item.json;
j.fullName = `${j.firstName} ${j.lastName}`;
j.domain = j.email.split('@')[1];
return $input.item;
반환 형태 함정: Code 노드는 반드시 [{ json: {...} }] 형태의 배열을 반환해야 한다(Each 모드는 단일 item). 그냥 return { foo: 1 } 하면 "A 'json' property isn't an object" 류 에러가 난다. 객체를 { json: ... }로 감싸는 걸 잊지 말 것.
파이썬: Code 노드는 Python(Pyodide 기반)도 지원하지만, npm 생태계 접근성과 성능 면에서 JavaScript가 기본값으로 더 매끄럽다. 무거운 데이터 과학 라이브러리가 필요하면 차라리 HTTP Request로 별도 파이썬 서비스를 호출하는 편이 낫다.
베스트 프랙티스: 비즈니스 로직을 Code 노드 10개에 흩뿌리지 말고, 한두 개의 "정규화" Code 노드에 모아라. 워크플로를 나중에 읽는 사람(미래의 당신)이 데이터 형태를 한곳에서 파악할 수 있어야 한다.
AI 노드: LangChain 기반 AI Agent 구성
n8n의 AI 기능은 LangChain을 백엔드로 쓰는 별도 노드 묶음으로 제공된다. 핵심은 AI Agent 노드와 거기에 꽂는 서브 노드들이다. 일반 노드와 연결 방식이 다른 게 포인트다 — AI Agent 노드 아래쪽에 Chat Model·Memory·Tool을 별도 커넥션 포트로 꽂는다.
┌──────────────────────┐
입력 ───▶ │ AI Agent │ ───▶ 출력
└──────────────────────┘
▲ ▲ ▲
Chat Model Memory Tool(s)
1. Chat Model 노드 — 실제 LLM. 모델 노드를 고르고 자격증명을 연결한다.
LLM 제공자로 Anthropic Claude를 쓰는 걸 권한다. n8n에는 Anthropic Chat Model 노드가 있고, 자격증명에 API 키만 넣으면 된다. 에이전트형·도구 호출(tool use)·긴 컨텍스트 작업에서 Claude가 안정적이다. 모델 ID는 하드코딩하지 말고 노드 드롭다운에서 현재 사용 가능한 최신 모델을 고른다(모델 ID는 시기에 따라 바뀌므로 외워 쓰지 말 것). 일반적으로 temperature는 분류·추출 같은 결정적 작업이면 0에 가깝게, 생성·요약이면 0.3~0.7로 둔다.
2. Memory 노드 — 대화 맥락 유지. 챗봇이면 Window Buffer Memory를 꽂아 최근 N턴을 기억하게 한다. sessionKey를 사용자 ID로 주면 사용자별로 대화가 분리된다. 단발성 처리(분류·추출)에는 메모리가 필요 없다 — 오히려 비용만 늘린다.
3. Tool 노드 — 에이전트가 호출할 수 있는 도구. HTTP Request Tool, Calculator, Code Tool, 그리고 다른 n8n 워크플로를 도구로 노출하는 것까지 가능하다. 에이전트가 "이 정보가 필요하다"고 판단하면 도구를 호출하고 결과를 받아 추론을 잇는다.
System prompt 설정: AI Agent 노드의 옵션에서 시스템 프롬프트를 정의한다. 여기서 역할·제약·출력 형식을 못박는다.
You are a support triage assistant.
Classify the user's message and decide which tool to call.
Never invent order numbers. If you lack an order number, ask for it.
Respond in Korean.
구조화 출력이 필요할 때: 에이전트의 자유 텍스트가 아니라 엄격한 JSON이 필요하면 두 갈래다. (a) AI Agent 대신 Basic LLM Chain + Structured Output Parser를 써서 스키마를 강제하거나, (b) 시스템 프롬프트에 JSON 스키마를 명시하고 뒤에 Code 노드로 파싱·검증한다. RAG·분류처럼 다운스트림이 JSON을 먹어야 하는 경우 (a)가 깔끔하다.
함정 — 도구 남발: 에이전트에 도구를 10개 꽂으면 모델이 어떤 걸 언제 쓸지 헷갈려서 엉뚱한 호출을 한다. 도구는 꼭 필요한 2~4개로 좁히고, 각 도구의 description을 명확히 써라(에이전트는 description만 보고 도구를 고른다). description이 부실하면 에이전트가 도구를 안 쓰거나 잘못 쓴다.
제어 흐름: IF, Switch, 루프, Merge
AI가 분류를 내놓으면 그 결과로 분기해야 한다. n8n의 분기는 "item을 어느 출력으로 보낼지"의 문제다.
IF 노드 — 2-way 분기:
조건을 만족하는 item은 true 출력으로, 아니면 false 출력으로 나간다. item 배열이 두 갈래로 쪼개진다.
Condition: {{ $json.score }} is greater than 0.8
→ true: 자동 승인 경로
→ false: 사람 검토 경로
타입 주의: n8n 조건은 타입에 민감하다. {{ $json.score }}가 문자열 "0.9"면 숫자 비교가 깨질 수 있다. 앞에서 Number(...)로 캐스팅하거나 노드의 타입 검증 옵션을 확인하라.
Switch 노드 — N-way 분기:
분류 결과가 여러 카테고리면 Switch가 정석이다. 규칙별로 출력을 만든다.
Mode: Rules
Rule 1: {{ $json.category }} equals "billing" → output 0
Rule 2: {{ $json.category }} equals "technical" → output 1
Rule 3: {{ $json.category }} equals "sales" → output 2
Fallback output → 기타 처리
Fallback을 반드시 연결하라. 매칭 안 되는 item이 조용히 사라지면(=다음 노드 실행 안 됨) "왜 어떤 건들이 처리가 안 되지?" 디버깅에 시간을 버린다.
루프 (Loop Over Items / Split in Batches):
앞서 말했듯 item 배열 자체가 곧 루프다. 보통은 명시적 루프가 필요 없다. 하지만 배치 단위로 끊어서 외부 API rate limit을 피하거나, 누적 상태를 들고 반복해야 하면 Loop Over Items(Split in Batches) 노드를 쓴다.
Loop Over Items (batchSize: 10)
→ HTTP Request (배치당 10건)
→ Wait (1초)
→ 다시 Loop로 (루프백 커넥션)
루프백 커넥션을 잊으면 첫 배치만 처리하고 끝난다. 루프 노드의 "done" 출력은 모든 배치가 끝난 뒤 한 번 실행된다.
Merge 노드 — 갈래 합치기:
분기했던 흐름이나 서로 다른 소스를 다시 합칠 때 쓴다. 모드가 중요하다.
| 모드 | 동작 |
|---|---|
| Append | 두 입력의 item을 단순 이어붙임 |
| Combine (by key) | 공통 키로 join (SQL JOIN처럼) |
| Combine (by position) | 같은 인덱스끼리 병합 |
AI 결과와 원본 데이터를 다시 합쳐야 할 때(예: LLM이 만든 요약을 원본 레코드에 붙이기) Combine by key를 자주 쓴다. 키가 안 맞으면 join 결과가 비니 키 일치 여부를 먼저 확인하라.
에러 핸들링과 재시도: 프로덕션의 진짜 난이도
데모는 happy path만 돌면 되지만 프로덕션은 외부 API가 죽고, LLM이 rate limit을 뱉고, JSON 파싱이 깨진다. n8n에서 에러를 다루는 레이어가 여러 개다.
1. 노드 레벨 — Retry On Fail:
각 노드 Settings에 재시도 옵션이 있다.
Settings → Retry On Fail: ON
Max Tries: 3
Wait Between Tries (ms): 2000
LLM·HTTP 노드는 일시적 5xx/429가 흔하므로 켜두면 깔끔하게 흡수된다. 단, 멱등하지 않은 작업(결제, 메시지 발송)에 무지성 재시도를 걸면 중복 실행된다 — 재시도는 읽기/멱등 작업 위주로.
2. 노드 레벨 — Continue On Fail:
Settings → On Error: Continue (using error output)
한 item이 실패해도 워크플로를 멈추지 않고, 실패 item을 별도 error 출력으로 보낸다. 배치 100건 중 3건이 깨져도 97건은 처리되게 하는 패턴. error 출력을 받아서 따로 로깅하거나 슬랙에 알린다.
// error 출력을 받는 Code 노드
for (const item of $input.all()) {
// item.json.error 에 실패 정보가 들어온다
console.log('failed:', item.json.error?.message);
}
return $input.all();
3. 워크플로 레벨 — Error Trigger / Error Workflow:
워크플로가 통째로 실패하면 잡는 안전망. 별도 워크플로를 만들어 첫 노드를 Error Trigger로 두고, 워크플로 Settings → Error Workflow에 그 워크플로를 지정한다. 어느 워크플로가 어디서 왜 죽었는지 정보가 들어온다.
// Error Trigger 뒤 — 슬랙 알림용 메시지 구성
const e = $json;
return [{ json: {
text: `🔴 워크플로 실패: ${e.workflow.name}\n` +
`노드: ${e.execution.lastNodeExecuted}\n` +
`에러: ${e.execution.error?.message}`
}}];
이걸 슬랙/PagerDuty로 보내면 자동화가 조용히 죽는 최악의 상황을 막는다.
4. AI 특유의 에러 — JSON 파싱 실패:
LLM에 "JSON으로만 답해"라고 해도 가끔 앞뒤에 설명을 붙이거나 마크다운 코드펜스로 감싼다. 그대로 JSON.parse 하면 깨진다. 방어적으로 파싱하라.
let raw = $json.text.trim();
// ```json ... ``` 코드펜스 제거
raw = raw.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
// 첫 { 부터 마지막 } 까지만 추출
const s = raw.indexOf('{'), e = raw.lastIndexOf('}');
if (s === -1) throw new Error('No JSON in LLM output: ' + raw.slice(0, 200));
const parsed = JSON.parse(raw.slice(s, e + 1));
return [{ json: parsed }];
더 견고하게는 앞 섹션의 Structured Output Parser를 쓰는 게 낫지만, 이 방어 코드는 어떤 LLM 출력에도 보험으로 둘 가치가 있다.
베스트 프랙티스: "이 워크플로가 새벽 3시에 조용히 실패하면 내가 어떻게 알지?"를 모든 프로덕션 워크플로에서 자문하라. 답이 "모른다"면 Error Workflow + 알림이 빠진 것이다.
실전 1: 인입 문서/문의 자동 분류 파이프라인
가장 흔한 AI 자동화. 폼이나 이메일로 들어온 문의를 LLM이 분류하고, 카테고리별로 다르게 처리한다.
전체 흐름:
Webhook (POST /intake)
→ Set (정규화: email, message, receivedAt)
→ AI: Basic LLM Chain (분류, JSON 강제)
→ Code (방어적 JSON 파싱)
→ Switch (category로 분기)
├─ billing → Airtable(결제팀 큐) + Slack #billing
├─ technical → GitHub Issue 생성 + Slack #eng
├─ urgent → 즉시 Slack DM + 담당자 멘션
└─ fallback → 일반 큐 적재
→ Respond to Webhook (200 + ticketId)
분류 프롬프트(시스템):
다음 고객 문의를 분류하라. 반드시 아래 JSON만 출력한다.
{
"category": "billing" | "technical" | "urgent" | "other",
"priority": "high" | "normal" | "low",
"summary": "한 문장 요약",
"language": "ko" | "en"
}
규칙:
- 환불/결제 실패/카드 → billing
- 버그/에러/접속 불가 → technical
- 데이터 유실/보안/서비스 전면 장애 → urgent
- 그 외 → other
설명 문장 없이 JSON만.
분류 같은 결정적 작업이므로 Chat Model의 temperature는 0으로 둔다. 같은 입력에 같은 분류가 나와야 운영이 예측 가능하다.
Switch 뒤 처리 예 — Airtable 적재:
Airtable 노드 자격증명을 연결하고 Create Record로 매핑한다.
Table: Support Tickets
Fields:
Email = {{ $node["Set"].json.email }}
Summary = {{ $json.summary }}
Category = {{ $json.category }}
Priority = {{ $json.priority }}
Status = New
ReceivedAt = {{ $node["Set"].json.receivedAt }}
$node["Set"]로 분류 이전의 원본을 다시 끌어오는 게 포인트다 — AI 노드를 거치며 원본 필드가 떨어졌어도 이름으로 다시 참조한다.
urgent 경로 — 즉시 알림:
// Slack 메시지 텍스트
return [{ json: {
text: `🚨 긴급 문의\n요약: ${$json.summary}\n` +
`이메일: ${$node["Set"].json.email}\n우선순위: ${$json.priority}`
}}];
함정·베스트 프랙티스:
- 분류 신뢰도 게이트: 모델이 애매한 입력을 억지로 4개 중 하나에 밀어넣는다. 프롬프트에 confidence(0~1)도 뱉게 하고, 낮으면 fallback(사람 검토)으로 보내라. AI를 100% 신뢰하지 말고 "확신 없으면 사람에게"를 기본값으로.
- PII 주의: 문의에 개인정보가 들어온다. 셀프호스팅이면 데이터가 자기 인프라에 머무르므로 유리하지만, LLM 제공자로 나가는 페이로드에서 불필요한 PII는 마스킹하는 걸 고려하라.
- 비용: 분류는 짧은 작업이니 토큰이 적게 든다. 입력 메시지가 길면(첨부 본문 등) 앞에서 truncate해서 토큰을 통제하라.
실전 2: RAG Q&A 봇 — 벡터 스토어 + 검색 증강
사내 문서나 제품 매뉴얼에 대해 질문하면 답하는 봇. n8n은 RAG의 두 단계(인덱싱 / 질의)를 각각 워크플로로 만든다.
워크플로 A — 인덱싱(문서를 벡터 스토어에 적재):
Manual/Schedule Trigger
→ 문서 로드 (HTTP/Google Drive/파일)
→ Default Data Loader (문서를 텍스트로)
→ Text Splitter (청크 분할)
→ Embeddings 노드 (텍스트 → 벡터)
→ Vector Store 노드 (Insert 모드)
n8n에는 Vector Store 노드 묶음이 있다. Supabase(pgvector), Pinecone, Qdrant, 그리고 메모리 내 임시 벡터 스토어 등을 지원한다. 프로덕션이면 Supabase pgvector를 권한다 — 셀프호스팅 Postgres와 자연스럽게 붙고 데이터 주권이 유지된다.
Text Splitter의 청크 크기와 오버랩이 검색 품질을 좌우한다. 너무 크면 한 청크에 잡음이 섞여 검색 정확도가 떨어지고, 너무 작으면 맥락이 끊긴다. 일반 산문은 청크 800~1200자 + 오버랩 100~200자 정도가 무난한 출발점이며, 문서 성격에 따라 튜닝한다.
워크플로 B — 질의(질문에 답):
Webhook/Chat Trigger (질문 수신)
→ AI Agent
├─ Chat Model (Anthropic Claude)
├─ Vector Store (Retrieve 모드, Tool로 연결)
└─ Memory (사용자별 sessionKey)
→ Respond to Webhook
핵심은 Vector Store를 AI Agent의 도구(Tool)로 꽂는 것이다. 에이전트가 질문을 보고 "문서 검색이 필요하다"고 판단하면 벡터 스토어를 조회해 관련 청크를 가져오고, 그걸 근거로 답을 생성한다.
시스템 프롬프트 — 환각 억제:
너는 사내 문서 기반 어시스턴트다.
반드시 검색된 문서 내용에 근거해서만 답하라.
문서에 없는 내용은 "해당 정보를 문서에서 찾지 못했습니다"라고 답하라.
추측하지 마라. 답변 끝에 참고한 문서 제목을 출처로 표기하라.
한국어로 답하라.
함정·베스트 프랙티스:
- 임베딩 모델 일관성: 인덱싱과 질의에서 반드시 같은 임베딩 모델을 써야 한다. 인덱싱은 모델 A, 질의는 모델 B로 하면 벡터 공간이 달라 검색이 무의미해진다. 이걸 어기면 "검색이 자꾸 엉뚱한 걸 가져온다"는 증상이 난다.
- 재인덱싱 전략: 문서가 바뀌면 해당 벡터를 갱신해야 한다. 무지성으로 전체 재삽입하면 중복 청크가 쌓인다. 문서 ID를 메타데이터로 넣고, 갱신 시 그 ID의 기존 벡터를 지운 뒤 다시 넣는 upsert 패턴을 짜라.
- 검색 후 답변 분리: 답변 품질이 안 좋으면 "검색이 잘못된 건지 / 생성이 잘못된 건지" 분리해서 본다. Vector Store 노드를 단독 실행해 어떤 청크가 검색되는지 먼저 확인하라. 대개 문제는 생성이 아니라 검색(청크 분할·임베딩) 단계에 있다.
- 컨텍스트 길이: 검색된 청크를 너무 많이(예: top 20) 넣으면 토큰이 폭증하고 모델이 핵심을 놓친다. top 3~5로 시작해 품질을 보며 조정하라.
실전 3: Slack 알림·운영 파이프라인과 휴먼인더루프
AI가 다 결정하게 두는 게 아니라, 사람이 마지막 승인을 하는 휴먼인더루프(HITL) 패턴은 실무에서 가장 안전하고 흔하다.
시나리오: AI가 콘텐츠 초안을 만들고 → 슬랙에서 승인받고 → 승인 시 발행:
Schedule Trigger (매일 09:00 KST)
→ 소스 수집 (RSS/HTTP)
→ AI: 초안 생성 (요약 + 제목 + 추천 태그)
→ Slack: 초안을 승인 버튼과 함께 게시
── (여기서 흐름이 끊김. 사람의 클릭을 기다림) ──
[별도 워크플로]
Webhook (Slack interactive 콜백)
→ Switch (approve / reject)
├─ approve → 발행 API 호출 + Slack 스레드에 "발행됨"
└─ reject → Slack 스레드에 "보류"
핵심 설계 — 비동기 승인: n8n 무료/기본 구성에서 HITL을 구현하는 정석은 "승인 요청을 보내고 워크플로를 끝낸 뒤, 사람의 응답을 별도 Webhook 워크플로로 받는" 비동기 분리다. 슬랙 버튼의 콜백 URL을 두 번째 워크플로의 production webhook으로 지정하면, 클릭 시 그 워크플로가 깨어나 후속 처리를 한다. (n8n 일부 버전엔 응답을 기다리는 Wait/승인 노드도 있지만, 장시간 대기는 비동기 분리가 더 견고하다.)
Slack 메시지에 버튼 붙이기 (Block Kit):
Slack 노드 대신 HTTP Request로 chat.postMessage를 직접 호출하면 인터랙티브 블록을 자유롭게 구성할 수 있다.
{
"channel": "C0123",
"text": "새 초안 검토 요청",
"blocks": [
{ "type": "section",
"text": { "type": "mrkdwn", "text": "*초안 제목:* {{ $json.title }}\n{{ $json.summary }}" } },
{ "type": "actions", "elements": [
{ "type": "button", "text": { "type": "plain_text", "text": "승인" },
"style": "primary", "action_id": "approve", "value": "{{ $json.draftId }}" },
{ "type": "button", "text": { "type": "plain_text", "text": "보류" },
"style": "danger", "action_id": "reject", "value": "{{ $json.draftId }}" }
]}
]
}
함정 — Slack 3초 규칙: Slack interactive 콜백은 3초 내에 200 응답을 받아야 한다. 아니면 사용자에게 에러가 뜬다. 따라서 콜백 워크플로는 Webhook 노드를 Respond Immediately로 두거나 Respond to Webhook을 앞에 배치해 즉시 ack하고, 무거운 발행 처리는 그 뒤에서 한다. 이걸 안 지키면 "버튼 눌렀는데 에러 뜨더라"는 사고가 난다.
Slack 메시지 포맷 함정: 멘션(<@U123>)·이모지·블록을 한 메시지에 섞으면 invalid_blocks 에러가 날 수 있다. 블록 구조와 텍스트 폴백을 분리하고, 멘션은 mrkdwn 텍스트 안에 넣어라.
베스트 프랙티스:
- 승인 컨텍스트를 메시지에 다 담아라. 승인하는 사람이 워크플로를 열어보지 않고도 결정할 수 있어야 한다(제목·요약·원본 링크).
- 멱등 발행: 같은 draftId로 승인 콜백이 두 번 들어와도(더블클릭/재시도) 한 번만 발행되게 draftId 기준 멱등성을 보장하라.
- 감사 로그: 누가 언제 승인/보류했는지 Airtable이나 DB에 남겨라. AI가 만든 콘텐츠의 책임 추적에 중요하다.
운영: 시크릿·버전 관리·로깅·성능
워크플로를 만드는 것과 그걸 6개월 굴리는 것은 다른 문제다. 운영 관점에서 챙길 것들.
시크릿 관리:
API 키를 Code 노드나 표현식에 하드코딩하지 마라. 두 가지 정석이 있다.
- Credentials — n8n 자격증명 시스템에 저장.
N8N_ENCRYPTION_KEY로 암호화되며 워크플로 JSON에는 키가 안 들어간다(레퍼런스만). 워크플로를 export/공유해도 키가 새지 않는다. - 환경변수 — Code 노드에서
$env.MY_SECRET로 접근. 컨테이너 환경변수로 주입하고, 표현식에서 환경변수 접근을 허용하는 설정(N8N_BLOCK_ENV_ACCESS_IN_NODE가 막혀있지 않은지)을 확인하라.
워크플로를 export할 때 시크릿이 새지 않는지 반드시 확인하라. 자격증명을 쓰면 안전하지만, 실수로 Set 노드에 평문 토큰을 박아두면 JSON에 그대로 들어간다.
버전 관리:
워크플로는 JSON으로 export된다. 이걸 Git에 넣어 변경 이력을 추적하라.
# CLI로 워크플로 export (컨테이너 내부)
n8n export:workflow --all --output=/data/workflows/
n8n export:credentials --all --decrypted=false --output=/data/creds/
--decrypted=false로 자격증명을 암호화된 채로 내보내야 Git에 평문 키가 안 들어간다. n8n Enterprise는 Git 기반 소스 컨트롤을 내장 지원하지만, 무료 구성에서도 export를 CI에 묶어 정기 백업할 수 있다.
환경 분리(dev/staging/prod): 워크플로 안에 환경별로 다른 값(웹훅 URL, DB, 채널 ID)을 하드코딩하면 환경 옮길 때 다 깨진다. 환경변수로 빼서 {{ $env.SLACK_CHANNEL }}처럼 참조하면 같은 워크플로 JSON이 모든 환경에서 돈다.
실행 로그와 데이터 보존:
실행마다 입출력 데이터가 DB에 저장된다. AI 워크플로는 페이로드가 커서(문서 본문, LLM 응답) DB가 빠르게 부푼다. 보존 정책을 환경변수로 건다.
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: 168 # 시간 단위 (7일)
# 성공 실행은 데이터 저장 안 함 (실패만 보관) — 절약
EXECUTIONS_DATA_SAVE_ON_SUCCESS: "none"
EXECUTIONS_DATA_SAVE_ON_ERROR: "all"
프루닝을 안 켜면 몇 달 뒤 Postgres가 수십 GB로 불어 실행 목록 조회가 느려지는 사고가 난다. 처음부터 켜라.
성능·동시성:
- 한 워크플로가 수천 item을 한 번에 처리하면 메모리를 많이 먹는다. 큰 배치는 Loop Over Items로 끊어 처리하라.
- 외부 API/LLM rate limit은 배치 사이 Wait 노드나 작은 batchSize로 통제한다.
- 트래픽이 커지면
EXECUTIONS_MODE=queue+ Redis + 워커 분리로 수평 확장한다. 메인 프로세스가 실행을 직접 안 돌리고 워커가 큐에서 가져가게 되어, 무거운 AI 워크플로가 에디터 응답성을 안 잡아먹는다.
베스트 프랙티스 요약:
- 모든 프로덕션 워크플로에 Error Workflow + 알림을 붙인다(섹션 9).
- 자격증명은 Credentials/환경변수로, 절대 하드코딩 금지.
- 워크플로 JSON을 Git에 정기 백업,
N8N_ENCRYPTION_KEY는 시크릿 매니저에 별도 보관. - 실행 데이터 프루닝을 켜고, 성공 실행은 데이터를 안 남겨 DB를 가볍게 유지.
- 환경 의존 값은 전부 환경변수로 빼서 워크플로를 이식 가능하게 만든다.