블로그 구축기 2 - CSR 프로젝트의 사이트맵 제출 with Cloudflare

들어가며
블로그 구축기 1에서 Module Federation 아키텍처로 블로그를 만든 이야기를 했습니다. 이제 블로그를 만들었으니, 사람들이 찾아올 수 있게 해야겠죠?
검색엔진 최적화(SEO)의 기본은 사이트맵입니다. 사이트에 어떤 페이지들이 있는지 검색엔진에게 알려주는 지도 같은 것이죠.
그런데 CSR(Client-Side Rendering) 프로젝트에서는 이게 생각보다 까다롭더라고요.
문제 발견: 구글이 사이트맵을 못 읽네?
처음엔 간단하게 생각했습니다. sitemap.xml을 만들어서 public/ 폴더에 넣으면 되겠지!
// scripts/generate-sitemap.ts
export async function generateSitemapString(): Promise<string> {
const postsContent = await fs.readFile(POSTS_JSON, 'utf-8');
const metadata: BlogMetadata = JSON.parse(postsContent);
const links = [
{
url: '/blog',
changefreq: 'daily',
priority: 1.0,
lastmod: metadata.lastUpdated,
},
// ... 포스트별 링크 추가
];
const stream = new SitemapStream({ hostname: BASE_URL });
return await streamToPromise(
Readable.from(links).pipe(stream)
).then(data => data.toString());
}
빌드 시 마크다운 파일들을 읽어서 사이트맵을 생성하는 스크립트를 만들었습니다. 깔끔하게 XML도 만들어지고, Google Search Console에 제출도 했습니다.
그런데 문제가 있었습니다.
구글이 사이트맵을 읽어가지 못했습니다.
왜일까요?
CSR의 함정
우리 블로그는 Vue 3로 만든 CSR 프로젝트입니다. 빌드하면 JavaScript 번들이 생성되고, 브라우저에서 실행되면서 DOM을 그려냅니다.
서치 콘솔이 사이트맵의 주소를 요청을 하면 아마 우선적으로 index.html이 반환될 것입니다. 이 이후에 JavaScript가 실행되면서 화면에 렌더링을 할것입니다.
따라서 서치 콘솔이 사이트맵의 주소를 요청할경우 index.html이 아니라 바로 실제 xml파일이 반환되어야 합니다.
해결 과정: R2 + Cloudflare Worker
그래서 생각한 방법이 이겁니다:
빌드 시 사이트맵을 Cloudflare R2에 업로드하고, 사이트맵 주소에 대한 응답은 Cloudflare Worker로 대신하자!
1단계: 빌드 시 R2에 업로드
먼저 사이트맵을 R2(Cloudflare의 S3 호환 스토리지)에 업로드하는 스크립트를 만들었습니다.
// scripts/upload-sitemap.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
async function uploadSitemapToR2(): Promise<void> {
// 사이트맵 XML 생성
const sitemapContent = await generateSitemapString();
// R2 클라이언트 초기화
const r2Client = new S3Client({
region: 'auto',
endpoint: process.env.VITE_R2_ENDPOINT,
credentials: {
accessKeyId: process.env.VITE_R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.VITE_R2_SECRET_ACCESS_KEY!,
},
});
// R2에 업로드
const command = new PutObjectCommand({
Bucket: 'blog',
Key: 'sitemap.xml',
Body: sitemapContent,
ContentType: 'application/xml',
CacheControl: 'no-cache, no-store, must-revalidate',
});
await r2Client.send(command);
console.log('✅ Sitemap uploaded successfully!');
}
그리고 package.json의 빌드 스크립트에 추가했습니다:
{
"scripts": {
"build": "pnpm build:posts && vite build && pnpm build:sitemap && pnpm upload:sitemap"
}
}
이제 블로그를 빌드하면 자동으로 R2에 최신 사이트맵이 올라갑니다.
2단계: Cloudflare Worker로 응답
R2에 파일은 올라갔는데, 이걸 jeongwoo.in/sitemap.xml로 어떻게 보여줄까요?
Cloudflare Worker를 만들었습니다:
// Cloudflare Worker (jeongwoo.in 도메인에 연결)
export default {
async fetch(request, env) {
// R2에서 sitemap.xml 읽기
const object = await env.BLOG_BUCKET.get('sitemap.xml');
if (!object) {
return new Response('Sitemap not found', { status: 404 });
}
return new Response(object.body, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600', // 1시간 캐싱
'X-Content-Source': 'cloudflare-r2',
},
});
}
}
이 Worker를 jeongwoo.in/sitemap.xml 경로에 연결했습니다.
워크플로우
최종 워크플로우는 이렇게 됩니다:
1. 블로그 포스트 작성 (markdown)
↓
2. 빌드 실행 (pnpm build)
↓
3. 사이트맵 생성 (build-posts.ts → generate-sitemap.ts)
↓
4. R2에 업로드 (upload-sitemap.ts)
↓
5. 구글이 jeongwoo.in/sitemap.xml 접근
↓
6. Cloudflare Worker가 R2에서 읽어서 반환
결과: 구글이 드디어 읽어갑니다
Google Search Console에 다시 제출하고 기다렸습니다.
성공!
구글이 사이트맵을 정상적으로 읽어갔고, 블로그 포스트들이 색인되기 시작했습니다.
추가 개선 사항
1. 캐시 전략
Worker에서 1시간 캐싱을 설정했습니다:
'Cache-Control': 'public, max-age=3600'
블로그 포스트가 자주 바뀌는 건 아니니까, 매번 R2를 조회할 필요는 없죠. CDN 엣지에서 캐싱되어 빠르게 제공됩니다.
2. 에러 핸들링
R2 업로드 실패 시 빌드가 멈추도록 했습니다:
uploadSitemapToR2().catch(error => {
console.error('❌ Sitemap upload failed:', error);
process.exit(1);
});
사이트맵이 업로드되지 않으면 배포도 실패하게 만들어서, 항상 최신 사이트맵이 유지되도록 보장합니다.
마치며
CSR 프로젝트에서 SEO를 챙기는 건 생각보다 까다롭습니다. 단순히 파일을 만들어서 끝나는 게 아니라, 빌드 프로세스와 인프라를 함께 고민해야 하죠.
하지만 Cloudflare의 R2와 Worker를 활용하니 깔끔하게 해결할 수 있었습니다:
- ✅ 빌드 시 자동으로 사이트맵 업로드
- ✅ 별도 서버 없이 Worker로 라우팅
- ✅ CDN 캐싱으로 빠른 응답
- ✅ 비용 거의 없음 (R2 무료 티어, Worker 무료 플랜)
SSR이나 SSG를 쓰면 이런 고민을 안 해도 되긴 합니다. 하지만 CSR로 구축한 프로젝트에서도 충분히 SEO를 챙길 수 있다는 걸 보여드리고 싶었습니다.
다음 편에서는 방명록 기능을 만들면서 Supabase와 씨름한 이야기를 해보겠습니다.
관련 글
- 블로그 구축기 1 - Module Federation 아키텍처
- 블로그 구축기 3 - 방명록 만들기 with Supabase (작성 예정)
참고 자료