매일 아침 뉴스를 볼 때마다 아쉬웠습니다. 연합뉴스, 한국경제만 보면 한국 관점만 편향되고, Reuters/BBC는 영어라 피곤하고, Google 뉴스 RSS는 요약이 없고, 네이버 뉴스는 국내 편집이 강합니다. "18개 글로벌 언론사 오늘의 헤드라인을 한국어 3줄 요약으로 + 관련 기사 클러스터링으로" 한 화면에 보고 싶다는 단순한 욕구에서 Pebbles는 시작되었습니다.
그리고 한 사람이, 주말에, 저녁에, 몇 주 동안 만들면서 예상치 못한 곳에서 계속 막혔습니다. RSS 포맷 지옥, LLM 배치 응답 파싱 실패, 같은 회사 기사가 클러스터에 같이 묶이는 문제, 이미지 추출이 안 되는 피드, launchd 환경 변수, Vercel 배포 실패 — "간단한 뉴스 사이트"라고 생각했던 것이 실제로 만들어보니 전혀 간단하지 않았습니다.

이 글은 Pebbles라는 뉴스 큐레이션 사이트를 혼자 만들면서 내린 기술적 선택과 현실에서 부딪힌 문제들을 정리한 개발기입니다. 자랑이 아니라 기록에 가깝습니다. 비슷한 걸 만들려는 분들이 "아 이런 함정이 있구나" 하고 피할 수 있으면 좋겠습니다.
목차
- 시작 — 왜 또 뉴스 사이트를 만드는가
- 프레임워크 없이 Vite + Vanilla TypeScript로 간 이유
- RSS의 지옥 — 18개 언론사, 제각각의 포맷
- Reuters의 RSS가 없다? Google News RSS 트릭
- Claude CLI를 subprocess로 부르는 크롤러
- "===NEXT===" 가 없으면 배치 번역이 돌아가지 않는다
- 클러스터링에서 같은 언론사는 묶지 않기로 했다
- 이미지 추출 — 하나의 RSS 표준은 없다
- 증분 업데이트와 4일 TTL — JSON 하나로 끝내는 법
- launchd + Vercel — 하루 8번 자동 배포
- 돌이켜보면 — 잘한 선택, 아쉬운 선택
1. 시작 — 왜 또 뉴스 사이트를 만드는가
뉴스 사이트는 이미 세상에 차고 넘칩니다. 네이버, 다음, 구글 뉴스. 그런데 제가 원한 것은 구체적으로 이랬습니다:
- Reuters, BBC, Bloomberg, WSJ 같은 해외 주요 언론의 오늘 헤드라인
- 영어 못해도 읽을 수 있게 한국어 번역
- 30초 안에 3줄 요약으로 핵심 파악
- 한국 언론도 함께 (연합뉴스, 한경, 매경, 한겨레)
- 같은 사건 다룬 여러 언론사 기사를 "관련 기사"로 자동 묶음
- 광고 없음, 추천 알고리즘 없음, 한 페이지에 모든 것
이걸 한 번에 주는 사이트가 없었습니다. 가장 가까운 Bloomberg나 Reuters 같은 개별 언론사 사이트는 영어 장벽이 있고, 네이버 뉴스는 국내 재가공이라 원문과 거리가 있고, Google 뉴스는 요약이 없습니다.
현실적인 규모
설계하면서 스스로에게 몇 가지 질문을 던졌습니다.
사용자는 누구인가?
→ 나 하나. 혹시 주변 지인 몇 명.
→ 수익화 목표 없음.
예상 트래픽은?
→ 하루 100회 방문 이하.
→ 서버리스로 충분.
유지 관리에 얼마나 쓸 수 있나?
→ 주말 몇 시간, 평일 저녁 1시간이 최대.
→ 복잡한 인프라는 부담.
운영 비용 한계는?
→ 월 $10 이하가 목표.
이 네 가지 질문이 모든 기술 선택을 지배했습니다. "나 혼자 쓰는데 팀이 10명인 것처럼 만들 필요 없다"가 핵심 원칙이었습니다.
최종 결정한 아키텍처
┌────────────────────────────────────────────────────┐
│ Pebbles 아키텍처 개요 │
│ │
│ [내 맥북 — launchd] │
│ │ 3시간마다 │
│ ▼ │
│ [Python 크롤러] │
│ - 18개 RSS 피드 병렬 수집 │
│ - Claude CLI 호출 (subprocess) │
│ ├─ 제목 번역 │
│ ├─ 3줄 요약 │
│ ├─ 엔티티 추출 │
│ └─ 본문 번역 │
│ - sentence-transformers 임베딩 │
│ - BFS 그래프 클러스터링 │
│ │ │
│ ▼ │
│ [news.json 하나로 덤프] │
│ │ git commit & push │
│ ▼ │
│ [Vercel 자동 배포] │
│ │ │
│ ▼ │
│ [정적 사이트 — Vite 빌드] │
│ - TypeScript Vanilla │
│ - 전체 데이터는 JSON fetch 한 번 │
└────────────────────────────────────────────────────┘
서버는 없습니다. DB도 없습니다. 내 맥북이 3시간마다 크롤러를 돌리고, 결과 JSON을 Git에 푸시하면 Vercel이 정적 사이트로 배포하는 구조입니다.
"맥북이 죽으면 어떡해?" — 죽으면 그냥 뉴스 업데이트가 멈추는 거고, 사이트 자체는 Vercel에서 계속 서빙됩니다. 충분합니다.
2. 프레임워크 없이 Vite + Vanilla TypeScript로 간 이유
가장 논쟁적이었던 결정입니다. React? Vue? Next.js? Svelte? 2025년에 프레임워크 없이 사이트를 만드는 것은 대부분의 경우 비합리적입니다. 그런데 이 프로젝트에서는 의도적으로 선택했습니다.
package.json을 보면 이 프로젝트의 성격이 보인다
{
"name": "pebbles",
"dependencies": {
"fast-xml-parser": "^4.5.1"
},
"devDependencies": {
"@vercel/node": "^5.0.0",
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
}
프로덕션 의존성이 fast-xml-parser 하나입니다. (이것도 결국 Python 크롤러로 옮긴 후 안 쓰고 있어서 지워야 하는데 남아 있네요.)
왜 프레임워크 없이 갈 수 있었나
Pebbles의 UI가 실제로 하는 일은 이겁니다.
// 전체 앱 상태 (src/main.ts에서 모두 모듈 전역 변수)
let allArticles: Article[] = [];
let currentCategory: Category = 'all';
let currentSource: string = 'all';
let updatedIso = '';
let currentDate: string = '';
let availableDates: string[] = [];
변수 6개. 그게 전부입니다. 리렌더링이 필요한 곳은 3개:
- 탭 클릭 → 그리드 다시 그리기
- 소스 칩 클릭 → 그리드 다시 그리기
- 날짜 변경 → 그리드 다시 그리기
이 정도 상태 관리에 React를 끌어오는 것은 망치로 파리 잡기였습니다. 그리고 이벤트 델리게이션 하나만 있으면 클릭 처리는 충분합니다.
// 카드 클릭, 탭 클릭, 소스 칩 클릭, 뒤로가기 — 전부 여기서 처리
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const tab = target.closest('.tab') as HTMLElement | null;
if (tab) {
currentCategory = tab.dataset.cat as Category;
currentSource = 'all';
// ...클래스 토글
renderGrid();
return;
}
const chip = target.closest('.source-chip') as HTMLElement | null;
if (chip) {
const src = chip.dataset.src!;
currentSource = currentSource === src ? 'all' : src;
// ...
renderGrid();
return;
}
// ...
});
이벤트 델리게이션은 동적으로 생성되는 DOM에도 자동으로 동작하는 장점이 있습니다. React의 onClick 바인딩을 고민할 필요가 없습니다.
라우팅도 hash로 충분
function handleRoute(): void {
const hash = location.hash;
if (hash.startsWith('#article-')) {
const id = parseInt(hash.replace('#article-', ''), 10);
const article = allArticles.find(a => (a as any).id === id);
if (article) {
renderDetail(article);
return;
}
}
renderList();
}
window.addEventListener('hashchange', handleRoute);
리스트 ↔ 상세 두 뷰. 서버 라우팅 필요 없고, 공유 링크도 #article-42 형태로 깔끔하게 작동합니다. React Router, Next.js file-based routing이 전혀 필요 없었습니다.
결과 — 빌드 사이즈와 빌드 시간
프레임워크를 쓰지 않은 효과는 명확합니다.
최종 번들:
- index-[hash].js: 5.4 KB (gzip)
- index-[hash].css: 2.1 KB (gzip)
- 합계: ~7.5 KB
비교 (React + React-DOM + Vite 템플릿):
- 약 150 KB (gzip)
→ 20배 차이
물론 로직 복잡도가 올라가면 이 선택은 무너집니다. 상태가 10개, 20개 되면 직접 리렌더를 관리하는 게 지옥이 됩니다. 하지만 Pebbles 같은 "데이터 보여주기만 하는" 앱에서는 프레임워크가 오버엔지니어링입니다.
주의 — 타입은 포기하지 마라
재미있는 점은, 프레임워크를 포기했다고 해서 TypeScript는 더 소중해졌다는 것입니다. 전역 변수에 의존하는 순수 함수 스타일 코드에서는 각 함수의 입력/출력 타입이 명확해야 버그가 안 생깁니다.
export interface Article {
source: string;
sourceName: string;
category: string;
title: string;
titleOriginal: string;
description: string;
descriptionOriginal: string;
content: string;
contentOriginal: string;
link: string;
pubDate: string;
image: string;
entities: string;
clusterId: number;
}
이 한 타입이 프론트와 백(크롤러)의 계약입니다. Python 크롤러에서 만드는 JSON 구조와 TypeScript 타입이 정확히 일치해야 합니다. 어긋나면 런타임에 조용히 망가집니다.
3. RSS의 지옥 — 18개 언론사, 제각각의 포맷
"RSS 파싱, 그거 한 줄 라이브러리 있잖아?" 네. 있습니다. feedparser 같은 훌륭한 라이브러리가. 그런데 저는 최종적으로 Python xml.etree.ElementTree를 직접 썼습니다. 이유는 이걸 해보면 압니다.

