RSS/Atom 피드 파싱
웹에서 블로그나 뉴스 사이트의 새 글을 자동으로 수집하려면, 각 사이트의 HTML을 직접 크롤링하는 방법을 떠올릴 수 있다. 하지만 사이트마다 HTML 구조가 다르고, 구조가 바뀔 때마다 파서를 수정해야 한다. 이 문제를 해결하기 위해 등장한 것이 피드(Feed) 라는 표준화된 포맷이다.
피드는 사이트가 자신의 콘텐츠를 구조화된 XML로 제공하는 방식이다. 대표적으로 RSS 2.0과 Atom 1.0 두 가지 포맷이 있다. 구독 클라이언트(피드 리더, 크롤러 등)는 이 피드 URL만 주기적으로 요청하면, 사이트 구조와 무관하게 일관된 방식으로 새 글을 가져올 수 있다.
RSS 2.0 구조
RSS(Really Simple Syndication) 2.0은 가장 널리 쓰이는 피드 포맷이다. 구조가 단순하고 직관적이다.
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>My Blog</title>
<link>https://example.com</link>
<description>개발 블로그</description>
<lastBuildDate>Mon, 21 Jul 2025 09:00:00 GMT</lastBuildDate>
<item>
<title>첫 번째 글</title>
<link>https://example.com/posts/1</link>
<pubDate>Mon, 21 Jul 2025 09:00:00 GMT</pubDate>
<description>글 내용 요약...</description>
<guid>https://example.com/posts/1</guid>
</item>
<item>
<title>두 번째 글</title>
<link>https://example.com/posts/2</link>
<pubDate>Sun, 20 Jul 2025 15:00:00 GMT</pubDate>
<description>또 다른 글 요약...</description>
</item>
</channel>
</rss>
핵심 구조는 rss > channel > item[]이다. channel은 사이트 정보를 담고, 각 item이 개별 글을 나타낸다. item의 주요 필드는 다음과 같다:
| 필드 | 설명 | 필수 |
|---|---|---|
title | 글 제목 | 권장 |
link | 글 URL | 권장 |
pubDate | 발행일 (RFC 822 형식) | 선택 |
description | 요약 또는 전문 | 권장 |
guid | 고유 식별자 | 선택 |
pubDate는 RFC 822 형식(Mon, 21 Jul 2025 09:00:00 GMT)을 사용한다. JavaScript의 new Date()로 바로 파싱할 수 있다.
Atom 1.0 구조
Atom은 RSS의 모호한 부분을 개선하기 위해 IETF에서 표준화한 포맷이다(RFC 4287). RSS보다 엄격하고 명확한 스펙을 가진다.
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>My Blog</title>
<link href="https://example.com" rel="alternate"/>
<updated>2025-07-21T09:00:00Z</updated>
<id>https://example.com/feed</id>
<entry>
<title>첫 번째 글</title>
<link href="https://example.com/posts/1" rel="alternate"/>
<published>2025-07-21T09:00:00Z</published>
<updated>2025-07-21T10:00:00Z</updated>
<summary>글 내용 요약...</summary>
<id>https://example.com/posts/1</id>
</entry>
<entry>
<title>두 번째 글</title>
<link href="https://example.com/posts/2" rel="alternate"/>
<published>2025-07-20T15:00:00Z</published>
<content type="html"><p>전체 내용...</p></content>
<id>https://example.com/posts/2</id>
</entry>
</feed>
핵심 구조는 feed > entry[]이다. RSS와의 주요 차이점:
| 비교 항목 | RSS 2.0 | Atom 1.0 |
|---|---|---|
| 루트 요소 | <rss><channel> | <feed> |
| 글 요소 | <item> | <entry> |
| 링크 | <link>URL</link> (텍스트) | <link href="URL" rel="alternate"/> (속성) |
| 날짜 형식 | RFC 822 | ISO 8601 (RFC 3339) |
| 발행일 | pubDate | published |
| 수정일 | 없음 | updated (필수) |
| 본문 | description | summary 또는 content |
| 식별자 | guid (선택) | id (필수) |
Atom의 link 태그가 특히 까다롭다. RSS에서는 <link> 안에 URL이 텍스트로 들어가지만, Atom에서는 href 속성에 들어간다. 게다가 link가 여러 개일 수 있고, rel 속성으로 용도를 구분한다:
<!-- 여러 link가 있을 때 -->
<link href="https://example.com/posts/1" rel="alternate"/>
<link href="https://example.com/posts/1/edit" rel="edit"/>
<link href="https://example.com/feed" rel="self" type="application/atom+xml"/>
실제 글 URL을 가져오려면 rel="alternate"인 링크를 찾아야 한다. 이런 처리가 없으면 피드 자체의 URL이나 편집 URL을 글 링크로 오인할 수 있다.
RSS vs Atom: 어떤 걸 써야 하나
피드를 제공하는 입장이라면 어떤 포맷이든 큰 차이 없다. 대부분의 블로그 플랫폼(WordPress, Tistory 등)이 둘 다 지원한다.
피드를 소비하는 입장(크롤러, 리더 등)이라면 둘 다 파싱할 수 있어야 한다. 현실에서는 RSS 2.0이 압도적으로 많지만, 기술 블로그에서는 Atom도 상당히 쓰인다. 특히 GitHub Pages(Jekyll), Medium 등이 Atom 피드를 기본 제공한다.
그래서 피드 크롤러를 만들 때는 보통 이런 전략을 쓴다:
- XML을 파싱한다
- 파싱 결과에
rss.channel.item이 있으면 RSS 2.0으로 처리 feed.entry가 있으면 Atom 1.0으로 처리- 둘 다 아니면 지원하지 않는 포맷
Node.js에서 XML 파싱: fast-xml-parser
피드 파싱의 첫 단계는 XML → JavaScript 객체 변환이다. Node.js에서 가장 많이 쓰이는 XML 파서는 fast-xml-parser다. 이름 그대로 성능이 뛰어나고, 네이티브 의존성 없이 순수 JavaScript로 동작한다.
설치
npm install fast-xml-parser
기본 사용법
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser();
const xml = `
<rss version="2.0">
<channel>
<title>My Blog</title>
<item>
<title>Hello World</title>
<link>https://example.com/hello</link>
</item>
</channel>
</rss>
`;
const result = parser.parse(xml);
console.log(result.rss.channel.item.title); // "Hello World"
console.log(result.rss.channel.item.link); // "https://example.com/hello"
XML 구조가 그대로 JavaScript 객체로 변환된다. rss > channel > item > title 경로를 따라가면 값을 꺼낼 수 있다.
옵션 설정
기본 설정으로는 XML 속성(attribute)이 무시된다. Atom 피드에서는 <link href="..."/> 같은 속성을 읽어야 하므로, 속성 파싱을 활성화해야 한다.
const parser = new XMLParser({
ignoreAttributes: false, // 속성을 파싱함
attributeNamePrefix: '@_', // 속성 키에 접두사 붙임
parseAttributeValue: true, // 속성 값도 타입 변환
trimValues: true, // 값 앞뒤 공백 제거
});
const atomXml = `
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Test Post</title>
<link href="https://example.com/test" rel="alternate"/>
</entry>
</feed>
`;
const result = parser.parse(atomXml);
const link = result.feed.entry.link;
console.log(link['@_href']); // "https://example.com/test"
console.log(link['@_rel']); // "alternate"
attributeNamePrefix: '@_'를 설정하면, 속성 키 앞에 @_가 붙어서 일반 자식 요소와 구분된다. 이 접두사 없이는 <link href="...">text</link> 같은 경우 속성과 텍스트 값이 충돌할 수 있다.
주의: 단일 아이템일 때 배열이 아님
fast-xml-parser의 가장 흔한 함정이다. item이 하나일 때는 배열이 아닌 객체로 파싱된다:
// item이 2개 이상 → 배열
result.rss.channel.item // [{...}, {...}]
// item이 1개 → 객체
result.rss.channel.item // {...} (배열 아님!)
이걸 모르면 item.map()이 터진다. 해결 방법은 두 가지다:
방법 1: 수동 배열 변환
let items = parsed.rss.channel.item;
if (!Array.isArray(items)) {
items = [items];
}
// 이제 안전하게 items.map() 가능
방법 2: isArray 옵션 사용
const parser = new XMLParser({
isArray: (name) => {
// 이 태그들은 항상 배열로 파싱
return ['item', 'entry', 'link'].includes(name);
},
});
isArray 옵션을 쓰면 특정 태그를 항상 배열로 만들어주므로 더 깔끔하다. 다만 모든 같은 이름의 태그에 적용되므로, 의도치 않은 곳에서도 배열이 될 수 있다는 점을 주의해야 한다.
피드 포맷 자동 감지
크롤러가 임의의 피드 URL을 받았을 때, 그게 RSS인지 Atom인지 미리 알 수 없다. XML을 파싱한 후 결과 구조를 보고 판단해야 한다:
function detectFeedFormat(xmlData: string): 'rss20' | 'atom10' | 'unknown' {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
});
try {
const parsed = parser.parse(xmlData);
if (parsed.rss?.channel?.item) {
return 'rss20';
}
if (parsed.feed?.entry) {
return 'atom10';
}
return 'unknown';
} catch {
return 'unknown';
}
}
이 감지 로직은 단순하지만 실용적이다. RSS는 rss.channel.item, Atom은 feed.entry라는 구조가 스펙 수준에서 고정되어 있기 때문이다.
더 견고하게 만들려면 rss 태그의 version 속성(2.0)이나 feed 태그의 xmlns 속성(http://www.w3.org/2005/Atom)을 추가로 확인할 수 있다.
추상 클래스를 활용한 파서 설계
RSS와 Atom은 최종적으로 같은 형태의 데이터(제목, 링크, 날짜, 내용)를 뽑아내야 한다. 공통 흐름은 같고 세부 추출 방식만 다르므로, Template Method 패턴으로 설계하면 깔끔하다.
// 공통 인터페이스: 포맷에 상관없이 동일한 구조로 변환
interface RawFeed {
title: string;
link: string;
pubDate: string;
description: string;
}
// 추상 기본 클래스
abstract class BaseFeedParser {
protected readonly xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseAttributeValue: true,
trimValues: true,
});
// 템플릿 메서드: 전체 파싱 흐름을 정의
parseFeed(xmlData: string, since: Date): RawFeed[] {
const rawFeeds = this.extractRawFeeds(xmlData); // 포맷별로 다름
const filtered = this.filterByTime(rawFeeds, since); // 공통
return filtered;
}
// 서브클래스가 구현해야 하는 부분
abstract canParse(xmlData: string): boolean;
protected abstract extractRawFeeds(xmlData: string): RawFeed[];
// 공통 로직: 시간 필터링
private filterByTime(feeds: RawFeed[], since: Date): RawFeed[] {
return feeds.filter((feed) => {
const pubDate = new Date(feed.pubDate);
return pubDate >= since;
});
}
}
RSS 2.0 파서 구현:
class Rss20Parser extends BaseFeedParser {
canParse(xmlData: string): boolean {
try {
const parsed = this.xmlParser.parse(xmlData);
return !!parsed.rss?.channel?.item;
} catch {
return false;
}
}
protected extractRawFeeds(xmlData: string): RawFeed[] {
const parsed = this.xmlParser.parse(xmlData);
let items = parsed.rss.channel.item;
if (!Array.isArray(items)) {
items = [items];
}
return items.map((item: any) => ({
title: item.title,
link: item.link,
pubDate: item.pubDate,
description: item.description || '',
}));
}
}
Atom 1.0 파서 구현:
class Atom10Parser extends BaseFeedParser {
canParse(xmlData: string): boolean {
try {
const parsed = this.xmlParser.parse(xmlData);
return !!parsed.feed?.entry;
} catch {
return false;
}
}
protected extractRawFeeds(xmlData: string): RawFeed[] {
const parsed = this.xmlParser.parse(xmlData);
let entries = parsed.feed.entry;
if (!Array.isArray(entries)) {
entries = [entries];
}
return entries.map((entry: any) => ({
title: entry.title,
link: this.extractLink(entry.link),
pubDate: entry.published || entry.updated,
description: entry.summary || entry.content || '',
}));
}
private extractLink(linkData: any): string {
// 단순 문자열인 경우
if (typeof linkData === 'string') {
return linkData;
}
// 여러 link 태그가 있는 경우 → alternate 찾기
if (Array.isArray(linkData)) {
const alternate = linkData.find((l) => l['@_rel'] === 'alternate');
return alternate?.['@_href'] || '';
}
// 단일 link 객체
return linkData['@_href'] || '';
}
}
Atom의 extractLink가 복잡한 이유는, link 태그가 세 가지 형태로 올 수 있기 때문이다:
<link>https://...</link>— 단순 텍스트 (비표준이지만 존재)<link href="..." rel="alternate"/>— 단일 속성- 여러
<link>태그 — 배열로 파싱됨
이 세 가지를 모두 처리하지 않으면, 특정 블로그 플랫폼의 피드에서 링크 추출이 실패한다.
파서 매니저: 올바른 파서 자동 선택
여러 파서를 등록해두고, 주어진 XML에 맞는 파서를 자동으로 찾아 사용하는 매니저 클래스를 만들면 확장이 쉬워진다:
class FeedParserManager {
private parsers: BaseFeedParser[];
constructor(parsers: BaseFeedParser[]) {
this.parsers = parsers;
}
parse(xmlData: string, since: Date): RawFeed[] {
const parser = this.parsers.find((p) => p.canParse(xmlData));
if (!parser) {
throw new Error('지원하지 않는 피드 포맷입니다.');
}
return parser.parseFeed(xmlData, since);
}
}
// 사용
const manager = new FeedParserManager([
new Rss20Parser(),
new Atom10Parser(),
]);
const feeds = manager.parse(xmlData, new Date('2025-07-20'));
새로운 피드 포맷(예: RSS 1.0, JSON Feed)을 지원하려면 BaseFeedParser를 상속한 클래스를 하나 만들어서 parsers 배열에 추가하기만 하면 된다. 기존 코드를 수정할 필요가 없다(OCP, 개방-폐쇄 원칙).
실전에서 마주치는 문제들
HTML 엔티티가 섞인 텍스트
피드의 description에는 HTML이 그대로 들어오는 경우가 많다. 순수 텍스트만 필요하다면 HTML 태그를 제거해야 한다:
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, '') // HTML 태그 제거
.replace(/ | /g, ' ') // 공백 엔티티
.replace(/&[^;]+;/g, '') // 기타 HTML 엔티티
.replace(/\s+/g, ' ') // 연속 공백 정리
.trim();
}
주의: 이 정규식 기반 접근은 완벽하지 않다. <script> 태그 안의 >나 속성 안의 > 같은 엣지 케이스에서 문제가 생길 수 있다. 더 견고하게 하려면 sanitize-html 같은 전용 라이브러리를 쓰는 게 낫다.
URL 인코딩
일부 피드는 링크에 URL 인코딩된 한글이 들어온다. %ED%95%9C%EA%B5%AD%EC%96%B4 같은 형태다. 저장하기 전에 디코딩해주는 게 좋다:
const cleanLink = decodeURIComponent(rawLink);
pubDate가 없는 피드
스펙상 pubDate는 선택 필드다. 없을 때를 대비한 폴백이 필요하다:
// Atom에서는 published가 없으면 updated 사용
const pubDate = entry.published || entry.updated;
// 그것도 없으면 현재 시간 사용 (크롤링 시점)
const date = pubDate ? new Date(pubDate) : new Date();
피드 전체가 잘못된 XML
실제 운영 환경에서는 잘못된 XML을 반환하는 피드가 꽤 있다. 닫히지 않은 태그, 잘못된 인코딩, BOM 문자 등이 흔하다. try-catch로 파싱 실패를 안전하게 처리하고, 해당 피드는 건너뛰도록 해야 한다:
try {
const parsed = parser.parse(xmlData);
// 정상 처리
} catch (error) {
console.error(`피드 파싱 실패: ${feedUrl}`, error.message);
// 이 피드는 건너뛰고 다음으로
}
정리
| 개념 | 핵심 |
|---|---|
| RSS 2.0 | rss > channel > item[], RFC 822 날짜, 단순한 구조 |
| Atom 1.0 | feed > entry[], ISO 8601 날짜, 엄격한 스펙 |
| fast-xml-parser | XML → JS 객체, ignoreAttributes: false 필수 |
| 단일 아이템 함정 | 1개일 때 객체, 2개 이상일 때 배열 → 수동 배열 변환 필요 |
| Atom link | 문자열/객체/배열 세 형태 모두 처리해야 함 |
| Template Method | 공통 흐름은 추상 클래스에, 포맷별 차이만 서브클래스에서 구현 |