Ghost MCP로 134개 글 일괄 메타 작업하기 — slug↔ID 매핑과 fd-3 stdin 패턴

Ghost MCP로 134개 글에 일괄 메타 작업을 적용하다 마주친 컨텍스트 폭증·동시성 함정. 슬러그↔ID 매핑 인덱스, 페이지네이션·search 함정, fd-3 stdin 분리, parallel tool_use 한도까지 운영 패턴 5가지를 코드와 함께 정리했습니다.

Ghost MCP로 134개 글 일괄 메타 작업하기 — slug↔ID 매핑과 fd-3 stdin 패턴

1. 문제 상황: MCP 한 호출이 한 글의 토큰

134개 블로그 글에 feature image 일괄 등록 작업이 떨어졌습니다. 시리즈 1편에서는 “이미지 생성을 codex에 위임”하는 분업 원리를 다뤘는데, 이번 글은 그 분업의 다른 한쪽 — Claude가 책임지는 오케스트레이션 작업 — 에서 마주친 함정을 정리합니다.

가장 단순한 흐름은 이렇게 생각됩니다.

for 글 in 134개:
  meta = ghost_get_post(slug=글.slug)
  url  = ghost_upload_image(file_path=글.image_path)
  ghost_update_post(id=meta.id, feature_image=url, tags=글.tags)

문제는 첫 줄입니다. ghost_get_post는 LLM이 호출하면 응답 전체가 자동으로 LLM 컨텍스트에 주입됩니다. 메타데이터만 받아도 한 호출당 ~700 토큰, include_content=true면 본문 5KB가 통째로 들어옵니다.

134 × 700 토큰 (메타만)   ≈ 94K 토큰
134 × 3,000 토큰 (본문 포함) ≈ 400K 토큰

이건 “이미지 생성”과 무관한 단순 ID 조회입니다. 작업의 본질은 슬러그(짧음)와 ID(24자리 hex)의 매핑인데, 그걸 알아내자고 본문 5KB가 컨텍스트로 들어옵니다.

MCP의 편의가 일괄 작업에선 비용이 됩니다.

2. 원인 분석: MCP의 컨텍스트 모델

MCP(Model Context Protocol)는 LLM이 도구를 호출하고 응답을 받는 방식을 표준화한 프로토콜입니다. LLM 입장에서는 도구가 함수처럼 보이고, 응답은 자동으로 다음 추론 컨텍스트로 들어옵니다.

LLM → ghost_get_post(slug="...")
        ↓ (host가 MCP 서버 호출)
      Ghost Admin API
        ↓ (응답 JSON)
        ↓ (host가 LLM 컨텍스트에 자동 주입)
LLM ← "다음 메시지에서 응답을 받음"

이 자동 주입이 LLM이 도구를 자연스럽게 쓰게 만들어 주는 핵심입니다. 한 번 호출만 하면 응답을 그 다음 사고에 바로 활용할 수 있습니다.

문제는 일괄 작업입니다. 같은 도구를 N번 호출하면 응답도 N번 컨텍스트로 들어옵니다.

작업 호출 수 호출당 응답 토큰 컨텍스트 누적
메타만 조회 (134번) 134 ~700 ~94K
본문 포함 조회 (134번) 134 ~3,000 ~400K
업로드 결과 (134번) 134 ~50 ~7K
업데이트 결과 (134번) 134 ~100 ~13K
합계 (본문 포함) 536 ~514K

134개 글 한 번 처리하는 데 컨텍스트 누적 50만 토큰이 됩니다. Sonnet 4.6의 200K 윈도우를 두 번 넘기는 양입니다. Claude는 그 사이 컨텍스트를 압축하고 — slug↔ID 매핑이 압축으로 뒤섞일 위험이 생깁니다.

원인을 한 줄로 정리하면 이렇습니다.

MCP 응답은 자동으로 LLM 컨텍스트에 들어간다. 일괄 작업에서는 응답 N개의 합이 그대로 토큰 비용이고, 200K 윈도우에 부딪힌다.

해결 방향은 두 가지입니다. (1) 응답 횟수를 줄이거나, (2) 응답을 컨텍스트로 끌어들이지 않거나.

3. 해결: 운영 패턴 4가지

3.1 slug → ID 매핑 인덱스: 한 번만 fetch

