n8n AI Agent로 블로그 태그 자동 분류하기: Ghost 연동 심화편

AI가 블로그 본문을 읽고 기존 태그에서 선택하고, 필요하면 새 태그도 제안합니다. Ghost API가 새 태그를 자동 생성하므로 프롬프트 수정 없이 태그가 확장됩니다.

1. 태그 분류의 고통

이전 글에서 n8n → Ghost 포스팅 자동화를 구축했습니다. 그런데 한 가지 불편함이 남았습니다:

매번 글을 올릴 때마다...
1. 본문을 다시 훑어봄
2. "이 글은 Docker 태그? 인프라 태그? 둘 다?"
3. Ghost Admin에서 수동으로 태그 선택
4. 태그 잊어버리면 나중에 수정하러 다시 들어감

태그가 5개일 때는 괜찮았습니다. 11개로 늘어나니 매번 고민됩니다. 그리고 새로운 주제의 글을 쓸 때마다 "태그를 추가해야 하나?" 고민도 생깁니다.

해결책: AI가 본문을 읽고 기존 태그에서 선택하고, 필요하면 새 태그도 제안하게 합니다.


2. 설계: Closed vs Open

2.1 Closed Set (기존 방식)

AI: "이 목록에서만 골라"
→ ["ghost-tag", "docker", "infra", ...]
→ 새 주제 나오면 수동으로 태그 추가

문제: Kubernetes 글을 쓰면? React 글을 쓰면? 매번 태그 추가하고 프롬프트 수정해야 합니다.

2.2 Open Set (개선 방식)

AI: "기존 태그 우선, 없으면 새로 제안해"
→ { existing: ["infra"], new: ["kubernetes"] }
→ Ghost가 새 태그 자동 생성

장점:

  • 새 주제에 유연하게 대응
  • 프롬프트 수정 없이 태그 확장
  • Ghost API가 존재하지 않는 slug는 자동 생성

3. 아키텍처

┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Webhook   │───▶│   Code      │───▶│  AI Agent   │───▶│    Code     │───▶│ HTTP Request│
│   (트리거)  │    │ (본문 추출) │    │ (태그 분류) │    │ (요청 생성) │    │  (Ghost)    │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
                                            │
                                       OpenAI GPT-4o-mini
                                            │
                                    ┌───────┴───────┐
                                    │ existing: [...] │
                                    │ new: [...]      │
                                    └───────────────┘

4. Ghost API의 태그 자동 생성

핵심 발견: Ghost Admin API는 존재하지 않는 태그 slug를 넣으면 자동으로 생성합니다.

// 포스트 생성 요청
{
  "posts": [{
    "title": "Kubernetes 입문",
    "tags": [
      { "slug": "infra" },        // 기존 태그
      { "slug": "kubernetes" }    // 없는 태그 → 자동 생성!
    ]
  }]
}

이 덕분에 워크플로우가 단순해집니다:

  1. AI가 태그 slug 목록 반환
  2. 그대로 Ghost API에 전달
  3. Ghost가 알아서 새 태그 생성

5. AI Agent 프롬프트

5.1 System Message

당신은 블로그 글 분류 전문가입니다. 주어진 블로그 본문을 읽고 가장 적절한 태그 2-4개를 선택합니다. 기존 태그를 우선 사용하되, 필요하면 새 태그를 제안할 수 있습니다. 반드시 JSON 형식으로만 응답하세요.

5.2 User Prompt

다음 블로그 글의 본문을 읽고 가장 적절한 태그를 2-4개 선택해주세요.

## 기존 태그 목록 (우선 사용)
- ghost-tag: Ghost 블로그 관련
- infra: 서버, 네트워크, DevOps
- automation: n8n, 스크립트, 워크플로우
- javascript: JS/TS 프론트엔드
- chrome-extension: 크롬 확장 개발
- typescript: TypeScript 관련
- docker: Docker/컨테이너
- security: 보안 관련
- ai: AI/LLM 관련
- n8n: n8n 워크플로우
- claude-code: Claude Code 관련

## 규칙
1. 기존 태그 목록에서 적합한 것을 먼저 선택하세요.
2. 기존 태그로 분류하기 어려운 핵심 주제가 있다면, 새 태그를 제안해도 됩니다.
3. 새 태그는 slug 형식(영문 소문자, 하이픈)으로 작성하세요. 예: kubernetes, react-native
4. 너무 구체적인 태그는 피하세요. 예: react-usestate-hook (X) → react (O)

