RSS 피드 검사기

RSS 2.0의 모든 규칙을, 근거와 함께 확인합니다.

원본 서버에서 직접 가져옵니다. 많은 서버가 이를 차단하므로, 차단된다면 내용을 붙여넣으세요.
입력
대기 중문서를 붙여넣으면 검사합니다. 입력하는 동안 검증이 실행됩니다.

모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.

위에 피드를 붙여넣거나 주소를 입력하고 가져오기를 누르면 RSS 2.0 명세에 비추어 검사합니다. 모든 지적은 요소 이름을 밝히고, 값을 인용하고, 근거 규칙을 함께 보여 줍니다. 마지막 부분이 중요합니다. 피드가 지적받는 것들 중 3분의 1쯤은 명세 위반이 아니라 리더들이 의존하는 관행이며, 여기서는 그렇게 표시합니다.

아마 아래쪽의 무언가가 피드를 거부해서 여기 오셨을 겁니다. 리더가 채널은 보여 주는데 에피소드는 안 나온다. RSS를 메일로 보내 주는 서비스가 거부한다. 구독자가 예전 글이 전부 새 글로 다시 떴다고 한다. 각 경우마다 유력한 원인 목록이 짧게 있고, 그 전부를 검사합니다.

이 페이지가 존재하는 이유는 표준처럼 쓰이던 도구가 공개적으로 삭아 가고 있기 때문입니다. 2026년 9월 확인 기준으로 feedvalidator.org는 만료된 TLS 인증서를 내주어 브라우저가 열기를 거부하고, SourceForge 미러는 저작권 표시가 2002~2004년이며 아직도 Atom 0.3을 앞세웁니다. W3C 검사기는 동작하지만 서버 쪽입니다. 여러분의 피드가 그쪽 기계로 갑니다.

RSS 2.0이 실제로 요구하는 것

대부분이 예상하는 것보다 적습니다. 루트는 version="2.0"을 가진 <rss>이고, 그 안에 <channel>이 정확히 하나 있습니다. channel에는 title, link, description이 필요하고 link는 절대 URL이어야 합니다. item은 더 느슨해서 title이나 description 중 하나만 있으면 되고, 그 밖에 필수인 것은 없습니다.

명세가 허용하지 않는 것은 반복입니다. channel 안의 <title> 두 개, item 안의 <link> 두 개는 오류이며, 두 번째 것이 위치와 함께 보고됩니다. 흔한 실수를 잡아내는 구조 검사도 있습니다. <item>이 <channel> 안이 아니라 <rss> 바로 아래에 놓인 경우입니다. 파싱은 되지만 어떤 리더도 그것을 보여 주지 않습니다.

  • channel: title, link, description 필수.
  • item: title이나 description 중 최소 하나.
  • 하나만 있어야 하는 요소를 channel이나 item 안에서 반복해서는 안 됩니다.
  • RSS 핵심 요소는 네임스페이스가 없으므로 <atom:link>는 RSS의 <link>가 아닙니다.

날짜는 RFC 822이지 ISO 8601이 아닙니다

RSS에서 가장 "맞아 보이는" 실수입니다. pubDate와 lastBuildDate는 RFC 822여야 합니다. Wed, 02 Oct 2024 13:00:00 GMT 처럼요. 대부분의 프레임워크가 기본으로 내놓는 것은 ISO 8601인 2024-10-02T13:00:00Z이고, 이는 Atom에서는 맞고 여기서는 틀립니다. pubDate를 해석하지 못하는 리더는 그 항목을 숨기거나 엉뚱한 자리에 정렬합니다.

네 가지를 확인합니다. 애초에 파싱이 되는지. 적힌 요일이 날짜와 맞는지(어긋나면 손으로 쓴 포맷터의 지문입니다). 연도가 두 자리인지(RSS는 허용하지만 수집기들이 잘못 읽습니다). 그리고 시간대가 있는지(RFC 822는 생략을 허용하고, 그러면 리더마다 다르게 추측합니다).

모든 언어에 호출 한 번짜리 답이 있고, 아래 각 예제에서 그것을 씁니다. toUTCString, format_datetime, RFC_1123_DATE_TIME, "r" 지정자, DATE_RSS입니다.

