정적 사이트에서 검색 엔진과 친해지기 — CSR 환경의 SEO 전략
CSR 기반 정적 사이트에서 SEO가 가능한가 — 메타데이터 설계, 사이트맵 자동화, JSON-LD, OG 이미지, 서치콘솔 등록까지의 실전 기록
시작 — SEO가 뭐길래
SEO(Search Engine Optimization)는 검색 엔진이 웹사이트를 올바르게 이해하고, 적절한 검색 결과에 노출시킬 수 있도록 돕는 일련의 기술적 작업입니다.
아무리 좋은 콘텐츠를 만들어도 검색 결과에 나타나지 않으면 사용자가 찾아올 수 없습니다. 특히 이 프로젝트처럼 틈새 주제(우주 시뮬레이션, Canvas 게임)를 다루는 사이트는, SNS 바이럴보다 검색 유입이 주요 트래픽 채널이 될 가능성이 높습니다. "중력 시뮬레이션 웹"이나 "starship simulation game"을 검색하는 사람이 이 사이트를 발견할 수 있어야 합니다.
문제는 이 프로젝트가 CSR(Client-Side Rendering) 기반 정적 사이트라는 점이었습니다.
SSR vs CSR — 검색 엔진 관점에서의 차이
SSR이 SEO에 유리한 이유
SSR(Server-Side Rendering)에서는 서버가 완성된 HTML을 반환합니다. 검색 엔진 크롤러가 페이지를 요청하면 <title>, <meta>, 본문 텍스트가 모두 포함된 HTML을 즉시 받습니다. 크롤러 입장에서는 파싱할 것이 명확합니다.
<!-- SSR 응답 -->
<html>
<head>
<title>중력 시뮬레이션 | js2devlog</title>
<meta name="description" content="N체 중력 시뮬레이션을..." />
<meta property="og:image" content="/og-image.png" />
</head>
<body>
<h1>중력 시뮬레이션</h1>
<p>뉴턴의 만유인력 법칙을 시각적으로 체험합니다...</p>
</body>
</html>
CSR의 문제점
전통적인 CSR(React SPA)에서는 서버가 빈 HTML 셸만 반환하고, JavaScript가 실행된 후에야 콘텐츠가 렌더링됩니다.
<!-- CSR 응답 -->
<html>
<head>
<title>React App</title>
</head>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</html>
Googlebot은 JavaScript를 실행할 수 있지만, 몇 가지 제약이 있습니다.
첫째, 크롤링과 렌더링이 분리되어 있습니다. Googlebot은 먼저 HTML을 파싱(크롤링)하고, JavaScript 실행(렌더링)은 별도 큐에서 나중에 처리합니다. 이 사이에 수초에서 수일의 지연이 발생할 수 있습니다.
둘째, 모든 크롤러가 JS를 실행하지는 않습니다. Googlebot과 Bingbot은 Chromium 기반으로 JS를 실행하지만, 네이버, 다음의 크롤러는 JS 실행 능력이 제한적이거나 없습니다. 소셜 미디어의 OG 파서(카카오톡, 슬랙, 트위터)도 HTML의 <meta> 태그만 읽고 JS를 실행하지 않습니다.
셋째, **크롤링 예산(crawl budget)**이 있습니다. 검색 엔진은 한 사이트에 할당하는 크롤링 자원이 제한되어 있는데, JS 실행이 필요한 페이지는 더 많은 자원을 소모합니다.
현실적 비교
| 항목 | SSR | CSR (순수 SPA) | 정적 내보내기 (이 프로젝트) |
|---|---|---|---|
| 초기 HTML에 메타데이터 | ✅ | ❌ | ✅ |
| 초기 HTML에 본문 텍스트 | ✅ | ❌ | ⚠️ (빌드 시점 콘텐츠만) |
| JS 비실행 크롤러 대응 | ✅ | ❌ | ✅ |
| OG 태그 소셜 프리뷰 | ✅ | ❌ | ✅ |
| 서버 비용 | 높음 | 낮음 | 매우 낮음 |
| 동적 콘텐츠 반영 | 즉시 | 즉시 | 빌드 필요 |
돌파구 — Next.js App Router의 정적 내보내기
이 프로젝트의 답은 **Next.js의 output: 'export'**였습니다. CSR 앱이지만, 빌드 시점에 모든 페이지의 완성된 HTML을 생성합니다.
핵심은 Next.js App Router의 generateMetadata 함수입니다. 빌드 타임에 실행되어 각 페이지의 <head> 태그를 완성합니다. 런타임 서버 없이도 SSR과 동일한 메타데이터를 HTML에 포함시킬 수 있습니다.
// app/[locale]/gravity/page.tsx
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'gravity' });
return {
title: t('meta.title'),
description: t('meta.description'),
openGraph: {
title: t('meta.title'),
description: t('meta.description'),
url: `${BASE_URL}/${locale}/gravity`,
images: OG_IMAGES,
},
alternates: {
canonical: `${BASE_URL}/${locale}/gravity`,
languages: {
ko: `${BASE_URL}/ko/gravity`,
en: `${BASE_URL}/en/gravity`,
},
},
};
}
이렇게 생성된 정적 HTML 파일에는 메타데이터가 이미 포함되어 있으므로, 어떤 크롤러든 JavaScript 실행 없이 페이지 정보를 파악할 수 있습니다.
메타데이터 설계 — 글로벌과 페이지별 계층
글로벌 메타데이터
metadata.ts에서 사이트 전체에 적용되는 기본 메타데이터를 정의합니다.
// src/constants/metadata.ts
export const metadata: Metadata = {
metadataBase: new URL('https://js2devlog.com'),
title: {
default: 'js2devlog — Interactive Space Universe',
template: '%s | js2devlog',
},
description: '우주를 인터랙티브하게 탐험하는 웹 경험...',
keywords: [
'js2devlog', 'Silver.K', 'Canvas 2D', 'Web Audio API',
'NASA APOD', 'gravity simulation', 'starship', ...
],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
title.template: '%s | js2devlog'가 중요합니다. 하위 페이지에서 title: '중력 시뮬레이션'만 설정하면 자동으로 중력 시뮬레이션 | js2devlog로 조합됩니다. 일관된 브랜딩을 유지하면서 각 페이지의 고유 제목을 보장합니다.
robots 설정에서 max-image-preview: 'large'는 Google 검색 결과에서 큰 이미지 프리뷰를 허용합니다. 시각적 콘텐츠가 많은 이 사이트에서는 검색 결과 클릭률(CTR)에 직접적인 영향을 줍니다.
페이지별 메타데이터
각 페이지의 page.tsx에서 generateMetadata를 export합니다. 다국어 지원을 위해 locale에 따라 제목과 설명을 분기합니다.
// app/[locale]/starship/page.tsx
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'starship' });
const jsonLd = buildJsonLd({
type: 'VideoGame',
name: t('meta.title'),
description: t('meta.description'),
url: `${BASE_URL}/${locale}/starship`,
locale,
applicationCategory: 'Game',
genre: 'Simulation',
});
return {
title: t('meta.title'),
description: t('meta.description'),
openGraph: {
title: t('meta.title'),
description: t('meta.description'),
url: `${BASE_URL}/${locale}/starship`,
images: OG_IMAGES,
},
alternates: {
canonical: `${BASE_URL}/${locale}/starship`,
languages: { ko: `${BASE_URL}/ko/starship`, en: `${BASE_URL}/en/starship` },
},
other: { 'script:ld+json': JSON.stringify(jsonLd) },
};
}
OG 이미지 상속 함정
Next.js에서 자식 페이지가 openGraph를 오버라이드하면, 부모의 openGraph.images가 자동 상속되지 않습니다. 처음에 이걸 모르고 하위 페이지에서 images를 빠뜨렸더니, 카카오톡이나 슬랙에서 링크를 공유할 때 이미지가 표시되지 않았습니다.
// ❌ images 누락 — 부모에서 상속 안 됨
openGraph: { title, description, url }
// ✅ 반드시 명시
openGraph: { title, description, url, images: OG_IMAGES }
이후 모든 페이지에 OG_IMAGES 배열을 공통 상수로 import해서 명시적으로 전달하도록 통일했습니다.
JSON-LD — 구조화 데이터
검색 엔진이 페이지의 "의미"를 이해하려면 HTML 태그만으로는 부족합니다. JSON-LD(Linked Data)는 Schema.org 어휘를 사용해 페이지의 유형, 저자, 날짜 등을 기계가 읽을 수 있는 형식으로 제공합니다.
// src/utils/jsonLd.ts
export function buildJsonLd(opts: JsonLdOptions) {
const base = {
'@context': 'https://schema.org',
'@type': opts.type,
name: opts.name,
description: opts.description,
url: opts.url,
inLanguage: opts.locale === 'en' ? 'en' : 'ko',
author: { '@type': 'Person', name: 'Silver.K' },
};
// 타입별 추가 필드
if (opts.applicationCategory) base.applicationCategory = opts.applicationCategory;
if (opts.genre) base.genre = opts.genre;
if (opts.datePublished) base.datePublished = opts.datePublished;
return base;
}
6가지 타입을 용도별로 사용합니다.
| JSON-LD 타입 | 사용 페이지 | 효과 |
|---|---|---|
VideoGame | Galaga, Asteroids, Starship 등 | Google "게임" 리치 결과 |
WebApplication | Gravity, Scale, Harmony 등 | 웹 앱 리치 결과 |
CollectionPage | Collections, 카테고리 허브 | 콘텐츠 모음 인식 |
Blog | Devlog 목록 | 블로그 구조 인식 |
BlogPosting | 개별 Devlog 포스트 | 게시 날짜, 저자 표시 |
ProfilePage | About | 인물 정보 인식 |
Devlog 포스트에서는 datePublished를 MDX frontmatter의 date 필드에서 추출해 전달합니다. Google 검색 결과에 게시 날짜가 표시되면 콘텐츠의 신선도를 판단하는 데 도움이 됩니다.
OG 이미지 — 자동 생성
소셜 미디어에서 링크를 공유할 때 표시되는 1200×630px 프리뷰 이미지를 스크립트로 생성합니다.
// scripts/generate-og.mjs
const W = 1200, H = 630;
const canvas = createCanvas(W, H);
const ctx = canvas.getContext('2d');
// 1. 우주 배경 (방사형 그라디언트)
const bgGrad = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, W*0.8);
bgGrad.addColorStop(0, '#0d0820');
bgGrad.addColorStop(1, '#020109');
// 2. 별 필드 (220개, 시드 기반 의사난수)
for (let i = 0; i < 220; i++) {
const x = seededRandom() * W;
const y = seededRandom() * H;
ctx.arc(x, y, 0.3 + seededRandom() * 1.2, 0, TAU);
}
// 3. 로고 + 타이틀 + 태그 배지
ctx.fillText('js2devlog', ...);
// 태그: Canvas 2D, Web Audio, NASA API, TypeScript, Next.js
// 4. PNG 출력
fs.writeFileSync('public/og-image.png', canvas.toBuffer('image/png'));
외부 서비스(Vercel OG, Cloudinary 등) 대신 Node.js canvas 라이브러리로 직접 생성하는 이유는 두 가지입니다. 첫째, 빌드 타임에 한 번만 생성하면 되므로 런타임 비용이 없습니다. 둘째, 프로젝트의 디자인 시스템(보라색 그라디언트, 우주 배경, Orbitron 폰트)을 정확히 반영할 수 있습니다.
사이트맵과 robots.txt — 크롤러 안내서
사이트맵이 필요한 이유
사이트맵은 검색 엔진에게 "이 사이트에 어떤 페이지가 있고, 각각 얼마나 자주 업데이트되며, 다른 언어 버전은 어디에 있는지"를 알려주는 XML 파일입니다.
사이트맵이 없어도 크롤러는 링크를 따라가며 페이지를 발견할 수 있지만, SPA 스타일의 사이트에서는 내부 링크가 JavaScript로 생성되는 경우가 많아 크롤러가 놓칠 수 있습니다. 사이트맵으로 모든 페이지를 명시적으로 알려주는 것이 안전합니다.
정적 내보내기에서의 문제
Next.js의 sitemap.ts는 서버 런타임이 필요합니다. output: 'export' 환경에서는 사용할 수 없으므로, 빌드 전에 실행되는 별도 스크립트로 생성합니다.
// scripts/generate-seo.mjs
const LOCALES = ['ko', 'en'];
const ROUTES = [
{ path: '/', changefreq: 'weekly', priority: '1.0' },
{ path: '/apod', changefreq: 'daily', priority: '0.8' },
{ path: '/gravity', changefreq: 'monthly', priority: '0.8' },
{ path: '/galaga', changefreq: 'monthly', priority: '0.6' },
// ... 25개 라우트
];
이 스크립트는 package.json의 prebuild 훅에 연결되어 빌드 직전에 자동 실행됩니다.
{
"prebuild": "node scripts/generate-seo.mjs",
"build": "next build"
}
다국어 hreflang 처리
사이트맵에서 가장 신경 쓴 부분은 hreflang 교차 참조입니다. 각 URL 엔트리에 모든 언어 버전의 대체 링크를 포함해야 합니다.
<url>
<loc>https://js2devlog.com/ko/gravity</loc>
<lastmod>2025-02-05</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="ko"
href="https://js2devlog.com/ko/gravity" />
<xhtml:link rel="alternate" hreflang="en"
href="https://js2devlog.com/en/gravity" />
</url>
25개 라우트 × 2개 언어 = 50개 URL 엔트리가 생성되며, 각각에 2개의 hreflang 링크가 포함됩니다. 검색 엔진은 이 정보를 기반으로 사용자의 언어 설정에 맞는 버전을 검색 결과에 표시합니다.
robots.txt
User-agent: *
Allow: /
Sitemap: https://js2devlog.com/sitemap.xml
Host: https://js2devlog.com
모든 크롤러에게 전체 사이트 크롤링을 허용하고, 사이트맵 위치를 명시합니다. Host 지시어는 네이버 크롤러(Yeti)가 참조합니다.
changefreq 전략
모든 페이지를 동일한 업데이트 빈도로 설정하면 의미가 없습니다. 콘텐츠 특성에 따라 차등 적용했습니다.
| 빈도 | 페이지 | 이유 |
|---|---|---|
daily | APOD, Daily | 매일 새 콘텐츠 (NASA API) |
weekly | 메인, Devlog 목록 | 주간 업데이트 |
monthly | 게임, 시뮬레이션 | 코드 변경 시에만 |
priority도 메인(1.0) > API 콘텐츠(0.8) > 시뮬레이션(0.8) > 아케이드(0.6) 순으로 차등 설정합니다. 이 값은 같은 사이트 내 페이지 간의 상대적 중요도를 나타냅니다.
서치콘솔 등록 — Google과 네이버
Google Search Console
사이트를 Google에 등록하는 과정입니다.
-
소유권 인증: DNS TXT 레코드 방식을 선택했습니다. 도메인의 DNS에 Google이 제공하는 TXT 레코드를 추가하면 소유권이 확인됩니다. HTML 파일 방식도 있지만, DNS 방식이 서브도메인까지 한 번에 커버하므로 더 편리합니다.
-
사이트맵 제출: Search Console에서
sitemap.xmlURL을 제출합니다. 제출 후 며칠 내에 크롤링이 시작되고, 인덱싱 상태를 확인할 수 있습니다. -
인덱싱 요청: 새 페이지를 추가했을 때 자연 크롤링을 기다리지 않고 URL Inspection 도구로 개별 인덱싱을 요청할 수 있습니다. 초기 등록 시에는 주요 페이지를 수동으로 인덱싱 요청해 크롤링을 촉진했습니다.
-
모니터링: Search Console의 "실적" 탭에서 검색 쿼리별 노출 수, 클릭 수, 평균 순위를 확인합니다. 어떤 키워드로 유입되는지 파악해 메타데이터를 개선하는 피드백 루프를 만들 수 있습니다.
네이버 서치어드바이저
네이버의 검색 등록 플랫폼입니다. 과정은 Google과 유사하지만 몇 가지 차이가 있습니다.
네이버 크롤러(Yeti)는 JavaScript 렌더링을 지원하지 않으므로, 정적 HTML에 메타데이터가 포함되어 있어야 합니다. 이 프로젝트의 정적 내보내기 방식은 네이버 SEO에 오히려 유리합니다.
네이버는 사이트맵 제출 외에 웹마스터 도구의 "수집 요청" 기능으로 개별 URL의 크롤링을 요청할 수 있습니다. 또한 robots.txt의 Host 지시어를 참조하므로 반드시 포함해야 합니다.
한 가지 주의할 점은, 네이버는 hreflang을 공식적으로 지원하지 않습니다. 대신 HTML의 <link rel="alternate" hreflang="en"> 태그를 페이지 <head>에 직접 포함하는 것이 권장됩니다. Next.js의 alternates 메타데이터 설정이 이 태그를 자동 생성해주므로 추가 작업은 필요하지 않았습니다.
AWS CloudFront — 정적 사이트의 SEO 보완
문제: S3 정적 호스팅만으로는 부족한 이유
S3에 정적 파일을 올리면 HTML에 메타데이터가 포함되어 있으므로 기본적인 SEO는 동작합니다. 하지만 몇 가지 추가 설정이 필요했습니다.
첫째, URL 정규화. /ko/about과 /ko/about/이 다른 URL로 취급되면 검색 엔진이 중복 콘텐츠로 판단할 수 있습니다. CloudFront Functions의 URL 리라이트로 trailingSlash 규칙을 통일했습니다.
둘째, HTTPS 강제. Google은 2014년부터 HTTPS를 랭킹 시그널로 사용합니다. CloudFront의 viewer protocol policy를 redirect-to-https로 설정해 모든 HTTP 요청을 HTTPS로 리다이렉트합니다.
셋째, 응답 헤더. X-Robots-Tag 헤더로 특정 리소스의 인덱싱을 세밀하게 제어할 수 있습니다. _next/static/ 하위의 JS/CSS 파일은 인덱싱할 필요가 없으므로 noindex를 설정합니다.
CloudFront Functions로 봇 대응
정적 사이트에서도 CloudFront Functions를 활용하면 SSR에 가까운 봇 대응이 가능합니다.
URL 리라이트 함수는 크롤러가 /ko/about을 요청하면 S3의 /ko/about/index.html로 매핑합니다. 이 과정은 사용자와 크롤러 모두에게 투명합니다.
// CloudFront Function (viewer-request)
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
이 함수가 없으면 크롤러가 /ko/about을 요청했을 때 S3에서 404를 반환합니다. 404 응답이 반복되면 검색 엔진은 해당 페이지를 인덱스에서 제거합니다.
캐시 제어와 크롤링 효율
CloudFront의 캐시 설정은 크롤링 효율에도 영향을 줍니다.
HTML에 no-cache를 설정한 이유 중 하나는 크롤러가 항상 최신 메타데이터를 받도록 보장하기 위함입니다. 배포 후 메타데이터를 수정했는데 크롤러가 캐시된 구 HTML을 받으면, 검색 결과에 잘못된 정보가 표시될 수 있습니다.
반면 JS/CSS 청크에 max-age=31536000, immutable을 설정하면, 크롤러가 이 파일들을 다시 요청하지 않아 크롤링 예산을 절약합니다.
빌드 파이프라인과 SEO의 통합
이 프로젝트의 SEO는 빌드 파이프라인에 완전히 통합되어 있습니다.
pnpm build 실행
│
├─ prebuild: generate-seo.mjs
│ ├─ public/sitemap.xml 생성 (50개 URL)
│ └─ public/robots.txt 생성
│
├─ next build (output: 'export')
│ ├─ generateMetadata → 각 페이지 <head> 메타 완성
│ ├─ generateStaticParams → 86+ 정적 HTML 생성
│ └─ JSON-LD 인라인 삽입
│
└─ out/ 디렉토리에 완성된 정적 파일
├─ ko/gravity/index.html (메타+JSON-LD 포함)
├─ en/gravity/index.html
├─ sitemap.xml
├─ robots.txt
└─ og-image.png
새 페이지를 추가할 때의 체크리스트입니다.
page.tsx에generateMetadataexportmessages/ko.json,en.json에meta.title,meta.description추가generate-seo.mjs의 ROUTES 배열에 경로 추가buildJsonLd에 적절한 타입 지정openGraph.images: OG_IMAGES명시
이 5단계를 하나라도 빠뜨리면 해당 페이지의 SEO가 불완전해집니다. 빌드 시점에 에러가 발생하지 않기 때문에 주의가 필요합니다.
마치며
"정적 사이트는 SEO가 불리하다"는 오해가 있습니다. 실제로는 정반대입니다. 빌드 시점에 모든 메타데이터가 확정된 HTML 파일은, SSR보다 오히려 예측 가능하고 안정적인 SEO 결과를 제공합니다.
물론 한계도 있습니다. NASA APOD처럼 매일 바뀌는 동적 콘텐츠의 최신 상태를 검색 결과에 반영하려면 매일 빌드-배포해야 합니다. 사용자 생성 콘텐츠(댓글, 리뷰)가 있는 사이트라면 정적 내보내기로는 한계가 명확합니다.
그럼에도 이 프로젝트처럼 "콘텐츠가 코드에 내장된" 사이트에서는 정적 내보내기 + Next.js App Router의 조합이 가장 합리적인 SEO 전략이었습니다. 서버 비용은 0원이면서, Google과 네이버 모두에서 올바르게 인덱싱되는 결과를 얻었습니다.
가장 중요한 교훈은, SEO는 "나중에 하면 되는 것"이 아니라 프로젝트 아키텍처 선택 시점부터 고려해야 한다는 점입니다. CSR vs SSR, 정적 vs 동적, 서버 유무 — 이 결정들이 SEO의 가능 범위를 이미 결정합니다.