블로그: SPA인데 검색이 될까 — 프리렌더링과 사이트맵
블로그: SPA인데 검색이 될까에 대해 "왜?"라고 물으면 답할 수 있으신가요?
\n\n> 블로그: SPA인데 검색이 될까에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n이전 글에서 메타 태그를 세팅했는데, 한 가지 찝찝한 게 있었습니다.
SPA는 자바스크립트가 실행돼야 콘텐츠가 나옵니다. 크롤러 입장에서 보면 이런 겁니다.
<!-- 크롤러가 보는 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을 파일로 저장하는 겁니다.
전체 흐름
1. vite build → dist/ 생성
2. dist/를 로컬 서버로 띄움
3. 마크다운 파일에서 라우트 목록 수집
4. Puppeteer로 각 라우트 방문
5. 렌더링된 HTML을 dist/에 저장
6. 서버 종료
라우트 수집
어떤 페이지를 프리렌더링할지 먼저 목록을 만들어야 합니다.
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인 글은 프리렌더링에서도 빠집니다. 일관성 있게 처리되니까 좋습니다.
페이지 렌더링
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을 볼 수 있게 했으면, 사이트맵으로 "여기 이런 페이지들이 있어요"라고 알려주는 것도 좋습니다.
// 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는 요즘 잘 안 쓰는 것 같기도 한데, 일부 검색 엔진이랑 피드 리더 사용자를 위해 넣어뒀습니다. 만드는 김에 큰 공수도 아니었으니까요.
// 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 한 줄로 돌아갑니다.
{
"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 없이 이 정도면 충분하다고 생각합니다. 물론 트래픽이 많거나 실시간 데이터가 필요한 서비스라면 얘기가 다르겠지만, 개인 블로그 수준에서는 프리렌더링이 가성비가 좋습니다.