2026년 10월 29일 목요일
미단재

요약: 라이브러리 없이 CodeIgniter 4 컨트롤러 하나로 /feed, /rss RSS 2.0 피드를 만들었다. 공개 글만 최신 20건을 1시간 캐시로 내보내고, 본문 요약에 섞인 HTML 엔티티가 피드를 깨뜨리거나 XSS가 되지 않도록 정화 순서를 잡았다.

왜 RSS인가

블로그를 직접 만들면 검색 노출은 sitemap.xml로, 구독은 RSS로 푼다. 이 시리즈의 3번째 기능은 RSS다. 요구사항은 단순했다.

  • 최신 글을 RSS 2.0 XML로 제공한다.
  • 비밀글, 비공개 게시판 글은 절대 나가면 안 된다.
  • 리더가 수시로 긁어 가므로 매 요청마다 DB를 치지 않는다.

라우트와 컨트롤러

주소는 두 개를 같은 컨트롤러에 물렸다. 리더나 사용자에 따라 /feed와 /rss 중 어느 쪽을 쓸지 모르기 때문이다.

// app/Config/Routes.php
$routes->get('feed', 'Front\FeedController::index');
$routes->get('rss', 'Front\FeedController::index');

컨트롤러는 캐시를 확인하고, 없을 때만 XML을 만든다. sitemap.xml, robots.txt와 같은 방식(cache()->remember)을 그대로 썼다.

private const CACHE_KEY  = 'seo_feed';
private const CACHE_TTL  = 3600;
private const ITEM_LIMIT = 20;
public function index(): ResponseInterface
{
    $xml = cache()->remember(self::CACHE_KEY, self::CACHE_TTL, fn (): string => $this->build());
    return $this->response
        ->setContentType('application/rss+xml')
        ->setBody($xml);
}

Content-Type을 application/rss+xml로 지정해야 브라우저가 화면에 뿌리지 않고 리더가 피드로 인식한다.

공개 글만 고르는 조건

비밀글이 피드로 새는 사고가 가장 무섭다. 그래서 조건을 컨트롤러가 아니라 모델 메서드 하나(getLatestPublic)에 모았다.

public function getLatestPublic(int $limit): array
{
    return $this->select('posts.*, boards.name AS board_name, boards.slug AS board_slug, users.nickname AS user_nickname')
        ->join('boards', 'boards.id = posts.board_id', 'inner')
        ->join('users', 'users.id = posts.user_id', 'left')
        ->where('posts.is_secret', 0)
        ->where('posts.is_active', 1)
        ->where('boards.is_active', 1)
        ->where('boards.read_permission', 'guest')
        ->orderBy('posts.id', 'DESC')
        ->findAll($limit);
}

글 자체가 비밀글이거나 비활성인 경우뿐 아니라, 게시판이 비활성이거나 읽기 권한이 guest가 아닌 경우도 걸러진다. 사이트 검색(searchPublic)도 같은 조건을 쓴다. 공개 범위 규칙이 두 곳에서 어긋나지 않도록 하기 위해서다.

XML을 직접 조립할 때 지킬 것

<item>은 문자열로 직접 이어 붙였다. 이 경우 모든 값을 htmlspecialchars()로 XML 이스케이프해야 한다. 제목에 &가 하나만 들어가도 피드 전체가 파싱 오류가 난다.

$items .= '    <title>' . htmlspecialchars((string) $post['title'], ENT_XML1 | ENT_QUOTES, 'UTF-8') . "</title>\n";
$items .= '    <link>' . htmlspecialchars($link, ENT_XML1 | ENT_QUOTES, 'UTF-8') . "</link>\n";
$items .= '    <guid>' . htmlspecialchars($link, ENT_XML1 | ENT_QUOTES, 'UTF-8') . "</guid>\n";

ENT_XML1이 핵심이다. 기본 플래그인 HTML 모드는 '를 &#039;로 바꾸는데, XML 쪽에서는 &apos;가 정석이다. guid에는 글 주소를 그대로 썼다. 글이 바뀌어도 주소가 같으면 리더가 같은 글로 본다.

나중에 터진 문제: 요약에 엔티티가 그대로 보였다

