IT 시행착오··약 8분

Astro 블로그 RSS 피드 구현 — escapeXml과 pubDate 포맷에서 부딪힌 것들

정적 사이트에 RSS 2.0 피드를 붙이면서 마주친 XML 이스케이프, 날짜 포맷, URL 처리 문제를 실제 코드와 함께 정리한다. 피드가 깨지는 원인과 재현 가능한 수습 과정이 핵심이다.

RSS 피드 아이콘 일러스트레이션 (cc0, openverse:rawpixel)

RSS 피드는 “그냥 XML 텍스트를 내려주면 되는 것”이라는 안일한 생각으로 시작했다. 실제로는 XML 이스케이프, 날짜 포맷, URL 정규화라는 세 개의 함정이 기다리고 있었다. 이 글은 이 블로그(seominhyuk.com)에 RSS 2.0 피드를 붙이면서 겪은 시행착오를 실제 코드와 함께 정리한다.

사실: RSS 피드가 깨진 증상

Astro 콘텐츠 컬렉션에서 글 목록을 가져와 rss.xml로 렌더링하는 작업이었다. 첫 버전은 단순했다.

const posts = (await getCollection('posts'))
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
  .slice(0, 20);

이 코드 자체는 문제가 없었다. 문제는 템플릿 문자열로 XML을 조립하는 과정에서 터졌다.

return `    <item>
  <title>${post.data.title}</title>
  <link>${new URL(`/posts/${post.id}`, SITE_URL).href}</link>
  <description>${post.data.description}</description>
  <pubDate>${date}</pubDate>
</item>`;

이대로 빌드하면 대부분의 글은 정상 출력됐지만, 제목이나 description에 &, <, >가 들어간 글에서 XML 파싱 에러가 발생했다. RSS 리더기(Feedly 등)가 피드를 읽지 못하고 “XML 파싱 실패”를 뱉는 증상이었다.

원인: XML은 HTML이 아니다

HTML은 브라우저가 관대하게 파싱하지만, XML은 엄격한 문법을 요구한다. & 하나만 있어도 파싱이 깨진다. 예를 들어 제목에 “A & B”가 들어가면 XML 파서는 & 뒤에 엔티티 이름이 와야 하는데 B가 와서 파싱 에러를 낸다. HTML에서는 브라우저가 자동 처리하지만, 텍스트로 XML을 조립할 때는 직접 이스케이프해야 한다.

해결책은 escapeXml 함수를 추가하는 것이었다. 이 함수는 다섯 개의 XML 특수 문자를 엔티티로 치환한다.

원문자 치환 엔티티 설명
& & 앰퍼샌드 (가장 먼저 치환)
< < 보다 작음
> > 보다 큼
" " 큰따옴표
' ' 작은따옴표

실제 구현은 String.prototype.replace를 체이닝하는 방식이다. 각 줄에서 정규식으로 원문자를 찾아 엔티티 문자열로 바꾼다. &를 가장 먼저 치환해야 한다는 점이 중요하다. 순서를 바꾸면 이미 치환된 &&가 다시 &amp;로 이중 치환되는 문제가 생긴다.

이 함수를 title, description에 적용하니 XML 파싱 에러가 사라졌다.

평가: pubDate 포맷의 함정

두 번째 함정은 날짜 포맷이었다. RSS 2.0 스펙은 <pubDate>RFC 822 포맷을 요구한다. 그런데 이 블로그의 frontmatter pubDate는 두 가지 형식이 혼재되어 있었다.

# 형식 A: 날짜만
pubDate: 2026-08-06

# 형식 B: ISO 8601 (시간대 포함)
pubDate: 2026-08-04T14:20:00+09:00

Astro의 getCollection은 두 형식을 모두 Date 객체로 파싱한다. 문제는 toISOString()을 그대로 쓰면 UTC 기준으로 변환된다는 점이다. 한국 시간(KST, UTC+9) 기준으로 작성된 글이 UTC로 변환되면서 날짜가 하루 밀리는 현상이 발생할 수 있다.

const date = post.data.pubDate.toISOString(); // UTC 기준

이걸 RSS 리더기가 받으면 “8월 4일 14:20 KST”가 “8월 4일 05:20 UTC”로 표시된다. 리더기마다 로컬 시간대로 다시 변환하므로 사용자에게는 같은 날짜로 보이지만, 정렬 순서가 꼬일 수 있다.

해결책은 RSS 스펙에 맞게 RFC 822 포맷으로 변환하는 것이다. JavaScript의 Date 객체에서 직접 RFC 822 문자열을 만들거나, toUTCString()을 사용할 수 있다. 다만 toUTCString()은 “Thu, 06 Aug 2026 05:20:00 GMT” 형식이라 RSS 스펙과 거의 호환된다.

부연: URL 정규화와 SITE_URL

세 번째 함정은 URL이었다. new URL('/posts/' + post.id, SITE_URL).hrefSITE_URLhttps://seominhyuk.com처럼 끝에 슬래시가 없을 때 정상 동작한다. new URL은 상대 경로를 베이스 URL에 안전하게 결합하므로, SITE_URL의 끝 슬래시 유무와 관계없이 정상 동작한다.

new URL('/posts/foo', 'https://seominhyuk.com/').href
// → https://seominhyuk.com/posts/foo (정상)

new URL('/posts/foo', 'https://seominhyuk.com').href
// → https://seominhyuk.com/posts/foo (정상)

다만 astro.config.mjssitesrc/consts.tsSITE_URL이 서로 다른 값이면 sitemap과 RSS 피드의 URL이 어긋날 수 있다. 이 블로그는 두 곳을 동기화하는 규칙을 지키고 있다.

남긴 체크리스트

  1. XML 이스케이프는 &&를 가장 먼저 처리할 것
  2. RSS <pubDate>는 RFC 822 포맷을 사용하고, UTC 변환 시 날짜 밀림을 확인할 것
  3. SITE_URLastro.config.mjssite를 동기화할 것
  4. 피드 검증은 RSS 리더기뿐 아니라 XML 파서로도 확인할 것 (예: xmllint --noout)

RSS 피드는 “단순한 XML 텍스트”가 아니라 스펙을 지키는 XML 문서다. 이스케이프 하나로 피드 전체가 죽을 수 있다는 걸 이번에 몸으로 배웠다.

출처

  • RSS 2.0 Specification (Harvard Law)<pubDate> RFC 822 포맷 요구사항 (열람: 2026-08-06)
  • MDN: XML 이스케이프 — XML 특수 문자 처리 (열람: 2026-08-06)
  • 내부 실측: src/pages/rss.xml.tsescapeXml 함수, toISOString() 사용, slice(0, 20) 제한
  • 내부 실측: npm run builddist/rss.xml 생성 확인, xmllint --noout dist/rss.xml 파싱 검증