guid, 그리고 구독자가 옛 글을 두 번 보게 되는 이유

guid는 항목의 정체성입니다. 리더는 그것으로 새 글인지 이미 보여 준 글인지 판단하므로, 안정적이고 유일해야 합니다. guid 중복은 오류입니다. 리더는 두 항목을 하나로 취급하고 그중 하나만 표시합니다.

함정은 isPermaLink입니다. 기본값이 true이므로, 그냥 <guid>abc-123</guid>이라고 쓰면 abc-123이 해석 가능한 URL이라고 주장하는 셈입니다. 불투명한 식별자에는 isPermaLink="false"를 명시해야 하고, 그것 없이 URL이 아닌 guid는 오류입니다. guid가 없는 항목은 경고입니다. 리더는 link나 title로 맞춰 보는 쪽으로 물러납니다.

가장 파국적인 형태는 빌드할 때마다 guid를 다시 만드는 플러그인입니다. 보통 퍼머링크처럼 잘 바뀌는 것에서 만들어 냅니다. 그 결과 모든 구독자가 지난 목록 전체를 한꺼번에 받게 됩니다.

앰퍼샌드, enclosure, 그리고 실행할 수 없는 검사들

피드가 깨지는 가장 흔한 방식은 URL 안의 그대로 쓰인 &입니다. p?id=12&sort=asc 처럼요. XML에서 앰퍼샌드는 엔티티 참조의 시작이므로 파싱이 실패하고, 리더는 그 항목 하나가 아니라 문서 전체를 거부합니다. 여기서는 구문 오류가 위치와 함께 나오고, RSS 규칙을 하나도 실행하지 못했다는 안내가 붙습니다.

<enclosure>에는 url, length, type이 필요합니다. length는 바이트 크기를 정수로 적은 것이고, 0은 경고입니다. 생성기가 파일을 stat하지 못했다는 뜻이기 때문입니다. 한 항목에 enclosure가 둘 있는 것은 명세가 아니라 Best Practices Profile에 따른 경고입니다.

channel에 <atom:link rel="self">가 있는지도 확인합니다. 이는 RSS 요소가 전혀 아니지만 수집기들이 기대는 것입니다. 세 가지 검사는 "실행하지 않음"으로 표시됩니다. 브라우저로는 불가능하기 때문입니다. rel="self"가 실제 피드가 제공되는 위치와 맞는지, 그리고 enclosure의 URL과 length가 맞는지입니다.

코드로 피드 검사하기

배포 파이프라인에서 돌릴 가치가 있습니다. 위의 실패들은 조용하기 때문입니다. 피드는 여전히 잘 빌드되고 잘 제공되며, 여러분은 구독자에게서 소식을 듣게 됩니다. 각 예제는 item 규칙, 날짜 형식, guid를 검사합니다.

// Node 18 or later.  npm i @xmldom/xmldom
import { DOMParser } from '@xmldom/xmldom';

const xml = await (await fetch('https://example.com/feed.xml')).text();
const doc = new DOMParser().parseFromString(xml, 'text/xml');

const channel = doc.getElementsByTagName('channel').item(0);
for (const required of ['title', 'link', 'description']) {
  if (channel.getElementsByTagName(required).length === 0) {
    console.error('channel has no <' + required + '>');
  }
}

// RFC 822, not ISO 8601. Anything with a "T" separator is the wrong format.
const RFC822 = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}(:\d{2})? (GMT|UT|[A-Z]{3}|[+-]\d{4})$/;

const items = channel.getElementsByTagName('item');
for (let i = 0; i < items.length; i++) {
  const item = items.item(i);
  const text = (name) => {
    const el = item.getElementsByTagName(name).item(0);
    return el ? el.textContent.trim() : null;
  };

  if (!text('title') && !text('description')) {
    console.error('item ' + (i + 1) + ' has neither a title nor a description');
  }

  const pubDate = text('pubDate');
  if (pubDate && !RFC822.test(pubDate)) {
    console.error('item ' + (i + 1) + ' pubDate is not RFC 822: ' + pubDate);
  }

  const guid = item.getElementsByTagName('guid').item(0);
  if (guid && guid.getAttribute('isPermaLink') !== 'false'
      && !/^https?:\/\//.test(guid.textContent.trim())) {
    console.error('item ' + (i + 1) + ' guid defaults to isPermaLink="true" but is not a URL');
  }
}