처음에는 요약을 mb_substr(trim(strip_tags($content)), 0, 200)로 만들었다. 그런데 본문에 &middot; 같은 HTML 엔티티가 있으면 태그만 걷어낼 뿐 엔티티는 그대로 남아, 목록 요약, 메타 설명, RSS 설명에 &middot; 글자가 그대로 보였다.

엔티티를 풀면 되지만, 풀기만 하면 두 가지 함정이 생긴다.

  1. XSS: 본문에 &lt;script&gt;가 있으면 엔티티를 푸는 순간 진짜 <script>가 된다. RSS 리더는 설명을 HTML로 해석하는 경우가 많다.
  2. XML 금지 문자: &#1; 같은 엔티티를 풀면 XML이 허용하지 않는 제어문자가 생겨 피드 전체가 깨진다.

그래서 요약 함수(post_excerpt)를 이렇게 고쳤다. 엔티티를 푼 뒤 한 번 더 태그를 걷어낸다.

function post_excerpt(?string $html, int $length = 120): string
{
    // 엔티티를 푼 뒤 다시 태그를 걷어낸다 — &lt;script&gt; 가 풀려 마크업으로 되살아나면
    // RSS 처럼 설명을 HTML 로 해석하는 소비자에서 XSS 가 된다
    $text = strip_tags(html_entity_decode(strip_tags((string) $html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
    $text = trim((string) preg_replace('/\s+/u', ' ', $text));
    return mb_strlen($text) <= $length ? $text : rtrim(mb_substr($text, 0, $length)) . '…';
}

피드 쪽에서는 XML이 허용하는 문자 범위 밖의 글자를 정규식으로 지우고, 이스케이프에 ENT_SUBSTITUTE를 더했다. 잘못된 UTF-8 바이트가 있어도 빈 문자열이 되지 않고 대체 문자로 바뀐다.

// 엔티티를 풀면 &#1; 같은 XML 금지 제어문자가 생겨 피드 전체가 깨진다 — 출력 전에 제거
$excerpt = (string) preg_replace('/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u', '', post_excerpt($post['content'], 200));
$items .= '    <description>' . htmlspecialchars($excerpt, ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</description>\n";

순서가 전부다. 태그 제거, 엔티티 디코드, 태그 제거, 금지 문자 제거, XML 이스케이프. 이 파이프라인은 RSS에만 쓰지 않고 목록 요약과 글 상세의 메타 설명(meta_desc)도 같은 post_excerpt로 바꿨다. 같은 버그가 세 곳에 있었으니 고치는 곳도 한 곳으로 모았다.

테스트

기능 테스트는 네 가지를 본다.

  • /feed가 200과 application/rss+xml, <rss, <channel>을 돌려준다.
  • /rss도 같은 피드를 돌려준다.
  • 공개 글의 제목과 글 주소(board/feed-board/...)가 피드에 들어간다.
  • 비밀글의 제목은 피드에 없다.

캐시 키(seo_feed)는 테스트 시작과 글을 넣은 직후에 지운다. 지우지 않으면 앞 테스트가 만든 1시간짜리 캐시가 남아 새 글이 안 보인다.

마지막 단계: 구독 링크 노출

피드가 있어도 독자가 주소를 모르면 쓸모가 없다. 푸터에 RSS 아이콘 링크를 달았다. 아이콘만 있으면 스크린 리더가 읽을 수 없으니 visually-hidden 텍스트를 함께 넣었다.

<a href="<?= esc(site_url('rss'), 'attr') ?>" class="text-secondary fs-5">
    <i class="bi bi-rss-fill" aria-hidden="true"></i>
    <span class="visually-hidden">RSS 구독</span>
</a>

배운 점

  • RSS 자체는 쉽다. 어려운 건 본문에서 뽑은 텍스트를 XML에 안전하게 넣는 일이다. 이스케이프만으로는 부족하고, 엔티티 디코드와 금지 문자까지 따져야 한다.
  • 공개 범위 조건은 모델 메서드 한 곳에 두고 피드와 검색이 같이 쓰게 하면 비밀글 유출 위험이 줄어든다.
  • 같은 가공 로직(요약)은 목록, 메타 태그, 피드가 공유하게 해야 버그를 한 번에 고친다.

댓글 0개