Next.js와 Supabase로 91,000개 페이지 동적 사이트맵 구축하기
다음.js 및 Supabase를 사용한 91,000페이지의 동적 사이트맵 구축
지난 달 Deluxe Astrology의 페이지가 91,000개에 도달했습니다. 유명인 출생 차트, 블로그 게시물, 6가지 언어로 지역화된 콘텐츠 -- 이 사이트는 이미 단일 사이트맵 파일이 처리할 수 있는 범위를 훨씬 넘어섰습니다. Google의 사이트맵 프로토콜은 파일당 50,000개의 URL과 50MB 압축 해제 상태로 제한합니다. Supabase에서 동적으로 생성되고 Vercel에서 ISR로 캐시된 청크 분할 하위 사이트맵을 포함하는 사이트맵 인덱스가 필요했으며, Google Search Console에 단일 인덱스 URL로 제출되어야 했습니다.
이것이 우리가 출시한 정확한 구현입니다. 이론적 설명이 아니라 오늘 91K URL을 처리하고 변경 없이 500K까지 확장될 실제 프로덕션 코드입니다.
목차
- 사이트맵 제한 및 아키텍처 이해
- Deluxe Astrology의 사이트맵 구조
- 오프셋 페이지 매김을 사용한 Supabase 쿼리 설정
- 사이트맵 인덱스 경로 구축
- 개별 청크 분할 사이트맵 구축
- 정적 페이지 사이트맵
- Hreflang을 사용한 지역화된 사이트맵
- ISR 재검증 전략
- 콘텐츠 유형별 우선순위 및 변경 빈도
- Google Search Console 제출
- Google이 페이지를 색인하지 않을 때의 디버깅
- 성능 및 비용 벤치마크
- FAQ

