junyeokk
Blog
RSS Feed·2025. 07. 23

RSS/Atom 피드 파싱

웹에서 블로그나 뉴스 사이트의 새 글을 자동으로 수집하려면, 각 사이트의 HTML을 직접 크롤링하는 방법을 떠올릴 수 있다. 하지만 사이트마다 HTML 구조가 다르고, 구조가 바뀔 때마다 파서를 수정해야 한다. 이 문제를 해결하기 위해 등장한 것이 피드(Feed) 라는 표준화된 포맷이다.

피드는 사이트가 자신의 콘텐츠를 구조화된 XML로 제공하는 방식이다. 대표적으로 RSS 2.0Atom 1.0 두 가지 포맷이 있다. 구독 클라이언트(피드 리더, 크롤러 등)는 이 피드 URL만 주기적으로 요청하면, 사이트 구조와 무관하게 일관된 방식으로 새 글을 가져올 수 있다.


RSS 2.0 구조

RSS(Really Simple Syndication) 2.0은 가장 널리 쓰이는 피드 포맷이다. 구조가 단순하고 직관적이다.

xml
<?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
<?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">&lt;p&gt;전체 내용...&lt;/p&gt;</content>
    <id>https://example.com/posts/2</id>
  </entry>
</feed>

핵심 구조는 feed > entry[]이다. RSS와의 주요 차이점:

비교 항목RSS 2.0Atom 1.0
루트 요소<rss><channel><feed>
글 요소<item><entry>
링크<link>URL</link> (텍스트)<link href="URL" rel="alternate"/> (속성)
날짜 형식RFC 822ISO 8601 (RFC 3339)
발행일pubDatepublished
수정일없음updated (필수)
본문descriptionsummary 또는 content
식별자guid (선택)id (필수)

Atom의 link 태그가 특히 까다롭다. RSS에서는 <link> 안에 URL이 텍스트로 들어가지만, Atom에서는 href 속성에 들어간다. 게다가 link가 여러 개일 수 있고, rel 속성으로 용도를 구분한다:

xml
<!-- 여러 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 피드를 기본 제공한다.

그래서 피드 크롤러를 만들 때는 보통 이런 전략을 쓴다:

  1. XML을 파싱한다
  2. 파싱 결과에 rss.channel.item이 있으면 RSS 2.0으로 처리
  3. feed.entry가 있으면 Atom 1.0으로 처리
  4. 둘 다 아니면 지원하지 않는 포맷

Node.js에서 XML 파싱: fast-xml-parser

피드 파싱의 첫 단계는 XML → JavaScript 객체 변환이다. Node.js에서 가장 많이 쓰이는 XML 파서는 fast-xml-parser다. 이름 그대로 성능이 뛰어나고, 네이티브 의존성 없이 순수 JavaScript로 동작한다.

설치

bash
npm install fast-xml-parser

기본 사용법

typescript
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="..."/> 같은 속성을 읽어야 하므로, 속성 파싱을 활성화해야 한다.

typescript
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이 하나일 때는 배열이 아닌 객체로 파싱된다:

typescript
// item이 2개 이상 → 배열
result.rss.channel.item // [{...}, {...}]

// item이 1개 → 객체
result.rss.channel.item // {...}  (배열 아님!)

이걸 모르면 item.map()이 터진다. 해결 방법은 두 가지다:

방법 1: 수동 배열 변환

typescript
let items = parsed.rss.channel.item;
if (!Array.isArray(items)) {
  items = [items];
}
// 이제 안전하게 items.map() 가능

방법 2: isArray 옵션 사용

typescript
const parser = new XMLParser({
  isArray: (name) => {
    // 이 태그들은 항상 배열로 파싱
    return ['item', 'entry', 'link'].includes(name);
  },
});

isArray 옵션을 쓰면 특정 태그를 항상 배열로 만들어주므로 더 깔끔하다. 다만 모든 같은 이름의 태그에 적용되므로, 의도치 않은 곳에서도 배열이 될 수 있다는 점을 주의해야 한다.


피드 포맷 자동 감지

크롤러가 임의의 피드 URL을 받았을 때, 그게 RSS인지 Atom인지 미리 알 수 없다. XML을 파싱한 후 결과 구조를 보고 판단해야 한다:

typescript
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 패턴으로 설계하면 깔끔하다.

typescript
// 공통 인터페이스: 포맷에 상관없이 동일한 구조로 변환
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 파서 구현:

typescript
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 파서 구현:

typescript
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 태그가 세 가지 형태로 올 수 있기 때문이다:

  1. <link>https://...</link> — 단순 텍스트 (비표준이지만 존재)
  2. <link href="..." rel="alternate"/> — 단일 속성
  3. 여러 <link> 태그 — 배열로 파싱됨

이 세 가지를 모두 처리하지 않으면, 특정 블로그 플랫폼의 피드에서 링크 추출이 실패한다.


파서 매니저: 올바른 파서 자동 선택

여러 파서를 등록해두고, 주어진 XML에 맞는 파서를 자동으로 찾아 사용하는 매니저 클래스를 만들면 확장이 쉬워진다:

typescript
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 태그를 제거해야 한다:

typescript
function stripHtml(html: string): string {
  return html
    .replace(/<[^>]*>/g, '')           // HTML 태그 제거
    .replace(/&nbsp;|&#160;/g, ' ')    // 공백 엔티티
    .replace(/&[^;]+;/g, '')           // 기타 HTML 엔티티
    .replace(/\s+/g, ' ')             // 연속 공백 정리
    .trim();
}

주의: 이 정규식 기반 접근은 완벽하지 않다. <script> 태그 안의 >나 속성 안의 > 같은 엣지 케이스에서 문제가 생길 수 있다. 더 견고하게 하려면 sanitize-html 같은 전용 라이브러리를 쓰는 게 낫다.

URL 인코딩

일부 피드는 링크에 URL 인코딩된 한글이 들어온다. %ED%95%9C%EA%B5%AD%EC%96%B4 같은 형태다. 저장하기 전에 디코딩해주는 게 좋다:

typescript
const cleanLink = decodeURIComponent(rawLink);

pubDate가 없는 피드

스펙상 pubDate는 선택 필드다. 없을 때를 대비한 폴백이 필요하다:

typescript
// Atom에서는 published가 없으면 updated 사용
const pubDate = entry.published || entry.updated;

// 그것도 없으면 현재 시간 사용 (크롤링 시점)
const date = pubDate ? new Date(pubDate) : new Date();

피드 전체가 잘못된 XML

실제 운영 환경에서는 잘못된 XML을 반환하는 피드가 꽤 있다. 닫히지 않은 태그, 잘못된 인코딩, BOM 문자 등이 흔하다. try-catch로 파싱 실패를 안전하게 처리하고, 해당 피드는 건너뛰도록 해야 한다:

typescript
try {
  const parsed = parser.parse(xmlData);
  // 정상 처리
} catch (error) {
  console.error(`피드 파싱 실패: ${feedUrl}`, error.message);
  // 이 피드는 건너뛰고 다음으로
}

정리

개념핵심
RSS 2.0rss > channel > item[], RFC 822 날짜, 단순한 구조
Atom 1.0feed > entry[], ISO 8601 날짜, 엄격한 스펙
fast-xml-parserXML → JS 객체, ignoreAttributes: false 필수
단일 아이템 함정1개일 때 객체, 2개 이상일 때 배열 → 수동 배열 변환 필요
Atom link문자열/객체/배열 세 형태 모두 처리해야 함
Template Method공통 흐름은 추상 클래스에, 포맷별 차이만 서브클래스에서 구현