사례 연구 · 교육 자료

Slack에 링크 하나를 던지면,
Notion 콘텐츠가 완성된다

원문 수집 → 한글 콘텐츠 기획(JSON) → Notion 페이지 생성 → AI 이미지 생성·삽입 → Slack 완료 보고까지, 사람의 개입 없이 도는 에이전트 기반 콘텐츠 생산 파이프라인의 구조와 설계 원칙을 뜯어봅니다.

무엇을 푸는가

"좋은 링크를 고르는 일"과 "콘텐츠로 만드는 일"을 분리한다

쓸만한 아티클·영상을 발견하는 것은 사람이 잘하는 일(큐레이션)이고, 그것을 일정한 포맷의 콘텐츠로 정리하는 것은 반복되는 공정입니다. 이 시스템은 후자를 자동화해서, 사람이 큐레이션에만 집중할 수 있게 만듭니다.

😩 자동화 전

  • 링크를 읽고 → 요약하고 → 제목 짓고 → Notion에 옮기고 → 이미지 만들고… 매번 수작업.
  • 사람마다 포맷·품질이 제각각. TL;DR 개수, 저자 표기, 카테고리가 들쭉날쭉.
  • "나중에 정리하지" 하다가 링크만 쌓이고 콘텐츠 DB는 비어 있음.

✅ 자동화 후

  • Slack에서 @봇 <URL> 한 줄이면 끝. 나머지는 파이프라인이 처리.
  • 제목·요약·TL;DR 5개·섹션·이미지까지 고정된 스키마로 일관되게 생성.
  • 같은 URL은 자동으로 중복 스킵. 결과는 Slack 스레드에 보고.

이 사례의 핵심 교훈

LLM을 쓰면서도 신뢰성을 지키는 법

모델에 프롬프트를 던져 자유롭게 답하게 하면, 매번 형식과 품질이 달라져 시스템으로 신뢰할 수 없습니다. 비결은 두 가지입니다 — LLM의 출력마저 JSON으로 구조화하고, 나머지는 전부 코드와 CLI로 감싸는 것.

❌ 프롬프트만 전달

"이 링크 정리해서 Notion에 올려줘"

매 실행마다 제목 형식·요약 길이·항목 개수가 제각각. 저자를 요청자로 착각하거나 형식이 깨져도 그대로 저장됨. 실패를 사후에 잡아낼 방법이 없음.

✅ 구조화 + 코드/CLI로 감싸기

LLM은 plan.json의 칸만 채운다

출력이 고정 스키마라 기계가 검증 가능. TL;DR 5개·저자 규칙·한글 포함을 코드가 강제하고, 어기면 VALIDATION_FAILED로 중단. 저장·이미지·보고는 전부 결정론적.

원문 수집
web_fetch · Firecrawl CLI
콘텐츠 기획
LLM → JSON
검증 · 저장 · 이미지 · 보고
코드 · Notion API · git · CLI
LLM 판단 — 좁게, 구조화된 출력으로만 결정론적 코드/CLI — 넓게, 신뢰의 토대
요점: LLM을 "자유로운 작가"가 아니라 "정해진 칸을 채우는 부품"으로 씁니다. 판단이 필요한 좁은 구간만 모델에 맡기고, 그 입출력을 구조화된 데이터로 고정한 뒤, 넓은 나머지 영역을 코드와 CLI가 책임집니다. 신뢰성은 여기서 나옵니다.

구성 부품

무엇으로 만들었나 (도구는 선택 가능)

본격적인 흐름에 들어가기 전에, 이 시스템이 어떤 부품으로 구성되는지 지도부터 봅니다. 아래는 이 사례가 실제로 쓴 도구일 뿐 정답이 아닙니다. 중요한 건 각 레이어의 역할이고, 도구는 팀 환경·비용·이미 쓰는 인프라에 맞춰 얼마든지 바꿔 끼울 수 있습니다.

