LLM으로 뉴스 점수를 매기는 데일리 AI 뉴스 파이프라인 구축 (RSS AI 인간 검수 발행)
요약
LLM을 활용하여 RSS 피드에서 뉴스를 수집, 필터링, 점수 매기기 및 인간의 검수를 거쳐 발행하는 데일리 AI 뉴스 파이프라인 구축 방법을 소개합니다. CDATA 처리와 같은 실무적인 파싱 트러블슈팅과 효율적인 워크플로우 패턴을 다룹니다.
핵심 포인트
- RSS/Atom 피드 파싱 시 CDATA를 먼저 처리해야 데이터 유실을 방지할 수 있음
- 키워드 필터링과 신선도 기준을 적용하여 노이즈를 제거하는 단계적 구조
- LLM을 통한 자동 점수 매기기와 인간의 최종 승인을 결합한 신뢰 경계 관리
- 다양한 뉴스 큐레이션 서비스에 재사용 가능한 파이프라인 패턴 제시
"에이전틱 AI (Agentic AI)"는 따라가는 것 자체가 파트타임 업무라고 느껴질 만큼 빠르게 움직이며, 대부분의 "AI 뉴스"는 보도 자료의 소음일 뿐입니다. 저는 소프트웨어를 출시하는 사람들에게 실제로 중요한지 여부를 모든 기사마다 점수를 매기는, 빌더 중심 (builder-focused) 피드를 원했고, 그것이 스스로 업데이트되기를 원했습니다.
그래서 작은 파이프라인을 구축했습니다. 매일 소수의 RSS/Atom 피드를 가져오고, 필터링하고, 중복을 제거하며, 하나의 구조화된 LLM 호출로 각 기사의 점수를 매긴 뒤, 사람이 승인할 수 있도록 모든 것을 초안 상태로 준비합니다. "완전 자율형 콘텐츠 팜 (fully autonomous content farm)"은 아닙니다. 여전히 사람이 발행 버튼을 누릅니다.
저를 괴롭혔던 부분들을 포함하여 전체 과정을 소개합니다. 이는 어떤 니치(niche) 뉴스/큐레이션 피드에도 재사용할 수 있는 패턴입니다.
구조
daily cron
→ N개의 RSS/Atom 피드 가져오기 (병렬 처리)
→ 파싱(parse) → 키워드 필터링 → 최신성 기준 적용
...
다섯 단계가 작업을 수행하며, 한 명의 사람이 신뢰 경계(trust boundary)를 관리합니다. 하나씩 살펴보겠습니다.
1. 가져오기 (Fetching): RSS 라이브러리는 필요 없지만 (CDATA는 주의하세요)
대부분의 양질의 소스들은 여전히 RSS 또는 Atom을 제공합니다. 라이브러리를 가져오는 대신, 의존성이 없는 아주 작은 함수로 잘 형성된(well-formed) 피드들을 파싱할 수 있습니다:
export function parseFeed(xml: string): RawFeedEntry[] {
const items = xml.match(/<item\b[\s\S]*?<\/item>/gi) ?? [] // RSS
const entries = xml.match(/<entry\b[\s\S]*?<\/entry>/gi) ?? [] // Atom
...
저에게 한 시간을 허비하게 만든 함정: replace(/<[^>]+>/g, " ")를 먼저 실행하는 단순한 stripTags 방식은 CDATA로 감싸진 제목을 통째로 삼켜버립니다. <![CDATA[OpenAI ships something]]>와 같은 제목은 하나의 커다란 태그처럼 보이기 때문에, 정규 표현식이 전체를 삭제해 버리고 빈 제목을 얻게 됩니다. 그러면 .filter()가 이를 조용히 삭제해 버립니다. 일부 피드(Google, HF)는 제목을 CDATA로 감싸지 않아 통과되지만, 다른 피드(OpenAI)는 모든 것을 감싸고 있어 1,000개의 항목이 있는 피드에서 0개의 항목을 얻게 됩니다.
해결책: 태그를 제거하기 전에 CDATA를 먼저 해제하세요.
function stripTags(input: string): string {
const unwrapped = input.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1") // CDATA를 먼저 처리!
return decode(unwrapped.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim()
}
교훈: 직접 만든 파서(hand-rolled parser)가 200-OK 응답을 보낸 피드에서 아무것도 반환하지 않는다면, 정규 표현식(regex)의 블록 매칭을 의심하기 전에 CDATA와 엔티티 디코딩 (entity decoding)을 먼저 의심하세요.
2. 필터링: 키워드 매칭 + 무시하면 큰일 나는 신선도 기준 (freshness cutoff)
키워드 필터링은 쉬운 절반입니다. 여러분의 주제 어휘를 언급하는 모든 항목을 유지하세요:
const KEYWORDS = ["agentic", "ai agent", "multi-agent", "mcp", "agent framework",
"tool calling", "computer use", "coding agent", /* ... */]
...
사람들이 건너뛰는 나머지 절반은 **신선도 기준 (freshness cutoff)**이며, 이는 생각보다 훨씬 중요합니다. OpenAI의 피드는 수년 전의 항목을 포함하여 약 1,000개의 항목이 담긴 전체 아카이브를 반환합니다. 여러분의 중복 제거 (dedup) 로직은 이미 DB에 있는 항목만 알기 때문에, 매 실행 시마다 이 오래된 게시물들이 "새로운 것"처럼 보이게 됩니다. 신선도 기준이 없다면, 매일 몇 개씩 수년간의 오래된 뉴스를 영원히 조금씩 흡수하게 될 것입니다.
const MAX_AGE_DAYS = 21
const isFresh = (iso: string | null) =>
!!iso && Date.now() - new Date(iso).getTime() <= MAX_AGE_DAYS * 864e5
단 한 줄의 코드입니다. 이 코드는 피드가 "최신 뉴스"라는 이름으로 과거의 기록을 조용히 채워 넣는 상황을 방지해 줍니다.
3. 중복 제거 (Dedup): 제목이 아닌 URL을 키(key)로 사용하기
이 작업을 매일 실행하면 똑같은 기사를 끊임없이 다시 보게 될 것입니다. 본능적으로 url + title을 해싱하고 싶겠지만, 그렇게 하지 마세요. 소스(sources)는 게시 후에도 제목을 수정할 수 있으며, 제목이 바뀌고 URL이 동일하면 url+title 키를 통과하여 중복 항목을 생성하게 됩니다.
정규화된(normalized) URL만으로 키를 생성하세요:
function dedupKey(url: string): string {
const u = new URL(url)
u.hash = ""
...
그 다음 두 곳에서 이를 강제하세요. 첫째, LLM 호출에 비용을 쓰기 전에 DB에 이미 존재하는 키와 대조하여 필터링하고, 둘째, 경합 조건(race condition)을 방지하는 안전장치로 해당 컬럼에 **고유 인덱스 (unique index)**를 설정하세요 (insert ... onConflictDoNothing). 기사 URL은 자연스럽게 고유하지만, 제목은 그렇지 않습니다.
4. 점수 매기기 (Scoring): 기사당 하나의 구조화된 LLM 호출
이 부분이 바로 "빌더 중심(builder-focused)"적인 핵심입니다. 각 새로운 후보 기사에 대해, 하나의 구조화된 호출(structured call)이 기사를 다시 작성하고 다섯 가지 축을 기준으로 점수를 매깁니다. Vercel AI SDK를 사용하여, 문자열 모델 ID가 AI Gateway를 통해 라우팅되며, generateObject를 통해 모델이 검증된 JSON을 반환하도록 강제합니다.
const Enriched = z.object({
title: z.string(), // 정제됨, PR용 최상급 표현 제거
summary: z.string(), // 복사하지 않고 자체적인 언어로 재작성
...
저렴한 모델(Haiku급 / 4o-mini급)을 사용하는 것이 적절한 선택입니다. 뉴스를 요약하고 점수를 매기는 데는 프런티어 모델(frontier model)이 필요하지 않으며, 이 작업은 대량으로 수행되기 때문입니다. 제가 시행착오를 통해 배운 두 가지가 있습니다:
- 스키마는 느슨하게, 제어는 코드에서. 만약 스키마 내부에서
scores를0–5로,tags를최대 5개로 제한하면, 모델이 가끔 6점이나 6번째 태그를 반환할 때가 있습니다. 이 경우 전체 호출이 "응답이 스키마와 일치하지 않음"이라는 오류와 함께 완전히 실패하며, 해당 기사를 놓치게 됩니다. 그냥 일반 숫자나 배열을 허용하도록 한 뒤, 코드에서 직접clamp(0,5)와slice(0,5)를 수행하세요. 검증(Validation)은 걸림돌(tripwire)이 아니라 경계(edges)에서 이루어져야 합니다. - 일시적인 실패에 대한 재시도. 속도 제한(Rate limits), 간헐적인 스키마 불일치, 타임아웃 등은 3회 재시도 백오프(backoff)로 감싸세요. 무료 티어 AI Gateway는 급격한 요청 증가를 공격적으로 제한하므로(저는 약 5번의 호출 후에 제한에 걸렸습니다), 실행당 풍부화(enrich)할 개수를 제한하고 나머지는 내일로 넘기도록 하세요.
실패한 기사는 삽입되지 않기 때문에, 해당 URL은 계속 "미확인(unseen)" 상태로 남아 다음 실행 시 자동으로 재시도됩니다. 데이터가 유실되는 일은 없으며, 단지 작업이 며칠에 걸쳐 분산될 뿐입니다.
5. 인간의 게이트 (신뢰성을 만드는 부분)
파이프라인은 draft(초안)를 생성할 뿐, 결코 published(발행)를 직접 생성하지 않습니다. 사람이 초안을 훑어보며 — 제목, AI가 작성한 "중요한 이유(why it matters)", 점수 등을 확인하고 — 좋은 것들을 승인합니다. 실제로는 30초면 끝나는 CLI 단계입니다:
news review # 초안 목록 표시, 높은 점수 순
news publish <id> <id> ... # 초안 → 발행
news archive <id> # 반려
제가 절대 넘지 않을 선은 이것입니다. LLM은 _초안 작성 및 순위 매기기 (drafting and ranking)_에는 뛰어나지만, "이것은 실제이며, 정확하고, 독자의 관심을 기울일 가치가 있다"라고 판단하는 것은 인간의 영역입니다. 이것은 큐레이션된 피드 (curated feed)와 AI 슬롭 팜 (AI slop farm, AI가 생성한 저질 콘텐츠 생성소) 사이의 차이이며, 독자들은 이를 알아챌 수 있습니다.
저장은 하나의 테이블에 있는 두 가지 상태(draft / published / archived)일 뿐이므로, "발행 (publishing)"은 재배포가 아닌 상태 전환입니다. 페이지는 published 상태를 쿼리하고 ISR (Incremental Static Regeneration)로 캐싱합니다.
여러분이 복사했으면 하는 내용
- RSS 의존성을 건너뛰세요. 이미 알고 있는 피드 세트의 경우 그렇습니다. 단, 태그를 제거하기 전에 CDATA를 먼저 해제해야 합니다.
- 신선도 차단 지점 (freshness cutoff)을 추가하세요. 피드는 과거 이력을 백필 (backfill)하므로, 여러분의 중복 제거 (dedup) 로직이 이를 잡아내지 못할 것입니다.
- 정규화된 URL (normalized URL)로 중복을 제거하세요. DB의 유니크 인덱스 (unique index)를 최후의 보루로 사용하십시오.
- **단 한 번의 구조화된 LLM 호출 (structured LLM call)**로 다시 쓰기 + 분류 + 점수 매기기를 수행하세요. 스키마 (schema)는 느슨하게 가져가되, 코드에서 엄격하게 제한하고, 일시적인 오류 발생 시 재시도하세요.
- 발행 단계에는 반드시 인간을 포함하세요. 모델이 초안을 작성하고 순위를 매기게 하되, 무엇을 발행할지는 여러분이 결정하십시오.
전체 시스템은 데일리 크론 (daily cron), 하나의 DB 테이블, 그리고 약 300줄의 코드로 이루어져 있습니다. 비용이 많이 드는 부분(모든 것에 점수를 매기는 LLM)은 소형 모델을 사용하면 하루에 단 몇 푼 정도면 충분합니다.
실행 모습 보기
이 파이프라인은 빌더(builder) 중심의 라이브 에이전틱 AI (agentic-AI) 피드에 데이터를 공급합니다. 모든 기사는 단순한 헤드라인이 아니라 "왜 이것이 빌더에게 중요한가"에 대한 노트를 포함합니다.
만약 도구 자체를 찾고 계신다면: Best AI agent tools.
여러분만의 버전을 구축한다면, 가장 큰 고통을 줄여줄 두 가지는 CDATA 수정과 신선도 차단 지점입니다. 궁금한 점은 댓글로 무엇이든 물어보세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기