// Producing a correct date is one call:
console.log(new Date().toUTCString());   // Wed, 02 Oct 2024 13:00:00 GMT
# pip install lxml requests
import sys
import requests
from email.utils import format_datetime, parsedate_to_datetime
from datetime import datetime, timezone
from lxml import etree

raw = requests.get('https://example.com/feed.xml', timeout=30).content

# resolve_entities=False and no_network=True are the safe form.
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
root = etree.fromstring(raw, parser)

channel = root.find('channel')
if channel is None:
    sys.exit('No <channel> element')

for required in ('title', 'link', 'description'):
    if channel.find(required) is None:
        print('channel has no <%s>' % required)

for i, item in enumerate(channel.findall('item'), start=1):
    if item.find('title') is None and item.find('description') is None:
        print('item %d has neither a title nor a description' % i)

    pub = item.findtext('pubDate')
    if pub:
        try:
            # parsedate_to_datetime is the RFC 2822 parser, and it rejects
            # ISO 8601 outright, which is exactly what you want here.
            parsedate_to_datetime(pub)
        except (TypeError, ValueError):
            print('item %d pubDate is not RFC 822: %s' % (i, pub))

    guid = item.find('guid')
    if guid is not None and guid.get('isPermaLink', 'true') == 'true':
        if not (guid.text or '').startswith(('http://', 'https://')):
            print('item %d guid is treated as a permalink but is not a URL' % i)

# Producing a correct date:
print(format_datetime(datetime.now(timezone.utc)))
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.ZonedDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

byte[] bytes = HttpClient.newHttpClient()
    .send(HttpRequest.newBuilder(URI.create("https://example.com/feed.xml")).build(),
          HttpResponse.BodyHandlers.ofByteArray())
    .body();

DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true);
f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setXIncludeAware(false);

Document doc = f.newDocumentBuilder().parse(new ByteArrayInputStream(bytes));
Element channel = (Element) doc.getElementsByTagName("channel").item(0);

for (String required : new String[] { "title", "link", "description" }) {
    if (channel.getElementsByTagName(required).getLength() == 0) {
        System.err.println("channel has no <" + required + ">");
    }
}

NodeList items = channel.getElementsByTagName("item");
for (int i = 0; i < items.getLength(); i++) {
    Element item = (Element) items.item(i);
    boolean hasTitle = item.getElementsByTagName("title").getLength() > 0;
    boolean hasDesc = item.getElementsByTagName("description").getLength() > 0;
    if (!hasTitle && !hasDesc) {
        System.err.println("item " + (i + 1) + " has neither a title nor a description");
    }

    NodeList pub = item.getElementsByTagName("pubDate");
    if (pub.getLength() > 0) {
        String v = pub.item(0).getTextContent().trim();
        try {
            // RFC_1123_DATE_TIME is the RFC 822 profile RSS wants, and it
            // refuses ISO 8601, so a wrong-format date fails loudly here.
            DateTimeFormatter.RFC_1123_DATE_TIME.parse(v);
        } catch (DateTimeParseException e) {
            System.err.println("item " + (i + 1) + " pubDate is not RFC 822: " + v);
        }
    }
}

// Producing a correct date:
System.out.println(DateTimeFormatter.RFC_1123_DATE_TIME
    .format(ZonedDateTime.now(ZoneOffset.UTC)));
using System.Globalization;
using System.Xml;
using System.Xml.Linq;

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,  // external entities are never fetched
    XmlResolver = null,
};

var bytes = await new HttpClient().GetByteArrayAsync("https://example.com/feed.xml");
using var stream = new MemoryStream(bytes);
using var reader = XmlReader.Create(stream, settings);
var doc = XDocument.Load(reader);