레이어이 사례에서 쓴 것대체 가능한 방식
트리거 / 보고 Slack (봇 멘션 + 스레드 댓글) Discord · Telegram · 이메일 · 웹훅 · CLI 직접 실행 · GitHub Issue 코멘트
런타임 Node.js (의존성 없는 순수 .mjs) Python · Deno · Bash 스크립트 · 서버리스 함수(Lambda/Cloud Functions)
원문 수집 web_fetch · Firecrawl Playwright/Puppeteer · Jina Reader · Readability · curl + 파서 · yt-dlp(영상 자막)
콘텐츠 기획 LLM (코딩 에이전트) Claude · GPT · Gemini · 로컬 모델(Ollama) — 단, 출력은 구조화(JSON)로 고정
저장소 Notion API (DB 페이지 + 블록) Google Docs/Sheets · Obsidian · 마크다운 파일 + Git · Airtable · WordPress
이미지 생성 Gemini 이미지 모델 → GitHub raw URL DALL·E · Stable Diffusion · 이미지 없음(텍스트만) · S3/Cloudinary 호스팅
고르는 기준: 유행이 아니라 비용 · 신뢰성 · 재현성 · 이미 쓰는 도구인지로 판단하세요. 레이어의 경계(계약)만 지키면 한 조각을 교체해도 나머지는 그대로 돌아갑니다 — 그게 이 구조의 핵심입니다.

진입점

딱 한 줄의 트리거

Slack 채널에서 봇을 멘션하고 URL을 붙이면 실행됩니다. 조건이 안 맞으면 조용히 무시(NO_REPLY)합니다.

# Slack 메시지
<@YOUR_BOT_ID> https://example.com/article

# 실행 조건 (둘 다 만족해야 함)
1. 봇 멘션(<@YOUR_BOT_ID>)이 포함
2. http(s):// URL이 최소 1개 포함
왜 Slack인가? 팀이 이미 하루 종일 머무는 공간이기 때문입니다. 새 도구를 학습시키지 않고, "링크 공유"라는 이미 하던 행동에 자동화를 얹었습니다. — 이것이 채택률을 높이는 핵심 설계입니다.

동작 흐름

7단계 파이프라인

LLM 판단은 딱 한 곳(3단계, 콘텐츠 기획)에만 있고, 나머지는 전부 결정론적 스크립트입니다.

1
Slack 컨텍스트 추출 read_last_slack_ctx.mjs

메시지 wrapper에 없는 채널 ID·스레드 ts를 로컬 세션 상태 파일에서 읽어 보완합니다.

핵심 코드 · read_last_slack_ctx.mjs// Slack 이벤트 메타에는 채널ID·ts가 없을 때가 있다 → 세션 상태 파일에서 직접 읽는다
const p = path.join(os.homedir(), '.openclaw', 'agents',
  'notion-codex-writer', 'sessions', 'sessions.json');
const entry = JSON.parse(fs.readFileSync(p, 'utf8'))[sessionKey];

// "channel:C012..." 형태에서 채널 ID만 정규식으로 추출
const slackChannelId = parseChannelId(entry.lastTo);
const slackThreadTs  = String(entry.lastThreadId).trim();
2
원문 수집 web_fetch · firecrawl_scrape_markdown.mjs

일반 URL은 web_fetch, 본문 추출이 불안정한 도메인은 Firecrawl로 폴백. 짧거나 nav/footer만 오면 실패로 간주하고 재시도합니다.

핵심 코드 · firecrawl_scrape_markdown.mjs// Firecrawl(MCP)을 CLI로 호출. 결과 JSON을 모델에 통째로 주지 않는다
const res = spawnSync('mcporter', ['call', 'firecrawl.firecrawl_scrape',
  '--args', argsJson, '--output', 'json']);

// jq는 응답 속 제어문자에서 깨진다 → 직접 제거 후 파싱하는 게 안전
const clean = String(res.stdout).replace(/[\u0000-\u001F]/g, '');
const md = JSON.parse(clean).markdown ?? '';   // markdown 본문만 뽑음
3
콘텐츠 기획 JSON 생성 ← 유일한 LLM 단계 tmp_plan.json

원문을 읽고 한글 제목·요약·TL;DR 5개·섹션별 본문·이미지 아이디어를 구조화된 JSON으로 만듭니다.

산출물 · tmp_plan.json (LLM이 이 계약을 채운다)// 스크립트에 넘길 유일한 "데이터 계약". Slack에 JSON 전문을 붙이지 않는다
{
  "title_ko": "'읽지 않은 코드도 배포한다'는 선언 …",
  "author": "요즘IT · 트파원",       // 원문 저자 (요청자 아님)
  "tldr_bullets": [ /* 정확히 5개 */ ],
  "sections": [{ "h1": …, "toggle_title": …,
     "image_placeholders": [/* 페이지 전체 3~5개 */],
     "bullets": [ … ] }]
}
4
JSON 검증·정리 json_minify_sanitize.mjs

