텍스트는 쉬운 부분이다: AI가 쓴 글에 이미지를 입히는 파이프라인
텍스트 생성은 이미 해결됐다. 이미지 입히기는 아니다. 라우터 프롬프트, 사진 검색, Mermaid 다이어그램, 검증된 차트, 그리고 이를 SEO로 바꾸는 alt 텍스트·출처 표기·ImageObject 마크업까지 아우르는 엔드투엔드 파이프라인.
당신은 콘텐츠 목표를 가진 개발자다: 한 달에 수십 편, 어쩌면 수백 편의 기사. 작성 모델이 초안, 개요, 메타 설명, 내부 링크까지 처리한다. 그런데 파이프라인은 자체 모델이 없는 단 하나의 단계 — 이미지 — 에서 멈춘다. 사진을 구하기 어려워서가 아니라, 스택 안의 어느 것도 어떤 사진을, 어디에서, 어떤 라이선스로, 어떻게 설명해야 하는지 모르기 때문이다.
이 글이 바로 그 빠진 단계를 처음부터 끝까지 연결한다: 어떤 종류의 섹션에 어떤 종류의 시각 자료를 만들지, 완성된 초안을 검색 브리프로 바꾸는 프롬프트, 사진을 반환하는 API 호출, 사진이 표현할 수 없는 것을 그려주는 두 번째 모델, 그리고 Google이 실제로 읽을 수 있는 마크업을 생성하는 조립기. 마지막에는 그대로 가져다 쓸 수 있는 완전한 워크플로 두 가지를 제시한다.
텍스트는 해결됐다. 막히는 곳은 삽화다.
자동화된 기사 파이프라인을 정직하게 감사해 보라. 개요: 해결. 초안: 해결. 제목, 메타 설명, 스키마, 내부 링크, 번역: 모두 해결, 모두 같은 모델이, 모두 텍스트로. 그런데:
| 파이프라인 단계 | 상태 | 실제로 막히는 지점 |
|---|---|---|
| 개요 & 초안 | 해결됨 | 모델 호출 한 번, 프롬프트 한 개 |
| 제목, 메타, 스키마, 링크 | 해결됨 | 텍스트 입력, 텍스트 출력 |
| 히어로 이미지 | 막힘 | 실제 파일, 라이선스, 치수, alt 텍스트가 필요함 — 텍스트 모델이 만들 수 있는 것이 하나도 없음 |
| 섹션 이미지 | 막힘 | 기사당 3~5장, 각각 달라야 하며 사이트 전체에서 중복되지 않아야 함 |
| 차트 & 다이어그램 | 막힘 | 정확해야 함 — 검색으로 얻을 수 없고 생성기가 지어내서도 안 되는 유일한 시각 자료 |
이 실패는 미학적인 문제가 아니라 구조적인 문제다: 기사가 스톡 자리채움 이미지로 나가거나, 바로 앞 열두 편과 똑같은 사진으로 나가거나,
손가락이 여섯 개인 손이 독자가 가장 먼저 보게 되는 생성 이미지로 나간다. 그리고 이미지는 장식이 아니다 — Google 자체의 이미지
문서는 명확히 말한다: alt 텍스트는 “이미지에 대한 메타데이터를 제공하는 데 있어 가장 중요한 속성”이며, 지침은 CSS 배경 대신
실제 <img> 요소를 설명적인 alt와 함께 사용하라는 것이다 — 그래야 이미지가 발견되고
이해될 수 있다.1
네 가지 종류의 시각 자료, 네 가지 종류의 모델
가장 큰 설계 실수는 "이미지"를 하나의 제공자가 처리하는 단일 문제로 취급하는 것이다. 실제로는 네 가지 문제이며, 그 사이의 라우터는 서비스가 아니라 프롬프트 한 줄이다:
| 섹션에 필요한 것… | 생성 방법 | 다른 방법이 안 되는 이유 |
|---|---|---|
| 실제 세계의 한 장면히어로, 사람의 상황, 장소, 사물, 몸짓 | 의미 기반 사진 검색 (Pexafy) | 생성기는 세부 사항을 지어내고, 차트는 그릴 데이터가 없음 |
| 실제로 보유한 수치벤치마크, 가격, 설문 결과, 지연 시간 | 플로팅 코드를 작성하는 모델, 샌드박스에서 실행 | 이미지 모델에 수치를 맡길 수 없고, 사진은 데이터를 담을 수 없음 |
| 구조나 흐름아키텍처, 시퀀스, 상태 머신 | Mermaid / Graphviz를 작성하는 모델, 결정적으로 렌더링 | 무료 사진 라이브러리에는 당신의 시스템 다이어그램이 없음 |
| 화면에 보이는 제품문서, 변경 이력, 튜토리얼 | 스크립트로 작성된 브라우저 스크린샷 (Playwright) | 당신의 빌드에만 존재하는 UI를 보여줄 수 있는 것은 이것뿐임 |
그럼 생성형 일러스트는? 정직하게 남는 자리가 하나 있다: 사진으로 찍을 수 없고 데이터도 아닌 장면 — 추상적인 메커니즘, 아직 존재하지 않는 제품, 직접 소유한 하우스 일러스트 스타일. (사진 생성보다 실사 사진을 써야 한다는 전체 논거 — 대량 처리 속도, 정확성, 획일화 문제 — 는 여기서 다룬다.) 방향성만 짚어두자면: 2026년 8월 2일부터 EU AI Act 제50조는 생성형 시스템 제공자에게 합성 결과물을 기계 판독 가능한 형식으로 표시하도록 요구한다.2 이는 AI 제공자와 배포자에게 부과된 의무이지, 블로그가 무엇을 게시해야 하는지에 대한 규칙은 아니다 — 하지만 이 때문에 여러분 글 상단 이미지의 출처는 점점 더 독자가 그냥 믿는 것이 아니라 확인할 수 있는 무언가가 되어가고 있다.
파이프라인, 처음부터 끝까지
다섯 단계. 이미지 API를 건드리는 것은 3단계뿐이고, 선택 사항인 것은 4단계뿐이다:
┌─ 1. WRITE ────────────────────────────────────────────────────┐
topic ──▶ LLM ──▶ draft.md (h2 sections, front-matter)
└───────────────────────────────┬───────────────────────────────┘
┌─ 2. BRIEF ────────────────────┴───────────────────────────────┐
draft.md ──▶ LLM ──▶ { hero: {...}, sections: [ {...} ] }
one JSON object: per slot, a kind +
either a camera brief or a data/diagram spec
└──────────┬──────────────────────────────────┬─────────────────┘
│ kind = "photo" │ kind = "chart" | "diagram"
▼ ▼
┌─ 3. SEARCH ───────────────┐ ┌─ 4. DRAW (optional) ─────────┐
GET /search/photos LLM ──▶ mermaid | plotting code
← credited photo + ──▶ sandbox ──▶ .svg / .png
w/h, blur_hash, alt, (deterministic render, no
licence, source URL invented numbers)
└──────────┬────────────────┘ └──────────────┬───────────────┘
└───────────────┬──────────────────┘
┌─ 5. ASSEMBLE ─────────────┴───────────────────────────────────┐
<img> with width/height + fetchpriority | loading
alt written for a human · visible credit · ImageObject JSON-LD
photo_id stored so no two pages share a hero
└───────────────────────────────────────────────────────────────┘
다이어그램보다 더 중요한 특성이 두 가지 있다. 2단계는 라우터다: 슬롯마다 어떤 생성기가 실행될지 결정하므로, 사진 라이브러리에 막대 차트를 요청하는 일은 절대 없다. 그리고 SEO는 5단계에 있다 — Google이 이미지에 대해 문서화한 모든 것(설명적인 alt, 라이선스 메타데이터, LCP에 안전한 히어로)이 검색 응답이 이미 담고 있던 필드로부터 여기서 생성된다.
2단계: 초안을 시각 브리프로 바꾸기
완성된 초안에 대해 모델을 한 번 호출하면, 돌아오는 것은 쿼리가 아니라 계획이다. 단일 사진 브리프를 어떻게 작성하는가 — 왜 기사 제목이 최악의 입력값인지, 12~25단어짜리 카메라 브리프가 어떤 모습인지, 그리고 어떤 식으로 실패하는지 — 는 단일 기사 삽화 가이드에서 이미 상세히 다뤘으므로 여기서 반복하지 않는다. 파이프라인이 더하는 것은 라우팅이다: 같은 호출이 슬롯마다 어떤 생성기를 실행할지 결정해야 하며 — 차트 항목은 데이터를 담고 사진 항목은 장면을 담는다는 차이가 있다.
# system prompt — run once per finished draft
You are the art director of a technical publication. Read the article and
return the visual plan: one entry for the hero, one per H2 section.
For each entry choose exactly one kind:
"photo" a real scene: someone doing something somewhere, a place,
an object, a gesture. The default for heroes.
"chart" the section states numbers that are IN the article. Never
invent values: copy them into data, verbatim.
"diagram" the section describes a structure, a flow or a sequence.
"none" the section is short, or already carries a code block.
Rules for "photo" entries — the field is query:
카메라 브리프를 작성하세요: 카메라가 촬영했을 법한 장면을, 12에서 25단어로,
영어로, 해당 섹션의 분위기에 맞게 작성합니다. 프레임 안에 무엇이 있는지 이름을
붙이되, 주제 자체를 언급하지 마세요. 텍스트, 로고, 브랜드나 유명인은 넣지 말고,
보이지 않는 은유도 사용하지 마세요. (전체 규칙과 예시, 실패 사례는:
pexafy.com/blog/illustrate-blog-articles-at-scale/)
Do NOT write the alt text of a photo entry: the picture you get back is the
closest match to the brief, not the scene you described, so its alt has to be
written from the chosen photo. Chart and diagram entries DO carry an alt —
there you control exactly what is rendered.
Return JSON only:
{
"hero": { "kind": "photo", "query": "…", "orientation": "landscape" },
"sections": [
{ "h2": "…", "kind": "photo", "query": "…" },
{ "h2": "…", "kind": "chart", "title": "…", "unit": "ms",
"data": [ {"label": "…", "value": 0} ], "alt": "…" },
{ "h2": "…", "kind": "diagram", "spec": "flowchart LR; …", "alt": "…" }
]
}
kind 항목이 대부분의 작업을 담당하고, data 지시가 나머지를 처리한다: 차트 항목은
초안에 이미 등장하는 숫자만 담을 수 있으므로 모델은 새로 만들어내는 대신 옮겨 적는다. 다음은 실제 개발자 기사의
세 섹션에 대한 라우터 결과다:
| 섹션 | kind | 라우터가 반환한 내용 |
|---|---|---|
| 히어로 — “우리의 야간 빌드가 40분 걸리는 이유” | photo |
“어두운 방에서 화면 불빛만 밝힌 채 두 대의 모니터와 기계식 키보드가 놓인 책상에서 늦게까지 일하는 개발자” |
| “시간이 실제로 어디로 가는가” | chart |
문단에서 그대로 복사한 data: 설치 480 초, 컴파일 1080 초, 테스트 720 초, 업로드 120 초 |
| “그래프를 어떻게 나눴는가” | diagram |
작업 의존성 그래프의 flowchart LR |
| “우리가 바꾼 것, 그리고 다시 그렇게 할 것” | photo |
“다이어그램으로 가득한 화이트보드 앞에서 함께 문제를 풀고 있는 두 명의 엔지니어” |
3단계: 사진이 크레딧과 함께 돌아온다
모든 kind: "photo" 항목은 요청 하나다. 위의 히어로 브리프를 공개 API에 실행하면
147 ms 만에 다음이 반환된다:
마지막 섹션 브리프, 완전히 다른 장면을 144 ms 만에:
사진마다 반환되는 것은 5단계를 가능하게 만드는 부분이다 — 단순한 파일이 아니라:
{
"photo_id": "019e1c7f-0063-759e-b498-33ce1714e6c9", // store it: no repeats
"urls": { "small": "…?w=400", "regular": "…?w=1080",
"large": "…?w=1920" },
"width": 3000, "height": 1688, // → no layout shift
"blur_hash": "LJ8gjv9rVq-6OFxanNNFI7xco$Na", // → real placeholder
"alt_description": "Person types on keyboard in front of dual monitors…",
"photographer_full_name": "Jakub Żerdzicki", // → ImageObject.creator
"source": "Unsplash", "license_type": "free",
"source_image_url": "https://unsplash.com/photos/…",
"attribution": { "html": "<span…>Photo by …</span>",
"plain": "Photo by Jakub Żerdzicki on Unsplash (…)" }
}
4단계: 사진이 표현할 수 없는 것
계획 속 두 슬롯은 검색으로 해결할 수 없으며, 바로 여기서 두 번째 모델이 제 몫을 한다 — 그림을 그리는 것이 아니라 그림을 그리는 코드를 작성하는 것이다. 이 구분이 중요하다: 코드는 검토 가능하고, 결정적이며, 막대의 높이를 지어낼 수 없다.
다이어그램: 텍스트를 넣으면 SVG가 나온다
Mermaid는 순수 텍스트 정의로부터 다이어그램을 렌더링하며,3 이것이 모델에게 가장 안전한 대상이 되는 이유다: 출력물을 검사할 수 있고, git에서 diff가 가능하며, 매번 동일한 방식으로 렌더링된다. 라우터가 이미 spec을 반환했다.
# the "spec" field of a diagram entry, written to build/graph.mmd
cat build/graph.mmd
flowchart LR
install["install deps · 480s"] --> compile["compile · 1080s"]
compile --> test["test suite · 720s"]
compile --> upload["upload artifacts · 120s"]
npx -y @mermaid-js/mermaid-cli -i build/graph.mmd -o static/img/graph.svg
# → an SVG you can review in the PR, not a picture you have to trust
차트: 기사에 이미 있는 수치만
같은 원칙이지만 안전장치가 하나 더 붙는다. 라우터가 초안에서 값을 그대로 복사했고, 모델이 플로팅 코드를 작성하고, 코드는 샌드박스에서 실행되며, 조립기는 차트가 페이지 근처에 가기 전에 렌더링된 값이 원본 수치와 일치하는지 다시 확인한다.
import re, matplotlib
matplotlib.use("Agg") # headless: no display in CI
import matplotlib.pyplot as plt
def assert_in_draft(value, draft: str) -> None:
"""A plotted number must appear in the article AS A NUMBER."""
# Substring matching is the trap here: "120" is inside "1200", and
# inside "?w=1200" — a naive `str(v) in draft` passes on anything.
# Match on word boundaries, and accept 1 234 / 1,234 / 1234.
body = re.sub(r"(?<=\d)[ ,](?=\d{3}\b)", "", draft) # strip separators
if not re.search(rf"(?<![\d.]){re.escape(str(value))}(?![\d.])", body):
raise ValueError(f"{value} is not stated in the article — refusing to plot")
def render_chart(entry: dict, draft: str, out: str) -> str:
labels = [d["label"] for d in entry["data"]]
values = [d["value"] for d in entry["data"]]
for v in values:
assert_in_draft(v, draft) # hallucinated value → no chart
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=160)
ax.barh(labels, values)
ax.set_xlabel(entry["unit"])
ax.set_title(entry["title"])
fig.tight_layout()
fig.savefig(out) # deterministic, reviewable artefact
return out
안전장치는 짧지만 신중하게 작성해야 한다: 부분 문자열 검사는 작동하지 않는다.
"120" in draft는 1200이나 ?w=1200이 포함된 기사에서도 참이 되므로,
순진한 버전은 모든 것을 통과시키며 아무것도 보호하지 못한다. 숫자 경계에 앵커를 걸고 천 단위 구분자를 정규화하면,
기술 기사가 댓글에서 갈기갈기 찢기게 만드는 종류의 오류 — 차트가 자기 문단과 모순되는 것 — 는 프로덕션에
도달할 수 없다. 스크린샷도 같은 원칙을 따른다: 실제 빌드에 대해 실행되는 스크립트 기반 page.screenshot()은
자신의 UI에 대한 유일한 진실의 원천이며, UI가 바뀌어도 계속 참으로 남는다.
5단계: 조립 단계가 곧 SEO다
여기까지는 파일과 필드를 만들어냈다. 이 단계는 그것들을 마크업으로 바꾸는데 — 여기서는 정확하게 짚을 가치가 있다. 문서화된 세 가지 동작이 이 몇 줄에서 결정되기 때문이다.
<!-- The bytes come from another origin: pay the handshake early -->
<link rel="preconnect" href="https://images.unsplash.com" crossorigin>
<!-- LCP element: never lazy, always high priority -->
<figure>
<img src="{urls.regular}"
width="{width}" height="{height}" <!-- kills layout shift -->
alt="{alt}" <!-- written AFTER the pick -->
fetchpriority="high" decoding="async"
style="background:{color_hex}"> <!-- dominant colour, 1 field -->
<figcaption>{attribution.html}</figcaption>
</figure>
<!-- Section images, below the fold: the opposite settings -->
<img src="{urls.regular}" width="{width}" height="{height}"
alt="{alt}" loading="lazy" decoding="async">
핫링크할까, 재호스팅할까? 위 스니펫은 핫링크 방식이며, 가장 빠르게 배포할 수 있는 방법이자
preconnect를 쓰는 이유다: 원격 히어로 이미지는 임계 경로에서 DNS 조회와 TLS 핸드셰이크 비용을
발생시키며, 이는 fetchpriority로 얻은 이득을 갉아먹을 수 있다. 재호스팅은 서드파티 원본을
완전히 제거하고, 자체 브레이크포인트에서 AVIF/WebP를 제공할 수 있게 하며, 업스트림 URL이 바뀌어도 살아남는다 —
대신 저장 공간, 파이프라인의 fetch 단계, 자체 CDN 비용이 든다. 무엇을 선택하든 color_hex는
필드 하나짜리 자리채움 색상을 제공한다(블러 해시가 더 예쁘지만, 먼저 데이터 URI로 디코딩해야 한다 — CSS 색상이
아니다). 대략적인 히어로 색상을 그대로 background:에 복사하는 것이야말로 대부분의 파이프라인이
조용히 빈 회색 박스를 내보내는 지점이다.
-
히어로 이미지는 절대 지연 로딩하지 말 것. web.dev는 명확하다: “LCP 이미지는 절대 지연 로딩하지
마세요. 항상 불필요한 리소스 로드 지연으로 이어지며 LCP에 부정적인 영향을 미칩니다”라고 하며, LCP가 될 가능성이 높은
요소에
fetchpriority="high"를 권장한다 — 이미지 하나에 아껴서 사용하라는 조건과 함께.4 히어로를 포함한 모든 이미지에loading="lazy"를 찍어버리는 파이프라인은 자동화된 퍼블리싱에서 가장 흔하게 스스로 자초하는 Core Web Vitals 상처다. -
width와height는 항상 내보낼 것 — 응답에 그대로 들어 있으니 핑계가 없다. 이 속성 쌍 하나가 브라우저가 공간을 미리 확보하게 해주고 레이아웃이 튀는 것을 막는다. 파일이 로딩되는 동안에는blur_hash를 자리채움으로 사용하라. -
alt는 사진을 고른 후에 작성하라, 미리 쓰지 말 것. 이것이 미묘한 부분이다. 계획의
alt필드는 요청한 장면을 설명하는데, 실제로 얻은 사진은 그 장면에 가장 가까운 것일 뿐 그 장면 자체가 아니다. 브리프의 텍스트를 그대로 alt로 내보내는 것은 이 파이프라인이 막으려는 바로 그 접근성 실패다 — 페이지에 없는 사진에 대한 설명이 되는 것이다. 선택된 사진의alt_description을 기반으로, 사진이 들어간 문단에 맞춰 다듬어서 alt를 작성하라. Google의 지침은 “키워드를 적절히 사용하면서 페이지 내용의 맥락에 맞는 유용하고 정보가 풍부한 콘텐츠를 만드는 데 집중하라”는 것이며, alt 속성에 키워드를 채워 넣으면 “사용자 경험을 저해하고 사이트가 스팸으로 간주될 수 있다”고 경고한다.1
거의 아무도 자동화하지 않는 부분: 라이선스 메타데이터
Google은 이미지 라이선싱을 위한 ImageObject 구조화 데이터를 지원한다. 이는 contentUrl과
더불어 creator, creditText, copyrightNotice, license 중
최소 하나를 요구하며, acquireLicensePage를 권장한다. 라이선스 정보를 포함한 이미지는 Google 이미지에서
Licensable 배지 대상이 될 수 있다.5 이 필드들은 이미 검색 응답에
모두 들어 있으므로 — 이를 내보내는 것은 프로젝트가 아니라 템플릿일 뿐이다:
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "ImageObject",
"contentUrl": "{urls.large}", // required
"creator": { "@type": "Person",
"name": "{photographer_full_name}" },
"creditText": "{photographer_full_name} on {source}",
"license": "{LICENSE_URL[source]}", // the library's own page
"acquireLicensePage": "{source_image_url}" // the photo's page
}
</script>
# LICENSE_URL maps the `source` field to the licence that actually governs
# the photo — unsplash.com/license, pexels.com/license, pixabay.com/…
정확히 짚어야 할 한 가지: license는 그 사진을 지배하는 라이선스 — 즉 소스 라이브러리
자체의 라이선스 페이지 — 를 가리켜야 하며, 자기 도메인의 요약 페이지를 가리켜서는 안 된다. Google은 이 값을
읽어 배지 자격 여부를 결정하며, 자기 참조 URL은 신호로서 약할 뿐 아니라 자기 자신으로 돌아가는 링크 이상으로
변호하기도 어렵다. 독자를 위한 라이선스 요약은 내부 페이지로 남겨 두고, 마크업에는 정본을 넣어라.
그리고 조립기를 손보는 김에: 파일에는 IMG_0042.jpg 대신 짧고 설명적인 이름을 붙이고, 이미지를
사이트맵에 추가하라 — Google의 이미지 사이트맵 형식은 페이지 URL당 최대 1,000장의 이미지를 허용한다.6
둘 다 파이프라인에서는 한 줄이면 되지만 수작업으로는 절대 되지 않는다.
그대로 가져다 쓸 두 가지 파이프라인
같은 다섯 단계지만 형태는 아주 다르다 — 하나는 직접 쓰는 기사용, 하나는 실행 중인 제품과 일치해야 하는 문서용. 자신이 겪는 실패 유형에 맞는 것을 고르면 된다.
1 · CI 안의 개발 블로그 — 저장소 속 Markdown
기사는 Markdown으로 존재하고, 이미지는 그 옆에 커밋되며, 전체 과정은 push 시 실행된다. 결정적이고, PR에서 검토 가능하며, 어떤 API에도 런타임 의존성이 없다:
on: { pull_request: { paths: ["content/**.md"] } }
jobs:
illustrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: python plan_to_pr.py $(git diff --name-only origin/main -- 'content/*.md')
env:
PEXAFY_API_KEY: ${{ secrets.PEXAFY_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- uses: peter-evans/create-pull-request@v6 # images land in the PR
with: { commit-message: "chore(content): illustrate" }
사람이 여전히 PR을 승인하며, 그것이 핵심이다: 파이프라인이 제안하고 리뷰어가 처분하며, 작성된 프런트매터는 diff로 확인할 수 있다.
검색 절반은 GET 요청 하나다 — 그 요청과 score_threshold, 반환되는 필드는
단일 기사 가이드에
한 줄씩 이미 적어두었으므로, 여기서는 다시 싣는 대신 가져다 쓴다. 이 파일이 추가하는 것은 계획에는 필요하지만
사진 한 장에는 필요 없는 모든 것이다: 아무것도 반환되지 않은 브리프에 대한 재시도, 두 페이지가 같은 사진을
공유하지 않도록 하는 photo_id 점유, 그리고 실제로 돌아온 사진으로부터 작성되는 대체 텍스트.
import frontmatter
from photo_search import search # GET /search/photos 한 번; `used`에 있는 id는 건너뜀
def find_photo(entry: dict, used: set) -> dict | None:
"""Search; if the brief was too specific, widen it once, then give up."""
orientation = entry.get("orientation", "landscape")
for query in (entry["query"], widen(entry["query"])):
photo = search(query, orientation, used)
if photo:
used.add(photo["photo_id"]) # claim it: no repeats site-wide
return photo
return None # caller decides: skip the slot, or fail
def widen(query: str) -> str:
"""Drop the last clause — usually the over-specific one."""
return query.rsplit(" in ", 1)[0] if " in " in query else query
def illustrate(path: str, plan: dict, used: set) -> None:
post = frontmatter.load(path)
hero = find_photo(plan["hero"], used)
if hero is None: # no hero is better than a bad one
raise SystemExit(f"{path}: no photo above threshold — rewrite the brief")
post["hero"] = { # everything the template needs
"src": hero["urls"]["regular"], "w": hero["width"], "h": hero["height"],
# The alt describes the photo we GOT, never the scene we asked for.
"alt": alt_for(hero, section=post.get("title", "")),
"bg": hero["color_hex"], "credit": hero["attribution"]["html"],
"creator": hero["photographer_full_name"], "id": hero["photo_id"],
"source": hero["source"],
"source_url": hero["source_image_url"], # → acquireLicensePage
}
open(path, "w").write(frontmatter.dumps(post))
def alt_for(photo: dict, section: str) -> str:
"""alt_description as the base, trimmed to ~125 chars for screen readers.
Send it back through the model with the paragraph if you want better."""
base = photo.get("alt_description") or photo.get("description", "")
return base[:125].rstrip(" ,;")
2 · 문서 & 변경 이력 — 스크린샷 우선, 사진은 마지막
라우터의 기본값을 뒤집어라. 제품 문서에서 정직한 시각 자료는 거의 항상 자체 UI다: 실제 빌드를 열고, 고정된 뷰포트를 설정하고, 문단이 설명하는 정확한 상태를 캡처하는 Playwright 스크립트. 다이어그램은 아키텍처 페이지를 담당하고, 사진은 개념 페이지와 랜딩 페이지에서만 등장한다 — 스크린샷이 아무것도 말해주지 못하는 곳에서만.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 900},
device_scale_factor=2) # retina-crisp
page.goto("http://localhost:3000/dashboard")
page.get_by_role("button", name="New API key").click()
page.screenshot(path="static/img/docs/new-api-key.png")
browser.close()
# Runs in the same CI job as the docs build → the screenshot can never
# describe a version of the UI that no longer exists.
언급할 가치는 있지만 별도 레시피까지는 필요 없는 변형이 두 가지 있다. 프로그래매틱 SEO는
루프를 뒤집는다: 데이터베이스에서 생성된 수백 개의 페이지에서는 페이지마다 검색하지 않는다 — 요청 한 번에
최대 100장의 사진이 반환되므로, 주제 클러스터 단위로 검색하고 풀에서 배정하며, 고유성 제약이
중복 제거를 담당한다
(해당 아키텍처 전문).
그리고 뉴스레터와 소셜 카드는 같은 사진을 네 가지 크롭으로 필요로 한다: photo_id를
보관하고, 필요한 크기를 urls에서 요청하며, 정말로 크롭이 실패했을 때만 다시 검색하라.
같은 파이프라인, 에이전트 하나로
모델이 이미 초안을 작성하고 있다면, 가장 짧은 경로는 프로세스 간에 JSON을 주고받는 대신 검색 도구를 직접 넘겨주는 것이다. Pexafy는 mcp.pexafy.com/mcp에서 호스팅되는 Model Context
Protocol 서버를 운영한다. 커넥터 설정과 이를 통해 노출되는 도구들은 다른 글에서 다루었다 — 데스크톱과 에디터 설정은
단일 아티클 가이드에서,
CI 러너를 위한 헤드리스 변형은
대규모 콘텐츠에 관한 글에서,
그리고 더 넓은 커넥터 시장이 어떤 모습인지 — 누가 어떤 조건으로 MCP 이미지 서버를 제공하는지는
AI 에이전트를 위한 이미지 검색 인프라 연구에서 확인할 수 있다.
여기서 보여줄 만한 것은 에이전트가 도구를 직접 쥐게 되면 이 파이프라인에 무슨 일이 일어나는가이다: 다섯 단계였던 것이 하나의 지시로 줄어든다.
You Here is the draft. Build the visual plan, illustrate it, and open a PR.
Charts only from numbers already in the text.
Agent → plan: hero=photo · §2=chart · §3=diagram · §4=photo
→ search_photos(q="a developer working late at a desk with two
monitors and a mechanical keyboard in a dark room…")
← 16 photos · 147 ms · picked #1, 3000×1688, credited
→ mermaid-cli build/graph.mmd → static/img/graph.svg
→ chart.py §2 → 4 values checked against the draft ✓
→ search_photos(q="two engineers standing at a whiteboard…")
← 16 photos · 144 ms · picked #1
✓ 4 slots filled · 2 API calls · alt + credit + ImageObject written
⚠ §5 returned nothing above 0.5 — brief too abstract, rewritten as
"a person at a kitchen table checking figures on a laptop"
마지막 줄이 바로 순수한 스크립트가 아니라 에이전트를 이 루프에 두는 이유다: 자동화된 삽화의 실패 유형은 나쁜 브리프이며, 나쁜 브리프를 다시 쓰는 것이야말로 언어 모델의 존재 이유다. 배정, 중복 제거, 숫자 검증 같은 결정적인 부분은 코드에 남겨 두라.
삽화가 들어간 기사 한 편의 비용
기사당 세 개의 사진 슬롯은 곧 세 번의 검색 요청을 의미하므로, 무료 플랜 (월 5,000 요청)만으로도 월 1,666편의 기사를 결제 여부를 고민하기도 전에 커버할 수 있다 — 그리고 기사 단위가 아니라 주제 클러스터 단위로 검색을 풀링하면 이 한도는 한 자릿수 더 올라간다. 플랜별 세부 내역과 이를 무의미하게 만드는 풀링 아키텍처는 대규모 콘텐츠 삽화에 관한 자매 기사에 나와 있다.
기사당 두 번의 모델 호출 — 초안 하나, 시각 계획 하나 — 은 토큰 수천 개 수준이며 파이프라인에서 가장 저렴한 항목이 될 것이다. Mermaid와 matplotlib 렌더링은 CI 초 단위 비용 외에는 들지 않는다. 사람들을 놀라게 하는 항목은 저렴하지 않은 쪽이다: 기사당 이미지 네 장, 한 달에 사백 장을 생성하는 것, 거기에 통과하지 못한 시도들까지 더하면 — 그러고도 결과물에는 사진작가도, 날짜도, 소스 URL도 없다.
이 파이프라인이 고치지 못하는 것
정직한 섹션 하나, 이것이 막아주는 실패가 비싸기 때문이다. 잘 삽화된 기사도 여전히 기사다: 실제 사진, 정확한 차트, 올바른 마크업은 존재할 가치가 있는 페이지를 개선한다. 그것들이 얇고 대량 생산된 콘텐츠를 순위에 올려주지는 않는다. Google의 스팸 정책은 대규모 콘텐츠 남용을 명시한다 — 순위 조작을 주목적으로 다량의 페이지를 생성하고 사용자에게 거의 가치를 주지 않는 행위, 자동화 여부와 무관하게 — 그리고 삽화의 품질은 그 판단에서 요소가 아니다.7
그러므로 유효한 프레임은 이렇다: 이 파이프라인은 이미 게시될 이유가 있는 페이지에 적용되는 스스로 통제하는 품질 하한선이다. 확실히 성과가 있는 지점은:
- 독자가 검증할 수 있는 출처. 실제 사진작가와 소스 URL이 담긴 크레딧 라인은
확인 가능한 주장이며 — 같은 필드가 Google이 읽는
ImageObject마크업에도 그대로 들어간다. - 접근성과 Core Web Vitals. 실제 alt 텍스트, 모든 이미지의 치수, 절대 지연 로딩되지 않는 히어로. 게시하는 기사마다 곱하면 이것이 곧 사이트의 이미지 품질 이야기가 된다.
- 검증 가능한 정확성. 기사 내용과 대조 검증된 수치의 차트, 실행 중인 빌드에서 생성된 스크린샷, PR에서 텍스트로 검토 가능한 다이어그램 — 테스트가 실패하지 않는 한 진실에서 벗어날 수 없는 세 가지 시각 자료.
출처 표시에 관해서 이 파이프라인 특유의 논점은 좁다:
항상 크레딧을 표시해야 한다는
주장은 다른 글에서 이미 다뤘고, 자동화된 어셈블러가 여기에 더하는 것은 그것이 출력하는 같은
attribution 문자열이 구조화 데이터가 필요로 하는 creditText와 같다는 점이다.
필드 하나가 같은 템플릿 처리 과정에서 두 곳에 쓰이는 셈이며 — 그렇기에 파이프라인은 사람보다 이를
누락할 핑계가 더 없다.
시작하는 방법
- 라우터 프롬프트를 추가하라 — 이미 초안을 작성하는 무엇에든 붙이고, 그에 따라 행동하지 말고 JSON만 출력해 보라. 계획 열 개를 읽어라. 브리프가 장면이 아니라 주제를 언급한다면, 어떤 통합을 구축하기 전에 프롬프트부터 고쳐라.
- 사진 슬롯만 먼저 연결하라. 브리프당
GET /search/photos하나, 그리고 첫날부터photo_id를 저장하라 — 이미 운영 중인 3,000개 페이지에 나중에 중복 제거를 덧붙이는 것은 컬럼 하나가 아니라 마이그레이션이다. - width, height, alt를 같은 커밋에서 내보내라. 지금까지 할 수 있는 가장 저렴한 Core Web Vitals 작업이다.
- 그다음 4단계를 추가하라 — 차트보다 다이어그램을 먼저: Mermaid는 텍스트이므로 구문 오류 외에는 실패할 방법이 없다.
- 숫자 검증 안전장치는 첫 차트가 독자에게 도달하기 전에 추가하라, 나중이 아니라.
참고 자료 & 각주
1 Google Search Central, 이미지 SEO 모범 사례: alt 텍스트는
“이미지에 대한 메타데이터를 제공하는 데 있어 가장 중요한 속성”이다. 지침은 “키워드를 적절히 사용하면서
페이지 내용의 맥락에 맞는 유용하고 정보가 풍부한 콘텐츠”를 만들고, 키워드로 가득 채운 alt 속성을 피하며,
CSS 이미지 대신 HTML <img> 요소를 사용하고, 파일에 짧지만 설명적인 이름을 붙이라는 것이다.
2 EU AI Act 50조 — 2026년 8월 2일부터 적용되는 투명성 의무: 합성 이미지, 오디오, 비디오 또는 텍스트를 생성하는 시스템의 제공자는 출력물을 기계 판독 가능한 형식으로 표시하고 인공적으로 생성되었음을 감지 가능하게 만들어야 한다. 이는 AI 제공자와 배포자를 구속하며, 웹사이트가 게시할 수 있는 이미지에 대한 규칙이 아니다.
3 Mermaid는 Markdown에서 영감을 받은 텍스트 정의로부터 다이어그램과 차트를 렌더링하며, 이것이 출력물을 검토 가능하고 결정적으로 만드는 이유다.
4 web.dev, Largest Contentful Paint 최적화: “페이지의 LCP
요소일 가능성이 높다고 생각되는 <img> 요소에 fetchpriority="high"를
설정하는 것이 좋습니다” — 아껴서 사용하라는 조건과 함께. 그리고 “LCP 이미지는 절대 지연 로딩하지
마세요. 항상 불필요한 리소스 로드 지연으로 이어지며 LCP에 부정적인 영향을 미칩니다.”
5 Google Search Central, 이미지 메타데이터(구조화 데이터):
ImageObject는 contentUrl과 더불어 creator,
creditText, copyrightNotice, license 중 최소 하나를
요구한다. acquireLicensePage가 권장되며, 라이선스 정보를 포함한 이미지는 Google
이미지에서 Licensable 배지 대상이 될 수 있다.
6 Google Search Central, 이미지 사이트맵: 이미지 사이트맵은 JavaScript를 통해 발견된 이미지를 포함해 사이트의 이미지를 Google에 알려주며, 페이지 URL당 최대 1,000장의 이미지를 허용한다.
7 Google 검색 스팸 정책 — 대규모 콘텐츠 남용: 자동화, 인간의 노력, 혹은 그 조합으로 만들어졌는지와 무관하게, 순위 조작을 주목적으로 다량의 페이지를 생성하고 사용자에게 거의 가치를 주지 않는 행위.
출처 확인일: 2026년 8월 17일: 이미지 SEO 모범 사례 · 이미지 메타데이터 · 이미지 사이트맵 · LCP 최적화 · 검색 스팸 정책 · AI Act 50조 · Mermaid · Pexafy API & MCP 문서. 검색 소요 시간(147 ms, 144 ms)과 표시된 모든 사진은 같은 날 캡처된 실제 API 응답이다.
자주 묻는 질문
LLM이 쓴 글에 이미지를 자동으로 넣으려면 어떻게 해야 하나요?
GET /api/v1/search/photos, 약 150ms)로 전달합니다. 차트와 다이어그램 항목은 플로팅 코드나 Mermaid를 작성하는 모델로 넘어가 결정론적으로 렌더링됩니다. 아티클 제목을 이미지 검색에 그대로 넣지 마세요. 제목은 추상적이라 이를 그대로 묘사하는 사진은 존재하지 않습니다.문서 사이트에는 스크린샷을 써야 할까, 스톡 사진을 써야 할까?
AI 파이프라인이 차트에 잘못된 숫자를 넣지 않게 하려면 어떻게 해야 하나요?
data 필드로 복사할 수 있습니다. 그런 다음 렌더링 전에 코드로 검증합니다 — 각 값에 대해 그 문자열이 아티클 안에 실제로 있는지 확인하고, 없으면 예외를 발생시킵니다. 단 9줄이면, 자기 문단과 모순되는 차트가 독자에게 전달되는 일은 절대 일어나지 않습니다.자동화된 파이프라인은 SEO를 위해 어떤 이미지 마크업을 생성해야 하나요?
alt — Google은 alt 텍스트를 가장 중요한 이미지 메타데이터라고 부르며 키워드 남용을 경고합니다. 모든 이미지에 width와 height를 지정하고, 히어로 이미지에는 fetchpriority="high"를 적용하되 절대 loading="lazy"를 쓰지 마세요. LCP 이미지는 지연 로딩되면 안 됩니다. 그리고 contentUrl, creator, creditText, license를 포함한 ImageObject 구조화 데이터를 넣으세요. 이것이 있어야 Google 이미지에서 라이선스 가능(Licensable) 배지 대상이 됩니다.AI가 쓴 글에 이미지를 넣으면 순위에 도움이 되나요?
편집 통제권을 잃지 않으면서 CI에서 이미지 작업 단계를 실행하려면 어떻게 해야 할까?
photo_id를 기준으로 한 중복 제거, 그래프에 표시된 모든 수치가 기사에 등장하는지에 대한 검증, 그리고 점수 기준을 넘는 사진이 하나도 없을 때의 강제 실패 처리. 잘못된 히어로 이미지보다는 아예 없는 편이 낫다.