RSS 피드들의 슬픈 현실
18개 언론사 피드를 하나씩 열어보면 모두 다릅니다.
RSS 2.0 (가장 흔함):
<item>
<title>제목</title>
<link>URL</link>
<description>본문 HTML</description>
<pubDate>Mon, 17 Apr 2026 12:00:00 GMT</pubDate>
</item>
Atom (The Verge, TechCrunch 일부):
<entry>
<title>제목</title>
<link href="URL"/>
<summary>요약</summary>
<published>2026-04-17T12:00:00Z</published>
</entry>
RDF/RSS 1.0 (오래된 한국 언론사):
<dc:date>, <dc:creator> 등 Dublin Core namespace
content:encoded 확장 (Guardian, NYT):
본문이 description이 아니라 content:encoded에 들어 있음
media namespace (이미지 포함):
<media:content>, <media:thumbnail>
or <enclosure type="image/..."/>
한 언론사는 <description>에 본문 전체를 넣고, 다른 언론사는 거기에 요약만 넣고 본문은 <content:encoded>에 넣습니다. 어떤 언론사는 이미지를 <media:content>에 넣고, 어떤 곳은 <enclosure>에, 어떤 곳은 <description> HTML 안에 <img> 태그로 넣습니다.
그래서 '최대한 긴 것'을 고르는 전략
본문 추출은 여러 후보를 다 모은 뒤 가장 긴 것을 고르는 단순한 전략으로 해결했습니다.
def extract_content(item: ET.Element) -> str:
"""Extract the longest content available from RSS item."""
candidates = []
# content:encoded (often has full article)
for tag in [
"{http://purl.org/rss/1.0/modules/content/}encoded",
"content:encoded",
]:
text = item.findtext(tag)
if text:
candidates.append(strip_html(text))
# Atom content
content_el = item.find("{http://www.w3.org/2005/Atom}content")
if content_el is not None:
text = content_el.text or ET.tostring(content_el, encoding="unicode", method="text")
if text:
candidates.append(strip_html(text))
# description / summary
for tag in ["description", "{http://www.w3.org/2005/Atom}summary"]:
text = item.findtext(tag)
if text:
candidates.append(strip_html(text))
if not candidates:
return ""
return max(candidates, key=len)
이 한 함수를 쓰기까지 피드를 하나하나 열어서 "아 이 언론사는 content:encoded를 쓰네", "아 여기는 Atom이네"를 확인하는 지루한 시간이 있었습니다.
날짜 파싱의 3중 방어
RSS 날짜 형식은 상상 이상으로 다양합니다.
def parse_date(date_str: str) -> str:
if not date_str:
return datetime.now(timezone.utc).isoformat()
# 1순위: RFC 2822 (RSS 2.0 표준)
try:
return parsedate_to_datetime(date_str).isoformat()
except Exception:
pass
# 2순위: ISO 8601 변형들
for fmt in ["%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M:%S"]:
try:
return datetime.strptime(date_str, fmt).isoformat()
except ValueError:
continue
# 3순위: 포기, 현재 시간으로
return datetime.now(timezone.utc).isoformat()
email.utils.parsedate_to_datetime이 생각보다 관대해서 대부분의 RFC 2822 변형을 처리해줍니다. 그래도 안 되는 것들이 있어서 ISO 8601 시도 루프를 추가했습니다.
Namespace는 튜플로 매번 따로 고려
xml.etree.ElementTree는 네임스페이스가 들어간 태그를 전체 URI로 경로 지정해야 합니다.
ns = {
"media": "http://search.yahoo.com/mrss/",
"atom": "http://www.w3.org/2005/Atom",
"dc": "http://purl.org/dc/elements/1.1/",
"content": "http://purl.org/rss/1.0/modules/content/",
}
그런데 어떤 피드는 네임스페이스 프리픽스를 쓰고 어떤 피드는 안 씁니다. 그래서 find(path, ns)와 find("{full_uri}localname") 두 방식을 모두 시도하는 패턴이 자주 나옵니다.
def extract_image(entry: ET.Element, ns: dict) -> str:
media = entry.find("media:content", ns) or entry.find("{http://search.yahoo.com/mrss/}content")
if media is not None:
return media.get("url", "")
# ...
이중 방어를 하는 이유는, 한쪽 방식으로만 하면 절반 정도의 피드에서 이미지를 못 뽑기 때문입니다.
4. Reuters의 RSS가 없다? Google News RSS 트릭
가장 의외의 문제는 주요 언론사들이 공식 RSS를 중단하거나 부실하게 제공한다는 것이었습니다.
문제 — "Reuters RSS는 어디?"
Reuters는 2020년에 공식 RSS를 중단했습니다. Bloomberg도 공식 RSS가 거의 없습니다. WSJ은 일부만 제공하고, Financial Times도 전체 기사는 유료입니다. Economist도 마찬가지입니다.
"구독 뉴스를 어떻게 끌어오지?" 기사 본문을 크롤링하는 건 저작권 문제가 있고, 유료 페이월은 우회할 수 없습니다. 그렇다고 이 주요 언론사들을 빼면 서비스 가치가 확 떨어집니다.
해결 — Google News RSS의 존재
Google News에는 특정 소스로 필터링된 RSS 피드가 존재합니다.
https://news.google.com/rss/search?q=source:reuters.com+when:1d&hl=en-US&gl=US&ceid=US:en
이 URL은:
- q=source:reuters.com — Reuters 사이트에서 나온 기사만
- when:1d — 최근 1일 이내
- hl=en-US&gl=US&ceid=US:en — 영어 인터페이스, 미국 지역
을 의미합니다. Google News가 크롤링한 결과를 RSS로 받는 것이고, 링크는 Google News 리다이렉트를 거쳐 원본으로 이동합니다.
적용
Pebbles의 소스 정의에서 직접 RSS가 없는 매체는 모두 이 트릭을 씁니다.
SOURCES = [
# 직접 RSS가 있는 곳
{"id": "bbc", "name": "BBC News", "cat": "world", "lang": "en",
"url": "https://feeds.bbci.co.uk/news/world/rss.xml"},
{"id": "nyt", "name": "The New York Times", "cat": "world", "lang": "en",
"url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml"},
# Google News RSS 경유
{"id": "reuters", "name": "Reuters", "cat": "world", "lang": "en",
"url": "https://news.google.com/rss/search?q=source:reuters.com+when:1d&hl=en-US&gl=US&ceid=US:en"},
{"id": "bloomberg", "name": "Bloomberg", "cat": "business", "lang": "en",
"url": "https://news.google.com/rss/search?q=source:bloomberg.com+when:1d&hl=en-US&gl=US&ceid=US:en"},
{"id": "ft", "name": "Financial Times", "cat": "business", "lang": "en",
"url": "https://news.google.com/rss/search?q=source:ft.com+when:1d&hl=en-US&gl=US&ceid=US:en"},
# ...
]
한계와 주의점
완벽한 해결책은 아닙니다.
장점:
✓ Reuters, Bloomberg, FT 등 접근 가능
✓ Google이 이미 필터링한 양질의 피드
✓ RSS 표준 준수 (파싱 쉬움)
단점:
✗ 제목/요약이 Google News에 맞게 가공됨 (원문과 다를 수 있음)
✗ 링크가 Google 리다이렉트 (클릭 추적됨)
✗ Google이 언제 정책 바꿀지 모름 (실제로 2024년 일부 파라미터 변경)
✗ 이미지가 없는 경우가 많음
Google News 의존은 구조적 리스크이지만, 개인 프로젝트에서는 허용 가능한 트레이드오프였습니다.
5. Claude CLI를 subprocess로 부르는 크롤러
번역과 요약을 어떻게 할지가 다음 질문이었습니다. 선택지는:
1. OpenAI API (SDK 설치, API 키 관리, 요금 관리)
2. Anthropic API (마찬가지)
3. Claude CLI (로컬에 이미 설치되어 있음, 내 개인 구독으로 호출)
Claude CLI를 subprocess로 부른다
"그냥 CLI 부르면 안 될까?" 하는 생각에서 시작했는데, 의외로 잘 작동합니다.
def _call_claude(prompt: str, timeout: int = 120) -> str:
env = os.environ.copy()
env.pop("CLAUDECODE", None)
result = subprocess.run(
["claude", "-p", "--model", "claude-haiku-4-5-20251001", prompt],
capture_output=True, text=True, timeout=timeout, env=env,
)
if result.returncode != 0:
raise RuntimeError(result.stderr[:200])
return result.stdout.strip()
몇 가지 포인트:
-p 플래그 — Claude Code CLI의 "print" 모드. 대화형이 아니라 한 번 프롬프트 넣으면 한 번 답하고 종료.
--model claude-haiku-4-5-20251001 — Haiku를 명시. 번역/요약에 Sonnet이나 Opus는 과합니다. Haiku가 4배 이상 저렴하고 번역 품질도 충분.
env.pop("CLAUDECODE", None) — 이게 뜻밖의 함정이었습니다. Claude Code 환경변수(CLAUDECODE)가 설정되어 있으면 CLI가 "내가 Claude Code 안에서 호출됐다"고 판단해서 이상하게 동작합니다. 이걸 벗겨내야 일반 CLI로 동작.
왜 API 대신 CLI?
실질적 이유는 구독 비용이 고정이기 때문입니다. API로 하면 사용량에 비례해 과금되는데, Claude Pro 구독은 월 정액입니다. 하루 8번 × 18개 소스 × 4단계 LLM 호출 (제목/요약/엔티티/본문) = 하루 수백 번의 Claude 호출이 일어나는데, 이걸 구독 한도 안에서 처리하는 게 훨씬 안정적입니다.
트레이드오프:
API 장점:
- 정식 지원, 안정적
- 배치 처리, 캐싱 등 공식 기능
- 여러 계정/키 분리 쉬움
API 단점:
- 종량제 (변동 비용)
- API 키 관리 (노출 위험)
- SDK 의존성
CLI 장점:
- 월 정액 (고정 비용)
- 설치만 되어 있으면 바로
- API 키 파일 관리 불필요 (이미 인증됨)
CLI 단점:
- "공식 용법"은 아님 (언제 막힐지 모름)
- 병렬 호출에 한계
- 응답 스트리밍 안 됨 (print 모드)
개인 프로젝트 규모에서는 CLI가 완승이었습니다. 언젠가 이 접근이 막힌다면 API로 옮기면 됩니다.
단점 — 호출이 느리다
CLI subprocess는 프로세스 fork, 인증 확인, 요청, 응답, 종료의 전체 라운드트립이 있어서 한 번에 5~10초 걸립니다. 그래서 배치 처리가 필수입니다. 다음 섹션에서 다룹니다.
6. "===NEXT===" 가 없으면 배치 번역이 돌아가지 않는다
LLM 호출 하나에 5초가 걸린다면, 144개 기사를 하나씩 번역하면 12분이 걸립니다. 그걸 4단계(제목/요약/엔티티/본문) 반복하면 48분. 너무 느립니다.
해결 — 배치로 묶어서 한 번에
여러 제목을 하나의 프롬프트에 넣어서 한 번에 번역합니다.
def translate_numbered(texts: list[str], context: str = "뉴스 제목") -> list[str]:
"""Translate a numbered list via Claude."""
if not texts:
return []
numbered = "\n".join(f"{i+1}. {t}" for i, t in enumerate(texts))
prompt = (
f"다음 영어 {context}을(를) 자연스러운 한국어로 번역하세요. "
"번호와 번역만 출력하세요. 설명이나 부가 텍스트 없이 번역만.\n\n"
f"{numbered}"
)
try:
raw = _call_claude(prompt, timeout=180)
lines = raw.strip().splitlines()
translated = []
for line in lines:
cleaned = re.sub(r"^\d+[\.\)]\s*", "", line.strip())
if cleaned:
translated.append(cleaned)
if len(translated) == len(texts):
return translated
print(f" [MISMATCH] expected {len(texts)}, got {len(translated)}")
except Exception as e:
print(f" [ERROR] {e}")
return texts # 실패 시 원본 반환
번호 매겨서 "1. ... 2. ... 3. ..." 형태로 주고, 응답도 같은 형태로 받아 정규식으로 번호를 벗겨낸 뒤 매핑합니다. 15개를 한 번에 번역하면 한 호출에 15배 효과입니다.
문제 — 본문은 길어서 번호로 안 됨
제목은 짧아서 15개 묶어도 괜찮지만, 본문은 각각이 1,500자까지 있습니다. 번호 매기기가 아니라 구분자가 필요합니다.
def translate_content_batch(contents: list[str]) -> list[str]:
if not contents:
return []
separator = "\n===NEXT===\n"
joined = separator.join(contents)
prompt = (
"다음 영어 뉴스 본문들을 자연스러운 한국어로 번역하세요. "
"각 본문은 ===NEXT=== 로 구분되어 있습니다. "
"번역도 동일하게 ===NEXT=== 로 구분하여 출력하세요. "
"설명 없이 번역만 출력.\n\n"
f"{joined}"
)
try:
raw = _call_claude(prompt, timeout=180)
parts = raw.split("===NEXT===")
parts = [p.strip() for p in parts if p.strip()]
if len(parts) == len(contents):
return parts
print(f" [CONTENT MISMATCH] expected {len(contents)}, got {len(parts)}")
except Exception as e:
print(f" [CONTENT ERROR] {e}")
return contents
===NEXT=== 라는 sentinel 문자열로 입력을 연결하고, 모델에게 "출력도 이렇게 구분해달라"고 요청한 뒤, split으로 복구합니다.
배치 크기 — 단계별로 다르다
한 배치에 몇 개를 담느냐는 LLM의 응답 길이와 정확도의 트레이드오프입니다. Pebbles에서는 단계별로 다른 크기를 씁니다.
# translate_articles() 안에서
batch_size = 15 # 제목 (짧음)
summary_batch_size = 5 # 요약 (각 3줄, 중간)
entity_batch_size = 10 # 엔티티 (짧은 키워드)
content_batch_size = 5 # 본문 (가장 김)
15개 이상 넣으면 모델이 중간에 번역을 건너뛰거나 포맷을 지키지 않는 경우가 생겼습니다. 본문은 5개 이상 묶으면 토큰 한도와 출력 길이 이슈가 생겼습니다.
실패 시 fallback — 원본 반환
LLM이 예상보다 적은 수의 결과를 반환할 때가 있습니다. 이 경우 원본 그대로 반환하는 것이 가장 안전했습니다.
if len(translated) == len(texts):
return translated
print(f" [MISMATCH] expected {len(texts)}, got {len(translated)}")
return texts # ← 원본 그대로
번역 안 된 영어가 남는 건 보기 안 좋지만, 사용자에게 잘못된 번역을 주는 것보다는 낫습니다. 다음 3시간 후 실행 때 다시 시도됩니다.
한 가지 더 — 엔티티 추출
클러스터링을 위해 각 기사에서 고유명사(인물/지명/기관)를 뽑는 단계도 배치로 합니다.
prompt = (
"다음 뉴스 기사들에서 핵심 개체(entities)를 추출하세요. "
"각 기사는 ===NEXT=== 로 구분되어 있습니다. "
"결과도 동일하게 ===NEXT=== 로 구분하여 출력하세요.\n"
"규칙:\n"
"- 인물명, 국가명, 기관명, 지명, 사건명 등 고유명사 위주\n"
"- 각 기사당 3~7개, 쉼표로 구분\n"
"- 설명 없이 개체명만 출력\n\n"
f"{joined}"
)
왜 엔티티가 필요한지는 다음 섹션에서 이야기합니다.
7. 클러스터링에서 같은 언론사는 묶지 않기로 했다
"트럼프가 오늘 관세 발표" 같은 빅 이슈는 18개 언론사 모두 보도합니다. 사용자가 기사 하나를 읽으면 "관련 기사"로 다른 언론사의 같은 이슈를 보여주고 싶었습니다.
첫 시도 — 단순 임베딩 유사도
기본적인 접근은 명확합니다.
# 각 기사를 임베딩
texts = [build_embedding_text(a) for a in articles]
embeddings = model.encode(texts, normalize_embeddings=True)
# 쌍별 유사도 계산
for i in range(len(articles)):
for j in range(i + 1, len(articles)):
sim = np.dot(embeddings[i], embeddings[j])
if sim >= 0.7:
# 같은 이슈
여기서 임베딩 입력으로 무엇을 넣을지가 중요합니다. 제목만으로는 부족합니다. Pebbles에서는 다음을 조합합니다.
def _build_embedding_text(article: dict) -> str:
title = article.get("title", "")
title_orig = article.get("titleOriginal", "")
summary = article.get("description", "")
entities = article.get("entities", "")
content = article.get("content", "")
parts = [title]
if title_orig:
parts.append(title_orig)
if summary and len(summary) > 50:
parts.append(summary[:300])
elif content:
parts.append(content[:300])
if entities:
parts.append(f"Entities: {entities}")
return "\n".join(parts)
한국어 제목 + 영어 원문 제목 + 요약 + 엔티티. 다국어 모델(paraphrase-multilingual-MiniLM-L12-v2)을 쓰므로 한국어/영어가 섞여 있어도 괜찮습니다. 엔티티를 넣는 이유는 "트럼프", "관세", "중국" 같은 키워드가 임베딩의 무게 중심을 명확히 하는 데 도움됩니다.
문제 — 같은 언론사끼리 묶이는 이상한 현상
로직을 돌렸더니 이상한 결과가 나왔습니다. Reuters의 "트럼프 관세 발표"와 Reuters의 "트럼프 관세 후속 반응"이 클러스터로 묶이는 겁니다.
곰곰이 생각하니 당연했습니다. 같은 언론사는 같은 기자, 같은 편집 스타일, 같은 템플릿으로 기사를 씁니다. 임베딩 유사도가 구조적으로 높게 나옵니다.
그런데 사용자 관점에서 "관련 기사"는 다른 언론사의 같은 이슈입니다. 같은 Reuters 기사 2개가 나란히 "관련 기사"로 뜨면 의미가 없습니다.
해결 — BFS에서 같은 소스 금지
BFS 기반 클러스터링을 하면서 "클러스터 안에 이미 있는 소스의 기사는 합류 금지"를 강제합니다.
def cluster_articles(articles: list[dict], threshold: float = 0.70):
# ... (임베딩 계산)
# Build adjacency list — 같은 소스 사이 엣지 제외
edges = []
for ii in range(len(recent_indices)):
for jj in range(ii + 1, len(recent_indices)):
i, j = recent_indices[ii], recent_indices[jj]
# Skip same source
if articles[i]["source"] == articles[j]["source"]:
continue
sim = float(np.dot(embeddings[ii], embeddings[jj]))
if sim >= threshold:
edges.append((ii, jj, sim))
# BFS with source conflict check
for node in range(len(recent_indices)):
if node in visited:
continue
if not neighbors[node]:
continue
cluster = [node]
sources_in_cluster = {articles[recent_indices[node]]["source"]}
visited.add(node)
queue = list(neighbors[node])
queue.sort(key=lambda x: x[1], reverse=True) # 강한 연결 우선
while queue:
candidate, sim = queue.pop(0)
if candidate in visited:
continue
cand_source = articles[recent_indices[candidate]]["source"]
# Allow at most one article per source in a cluster
if cand_source in sources_in_cluster:
continue
visited.add(candidate)
cluster.append(candidate)
sources_in_cluster.add(cand_source)
for nb, nb_sim in neighbors[candidate]:
if nb not in visited:
queue.append((nb, nb_sim))
queue.sort(key=lambda x: x[1], reverse=True)
핵심은 sources_in_cluster 집합과 if cand_source in sources_in_cluster: continue 조건입니다. 이미 이 소스의 기사가 클러스터에 있으면 새 후보가 아무리 유사해도 합류 못 합니다.
부수 효과 — 클러스터가 정확히 18개 이하
자동으로 클러스터당 최대 기사 수가 18개(소스 수)로 제한됩니다. "관련 기사 20개" 같은 비현실적인 결과가 나오지 않습니다. 실제 대부분의 빅 이슈 클러스터는 5~10개 언론사를 포함합니다.
Threshold 튜닝 — 0.70
threshold: float = 0.70
이 숫자는 여러 번 조정했습니다.
0.60: 너무 낮음. 다른 이슈들이 같은 클러스터로 묶임.
0.65: 약간 과결합. 기준이 겹치는 기사들이 섞임.
0.70: 실험적으로 가장 깔끔. 현재 값.
0.75: 약간 과엄격. 명백히 같은 이슈도 안 묶임.
0.80: 거의 아무것도 안 묶임.
Pebbles가 쓰는 임베딩 모델(paraphrase-multilingual-MiniLM-L12-v2)에 맞춘 값입니다. 다른 모델이면 다른 수치가 맞을 겁니다.
8. 이미지 추출 — 하나의 RSS 표준은 없다
뉴스 카드에 썸네일이 없으면 "밋밋한 텍스트 리스트"가 됩니다. RSS에서 이미지를 뽑는 것은 간단할 것 같지만 그렇지 않았습니다.
이미지를 담는 4가지 위치
def extract_image(entry: ET.Element, ns: dict) -> str:
# 1. media:content (Yahoo Media RSS 확장)
media = entry.find("media:content", ns) or entry.find("{http://search.yahoo.com/mrss/}content")
if media is not None:
return media.get("url", "")
# 2. media:thumbnail
thumb = entry.find("media:thumbnail", ns) or entry.find("{http://search.yahoo.com/mrss/}thumbnail")
if thumb is not None:
return thumb.get("url", "")
# 3. enclosure (RSS 2.0 표준)
enc = entry.find("enclosure")
if enc is not None and "image" in enc.get("type", ""):
return enc.get("url", "")
# 4. description HTML 안의 <img> 태그
desc = entry.findtext("description") or ""
m = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', desc)
if m:
return m.group(1)
return ""
네 가지를 순서대로 시도합니다. 최근 큰 언론사는 대부분 media:content를 쓰지만, 오래된 RSS나 일부 피드는 enclosure나 <img> 태그를 씁니다.
프론트에서의 Graceful Degradation
이미지가 없거나 URL이 깨진 경우를 위해 프론트에서도 방어합니다.
const imgHtml = a.image
? `<div class="card-img">
<img src="${a.image}" alt="" loading="lazy"
onerror="this.parentElement.remove()" />
</div>`
: '';
onerror="this.parentElement.remove()" — 이미지 로딩 실패 시 이미지 컨테이너 자체를 DOM에서 제거합니다. 깨진 이미지 아이콘이 뜨는 것보다 낫습니다.
loading="lazy" — 뷰포트 밖 이미지는 지연 로딩. 초기 페이지 로드가 빨라집니다.
Google News RSS의 함정 — 이미지가 거의 없음
Section 4에서 언급한 Google News RSS 경유는 이미지 추출에 취약합니다. Reuters, Bloomberg, FT 피드는 Google News를 거치므로 이미지가 거의 안 나옵니다. 이 소스들은 카드에 이미지가 없는 상태로 표시되는데, 이게 오히려 UI에서 이미지 있는 카드와 섞여서 다채로워 보이기도 합니다.
9. 증분 업데이트와 4일 TTL — JSON 하나로 끝내는 법
DB 없이 운영하려면 데이터를 어떻게 관리해야 할까? Pebbles의 답은 하나의 JSON 파일에 4일치 데이터만 유지입니다.
왜 DB 안 썼나
옵션 1: PostgreSQL + pgvector
- 장점: 구조화된 쿼리, 스케일링 가능
- 단점: 서버 필요, 연 $60+ 비용, 관리 복잡
옵션 2: SQLite
- 장점: 파일 기반, 간단
- 단점: Vercel 서버리스와 궁합 안 좋음
옵션 3: Vercel KV (Redis)
- 장점: 서버리스와 궁합
- 단점: 무료 한도 좁음
옵션 4: 그냥 JSON 파일 → git
- 장점: 정말로 0원
- 단점: 데이터가 커지면 안 됨
최종적으로 옵션 4. 단, "데이터가 커지면 안 됨" 문제는 4일 TTL로 해결.
하루 크기 계산
18 소스 × 8 기사 × 3시간마다 = 하루 432 신규 기사
중복 제거 후 = 하루 약 250~300개 실제 기사
4일 유지 = 약 1,000~1,200개 기사
각 기사 평균 크기: ~20KB (본문 + 번역)
총 크기: ~20MB
→ JSON 파일 하나로 감당 가능
→ Vercel 정적 파일로 충분히 서빙
증분 업데이트 로직
3시간마다 실행될 때, 매번 모든 기사를 다시 번역하지는 않습니다. 이미 있는 기사는 스킵합니다.
def merge_articles(existing: list[dict], new: list[dict]) -> list[dict]:
"""Merge new articles into existing, deduplicate by link, drop older than KEEP_DAYS."""
cutoff = (datetime.now(timezone.utc) - timedelta(days=KEEP_DAYS)).isoformat()
# Index existing by link for dedup
by_link: dict[str, dict] = {}
for a in existing:
if a.get("link"):
by_link[a["link"]] = a
# New articles override existing
for a in new:
if a.get("link"):
by_link[a["link"]] = a
# Filter out articles older than cutoff
merged = [a for a in by_link.values() if a.get("pubDate", "") >= cutoff]
return merged
링크 URL이 기사의 unique key. 같은 기사가 다시 크롤링되면 덮어씁니다(새 번역이 더 좋을 수 있음). 4일 지난 기사는 자동 드롭.
그런데 같은 기사를 매번 번역해야 하나?
아닙니다. 핵심은 translate_articles()가 새로 fetch한 기사에만 번역을 수행한다는 점입니다.
def main():
new_articles = []
for source in SOURCES:
articles = parse_feed(source)
new_articles.extend(articles)
# 여기서 new_articles에는 이미 번역된 어제 기사와
# 아직 번역 안 된 오늘 새 기사가 섞여 있을 수 있음
# → RSS가 이미 있는 링크를 다시 내보내기 때문
translate_articles(new_articles) # ← 모두 다시 번역
existing = load_existing_articles()
all_articles = merge_articles(existing, new_articles)
사실 현재 구현은 같은 기사를 3시간마다 다시 번역하는 비효율이 있습니다. 이 부분은 향후 개선 포인트입니다. new_articles 중에서 기존에 번역된 적 없는 것만 번역하도록 최적화하면 LLM 호출을 크게 줄일 수 있습니다.
클러스터링은 전체를 다시 하기
번역과 달리 클러스터링은 매번 전체를 다시 돌립니다.
existing = load_existing_articles()
all_articles = merge_articles(existing, new_articles)
# Re-cluster across all articles
cluster_articles(all_articles)
이유는 새 기사가 들어오면 기존 기사와의 관계가 바뀔 수 있기 때문입니다. "오늘 A 이슈 첫 보도"가 어제는 혼자였지만 오늘 다른 언론사도 보도하면 클러스터가 됩니다. 이 관계 재평가가 필요합니다.
1,200개 기사 클러스터링은 10~20초 정도 걸립니다. 하루 8번 돌려도 총 2~3분이라 허용 가능합니다.
ID 재할당
# Sort by date (newest first), assign IDs
all_articles.sort(key=lambda a: a["pubDate"], reverse=True)
for idx, article in enumerate(all_articles):
article["id"] = idx
매번 정렬 후 인덱스 기반 ID를 새로 매깁니다. 링크 URL이 진짜 unique key이고 ID는 프론트에서 편의상 사용하는 내부 참조입니다. 프론트는 #article-42로 라우팅하는데, 실제로 42번 기사는 매번 바뀔 수 있습니다. 이게 단점입니다 — 공유 링크의 영속성이 없음. 개인 사이트에서는 문제 안 됩니다.
10. launchd + Vercel — 하루 8번 자동 배포
마지막 조각은 자동화입니다. 크롤러가 수동으로 돌아가면 의미가 없습니다.
run_crawler.sh — 모든 것을 한 스크립트에
#!/bin/bash
# Pebbles News Crawler — run crawler, commit, deploy
set -e
PROJECT_DIR="/Users/kongmini/workspace/git/pebbles"
LOG_FILE="$PROJECT_DIR/crawler.log"
PYTHON="/usr/bin/python3"
export PATH="/Users/kongmini/.local/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
cd "$PROJECT_DIR"
echo "$(date '+%Y-%m-%d %H:%M:%S') — Crawler started" >> "$LOG_FILE"
# 1. Run crawler
$PYTHON crawler.py >> "$LOG_FILE" 2>&1
# 2. Git commit & push if data changed
if ! git diff --quiet public/data/news.json 2>/dev/null; then
git add public/data/
git commit -m "data: update news $(date '+%Y-%m-%d %H:%M')" >> "$LOG_FILE" 2>&1
git push origin main >> "$LOG_FILE" 2>&1
echo "$(date '+%Y-%m-%d %H:%M:%S') — Git pushed" >> "$LOG_FILE"
fi
# 3. Deploy to Vercel
npx vercel --prod --yes >> "$LOG_FILE" 2>&1
echo "$(date '+%Y-%m-%d %H:%M:%S') — Deploy complete" >> "$LOG_FILE"
한 스크립트가 세 가지를 순차 실행:
1. Python 크롤러 실행
2. JSON이 변경됐으면 git commit & push
3. Vercel 배포
set -e — 한 단계라도 실패하면 즉시 중단. 에러를 조용히 묻어서 깨진 데이터로 배포되는 것 방지.
if ! git diff --quiet — 변경사항이 없으면 커밋도 배포도 스킵. 3시간 사이에 새 기사가 전혀 없었을 수도 있음.
launchd plist — macOS 네이티브 스케줄러
cron이 익숙하지만 macOS는 launchd가 공식입니다. 그리고 cron은 화면 잠김/절전 상태에서 부활하지 않는 경우가 있어서 launchd가 더 안정적입니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.pebbles.crawler</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/kongmini/workspace/git/pebbles/run_crawler.sh</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>0</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>3</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>15</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>18</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>21</integer><key>Minute</key><integer>0</integer></dict>
</array>
<key>WorkingDirectory</key>
<string>/Users/kongmini/workspace/git/pebbles</string>
<key>StandardOutPath</key>
<string>/Users/kongmini/workspace/git/pebbles/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/kongmini/workspace/git/pebbles/launchd.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>/Users/kongmini</string>
</dict>
</dict>
</plist>
~/Library/LaunchAgents/com.pebbles.crawler.plist에 저장 후:
launchctl load ~/Library/LaunchAgents/com.pebbles.crawler.plist
이러면 3시간 간격으로 자동 실행됩니다.
왜 3시간 간격인가
1시간 간격: 과한 부하, Claude 호출 비용 4배
2시간 간격: 여전히 많음
3시간 간격: 적당함 — 하루 8회
6시간 간격: 너무 느림, 뉴스 사이트 의미 퇴색
3시간은 경험적으로 결정된 값입니다. 사용자가 어느 때 와도 "30분~3시간 이내" 정보를 볼 수 있고, 크롤러 실행 부담도 감당 가능합니다.
환경 변수 함정
launchd에서 가장 자주 막히는 부분은 환경 변수입니다. 터미널에서 수동 실행할 때는 .zshrc, .bash_profile의 PATH가 로드되지만, launchd는 매우 제한적인 기본 PATH만 갖습니다.
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>/Users/kongmini</string>
</dict>
이걸 명시하지 않으면 npx vercel이나 git이 "command not found"로 실패합니다. 처음에 이걸로 몇 시간 헤맸습니다.
Vercel 배포 — 정적 사이트의 장점
// vercel.json
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "vite"
}
Vercel은 Git push를 감지해서 자동으로 빌드하고 배포할 수도 있지만, Pebbles는 CLI로 직접 배포합니다.
npx vercel --prod --yes
이유는 Git hook 지연을 피하기 위해서입니다. Git push → Vercel 감지 → 빌드 → 배포까지 5~10분 걸릴 수 있는데, CLI 직접 배포는 1~2분입니다. 크롤러 스케줄이 빡빡하지 않지만 빠른 편이 좋습니다.
실패 모니터링
자동화에서 제일 중요한 건 "실패를 모르고 지나가지 않는 것"입니다. Pebbles는 단순하게 처리합니다.
1. launchd.err.log를 가끔 확인 (수동)
2. 사이트의 "마지막 업데이트: ..." 표시로 구독자 자체 모니터링
3. crawler.log의 "Crawler started" 간격이 3시간을 넘으면 뭔가 문제
Slack 알림, PagerDuty 같은 건 과합니다. 개인 사이트는 "망가져 있으면 내가 직접 보고 고친다"가 합리적입니다.
11. 돌이켜보면 — 잘한 선택, 아쉬운 선택
몇 주 운영하고 나서 돌이켜 보면 잘한 결정과 아쉬운 결정이 보입니다.
✅ 잘한 선택
프레임워크를 안 쓴 것
번들 사이즈 7.5KB, 로딩 속도 300ms, 유지보수할 의존성이 거의 없음. Pebbles 규모에서는 React/Vue가 오히려 비용이었음.
Python 표준 라이브러리만으로 크롤러
feedparser, requests, beautifulsoup4 없이 urllib + xml.etree + re만으로 구현. 외부 의존성 추가할 때마다 "정말 필요한가" 물은 결과. Python 3.11+ 설치만 되어 있으면 바로 돌아감.
Claude CLI subprocess 전략
월 정액 구독 안에서 하루 수백 번 LLM 호출을 공짜에 가깝게 수행. API 키 관리 불필요. 단, "공식 용법"이 아니므로 언제 막혀도 이상하지 않음. 대안(API)이 준비되어 있음.
JSON 하나 + 4일 TTL
DB 없음. 월 비용 $0. 파일 크기 20MB로 제한. 단순함이 전부 이김.
같은 소스 클러스터 금지 규칙
클러스터링 품질 즉각 개선. 경험적 규칙이지만 도메인(뉴스 다소스 집계)에 완벽히 맞는 결정.
⚠️ 아쉬운 선택 / 개선 과제
매번 같은 기사를 다시 번역
translate_articles가 이미 번역된 링크를 필터링하지 않아서 3시간마다 같은 기사를 다시 번역. Claude 호출의 70%가 낭비될 가능성. new_articles를 existing과 비교해 "진짜 신규"만 번역하도록 개선 필요.
기사 ID가 인덱스 기반
#article-42는 크롤러가 다시 돌 때마다 다른 기사일 수 있음. 공유 링크가 불안정. 해결하려면 링크 해시나 UUID 기반으로 바꿔야 함.
Google News RSS 의존
Reuters, Bloomberg, FT 등 주요 소스가 Google News 정책에 종속. Google이 파라미터를 바꾸거나 정책을 바꾸면 타격. 대안 RSS가 거의 없어서 구조적 리스크로 남아 있음.
에러 처리의 silent fallback
번역 실패 시 원본 반환이 "안전"하지만, 이게 계속 실패해도 모르고 지나갈 수 있음. 실패율 모니터링 또는 임계치 넘으면 알림 필요.
sentence-transformers 모델 로딩 오버헤드
매 실행마다 SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")를 새로 로드. 5~10초 소요. 데몬 프로세스로 상주시키면 제거 가능하지만, 현재 launchd 모델과 안 맞음.
테스트가 없다
RSS 파싱, 날짜 파싱, 클러스터링 — 모두 실제 데이터로 디버깅했고 유닛 테스트 없음. 리팩토링하면 뭐가 깨질지 모름. 중요한 함수부터 테스트 추가 필요.
사이드 프로젝트의 경제학
Pebbles는 상업 프로젝트가 아닙니다. 그래서 "완벽한 구현"보다 "지금 돌아감"이 우선이었습니다. 위 아쉬운 점들은 알고 있지만 서비스에 치명적이지 않으면 개선을 미룹니다.
사이드 프로젝트의 3가지 원칙:
1. 돌아가는 것이 완벽한 것보다 낫다
2. 돈 안 드는 것이 최고다
3. 쓰지 않는 기능은 빼라
내 시간은 유한하고 더 중요한 것에 써야 한다.
무엇을 배웠나
기술적인 것 몇 가지:
- RSS 표준은 여전히 카오스이고, 언젠가 다시 부딪힐 일이 있다.
- Vanilla TypeScript + 이벤트 델리게이션은 작은 앱에 충분히 강력하다.
- LLM 배치 처리는 구분자 전략 + 개수 검증 + fallback이 세트다.
- 임베딩 클러스터링은 도메인 규칙(같은 소스 금지)으로 즉시 개선 가능하다.
- launchd 환경 변수는 항상 명시해야 한다.
그리고 프로젝트 관점:
- "나만 쓸 건데 왜 이렇게 공들이나?" 라는 자문이 중요하다.
- DB 없이도 사이트 운영이 된다. 제약이 단순함을 강제한다.
- 자동화는 "돌아가는 걸 확인"하기까지가 프로젝트다. 배포까지 자동화 안 된 크롤러는 버그다.
- 선택에 이유가 있으면 나중에 바꿀 때도 명확하다. "그냥 그렇게 했어"는 부채가 된다.
마무리
Pebbles는 지금도 매일 3시간마다 조용히 돌아가고 있습니다. 제가 보기 편하기 때문에 만든 것이고, 그 목적을 매우 잘 수행하고 있습니다. 아침에 커피 마시면서 오늘의 글로벌 뉴스를 한 페이지로 훑어보는 습관이 자리 잡았습니다.
이 글이 다른 분께 도움이 된다면 두 가지 포인트에서일 것 같습니다.
첫째, "간단한 사이트"에 대한 감각을 맞추는 데 도움되기를. 개인 프로젝트는 프레임워크, DB, 마이크로서비스 같은 거대한 도구들 없이도 훨씬 많은 것을 할 수 있습니다. 제약을 스스로 거는 것이 오히려 빠르게 완성하는 길입니다.
둘째, "LLM을 백엔드처럼 쓴다"는 발상에 대한 참고 사례가 되기를. Claude CLI + subprocess + 배치 구분자는 우아한 방식은 아니지만 실용적이고 저렴합니다. 더 큰 스케일이라면 API로 옮겨야겠지만, 사이드 프로젝트에서는 충분합니다.
혼자 만드는 프로젝트의 진짜 보상은 배포 숫자나 유저 수가 아니라, 내가 실제로 쓰는 도구를 내가 원하는 모양으로 매일 쓸 수 있다는 것이라고 생각합니다. Pebbles는 그 점에서 제가 만든 것 중에 가장 만족스러운 결과물입니다.
다음에는 크롤러 증분 번역을 먼저 손볼 생각입니다. 그리고 또 뭔가 아쉬운 게 생기면, 주말에 조용히 고치겠지요. 그런 식으로 오래 살아남는 작은 프로젝트가 되길 바랍니다.