사이트맵 제한 및 아키텍처 이해
알아야 할 하드 제한은 다음과 같습니다:
| 제약 조건 | 제한 | 출처 |
|---|---|---|
| 사이트맵 파일당 URL | 50,000 | sitemaps.org 프로토콜 |
| 사이트맵당 파일 크기 | 50MB 압축 해제 | sitemaps.org 프로토콜 |
| 사이트맵 인덱스당 사이트맵 | 50,000 | sitemaps.org 프로토콜 |
Supabase .range() 쿼리당 최대값 |
1,000개 행(기본값) | Supabase PostgREST 구성 |
| Vercel 서버리스 함수 제한 시간(Pro) | 60초 | Vercel 문서 2025 |
| Vercel 응답 본문 크기 제한 | 10MB | Vercel 엣지 캐싱 |
91,000개의 URL의 경우 최소한 2개의 사이트맵 파일이 필요합니다. 하지만 우리는 모든 것을 단순히 두 개의 50K URL 버킷에 덤프하지 않습니다. 콘텐츠 유형별로 나눕니다 -- 유명인, 블로그 게시물, 정적 페이지, 지역화된 페이지 -- 각 유형은 다른 changefreq, priority, 업데이트 패턴을 가지고 있기 때문입니다. 이는 더 나은 제어를 제공하고 무언가 잘못되었을 때 GSC에서의 디버깅을 훨씬 더 쉽게 만듭니다.
Deluxe Astrology의 사이트맵 구조
최종 사이트맵 아키텍처는 다음과 같습니다:
/sitemap.xml → 사이트맵 인덱스 (모든 하위 사이트맵을 가리킴)
/sitemap-pages.xml → 정적 페이지 (~30 URL)
/sitemap-blog-0.xml → 블로그 게시물 청크 0 (최대 50K)
/sitemap-blog-1.xml → 블로그 게시물 청크 1 (오버플로우)
/sitemap-celebrities-0.xml → 유명인 페이지 청크 0 (최대 50K)
/sitemap-celebrities-1.xml → 유명인 페이지 청크 1 (오버플로우)
/sitemap-locale-es.xml → 스페인어 지역화된 페이지
/sitemap-locale-fr.xml → 프랑스어 지역화된 페이지
/sitemap-locale-de.xml → 독일어 지역화된 페이지
/sitemap-locale-pt.xml → 포르투갈어 지역화된 페이지
/sitemap-locale-ja.xml → 일본어 지역화된 페이지
각 하위 사이트맵은 런타임에 Supabase를 쿼리하고 XML을 생성하며 revalidate = 3600(매시간)으로 ISR을 통해 캐시하는 다음.js 앱 라우터 경로 핸들러입니다. 사이트맵 인덱스 자체도 경로 핸들러입니다.
오프셋 페이지 매김을 사용한 Supabase 쿼리 설정
대부분의 튜토리얼이 잘못 이해하는 중요한 부분입니다: 단순히 supabase.from('celebrities').select('*')을 수행하고 91,000개의 행이 반환되기를 기대할 수는 없습니다. Supabase의 PostgREST 레이어는 기본적으로 최대 1,000개 행을 반환합니다. 페이지 매김이 필요합니다.
우리는 1,000개 배치의 범위 기반 오프셋 페이지 매김을 사용합니다:
// lib/supabase-sitemap.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // 서버 측을 위해 서비스 역할 사용
);
const BATCH_SIZE = 1000;
export interface SitemapEntry {
slug: string;
updated_at: string;
}
export async function fetchAllSlugs(
table: string,
selectColumns: string = 'slug, updated_at'
): Promise<SitemapEntry[]> {
const allRows: SitemapEntry[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
const { data, error } = await supabase
.from(table)
.select(selectColumns)
.order('updated_at', { ascending: false })
.range(offset, offset + BATCH_SIZE - 1);
if (error) {
console.error(`Sitemap fetch error for ${table}:`, error.message);
break;
}
if (data && data.length > 0) {
allRows.push(...data);
offset += BATCH_SIZE;
hasMore = data.length === BATCH_SIZE;
} else {
hasMore = false;
}
}
return allRows;
}
export async function fetchSlugsChunked(
table: string,
chunkIndex: number,
chunkSize: number = 50000
): Promise<{ entries: SitemapEntry[]; totalCount: number }> {
// 사이트맵 인덱스를 위해 먼저 총 개수 가져오기
const { count } = await supabase
.from(table)
.select('*', { count: 'exact', head: true });
const totalCount = count || 0;
const startOffset = chunkIndex * chunkSize;
const entries: SitemapEntry[] = [];
let offset = startOffset;
const endOffset = Math.min(startOffset + chunkSize, totalCount);
while (offset < endOffset) {
const batchEnd = Math.min(offset + BATCH_SIZE - 1, endOffset - 1);
const { data, error } = await supabase
.from(table)
.select('slug, updated_at')
.order('updated_at', { ascending: false })
.range(offset, batchEnd);
if (error || !data || data.length === 0) break;
entries.push(...data);
offset += data.length;
}
return { entries, totalCount };
}
export function getChunkCount(totalCount: number, chunkSize: number = 50000): number {
return Math.ceil(totalCount / chunkSize);
}
여기서 주목할 몇 가지 사항이 있습니다. 이러한 경로 핸들러는 서버 측에서 실행되므로 SUPABASE_SERVICE_ROLE_KEY를 사용합니다 -- anon 키가 아닙니다. RLS 정책이 사이트맵 쿼리를 느리게 하기를 원하지 않기 때문입니다. fetchSlugsChunked 함수는 전체 데이터셋이 아닌 주어진 사이트맵 파일에 필요한 특정 청크만 가져옵니다. Vercel의 60초 함수 제한 시간에서 실행 중일 때 중요합니다.