가장 큰 누적은 “한 글당 ghost_get_post 한 번”입니다. 그런데 메타 작업은 결국 ID만 있으면 됩니다. 슬러그 ↔ ID 매핑을 한 번만 만들어 두면 이후 134번의 ID 조회를 0번으로 줄일 수 있습니다.

# slug→ID 매핑 인덱스 (한 번만)
import json, os

def build_slug_id_map(known_ids: dict, slugs: list) -> dict:
    """이미 아는 ID는 그대로, 모르는 슬러그만 ghost_get_post로 fetch."""
    missing = [s for s in slugs if s not in known_ids]
    print(f"matched={len(known_ids)}, need fetch={len(missing)}")
    return missing  # Claude가 이 목록을 받아 fetch

호출 패턴:

1) ghost_list_posts(status="published", limit=100)
   → slug 목록 (ID 없음)
2) 이미 알고 있는 ID 12개는 직접 매핑
3) 남은 76개만 ghost_get_post 호출 → ID 채움
4) 이 매핑 인덱스를 디스크에 캐시

이렇게 하면 다음 일괄 작업에서는 캐시된 인덱스를 그대로 씁니다. 두 번째 작업의 ghost_get_post 호출은 0번입니다.

추가 절약: ghost_list_posts 응답의 한 행은 슬러그·제목·태그·상태·날짜 정도라 한 글당 ~150 토큰. 134개여도 ~20K 토큰 한 번이면 끝입니다. ghost_get_post 134번(~94K)에 비해 5배 절약.

3.2 페이지네이션 default 함정

Ghost Admin API의 list 엔드포인트는 기본 limit=15입니다. MCP의 ghost_list_posts는 기본 limit=50으로 좀 더 너그럽지만, 134개 글 전부 한 번에 가져오려면 명시해야 합니다.

# ❌ default — 50개만 옴
ghost_list_posts(status="published")

# ✅ 명시 — 전체
ghost_list_posts(status="published", limit=100)
ghost_list_posts(status="scheduled", limit=100)
ghost_list_posts(status="draft",     limit=100)

이 함정을 안 잡으면 “91개 중 50개만 처리됐는데 작업이 끝났다고 인식”하는 silent failure가 납니다. 일괄 작업의 가장 흔한 함정입니다.

베스트 프랙티스:

  1. 응답 윗부분의 Total: N posts 같은 메타를 항상 확인
  2. Total > limit이면 다음 page를 명시적으로 fetch
  3. 또는 처음부터 limit=현실적 최대치(예: 100)로 호출

Ghost MCP ghost_list_posts는 응답 메타에 Total: 91 posts를 명시하므로, 그 숫자와 받은 행 수가 같은지 확인하면 됩니다.

3.3 검색의 함정: search는 fuzzy다

“같은 주제 글이 이미 있나?” 같은 중복 확인을 하려면 ghost_list_posts(search="키워드")를 쓰게 됩니다. 그런데 Ghost의 search는 fuzzy + 전체 텍스트 매칭이라 의외로 너무 많이 매칭됩니다.

ghost_list_posts(search="멀티 에이전트 분업")
→ Total: 152 posts (!?)

키워드와 직접 관련 없는 글까지 매칭됩니다. fuzzy 점수 정렬도 모호해 상위 10개가 “진짜 후보”인지 알기 어렵습니다.

실용적인 우회:

  1. 슬러그 정확 매칭 — 새 글 슬러그를 정한 뒤 ghost_get_post(slug=...)로 직접 조회. 404면 신규
  2. 제목 정확 매칭 — 글 제목 전체를 따옴표로 감싸 search
  3. search 결과는 상위 5개만 표시하고 나머지 무시 (정확도 낮은 매칭이라 노이즈)

3.4 동시성 + fd-3 stdin 분리 패턴

ID 매핑이 끝나면 다음은 134개 update_post입니다. 순차 처리하면 한 호출당 ~1-2초로 134 × 1.5s = 약 3.4분. 동시성으로 줄이려는데 bash에서 함정이 하나 있습니다.

# ❌ 함정 있는 패턴
while IFS=$'\t' read -r slug url id; do
  (
    update_via_mcp "$slug" "$url" "$id"
  ) &
  if (( $(jobs -p | wc -l) >= 6 )); then
    wait -n
  fi