제어문자를 제거하고 파싱 가능 여부를 확인합니다. jq가 제어문자에 깨지는 문제를 회피하는 전용 도구.

핵심 코드 · json_minify_sanitize.mjs// 제어문자를 지운 뒤 파싱 → 성공해야 다음 단계로 넘어간다
const clean = String(raw).replace(/[\u0000-\u001F]/g, '');
try { var obj = JSON.parse(clean); }
catch (e) { process.exit(1); }        // 파싱 실패 = 검증 실패

process.stdout.write(JSON.stringify(obj));  // 한 줄(minify)로 출력
5
오케스트레이션 wrapper run_notion_apply_from_files.mjs

Slack ctx 로드 + --planFile 전달 + 실패 시 재시도 + "최종 실패 댓글 1개만" 정책을 담당합니다.

핵심 코드 · run_notion_apply_from_files.mjslet r1 = runApply({ planFilePath: planFile });
if (r1.status === 0) process.exit(0);           // 성공하면 그대로 종료

// 'Invalid plan JSON'일 때만 sanitize 후 1회 재시도 (그 외엔 재시도 안 함)
if (shouldRetryPlanParse) r2 = runApply({ planFilePath: planMinPath });

// 끝내 실패하면 스레드에 실패 댓글을 "1개만" 남긴다 (중간 실패는 숨김)
await slackPostMessage({ token, channelId, threadTs, text });
6
Notion 반영 (중심 스크립트) notion_apply.mjs

URL 중복 체크 → 페이지 생성 → 속성/본문 블록 작성 → 소요시간 기록 → Slack 완료 보고. LLM 호출 없음.

핵심 코드 · notion_apply.mjsconst existing = await queryByUrl(notionKey, dbid, url, props.URL);
if (existing) {                       // 같은 URL이면 스킵 (업데이트도 금지)
  return { action: 'skipped', reason: 'URL_EXISTS' };
}
// 가드레일: 저자가 요청자와 같으면 버리고, 없으면 '알수없음'
if (authorNorm === requestedByNorm) author = '';
if (!author) author = '알수없음';
if (tldr.length !== 5) die('TL;DR must have exactly 5 bullets');
7
AI 이미지 생성·삽입 notion_images_replace.mjs

플레이스홀더 문단을 찾아 이미지를 생성(Gemini) → GitHub 레포에 커밋·푸시 → raw URL 200 검증 → Notion 이미지 블록으로 교체.

핵심 코드 · notion_images_replace.mjs// 본문에서 "[이미지 플레이스홀더]"로 시작하는 문단만 골라낸다
const targets = children.filter(b =>
  b.type === 'paragraph' && isImagePlaceholderText(b.text));

// 생성 → GitHub 커밋/푸시 후, raw URL이 200일 때만 Notion에 삽입
if (await verifyHttp200WithRetry(s.rawUrl) !== 200) die('raw url not 200');
await insertAfterInPage(notionKey, pageId, s.placeholderId, imgBlock);
await notionRequest(notionKey, 'DELETE', `/blocks/${s.placeholderId}`); // 자리표시자 제거

구성 요소

6개의 작은 스크립트

각 스크립트는 한 가지 일만 합니다. 거대한 단일 프로그램 대신, 셸에서 조합 가능한 작은 도구들로 나눴습니다.

스크립트역할
read_last_slack_ctx.mjs세션 상태에서 Slack 채널 ID·스레드 ts를 읽어옴
firecrawl_scrape_markdown.mjsFirecrawl 결과에서 markdown 본문만 안전하게 추출(긴 JSON을 모델에 직접 주지 않는 wrapper)
json_minify_sanitize.mjsplan JSON의 파싱 가능성 검증 + minify/sanitize
run_notion_apply_from_files.mjsSlack ctx 로드·plan 전달·재시도·실패 보고를 묶은 오케스트레이터
notion_apply.mjsNotion 페이지 생성/스킵/본문 작성/Slack 보고의 중심 스크립트
notion_images_replace.mjs이미지 플레이스홀더를 실제 생성 이미지로 교체·삽입

데이터 계약

콘텐츠 기획 JSON 스키마