// RSS core elements are in no namespace, so plain element names are correct here.
var channel = doc.Root?.Element("channel");
if (channel is null) throw new InvalidOperationException("No <channel> element");

foreach (var required in new[] { "title", "link", "description" })
{
    if (channel.Element(required) is null)
        Console.Error.WriteLine("channel has no <" + required + ">");
}

int n = 0;
foreach (var item in channel.Elements("item"))
{
    n++;
    if (item.Element("title") is null && item.Element("description") is null)
        Console.Error.WriteLine("item " + n + " has neither a title nor a description");

    var pub = (string?)item.Element("pubDate");
    if (pub is not null)
    {
        // .NET has no dedicated RFC 822 parser. TryParse handles the usual
        // spellings but also accepts ISO 8601, which RSS forbids, so reject
        // anything carrying a "T" separator before parsing.
        if (pub.Contains('T') ||
            !DateTimeOffset.TryParse(pub, CultureInfo.InvariantCulture,
                                     DateTimeStyles.None, out _))
        {
            Console.Error.WriteLine("item " + n + " pubDate is not RFC 822: " + pub);
        }
    }

    var guid = item.Element("guid");
    if (guid is not null && (string?)guid.Attribute("isPermaLink") != "false"
        && !guid.Value.StartsWith("http", StringComparison.Ordinal))
    {
        Console.Error.WriteLine("item " + n + " guid is a permalink by default but is not a URL");
    }
}

// Producing a correct date: "r" is the RFC 1123 pattern RSS accepts.
Console.WriteLine(DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture));
<?php
// LIBXML_NONET stops libxml fetching anything the document references.
libxml_use_internal_errors(true);

$raw = file_get_contents('https://example.com/feed.xml');
$feed = simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NONET);

if ($feed === false) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "XML error at line %d: %s", $e->line, trim($e->message) . "\n");
    }
    exit(1);
}

foreach (['title', 'link', 'description'] as $required) {
    if (!isset($feed->channel->{$required})) {
        fprintf(STDERR, "channel has no <%s>\n", $required);
    }
}

$n = 0;
foreach ($feed->channel->item as $item) {
    $n++;

    if (!isset($item->title) && !isset($item->description)) {
        fprintf(STDERR, "item %d has neither a title nor a description\n", $n);
    }

    if (isset($item->pubDate)) {
        $raw_date = (string) $item->pubDate;
        // DATE_RSS expects a numeric offset; the second pattern covers "GMT".
        $parsed = DateTimeImmutable::createFromFormat(DATE_RSS, $raw_date)
            ?: DateTimeImmutable::createFromFormat('D, d M Y H:i:s T', $raw_date);
        if ($parsed === false) {
            fprintf(STDERR, "item %d pubDate is not RFC 822: %s\n", $n, $raw_date);
        }
    }

    $guid = $item->guid;
    if ($guid !== null && (string) ($guid['isPermaLink'] ?? 'true') !== 'false'
        && !preg_match('#^https?://#', (string) $guid)) {
        fprintf(STDERR, "item %d guid defaults to isPermaLink=true but is not a URL\n", $n);
    }
}

// Producing a correct date: DATE_RSS is RFC 822.
echo (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_RSS), "\n";

모든 예제가 외부 엔티티 해석을 끕니다. 피드는 다른 곳에서 가져온, 여러분이 쓰지 않은 문서이기 때문입니다. Java와 PHP에서는 위험한 쪽 동작이 기본값입니다.

자주 묻는 질문

제 피드가 어떤 리더에서는 되고 다른 리더에서는 안 됩니다. 무엇이 문제인가요?

대개 날짜나 guid, 아니면 인코딩입니다. 리더마다 봐주는 범위가 크게 달라서, Feedly가 무리 없이 그려 주는 피드도 더 엄격한 클라이언트에서는 거부될 수 있습니다.

순서대로 짚어 보세요. 우선 문서가 적격 형식의 XML이기는 한지. 그대로 쓰인 앰퍼샌드 하나가 그 뒤 전부를 깨뜨립니다. 그다음 모든 항목의 pubDate. 파싱되지 않는 날짜는 항목을 사라지게 하거나 이상하게 정렬합니다. 그다음 guid의 유일성. 여기까지 깨끗한데도 특정 리더만 이상하다면 원인은 전송입니다. 잘못된 Content-Type, 리디렉션 연쇄, 또는 캐시입니다.