사이트맵 인덱스 경로 구축
사이트맵 인덱스는 Google에 제출하는 단일 URL입니다. 모든 하위 사이트맵을 참조합니다.
// app/sitemap.xml/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
export const revalidate = 3600; // ISR: 매시간 재생성
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const CHUNK_SIZE = 50000;
const SITE_URL = 'https://deluxeastrology.com';
const LOCALES = ['es', 'fr', 'de', 'pt', 'ja'];
async function getTableCount(table: string): Promise<number> {
const { count } = await supabase
.from(table)
.select('*', { count: 'exact', head: true });
return count || 0;
}
export async function GET() {
const blogCount = await getTableCount('blog_posts');
const celebrityCount = await getTableCount('celebrities');
const blogChunks = Math.ceil(blogCount / CHUNK_SIZE);
const celebrityChunks = Math.ceil(celebrityCount / CHUNK_SIZE);
const now = new Date().toISOString();
let sitemaps = '';
// 정적 페이지 사이트맵
sitemaps += `
<sitemap>
<loc>${SITE_URL}/sitemap-pages.xml</loc>
<lastmod>${now}</lastmod>
</sitemap>`;
// 블로그 사이트맵
for (let i = 0; i < blogChunks; i++) {
sitemaps += `
<sitemap>
<loc>${SITE_URL}/sitemap-blog-${i}.xml</loc>
<lastmod>${now}</lastmod>
</sitemap>`;
}
// 유명인 사이트맵
for (let i = 0; i < celebrityChunks; i++) {
sitemaps += `
<sitemap>
<loc>${SITE_URL}/sitemap-celebrities-${i}.xml</loc>
<lastmod>${now}</lastmod>
</sitemap>`;
}
// 로캘 사이트맵
for (const locale of LOCALES) {
sitemaps += `
<sitemap>
<loc>${SITE_URL}/sitemap-locale-${locale}.xml</loc>
<lastmod>${now}</lastmod>
</sitemap>`;
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemaps}
</sitemapindex>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});
}
여기서는 count 쿼리만 수행합니다 -- head: true는 Supabase가 행 데이터 없이 개수만 반환한다는 의미입니다. 이는 사이트맵 인덱스 생성을 거의 즉시로 만듭니다.
개별 청크 분할 사이트맵 구축
전체 페이지 매김이 있는 유명인 사이트맵 핸들러는 다음과 같습니다:
// app/sitemap-celebrities-[chunk].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchSlugsChunked } from '@/lib/supabase-sitemap';
export const revalidate = 3600;
const SITE_URL = 'https://deluxeastrology.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ chunk: string }> }
) {
const { chunk } = await params;
const chunkIndex = parseInt(chunk, 10);
if (isNaN(chunkIndex) || chunkIndex < 0) {
return new NextResponse('Invalid chunk index', { status: 400 });
}
const { entries } = await fetchSlugsChunked('celebrities', chunkIndex);
const urls = entries
.map(
(entry) => `
<url>
<loc>${SITE_URL}/celebrities/${entry.slug}</loc>
<lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});
}
블로그 사이트맵도 동일한 패턴을 따르지만 다른 우선순위와 변경 빈도를 가집니다:
// app/sitemap-blog-[chunk].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchSlugsChunked } from '@/lib/supabase-sitemap';
export const revalidate = 3600;
const SITE_URL = 'https://deluxeastrology.com';
export async function GET(
request: Request,
{ params }: { params: Promise<{ chunk: string }> }
) {
const { chunk } = await params;
const chunkIndex = parseInt(chunk, 10);
const { entries } = await fetchSlugsChunked('blog_posts', chunkIndex);
const urls = entries
.map(
(entry) => `
<url>
<loc>${SITE_URL}/blog/${entry.slug}</loc>
<lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});
}
동적 세그먼트를 처리하기 위해 다음.js 라우팅을 구성해야 합니다. 앱 라우터에서는 폴더 이름이 괄호를 사용합니다:
app/
sitemap.xml/
route.ts
sitemap-pages.xml/
route.ts
sitemap-blog-[chunk].xml/
route.ts
sitemap-celebrities-[chunk].xml/
route.ts
sitemap-locale-[lang].xml/
route.ts
괄호 안의 폴더명 접근 방식이 파일 시스템이나 IDE에서 문제를 일으키는 경우(간혹 발생), 대신 next.config.ts의 경로 재쓰기를 사용합니다:
// next.config.ts
const nextConfig = {
async rewrites() {
return [
{
source: '/sitemap-blog-:chunk(\\d+).xml',
destination: '/api/sitemap-blog/:chunk',
},
{
source: '/sitemap-celebrities-:chunk(\\d+).xml',
destination: '/api/sitemap-celebrities/:chunk',
},
{
source: '/sitemap-locale-:lang.xml',
destination: '/api/sitemap-locale/:lang',
},
];
},
};
export default nextConfig;
정적 페이지 사이트맵
정적 페이지 사이트맵의 경우, 거의 변경되지 않으므로 URL을 하드코딩합니다:
// app/sitemap-pages.xml/route.ts
import { NextResponse } from 'next/server';
export const revalidate = 86400; // 정적 페이지는 하루에 한 번이면 충분
const SITE_URL = 'https://deluxeastrology.com';
const staticPages = [
{ path: '/', priority: '1.0', changefreq: 'daily' },
{ path: '/about', priority: '0.7', changefreq: 'monthly' },
{ path: '/solutions/birth-chart', priority: '0.9', changefreq: 'weekly' },
{ path: '/solutions/compatibility', priority: '0.9', changefreq: 'weekly' },
{ path: '/solutions/transit-report', priority: '0.9', changefreq: 'weekly' },
{ path: '/blog', priority: '0.8', changefreq: 'daily' },
{ path: '/celebrities', priority: '0.8', changefreq: 'daily' },
{ path: '/contact', priority: '0.5', changefreq: 'yearly' },
{ path: '/pricing', priority: '0.7', changefreq: 'monthly' },
];
export async function GET() {
const now = new Date().toISOString();
const urls = staticPages
.map(
(page) => `
<url>
<loc>${SITE_URL}${page.path}</loc>
<lastmod>${now}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}
</urlset>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=3600',
},
});
}
Hreflang을 사용한 지역화된 사이트맵
여기서 흥미로워집니다. 다국어 콘텐츠의 경우 xhtml:link 요소와 hreflang 속성이 필요합니다. 각 지역화된 사이트맵은 각 페이지의 모든 대체 언어 버전을 참조합니다:
// app/sitemap-locale-[lang].xml/route.ts
import { NextResponse } from 'next/server';
import { fetchAllSlugs } from '@/lib/supabase-sitemap';
export const revalidate = 3600;
const SITE_URL = 'https://deluxeastrology.com';
const ALL_LOCALES = ['en', 'es', 'fr', 'de', 'pt', 'ja'];
export async function GET(
request: Request,
{ params }: { params: Promise<{ lang: string }> }
) {
const { lang } = await params;
if (!ALL_LOCALES.includes(lang)) {
return new NextResponse('Invalid locale', { status: 404 });
}
const entries = await fetchAllSlugs('localized_pages');
// 이 로캘을 가진 페이지로 필터링
const localeEntries = entries.filter((e: any) => e.locale === lang);
const urls = localeEntries
.map((entry: any) => {
const alternates = ALL_LOCALES.map(
(loc) =>
` <xhtml:link rel="alternate" hreflang="${loc}" href="${SITE_URL}/${loc}/${entry.slug}" />`
).join('\n');
return `
<url>
<loc>${SITE_URL}/${lang}/${entry.slug}</loc>
<lastmod>${new Date(entry.updated_at).toISOString()}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
${alternates}
</url>`;
})
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}
</urlset>`;
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});
}
ISR 재검증 전략
모든 사이트맵 경로에 revalidate = 3600을 설정합니다. 즉, Vercel은 최대 1시간 동안 캐시된 XML을 제공한 다음 다음 요청에서 백그라운드에서 재생성합니다. 91K 페이지의 경우 이것이 최적의 지점입니다 -- 새 콘텐츠가 같은 날에 표시될 정도로 빈번하지만 Supabase를 계속 해머링할 정도로 공격적이지 않습니다.
콘텐츠가 게시될 때 온디맨드 재검증을 위해 재검증 엔드포인트를 추가합니다:
// app/api/revalidate-sitemap/route.ts
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { secret, paths } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 특정 사이트맵 경로 재검증
const targetPaths = paths || ['/sitemap.xml'];
for (const path of targetPaths) {
revalidatePath(path);
}
return NextResponse.json({ revalidated: true, paths: targetPaths });
}
그런 다음 celebrities 또는 blog_posts 테이블이 업데이트될 때마다 이 엔드포인트를 호출하도록 Supabase 데이터베이스 웹훅(또는 pg_net을 통한 Postgres 트리거)을 설정합니다.
콘텐츠 유형별 우선순위 및 변경 빈도
우리가 사용하는 우선순위 매트릭스는 다음과 같습니다. Google은 priority와 changefreq를 대부분 무시한다고 말했지만, 다른 크롤러(Bing, Yandex)는 여전히 사용하고 해가 되지 않습니다:
| 콘텐츠 유형 | 우선순위 | 변경 빈도 | 근거 |
|---|---|---|---|
| 홈페이지 | 1.0 | 매일 | 최고 중요도, 자주 업데이트됨 |
| 솔루션/기능 | 0.9 | 주간 | 핵심 제품 페이지 |
| 블로그 목록 | 0.8 | 매일 | 정기적으로 새 게시물 |
| 블로그 게시물 | 0.8 | 주간 | 콘텐츠가 가끔 업데이트됨 |
| 유명인 페이지 | 0.6 | 월간 | 생성 후 거의 변경 안 됨 |
| 지역화된 페이지 | 0.6 | 월간 | 번역 업데이트가 드물다 |
| 연락처/법률 | 0.5 | 연간 | 거의 변경 안 됨 |
lastmod 값은 중요하며 항상 데이터베이스의 updated_at 열에서 가져와야 합니다 -- 절대로 new Date()로 하드코딩하면 안 됩니다. Google은 lastmod를 사용하여 재크롤링을 우선 순위에 따라 지정하고, 모든 페이지가 지금 수정되었다고 말하면 결국 Google이 완전히 lastmod를 무시합니다.
Google Search Console 제출
여기가 간단한 부분입니다. GSC에서:
- 왼쪽 사이드바에서 사이트맵으로 이동합니다
https://yourdomain.com/sitemap.xml(인덱스 URL만)을 입력합니다- 제출을 클릭합니다
이제 끝입니다. 개별 하위 사이트맵을 제출하지 마십시오. Google이 인덱스를 읽고 모든 자식을 자동으로 검색합니다. 몇 시간 내에 상태 "성공"이 표시되고, 인덱싱된 URL 수는 다음 2-4주에 걸쳐 증가합니다.
91K URL의 경우 Google이 첫 달 내에 70-90%를 인덱싱할 것으로 예상합니다. 나머지 페이지는 일반적으로 얇은 콘텐츠, 중복 콘텐츠 문제, 또는 단순히 Google의 크롤 예산 할당에서 낮은 우선순위를 가지고 있습니다.
또한 robots.txt에 사이트맵을 추가합니다:
# robots.txt
User-agent: *
Allow: /
Sitemap: https://deluxeastrology.com/sitemap.xml
Google이 페이지를 색인하지 않을 때의 디버깅
대부분의 사람들이 여기서 막힙니다. 91K개의 URL을 제출했지만 GSC는 40K개만 인덱싱됩니다. 우리가 따르는 체계적인 디버깅 체크리스트는 다음과 같습니다:
실수로 인한 Noindex 태그 확인
이것이 #1 원인입니다. 지점 확인을 실행합니다:
curl -s https://deluxeastrology.com/celebrities/some-slug | grep -i 'noindex'
또한 다음.js 레이아웃 또는 페이지 메타데이터를 확인합니다. 일반적인 실수는 수천 개의 페이지에 적용되는 레이아웃에서 noindex를 설정하는 것입니다:
// 나쁜 예: 이 레이아웃을 사용하는 모든 페이지를 noindex합니다
export const metadata = {
robots: { index: false, follow: true },
};
robots.txt가 크롤링을 차단하지 않는지 확인
브라우저에서 https://yourdomain.com/robots.txt을 확인합니다. 동적 경로를 실수로 차단하지 않았는지 확인합니다. Vercel에서도 Googlebot에 403을 반환할 수 있는 모든 미들웨어를 확인합니다.
GSC에서 크롤 오류 검사
페이지 → 페이지가 인덱싱되지 않는 이유로 이동합니다. 일반적인 문제:
- "크롤링 - 현재 색인이 아님": Google은 페이지를 봤지만 인덱싱하지 않기로 결정했습니다. 보통 얇은 콘텐츠입니다.
- "검색됨 - 현재 색인이 아님": Google은 URL이 존재함을 알고 있지만 아직 크롤링하지 않았습니다. 크롤 예산 문제입니다.
- "Noindex 태그로 제외됨": 명백한 의미입니다. 태그를 수정합니다.
- "정규 없이 중복됨": 적절한 정규 태그를 추가합니다.
내부 링크로 고아 페이지 수정
대규모 사이트의 경우 이것이 매우 중요합니다. 유명인 페이지가 사이트맵을 통해서만 검색 가능하고 이를 가리키는 내부 링크가 없으면 Google은 크롤링을 낮은 우선순위로 취급합니다. 추가:
- 유명인 페이지 그룹에 링크하는 카테고리/목록 페이지
- 각 유명인 페이지의 관련 유명인 링크
- 높은 트래픽 페이지의 "인기" 또는 "최근 업데이트" 섹션
- 구조화된 데이터가 있는 breadcrumb 네비게이션
개별 URL 검증
인덱싱되지 않은 특정 페이지에서 GSC의 URL 검사 도구를 사용합니다. Google이 보는 렌더링된 HTML, 모든 오류, 모바일 유용성 문제, 인덱싱 상태를 정확히 보여줍니다.
사이트맵 응답 헤더 확인
사이트맵 경로가 적절한 헤더를 반환하는지 확인합니다:
curl -I https://deluxeastrology.com/sitemap.xml
Content-Type: application/xml과 200 상태가 표시되어야 합니다. 오래된 캐시에서 304 Not Modified 응답을 받으면 Google이 사이트맵을 다시 읽는 것을 건너뛸 수 있습니다.
성능 및 비용 벤치마크
2025년 초 현재 프로덕션 배포의 실제 수치는 다음과 같습니다:
| 메트릭 | 값 |
|---|---|
| 사이트맵의 총 URL | 91,247 |
| 사이트맵 인덱스 생성 시간 | ~120ms(count 쿼리만) |
| 개별 사이트맵 생성(50K URL) | ~4.2초 |
| 사이트맵 재생성당 Supabase 쿼리 비용 | ~$0.01 |
| 전체 사이트맵 XML 크기(모든 파일 통합) | ~8.4MB 압축 해제 |
| 월별 사이트맵에 대한 Vercel 대역폭 | ~2.1GB(대부분 Googlebot) |
| Vercel Pro 계획 비용 | $20/사용자/월 |
| Supabase Pro 계획 비용 | $25/월 |
| 30일 후 GSC 인덱싱 비율 | 제출된 URL의 84% |
| 콘텐츠 게시에서 사이트맵 업데이트까지의 시간 | ≤1시간(ISR) 또는 ~5초(온디맨드) |
핵심 요점: 이 전체 설정은 실행 비용이 기본적으로 없습니다. 사이트맵 생성은 Vercel 및 Supabase 청구서에서 반올림 오류입니다.
비슷한 대규모 프로젝트를 구축 중이고 아키텍처 도움이 필요한 경우, 여러 클라이언트 사이트에서 이를 수행했습니다. 다음.js 개발 역량 또는 헤드리스 CMS 개발 작업을 확인합니다. 비슷한 규모 요구사항이 있는 Astro 기반 사이트의 경우, Astro의 엔드포인트 접근 방식을 사용하여 비교 가능한 솔루션을 구축했습니다.
전체 작동 코드는 GitHub gist로 제공됩니다: 모든 경로 핸들러, Supabase 쿼리 라이브러리, next.config.ts 재쓰기. 프로젝트가 더 사용자 정의된 것이 필요한 경우 -- 다중 테넌트 사이트맵, 실시간 재검증, 또는 1M+ 페이지의 사이트맵 -- 당사에 연락합니다 그리고 우리가 범위를 정할 것입니다.
FAQ
단일 사이트맵 파일이 포함할 수 있는 URL은 몇 개입니까?
사이트맵 프로토콜은 파일당 최대 50,000개 URL과 50MB 압축 해제 파일 크기를 허용합니다. 50K개 이상의 페이지가 있는 사이트의 경우, 여러 청크 분할 사이트맵 파일을 참조하는 사이트맵 인덱스가 필요합니다. 실제로 대부분의 사이트맵 생성기는 안전 여유를 남기기 위해 45,000-50,000 URL에서 청크합니다.
next-sitemap을 사용해야 합니까, 아니면 사용자 정의 경로 핸들러를 구축해야 합니까?
next-sitemap(v4+)은 더 간단한 설정에 좋으며 자동 청크 분할을 잘 처리합니다. 하지만 콘텐츠 유형별 우선순위, hreflang이 있는 지역화된 사이트맵, 세밀한 ISR 제어가 필요한 91K+ 동적 페이지의 경우 사용자 정의 경로 핸들러가 더 많은 제어를 제공합니다. 우리는 콘텐츠 유형별로 다른 재검증 간격이 필요했고 사이트맵 구조가 GSC 디버깅 워크플로우와 일치하기를 원했기 때문에 사용자 정의를 선택했습니다.
각 개별 사이트맵 파일을 Google Search Console에 제출해야 합니까?
아니요. 사이트맵 인덱스 URL만 제출합니다(예: https://yourdomain.com/sitemap.xml). Google은 인덱스를 읽고 참조된 모든 하위 사이트맵을 자동으로 검색하고 처리합니다. 개별 파일을 제출하는 것은 불필요하며 GSC 대시보드를 복잡하게 만듭니다.
대규모 동적 사이트의 사이트맵을 얼마나 자주 재생성해야 합니까?
대부분의 콘텐츠 집약적 사이트의 경우, ISR을 통한 매시간 재생성(revalidate = 3600)이 좋은 기본값입니다. 콘텐츠를 매우 자주 게시하는 경우 데이터베이스 웹훅으로 트리거된 온디맨드 재검증과 쌍을 이룹니다. 모든 요청에 대해 재생성하지 마십시오 -- 캐싱을 무효화하고 Supabase 부하를 불필요하게 증가시킵니다.
Google이 모든 사이트맵 URL을 색인하지 않는 이유는 무엇입니까?
가장 일반적인 원인은: 실수로 인한 noindex 메타 태그, robots.txt 차단, 얇은/중복 콘텐츠, 내부 링크가 없는 고아 페이지, 크롤 예산 제한입니다. GSC의 페이지 보고서에서 "페이지가 인덱싱되지 않는 이유"를 확인하여 구체적인 이유를 알아봅니다. 대규모 사이트의 경우 고아 페이지에 대한 내부 링킹을 개선하는 데 집중하십시오 -- 이것이 종종 가장 큰 영향 요소입니다.
사이트맵의 priority 값이 실제로 Google 순위에 영향을 줍니까?
Google은 priority와 changefreq 값을 대부분 무시한다고 공개적으로 언급했습니다. 하지만 Bing 및 다른 검색 엔진은 사용합니다. lastmod 필드가 가장 중요한 사이트맵 신호입니다 -- 현재 타임스탬프가 아닌 데이터베이스의 실제 콘텐츠 변경 사항을 반영해야 합니다.
Supabase의 1,000개 행 제한을 사이트맵 쿼리에 어떻게 처리합니까?
Supabase의 .range(offset, offset + batchSize - 1) 메서드를 사용하여 1,000개 배치로 페이지 매김합니다. 현재 사이트맵 청크의 모든 행을 가져올 때까지 반복합니다. count만 필요한 쿼리(사이트맵 인덱스에 사용됨)의 경우 .select('*', { count: 'exact', head: true })를 사용하여 행 데이터를 전송하지 않고 개수만 반환합니다.
이 접근 방식이 500K 또는 100만 페이지를 처리할 수 있습니까?
예, 약간의 조정으로 가능합니다. 청크 분할 아키텍처는 선형으로 확장됩니다 -- 100만 페이지는 약 20개의 하위 사이트맵을 생성합니다. 주요 관심사는 50K URL 사이트맵을 생성할 때 Vercel의 60초 함수 제한 시간입니다. 해당 제한에 도달하면 청크 크기를 파일당 25,000 또는 10,000 URL로 줄입니다. 사이트맵 프로토콜은 단일 인덱스에 최대 50,000개 사이트맵을 허용하므로 인덱스 수준 제한에 곧 도달하지 않습니다.