LLM과 스크립트 사이의 인터페이스입니다. 이 "계약"이 고정돼 있기 때문에, 생성 단계(LLM)와 반영 단계(결정론적 코드)를 완전히 분리할 수 있습니다.

{
  "title_ko": string,        // 한글 제목 (원문 제목 그대로 복사 금지)
  "author": string,          // 원문 저자. 불명 시 "알수없음"
  "category": string,
  "keywords": string[],
  "summary_ko": string,      // 한글 3~5문장
  "tldr_bullets": string[5],  // 정확히 5개
  "sections": [{             // 최소 2개
    "h1": string,
    "image_placeholders": string[],  // 페이지 전체 3~5개
    "toggle_title": string,
    "bullets": string[]
  }]
}
검증 게이트(하드 룰)는 코드로 강제됩니다: 제목·요약에 한글 포함, TL;DR 정확히 5개, 섹션 최소 2개, 저자는 Slack 요청자가 아니라 원문 저자. 하나라도 어기면 VALIDATION_FAILED로 중단합니다.

여기서 배울 것

재사용 가능한 설계 원칙

이 사례가 교육적으로 가치 있는 이유 — 다른 자동화에도 그대로 적용되는 패턴들입니다.

01 · SEPARATION

사람 vs 에이전트 역할 분리

판단이 필요한 큐레이션은 사람에게, 반복되는 공정은 에이전트에게. 자동화의 범위를 이렇게 그으면 신뢰와 속도를 동시에 얻습니다.

02 · CODE-FIRST

LLM은 접합부에만

7단계 중 LLM 판단은 단 한 곳. 나머지는 결정론적 스크립트라 재현 가능하고 디버깅이 쉽습니다. "가능한 한 코드로, 꼭 필요할 때만 LLM."

03 · CONTRACT

구조화된 데이터 계약

LLM과 코드 사이를 고정된 JSON 스키마로 연결. 자연어를 흘려보내는 대신 계약을 두면, 양쪽을 독립적으로 발전시킬 수 있습니다.

04 · GUARDRAILS

품질을 코드로 강제

"저자는 요청자가 아니다", "TL;DR은 5개" 같은 규칙을 문서 권고가 아니라 실행 코드로 검증. 어기면 실패시킵니다.

05 · IDEMPOTENT

중복 방지 · 멱등성

같은 URL은 URL_EXISTS로 스킵하고 업데이트도 막습니다. 재실행해도 본문이 중복 append 되지 않습니다.

06 · GOTCHA-LOG

함정을 기록으로 남긴다

zsh 따옴표 깨짐, jq 제어문자 크래시 같은 실패를 우회하고 끝내지 않고, 근본 원인과 대응을 코드·문서에 박아뒀습니다.

실제 산출물

생성된 콘텐츠 사례

examples/plans/에 실제로 생성된 기획 JSON 8건이 들어 있습니다. 단순 요약이 아니라 제목·TL;DR·섹션·이미지 프롬프트까지 갖춘 Notion용 콘텐츠로 재구성된 형태를 확인할 수 있습니다.

제목카테고리섹션이미지
AI는 일을 줄이지 않는다: 오히려 일을 '진하게' 만든다AI/업무혁신44
Anthropic은 왜 '바이브'로 굴러가는데도 강해 보일까AI/조직문화65
컴파운드 엔지니어링: 오늘 한 작업이 내일을 더 쉽게 만들게 하는 법개발/프로덕트54
회사 AI 도입이 늘 '체감'이 없는 이유AI/전략55
검색 대신 '리서치'가 필요한 시대: 단계별 AI 도구 7가지업무/리서치55
LLM이 코드를 잘 짜도 개발자를 대체 못 하는 이유AI/개발인사이트44
'읽지 않은 코드도 배포한다'는 선언: AI 네이티브 개발의 요점AI/개발문화54
클로드 코드, '잘 쓰는 사람들'은 무엇을 다르게 하나AI/업무도구44
AI/업무혁신 AI/조직문화 개발/프로덕트 AI/전략 업무/리서치 AI/개발인사이트 AI/개발문화 AI/업무도구
이 저장소의 식별자(경로·DB ID·봇 ID·GitHub 계정)는 모두 플레이스홀더로 치환돼 있습니다. 실제 실행에는 환경변수 설정이 필요하며, 자세한 목록은 README를 참고하세요.