Theme:

블로그: SPA인데 검색이 될까에 대해 "왜?"라고 물으면 답할 수 있으신가요?

\n\n> 블로그: SPA인데 검색이 될까에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n이전 글에서 메타 태그를 세팅했는데, 한 가지 찝찝한 게 있었습니다.

SPA는 자바스크립트가 실행돼야 콘텐츠가 나옵니다. 크롤러 입장에서 보면 이런 겁니다.

HTML
<!-- 크롤러가 보는 SPA의 HTML -->
<div id="root"></div>
<script src="/assets/index.js"></script>
<!-- 콘텐츠? 없습니다 -->

구글 크롤러는 JS를 어느 정도 실행한다고 알려져 있긴 한데, 모든 검색 엔진이 그런 건 아닙니다. 그리고 카카오톡이나 슬랙 같은 링크 미리보기도 JS를 실행하지 않는 경우가 많죠.

이 문제를 해결하는 방법은 크게 세 가지인데:

방법설명이 블로그
SSR매 요청마다 서버에서 HTML 생성X (서버 없음)
SSG빌드 시 모든 페이지를 HTML로 생성X (React SPA 유지)
프리렌더링빌드 후 헤드리스 브라우저로 렌더링O

서버가 없으니 SSR은 안 되고, Next.js나 Gatsby 같은 프레임워크로 갈아타기엔 이미 만들어놓은 게 많아서 프리렌더링을 선택했습니다.


Puppeteer 프리렌더링

원리는 간단합니다. 빌드 후에 헤드리스 Chrome(Puppeteer)으로 각 페이지를 방문해서, React가 렌더링한 결과 HTML을 파일로 저장하는 겁니다.

전체 흐름

PLAINTEXT
1. vite build → dist/ 생성
2. dist/를 로컬 서버로 띄움
3. 마크다운 파일에서 라우트 목록 수집
4. Puppeteer로 각 라우트 방문
5. 렌더링된 HTML을 dist/에 저장
6. 서버 종료

라우트 수집

어떤 페이지를 프리렌더링할지 먼저 목록을 만들어야 합니다.

JAVASCRIPT
function getRoutes() {
    // 정적 페이지
    const routes = ['/', '/posts', '/projects', '/about'];

    // 마크다운에서 동적 라우트 생성
    const mdFiles = collectMarkdownFiles(POSTS_DIR);
    for (const file of mdFiles) {
        const metadata = parseVisibility(file);
        if (metadata.visibility === 'hidden') continue;

        const relative = path.relative(POSTS_DIR, file).replace(/\.md$/, '');
        const encoded = relative.split(path.sep).map(encodeURIComponent).join('/');
        routes.push(`/posts/${encoded}`);
    }

    return routes;
}

visibility: hidden인 글은 프리렌더링에서도 빠집니다. 일관성 있게 처리되니까 좋습니다.

페이지 렌더링

JAVASCRIPT
for (const route of routes) {
    const page = await browser.newPage();

    // GA, AdSense 같은 외부 리소스 차단 — 속도 향상
    await page.setRequestInterception(true);
    page.on('request', (req) => {
        if (BLOCKED_DOMAINS.some(d => req.url().includes(d))) {
            req.abort();
        } else {
            req.continue();
        }
    });

    await page.goto(`http://localhost:${PORT}${route}`, {
        waitUntil: 'networkidle0'
    });

    const html = await page.content();
    fs.writeFileSync(outputPath, html);
}

networkidle0은 네트워크 요청이 완전히 끝날 때까지 기다리는 옵션입니다. React가 렌더링을 마치고, 비동기 데이터도 다 불러온 뒤에 HTML을 저장합니다.

GA나 AdSense 스크립트는 SEO와 관련 없으니 차단해서 렌더링 속도를 높였습니다. 글이 50개쯤 되면 프리렌더링에 시간이 꽤 걸리거든요.


사이트맵 자동 생성

프리렌더링으로 크롤러가 HTML을 볼 수 있게 했으면, 사이트맵으로 "여기 이런 페이지들이 있어요"라고 알려주는 것도 좋습니다.

JAVASCRIPT
// scripts/generate-sitemap.js
function run() {
    const staticPages = [
        { url: '/', priority: '1.0', changefreq: 'daily' },
        { url: '/posts', priority: '0.9', changefreq: 'daily' },
        { url: '/projects', priority: '0.7', changefreq: 'monthly' },
        { url: '/about', priority: '0.6', changefreq: 'monthly' },
    ];

    const postEntries = posts.map(post => ({
        url: `/posts/${post.encodedPath}`,
        priority: '0.8',
        changefreq: 'monthly',
        lastmod: post.lastModifiedDate,
    }));

    const xml = generateXml([...staticPages, ...postEntries]);
    fs.writeFileSync('dist/sitemap.xml', xml);
}

빌드할 때마다 자동으로 sitemap.xml이 생성되니까 수동으로 관리할 필요가 없습니다.


RSS 피드

RSS는 요즘 잘 안 쓰는 것 같기도 한데, 일부 검색 엔진이랑 피드 리더 사용자를 위해 넣어뒀습니다. 만드는 김에 큰 공수도 아니었으니까요.

JAVASCRIPT
// scripts/generate-rss.js
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
    <channel>
        <title>sim.junghun</title>
        <link>${SITE_URL}</link>
        ${items}
    </channel>
</rss>`;

fs.writeFileSync('dist/rss.xml', rss);

빌드 파이프라인

이 모든 게 npm run build 한 줄로 돌아갑니다.

JSON
{
    "build": "vite build && node scripts/generate-sitemap.js && node scripts/generate-rss.js && node scripts/prerender.js"
}

순서가 중요합니다. Vite 빌드 → 사이트맵 → RSS → 프리렌더링. 프리렌더링이 마지막인 이유는, 사이트맵과 RSS가 dist/에 먼저 들어가 있어야 프리렌더링 시 로컬 서버에서 제대로 서빙되기 때문입니다.


효과

Google Search Console에 사이트맵을 제출하면 며칠 내로 인덱싱이 시작됩니다. 프리렌더링 덕분에 크롤러가 완전한 HTML을 수집할 수 있고, OG 태그도 제대로 들어가 있어서 링크 미리보기도 잘 나옵니다.

SSR 없이 이 정도면 충분하다고 생각합니다. 물론 트래픽이 많거나 실시간 데이터가 필요한 서비스라면 얘기가 다르겠지만, 개인 블로그 수준에서는 프리렌더링이 가성비가 좋습니다.

댓글 로딩 중...