done < tasks.tsv

wait

이 패턴이 “wave 1만 돌고 종료된” 버그를 만들었습니다. 원인은 subshell이 부모의 stdin을 상속받기 때문입니다. update 안에서 codex/cli 같은 외부 프로세스가 stdin을 한 줄 소비하면, 다음 read가 한 줄을 건너뜁니다. 134줄짜리 입력이 wave 1에서 다 소비되어 버린 적도 있습니다.

해결은 stdin을 부모 read 전용으로 분리하는 것입니다. fd 3을 새로 열어 거기로 입력을 받고, 자식 프로세스는 빈 stdin을 받게 합니다.

# ✅ fd-3 분리 패턴
while IFS=$'\t' read -r -u 3 slug url id; do
  (
    update_via_mcp "$slug" "$url" "$id" < /dev/null  # ← 자식은 stdin 닫음
  ) &
  if (( $(jobs -p | wc -l) >= 6 )); then
    wait -n
  fi
done 3< tasks.tsv  # ← fd 3으로 입력

wait

세 가지 디테일이 핵심입니다.

  1. read -r -u 3 — 부모는 fd 3에서 읽음
  2. 3< tasks.tsv — 입력 파일이 fd 3에 묶임
  3. < /dev/null — 자식 프로세스는 빈 stdin (이중 방어)

이 패턴은 codex 같은 LLM CLI를 자식으로 호출할 때 특히 중요합니다. LLM CLI가 stdin을 prompt로 읽으면 다음 줄이 통째로 사라집니다.

3.5 Parallel tool_use 한도

Claude Code는 한 메시지에 여러 도구 호출을 병렬로 묶을 수 있습니다. 134개 update_post를 한 메시지에 다 넣으면 어떨까요?

실험 결과 한 번에 ~15개 동시 호출이 안정적이었습니다. 더 많이 넣으면 MCP 서버나 Ghost API 쪽에서 rate limit 또는 timeout이 납니다. 그래서 134개를 9 batches × 15개로 쪼개서 호출했습니다.

batch 1: 15개 ghost_upload_image 동시 호출
batch 2: 15개 ghost_upload_image
...
batch 9: 14개 (나머지)

batch 1: 15개 ghost_update_post 동시 호출
...

이렇게 묶으면 9 × 2 = 18 turn × 15 호출 = 270번의 MCP 호출이 한 세션 안에서 부드럽게 처리됩니다. 응답도 15개씩 묶여 와서 컨텍스트 정리도 깔끔합니다.

베스트 프랙티스:

  1. 한 메시지의 parallel tool_use는 10-20개 사이가 안정적
  2. 응답이 짧은 도구(update_post 같은)는 더 묶을 수 있음
  3. 응답이 긴 도구(get_post with content)는 5-10개 정도가 안전

4. 정량 결과

134개 작업 실측 vs 추정 비교입니다.

항목 단순 접근 (추정) 운영 패턴 적용 (실측) 비율
ghost_get_post 호출 134 (본문 포함) 76 (메타만, 캐시 후 0) 1.8x ↓
전체 MCP 호출 ~536 ~270 2x ↓
토큰 누적 ~514K ~110K 4.7x ↓
Sonnet 4.6 비용 ~$5.30 ~$1.10 4.8x ↓
시간 (순차) ~3시간 (동시성으로 별도)
시간 (실측, 동시 15) ~30분

토큰 비용 계산 (Sonnet 4.6, input $3/MTok + output $15/MTok 가정):

단순 접근:
  입력 ~400K × $3 + 출력 ~70K × $15
  = $1.20 + $1.05 = $2.25 (도구 응답만)
  실제론 LLM 추론 토큰 누적이 더 큼 (~$5.30 추정)

운영 패턴:
  입력 ~80K × $3 + 출력 ~30K × $15
  = $0.24 + $0.45 = $0.69 (도구 응답)
  추론 누적 포함 ~$1.10

더 큰 의미는 컨텍스트 위생입니다. 110K는 200K 윈도우 안에 여유로 들어가므로 압축이 일어나지 않습니다. slug↔ID 매핑이 압축으로 뒤섞일 일이 없습니다.

5. 핵심 코드 모음

