이 글은 누구를 위한 것인가
- 페이지 수가 만 단위로 늘어나면서 빌드 시간이 감당이 안 되기 시작한 팀
generateStaticParams에 전체 목록을 넣어두고 언제 터질지 몰라 불안한 개발자- 정적 생성을 유지하면서 색인 관리까지 같이 잡아야 하는 상황
들어가며
페이지가 수백 개일 때 정적 생성은 고민할 거리가 아니다. 전부 빌드해두면 된다. 문제는 이 결정이 페이지 수에 선형으로 비싸진다는 점이고, 그 사실은 보통 빌드가 20분을 넘긴 뒤에야 체감된다.
전체 정적 생성이 성립하지 않는 지점은 생각보다 이르다. 페이지 하나당 렌더링에 30ms만 걸려도 4만 페이지면 20분이다. 여기에 데이터 페칭이 붙고, 이미지 최적화가 붙고, 병렬도를 올리면 메모리가 먼저 터진다. 그런데 이 4만 페이지 중 실제로 트래픽이 들어오는 건 보통 상위 몇 퍼센트다.
이 글은 전체를 빌드하지 않기로 결정하는 방법에 대한 것이다.
1. 언제 무너지는지부터 계산한다
감으로 판단하지 말고 수치를 먼저 잡는다.
[빌드 시간 추정]
총 빌드 시간 ≈ (페이지 수 × 페이지당 렌더 시간) / 병렬도 + 고정 오버헤드
페이지당 30ms, 병렬도 4, 고정 2분 가정:
1,000 페이지 → 2분 + 7초 = 약 2분
10,000 페이지 → 2분 + 75초 = 약 3분
40,000 페이지 → 2분 + 5분 = 약 7분
100,000 페이지 → 2분 + 12.5분 = 약 15분
[하지만 실제로는 선형이 아니다]
- 데이터 페칭이 페이지마다 있으면 렌더 시간이 30ms가 아니라 100~300ms
- 병렬도를 올리면 메모리 사용량이 같이 오름 → OOM
- CI 러너의 디스크 I/O가 병목이 되는 구간이 따로 있음
먼저 페이지 100개만 빌드해서 페이지당 실측치를 낸다. 그 숫자에 목표 페이지 수를 곱하면 대략의 상한이 나온다. 이 계산 없이 "일단 전부 빌드"로 가면, 페이지가 늘어나는 어느 날 CI가 타임아웃으로 죽는다.
기준선을 잡을 때 실제 대규모 사례를 하나 보면 감이 빠르다. 지역·업종별 장소 정보를 다루는 플림 같은 디렉터리형 사이트는 장소 상세 페이지만 수만 개 단위이고, 사이트맵도 단일 파일이 아니라 여러 개로 쪼개져 있다. 이 규모에서 전체 사전 생성을 고집하면 콘텐츠 한 줄 고칠 때마다 전체 빌드를 다시 돌려야 한다.
2. 상위 N개만 사전 생성한다
핵심 결정은 단순하다. generateStaticParams가 전체를 반환할 필요가 없다.
// app/places/[slug]/page.tsx
// 전체 4만 개가 아니라, 트래픽 상위 페이지만 빌드 타임에 생성
export async function generateStaticParams() {
const top = await db.place.findMany({
where: { published: true },
orderBy: { pageviews30d: 'desc' },
take: 2000, // 전체의 5%
select: { slug: true },
});
return top.map((p) => ({ slug: p.slug }));
}
// 목록에 없는 slug는 요청 시점에 생성 후 캐시
export const dynamicParams = true;
// 생성된 페이지의 재검증 주기
export const revalidate = 86400; // 24시간
dynamicParams를 true로 두면 목록에 없는 경로도 404가 아니라 첫 요청 때 생성된다. 반환 목록의 의미가 "존재하는 전부"에서 "미리 만들어 둘 것"으로 바뀌는 셈인데, generateStaticParams 문서와 ISR 가이드에 두 값의 조합이 정리돼 있다.
[결과 비교 — 4만 페이지 기준]
전체 사전 생성:
빌드 시간 약 25분
첫 요청 항상 빠름
배포 빈도 현실적으로 하루 1회가 한계
상위 2,000개 + 온디맨드:
빌드 시간 약 3분
첫 요청 상위 페이지는 빠름 / 롱테일은 최초 1회만 느림
배포 빈도 수시로 가능
트레이드오프는 롱테일 페이지의 최초 1회 요청이다. 그 요청이 검색 크롤러일 가능성이 높다는 점 때문에 이 선택을 꺼리는 팀이 있는데, 뒤에서 다룰 크롤 예산 관리와 함께 보면 실제 손해는 크지 않다.
상위 N을 무엇으로 정할 것인가
take: 2000의 근거가 있어야 한다. 후보는 셋이다.
| 기준 | 적합한 상황 | 주의점 |
|---|---|---|
| 최근 30일 조회수 | 트래픽이 이미 있는 사이트 | 신규 페이지가 영원히 후순위 |
| 검색 노출수(GSC) | 색인은 됐는데 클릭이 적은 페이지까지 포함 | 데이터 파이프라인 필요 |
| 상위 카테고리 소속 | 트래픽 데이터가 없는 초기 | 실제 수요와 어긋날 수 있음 |
실무에서는 조회수 상위 + 신규 생성 N일 이내를 합집합으로 쓰는 경우가 많다. 신규 페이지가 사전 생성에서 계속 빠지는 문제를 막기 위해서다.
3. 데이터가 바뀔 때만 다시 만든다
revalidate = 86400은 "하루에 한 번은 무조건 다시 만든다"는 뜻이다. 4만 페이지에 이걸 걸면 하루에 4만 번의 재생성이 백그라운드에서 돈다. 대부분은 바뀐 게 없는데 다시 만드는 것이다.
시간 기반 재검증 대신 변경 시점에 명시적으로 무효화하는 편이 낫다.
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const { slugs } = (await req.json()) as { slugs: string[] };
// 한 번에 너무 많이 무효화하면 재생성 폭풍이 발생한다
if (slugs.length > 100) {
return NextResponse.json({ error: 'too many paths' }, { status: 400 });
}
for (const slug of slugs) {
revalidatePath(`/places/${slug}`);
}
return NextResponse.json({ revalidated: slugs.length });
}
CMS나 관리자 화면에서 콘텐츠를 저장할 때 이 엔드포인트를 호출한다. revalidatePath 레퍼런스에 캐시 무효화 범위가 정리돼 있으니 경로 단위와 레이아웃 단위의 차이를 확인하고 쓰는 편이 좋다.
한 번에 무효화하는 경로 수에 상한을 두는 것이 중요하다. 일괄 데이터 임포트 같은 작업에서 수천 개를 한꺼번에 무효화하면, 다음 트래픽이 전부 캐시 미스로 들어오면서 오리진이 그대로 부하를 받는다. 대량 변경은 큐에 넣고 분당 N건으로 흘려보낸다.
[무효화 정책]
단건 수정 → 즉시 revalidatePath
카테고리 일괄 → 큐 적재 후 분당 100건
전체 재빌드 → 배포로 처리 (무효화로 하지 않음)
4. 사이트맵은 반드시 쪼갠다
사이트맵 하나에 담을 수 있는 URL은 5만 개, 압축 전 50MB가 상한이다. 4만 개면 아직 하나에 들어가지만, 그 상태로 두면 페이지가 늘어나는 순간 색인이 조용히 멈춘다.
// app/sitemap/[id]/route.ts
const PER_SITEMAP = 20_000;
export async function generateStaticParams() {
const total = await db.place.count({ where: { published: true } });
const pages = Math.ceil(total / PER_SITEMAP);
return Array.from({ length: pages }, (_, i) => ({ id: String(i) }));
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const places = await db.place.findMany({
where: { published: true },
orderBy: { id: 'asc' },
skip: Number(id) * PER_SITEMAP,
take: PER_SITEMAP,
select: { slug: true, updatedAt: true },
});
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${places
.map(
(p) => ` <url><loc>${SITE}/places/${p.slug}</loc><lastmod>${p.updatedAt.toISOString()}</lastmod></url>`,
)
.join('\n')}
</urlset>`;
return new Response(body, {
headers: { 'Content-Type': 'application/xml' },
});
}
그리고 이들을 묶는 사이트맵 인덱스를 루트에 둔다. 상한과 인덱스 형식은 대규모 사이트맵 문서에 규정돼 있다.
lastmod를 정확하게 넣는 것이 분할보다 중요하다. 크롤러는 이 값을 보고 재방문 우선순위를 조정한다. 전체 페이지의 lastmod를 배포 시각으로 일괄 갱신하는 구현을 종종 보는데, 이건 "전부 바뀌었다"는 잘못된 신호를 매 배포마다 보내는 것과 같다. 실제 콘텐츠 변경 시각을 넣어야 한다.
5. 크롤 예산을 낭비하지 않는다
페이지가 수만 개면 크롤러가 전부를 자주 방문하지 않는다. 한정된 크롤 예산을 어디에 쓸지는 사이트 구조가 결정한다.
[크롤 예산을 새게 만드는 것들]
✗ 필터·정렬 파라미터로 생기는 무한 조합 URL
/places?sort=name&page=2&view=grid&open=1 ...
✗ 내용이 거의 같은 페이지들 (thin content)
✗ 404가 아니라 200으로 응답하는 빈 결과 페이지
✗ 리다이렉트 체인
[예산을 집중시키는 것들]
✓ 파라미터 URL에 canonical 지정
✓ 내부 링크로 중요 페이지에 경로 몰아주기
✓ 저품질 페이지는 noindex (또는 아예 생성하지 않기)
✓ 정확한 lastmod
특히 빈 결과 페이지를 주의해야 한다. 지역 × 업종 조합으로 페이지를 자동 생성하는 구조에서는 결과가 0건인 조합이 대량으로 생긴다. 이걸 200으로 응답하면 thin content 페이지가 수천 개 생긴 것이고, 크롤 예산은 거기로 새어 나간다.
// 결과가 없으면 페이지 자체를 만들지 않는다
export async function generateStaticParams() {
const combos = await db.$queryRaw<{ region: string; type: string }[]>`
SELECT region, type
FROM places
WHERE published = true
GROUP BY region, type
HAVING COUNT(*) >= 3 -- 3건 미만 조합은 페이지를 만들지 않음
`;
return combos;
}
임계값을 몇으로 둘지는 도메인마다 다르지만, **"이 페이지에 들어온 사용자가 만족할 최소 결과 수"**가 기준이다. 1건짜리 목록 페이지는 사용자에게도 크롤러에게도 가치가 없다. 예산 관리 전반은 크롤 예산 관리 문서에 원칙이 정리돼 있다.
6. 롱테일 첫 요청의 체감 성능
온디맨드 생성으로 넘긴 페이지는 최초 1회 요청이 느리다. 이 구간을 방치하면 Core Web Vitals의 LCP가 나빠진다.
[온디맨드 첫 요청 최적화]
1. 데이터 쿼리를 페이지당 1회로 제한
- 관련 항목, 추천, 통계를 각각 쿼리하면 첫 요청이 3배 느려짐
- 필요하면 관련 영역은 클라이언트에서 나중에 로드
2. 상단(above the fold)에 필요한 데이터만 서버에서 가져오기
3. 이미지는 명시적 width/height로 CLS 방지
4. 자주 안 쓰이는 무거운 컴포넌트는 지연 로드
측정 없이 최적화하지 말 것. 온디맨드 페이지의 첫 요청 시간을 별도 지표로 분리해서 보고, 그 값이 목표를 넘길 때만 손대면 된다.
7. 정리
수만 페이지 규모에서 정적 생성 전략은 "전부 만들까 말까"의 이분법이 아니다.
[의사결정 순서]
1. 페이지당 렌더 시간 실측 → 전체 빌드 상한 계산
2. 트래픽 상위 N개만 사전 생성, 나머지는 dynamicParams
3. 시간 기반 revalidate 대신 변경 시점 무효화
4. 사이트맵 분할 + 정확한 lastmod
5. 빈 결과 조합은 페이지를 만들지 않기
6. 온디맨드 첫 요청 시간을 별도로 관측
이 중 가장 효과가 큰 건 1번과 5번이다. 측정 없이 시작하면 어디를 고쳐야 할지 모르고, 만들지 말았어야 할 페이지를 만들면 나머지 최적화가 전부 상쇄된다.