## 본문
{{ $json.body }}

## 응답 형식 (JSON만, 설명 없이)
{
  "existing": ["ghost-tag", "docker"],
  "new": ["kubernetes"]
}

5.3 프롬프트 설계 포인트

규칙 이유
기존 태그 우선 태그 일관성 유지
slug 형식 강제 Ghost URL과 호환
구체적 태그 금지 태그 폭발 방지
2-4개 제한 과도한 태그 방지

6. Code 노드: 태그 파싱 + Ghost Body 생성

// 이전 노드에서 데이터 가져오기
const extractedData = $('Extract Content').first().json;
const aiResponse = $input.first().json.output;

// AI 응답에서 태그 파싱
let existing = [];
let newTags = [];

try {
  // JSON 객체 추출 (설명이 섞여 있을 수 있음)
  const jsonMatch = aiResponse.match(/\{[\s\S]*\}/);
  if (jsonMatch) {
    const parsed = JSON.parse(jsonMatch[0]);
    existing = parsed.existing || [];
    newTags = parsed.new || [];
  }
} catch (e) {
  console.log('태그 파싱 실패:', e.message);
  // 폴백: 배열 형식 시도 (이전 버전 호환)
  try {
    const arrayMatch = aiResponse.match(/\[[\s\S]*?\]/);
    if (arrayMatch) {
      existing = JSON.parse(arrayMatch[0]);
    }
  } catch (e2) {
    existing = ['ghost-tag']; // 최종 폴백
  }
}

// 모든 태그를 slug 형식으로 통합
const allTagSlugs = [...existing, ...newTags];
const tags = allTagSlugs.map(slug => ({
  slug: slug.toLowerCase().replace(/\s+/g, '-')
}));

// Ghost API 요청 본문 생성
const ghostBody = {
  posts: [{
    title: extractedData.title,
    mobiledoc: JSON.stringify({
      version: "0.3.1",
      markups: [],
      atoms: [],
      cards: [["markdown", { markdown: extractedData.body }]],
      sections: [[10, 0]]
    }),
    slug: extractedData.slug,
    custom_excerpt: extractedData.excerpt,
    meta_description: extractedData.metaDescription,
    tags: tags,  // slug로 전달 → Ghost가 없으면 자동 생성
    status: "draft"
  }]
};

return {
  ghostBody,
  existingTags: existing,
  newTags: newTags,
  allTags: allTagSlugs,
  title: extractedData.title
};

6.1 핵심 변경점

기존 (Closed):

// ID로 매핑 필요
const TAG_IDS = { 'ghost-tag': '69761ec...' };
const tags = slugs.map(s => ({ id: TAG_IDS[s] }));

개선 (Open):

// slug만 전달, Ghost가 자동 생성
const tags = slugs.map(s => ({ slug: s }));

7. 테스트

7.1 기존 태그만 사용되는 케이스

curl -X POST https://n8n.example.dev/webhook/ghost-post \
  -H "Content-Type: application/json" \
  -d '{
    "content": "# Docker Compose로 Ghost 설치하기\n\n## 1. 소개\n\nDocker Compose로 Ghost를 설치합니다...\n\n---\n\n## Ghost SEO 설정\n\n| 항목 | 값 |\n|------|-----|\n| Post URL | `test-docker` |"
  }'

AI 응답:

{
  "existing": ["ghost-tag", "docker", "infra"],
  "new": []
}

7.2 새 태그가 제안되는 케이스

curl -X POST https://n8n.example.dev/webhook/ghost-post \
  -H "Content-Type: application/json" \
  -d '{
    "content": "# Kubernetes에서 Ghost 배포하기\n\n## 1. 소개\n\nHelm Chart로 Ghost를 Kubernetes 클러스터에 배포합니다...\n\n---\n\n## Ghost SEO 설정\n\n| 항목 | 값 |\n|------|-----|\n| Post URL | `k8s-ghost` |"
  }'

AI 응답:

{
  "existing": ["ghost-tag", "infra"],
  "new": ["kubernetes"]
}

Ghost Admin에서 확인하면 kubernetes 태그가 자동 생성되어 있습니다.