slug→ID 매핑 빌더 (Python)

import json
from pathlib import Path

CACHE_PATH = Path.home() / ".cache/ghost-mcp/slug-id-map.json"

def load_cache() -> dict:
    if CACHE_PATH.exists():
        return json.loads(CACHE_PATH.read_text())
    return {}

def save_cache(mapping: dict) -> None:
    CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
    CACHE_PATH.write_text(json.dumps(mapping, indent=2, ensure_ascii=False))

def missing_slugs(target_slugs: list, cache: dict) -> list:
    return [s for s in target_slugs if s not in cache]

이 매핑 인덱스를 디스크에 두면 다음 일괄 작업에서 fetch가 0번으로 떨어집니다.

Parallel update 흐름

turn 1: [
  ghost_upload_image(file=img1) || ghost_upload_image(file=img2) ||
  ... × 15
]
→ turn 1 응답에서 15개 URL 회수

turn 2: [
  ghost_update_post(id=ID1, feature_image=URL1) || ... × 15
]
→ turn 2에서 15개 update 적용

(반복 × 9)

이 패턴이 Claude의 turn당 응답 시간(API 한 번)에 15배 작업을 끼워 넣습니다.

fd-3 stdin 분리 — Bash 완전 예시

#!/usr/bin/env bash
# bulk-runner.sh <tasks.tsv>
# tasks.tsv format: slug\tsource_path\ttarget_path
set -uo pipefail

TASKS_FILE="${1:?usage: $0 tasks.tsv}"
RESULTS=/tmp/bulk-results
MAX=6
mkdir -p "$RESULTS"