feedvalidator.org를 아직도 써야 하나요?

20년 동안 표준 답이었지만, 2026년 9월 기준으로 만료된 TLS 인증서를 내주어 브라우저가 차단합니다. SourceForge 미러는 저작권 표시가 2002~2004년이고, RFC 4287이 2005년에 대체한 Atom 0.3을 여전히 광고합니다.

W3C의 Feed Validation Service는 유지보수되고 있고 오류 목록도 꼼꼼합니다. 한계는 나이와 구조입니다. 메시지가 퉁명스럽고, 여러분의 피드가 그쪽 서버로 전송됩니다. 이 페이지는 그 목록 중 실제 고장과 대응되는 검사들을 재현해 로컬에서 실행합니다.

RSS에는 어떤 날짜 형식이 필요한가요?

RFC 822입니다. Wed, 02 Oct 2024 13:00:00 GMT 처럼요. 시간대 이름 대신 +0000 같은 숫자 오프셋도 똑같이 유효합니다. 요일은 선택 사항이지만, 넣는다면 맞는 요일이어야 합니다.

ISO 8601, 즉 2024-10-02T13:00:00Z는 RSS에서 유효하지 않습니다. Atom에서는 유효하고, 그래서 수많은 생성기가 그것을 내놓습니다. 누군가 Atom 템플릿에서 날짜 헬퍼를 베껴 온 것이죠. 두 자리 연도는 피하고, 시간대는 항상 넣으세요.

왜 제 옛 글이 전부 구독자에게 새 글로 다시 떴나요?

무언가가 모든 항목의 guid를 바꿨기 때문입니다. 리더는 이미 보여 준 guid 목록을 갖고 있고, 거기에 없는 것은 전부 새 글입니다. 전부 한꺼번에 바꾸면 구독자는 보관함을 다시 받게 되고, 팟캐스트라면 모든 에피소드가 모든 기기에 다시 내려받아진다는 뜻입니다.

원인은 영구적이지 않은 것에서 뽑아낸 guid입니다. 퍼머링크로 만들면 https로 옮길 때 깨지고, 제목으로 만들면 오타를 고칠 때 깨집니다. 데이터베이스 키, UUID, 또는 tag URI를 isPermaLink="false"와 함께 쓰세요.

제 피드가 서버로 전송되나요?

아닙니다. 파서도 모든 규칙도 이 탭에서 JavaScript로 돕니다. 무언가를 보낼 백엔드도, 편집기에 접근하는 분석 도구도 없습니다. 검사하는 동안 네트워크 패널을 열어 두고 계속 비어 있는지 보세요.

RSS처럼 공개가 전제인 형식에서는 대수롭지 않아 보입니다. 그러나 사람들이 검사기에 붙여넣는 피드가 어떤 것인지 생각해 보세요. 아직 공개하지 않은 프로그램, enclosure URL에 구독자별 토큰이 들어 있는 비공개 팟캐스트 피드, 초고로 가득한 스테이징 피드입니다.

팟캐스트 피드와 iTunes 태그도 검사하나요?

RSS 2.0 계층은 검사합니다. 팟캐스트 반려의 상당수가 거기서 시작합니다. 형식이 잘못된 pubDate, 중복된 guid, length나 type이 빠진 enclosure. 여기서는 그 규칙들이 특히 중요합니다. enclosure가 곧 에피소드이기 때문입니다.

검사하지 않는 것은 itunes 네임스페이스입니다. itunes:image와 그 크기 규칙, itunes:category, itunes:explicit, itunes:duration. 접두사가 선언되지 않은 경우가 아니면 그대로 둡니다. 애플의 규칙 하나는 다시 말해 둘 만합니다. 각 에피소드에는 절대 바뀌지 않는 전역 고유 식별자가 필요하며, 그것이 위의 guid 검사입니다.

관련 도구

참고 자료

이 도구로 해결되는 오류