Ghost MCP로 134개 글 일괄 메타 작업하기 — slug↔ID 매핑과 fd-3 stdin 패턴
Ghost MCP로 134개 글에 일괄 메타 작업을 적용하다 마주친 컨텍스트 폭증·동시성 함정. 슬러그↔ID 매핑 인덱스, 페이지네이션·search 함정, fd-3 stdin 분리, parallel tool_use 한도까지 운영 패턴 5가지를 코드와 함께 정리했습니다.
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가 납니다. 일괄 작업의 가장 흔한 함정입니다.
베스트 프랙티스:
- 응답 윗부분의
Total: N posts같은 메타를 항상 확인 Total > limit이면 다음 page를 명시적으로 fetch- 또는 처음부터
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개가 “진짜 후보”인지 알기 어렵습니다.
실용적인 우회:
- 슬러그 정확 매칭 — 새 글 슬러그를 정한 뒤
ghost_get_post(slug=...)로 직접 조회. 404면 신규 - 제목 정확 매칭 — 글 제목 전체를 따옴표로 감싸 search
- 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
세 가지 디테일이 핵심입니다.
read -r -u 3— 부모는 fd 3에서 읽음3< tasks.tsv— 입력 파일이 fd 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개씩 묶여 와서 컨텍스트 정리도 깔끔합니다.
베스트 프랙티스:
- 한 메시지의 parallel tool_use는 10-20개 사이가 안정적
- 응답이 짧은 도구(update_post 같은)는 더 묶을 수 있음
- 응답이 긴 도구(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_posts에limit=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 일괄 작업에 모두 적용됩니다. 핵심 원칙:
- 응답이 컨텍스트에 자동 주입되는 도구는 N번 호출 시 N × 응답 크기가 누적된다
- 식별자 매핑은 한 번만 빌드해서 캐시한다
- 응답에 긴 콘텐츠가 있으면 디스크로 빼서 컨텍스트를 청소한다
- 동시성은 LLM 메인 컨텍스트 밖(bash 러너)에서 관리한다
9. 참고 자료
10. 다음 단계
오케스트레이션 측의 운영 패턴까지 정리했습니다. 시리즈 마지막 3편에서는 분업의 다른 한쪽 — codex /imagen이 본문을 직접 읽고 브랜드 시스템에 맞춰 이미지를 생성하는 디테일 — 을 다룹니다. brand-spec 락, 본문 기반 정확도(v1 vs v2 비교), gpt-image-2 capability snapshot, anti-pattern 차단 목록을 풀어 봅니다.
시리즈 목차:
- 134개 블로그 이미지를 0 Claude 토큰으로 — Claude×Codex 멀티에이전트 분업 패턴
- Ghost MCP로 134개 글 일괄 메타 작업하기 — slug↔ID 매핑과 fd-3 stdin 패턴 ← 현재 글
- codex
/imagen에 한글 헤드라인 시각화 맡기기 — brand-spec 락과 본문 기반 정확도 (예정)