8. 태그 관리 전략

8.1 새 태그 모니터링

주기적으로 Ghost에서 태그 목록을 확인합니다:

curl -s "https://blog.example.dev/ghost/api/admin/tags/?limit=all" \
  -H "Authorization: Ghost $TOKEN" | jq '.tags[] | {name, slug, count: .count.posts}'

8.2 태그 정리 기준

상황 조치
글 1개뿐인 태그 상위 태그로 병합 고려
비슷한 태그 중복 하나로 통합 (예: k8s → kubernetes)
너무 구체적 삭제 또는 상위 태그로 대체

8.3 프롬프트 업데이트 시점

새 태그가 3개 이상 글에 사용되면 프롬프트의 기존 태그 목록에 추가합니다. 이렇게 하면 AI가 더 일관되게 해당 태그를 선택합니다.


9. 비용 분석

9.1 OpenAI API 비용

gpt-4o-mini 기준:

항목 비용
Input $0.15 / 1M tokens
Output $0.60 / 1M tokens

블로그 글 하나당:

  • 프롬프트 + 본문: ~2,500 토큰
  • AI 응답: ~50 토큰
  • 글 당 비용: ~$0.0004 (약 0.5원)

월 30개 글 = 약 15원

9.2 대안: 로컬 LLM

로컬 LLM을 사용하면 API 비용 0원입니다. n8n의 Ollama Chat Model 노드를 AI Agent에 연결하면 됩니다.


10. 트러블슈팅

10.1 AI 응답 파싱 실패

원인: LLM이 JSON 외에 설명을 덧붙임
해결: 정규식으로 JSON 부분만 추출

const jsonMatch = aiResponse.match(/\{[\s\S]*\}/);

10.2 태그가 생성되지 않음

원인: slug에 대문자나 공백 포함
해결: 소문자 변환 + 공백을 하이픈으로

slug.toLowerCase().replace(/\s+/g, '-')

10.3 너무 구체적인 태그 생성

원인: 프롬프트 규칙이 약함
해결: 규칙 4번 강화 + few-shot 예시 추가

"예: react-usestate-hook (X) → react (O)
     docker-compose-yaml (X) → docker (O)"

10.4 이전 노드 데이터 접근 불가

원인: AI Agent 이후 노드에서 컨텍스트 분리
해결: 노드 이름으로 명시적 참조

$('Extract Content').first().json

11. 확장 아이디어

11.1 태그 통계 대시보드

n8n에서 주기적으로 Ghost 태그 통계를 수집해서 Notion이나 Slack으로 전송:

매주 월요일:
- 새로 생성된 태그 목록
- 태그별 글 수
- 사용되지 않는 태그 알림

11.2 태그 제안 검토 워크플로우

새 태그가 제안되면 Slack으로 승인 요청:

AI가 새 태그 'kubernetes' 제안
→ Slack 알림: "승인하시겠습니까?"
→ 승인 시 Ghost에 포스트 생성
→ 거부 시 기존 태그만 사용

11.3 다국어 태그

// 프롬프트에 추가
"한국어 글은 'korean' 태그 추가
 영어 글은 'english' 태그 추가"

12. 핵심 요약

항목 Closed Set Open Set
태그 범위 지정된 목록만 기존 + 새 태그 제안
새 주제 대응 수동 추가 필요 AI가 자동 제안
Ghost 연동 태그 ID 매핑 slug만 전달 (자동 생성)
유지보수 프롬프트 수정 필요 주기적 정리만

13. FAQ

Q: 왜 gpt-4o-mini인가요?
A: 태그 분류는 간단한 작업이라 저렴한 모델로 충분합니다.

Q: AI가 이상한 태그를 만들면요?
A: Draft로 저장되므로 발행 전 확인할 수 있습니다. 주기적으로 태그 목록을 정리하세요.

Q: 기존 글의 태그도 AI로 재분류할 수 있나요?
A: 네. Ghost API로 모든 글을 조회하고 AI로 재분류한 후 업데이트하는 워크플로우를 만들 수 있습니다.

Q: 태그 이름(name)은 어떻게 되나요?
A: Ghost가 slug에서 자동 생성합니다. kubernetesKubernetes. 나중에 Ghost Admin에서 한글 이름으로 수정할 수 있습니다.


14. 참고 자료