N=0
while IFS=$'\t' read -r -u 3 slug src tgt; do
  N=$((N+1))
  (
    log="$RESULTS/${slug}.log"
    if [[ -s "$tgt" ]]; then
      echo skip > "$RESULTS/${slug}.status"
    elif bash worker.sh "$slug" "$src" "$tgt" \
         > "$log" 2>&1 < /dev/null; then
      echo ok > "$RESULTS/${slug}.status"
    else
      echo fail > "$RESULTS/${slug}.status"
    fi
  ) &
  if (( N % MAX == 0 )); then
    wait
    ok=$(grep -l '^ok$' "$RESULTS"/*.status 2>/dev/null | wc -l)
    echo "[wave $((N/MAX))] processed=$N ok=$ok"
  fi
done 3< "$TASKS_FILE"

wait
echo "=== done. ok=$(grep -l '^ok$' "$RESULTS"/*.status | wc -l) ==="

핵심: 3< "$TASKS_FILE"로 입력을 fd 3에 묶고, read -u 3로 받고, 자식엔 < /dev/null. 134줄짜리 tasks.tsv가 빠짐없이 처리됩니다.

6. 핵심 개념 정리

개념 한 줄 정의
MCP 자동 컨텍스트 주입 LLM이 MCP 도구를 호출하면 응답이 다음 추론 컨텍스트에 자동 포함
slug↔ID 매핑 인덱스 슬러그→ID(24자 hex) 매핑을 한 번만 빌드, 디스크에 캐시
페이지네이션 default 함정 ghost_list_posts는 기본 limit=50. 134개 모두 필요하면 limit=100 명시
search fuzzy Ghost search는 전체 텍스트 fuzzy 매칭, 정확도 낮음. 슬러그 정확 매칭 권장
fd-3 stdin 분리 bash에서 자식이 부모의 stdin을 소비하지 못하게 fd 3에 입력 묶기
Parallel tool_use 한 메시지에 여러 도구 호출 묶기. 10-20개가 안정 한계

7. 베스트 프랙티스 체크리스트

작업 시작 전:

  • [ ] slug→ID 매핑 인덱스가 이미 있는지 확인 (없으면 한 번만 빌드)
  • [ ] ghost_list_postslimit=100 명시 (status별 분리 권장)
  • [ ] 응답 메타의 Total: N posts 확인 후 received 행 수와 비교
  • [ ] 중복 확인은 search 대신 슬러그 정확 매칭 우선
  • [ ] 한 메시지의 parallel tool_use는 10-20개 사이

bash 일괄 러너:

  • [ ] 입력 파일을 fd 3에 묶고 read -u 3 사용
  • [ ] 자식 프로세스에 < /dev/null 명시
  • [ ] 동시성 제한 (보통 6-10개) + wave wait
  • [ ] 결과 status 파일 + log 파일 분리

응답 정리:

  • [ ] 받은 응답이 컨텍스트에 들어와 있는 걸 인지하고 다음 step 설계
  • [ ] 본문(긴 콘텐츠)이 응답에 포함되면 즉시 디스크로 빼서 컨텍스트 청소
  • [ ] 매핑 인덱스 같은 짧은 정보는 컨텍스트에 두고 활용

8. FAQ

Q. MCP 자동 컨텍스트 주입을 끌 수는 없나요?

A. 도구마다 다릅니다. 일부 MCP 도구는 응답을 “파일 경로만 반환”하도록 설계되어 LLM 컨텍스트로 본문이 들어오지 않게 합니다(예: attachment 패턴). Ghost MCP는 응답에 본문 자체를 넣어 주는 설계라 우회하려면 “호출 자체를 줄이는” 쪽으로 가야 합니다. 슬러그/ID 캐시가 그 방법입니다.

Q. ghost_list_posts에 모든 status를 한 번에 받을 수 없나요?

A. status 미지정으로 호출하면 모든 status가 섞여 옵니다. 단 정렬·페이지네이션 측면에서 status별 분리가 더 깔끔합니다. 또 한 번에 너무 큰 응답(150개 이상)은 컨텍스트 부담이라 분리 호출이 안전합니다.

Q. parallel tool_use 20개를 넘기면 무슨 일이 생기나요?

A. 두 가지 중 하나입니다. (1) MCP 서버가 rate limit으로 일부 실패. (2) Ghost API가 timeout. 보통 30개 이상에서 한두 개가 실패하고, 50개를 넘기면 대부분 실패합니다. 안전선은 15개입니다.

Q. fd-3 패턴 대신 xargs -P를 쓰면 안 되나요?

A. xargs -P로도 됩니다. 다만 wave 사이 진행 보고를 끼우기 어렵고, macOS xargs는 GNU와 옵션이 살짝 달라 이식성이 떨어집니다. while + fd-3 + wait이 가독성과 진행 보고 면에서 더 낫습니다.

Q. ghost_push_local로 본문을 푸시하면 태그가 비워지던데 버그인가요?

A. 의도된 동작입니다. ghost_push_local은 “로컬 마크다운으로 글을 만들거나 덮어쓰기”의 책임만 가지며, 태그·발행 상태·feature_image 같은 별도 메타는 건드리지 않습니다. push 후 ghost_update_post로 메타를 다시 적용해야 합니다. 운영 시 push와 update를 짝지어 호출하는 패턴을 만들어두면 편합니다.

Q. 이 패턴은 Ghost MCP에만 적용되나요?

A. 아닙니다. 일반적인 MCP 일괄 작업에 모두 적용됩니다. 핵심 원칙:

  1. 응답이 컨텍스트에 자동 주입되는 도구는 N번 호출 시 N × 응답 크기가 누적된다
  2. 식별자 매핑은 한 번만 빌드해서 캐시한다
  3. 응답에 긴 콘텐츠가 있으면 디스크로 빼서 컨텍스트를 청소한다
  4. 동시성은 LLM 메인 컨텍스트 밖(bash 러너)에서 관리한다

9. 참고 자료

10. 다음 단계

오케스트레이션 측의 운영 패턴까지 정리했습니다. 시리즈 마지막 3편에서는 분업의 다른 한쪽 — codex /imagen이 본문을 직접 읽고 브랜드 시스템에 맞춰 이미지를 생성하는 디테일 — 을 다룹니다. brand-spec 락, 본문 기반 정확도(v1 vs v2 비교), gpt-image-2 capability snapshot, anti-pattern 차단 목록을 풀어 봅니다.

시리즈 목차:

  1. 134개 블로그 이미지를 0 Claude 토큰으로 — Claude×Codex 멀티에이전트 분업 패턴
  2. Ghost MCP로 134개 글 일괄 메타 작업하기 — slug↔ID 매핑과 fd-3 stdin 패턴 ← 현재 글
  3. codex /imagen에 한글 헤드라인 시각화 맡기기 — brand-spec 락과 본문 기반 정확도 (예정)