4만 페이지의 내부 링크 설계 — 고아 페이지와 크롤 깊이를 관리하는 법

프론트엔드

내부 링크크롤 예산사이트맵정보 구조대규모 사이트

이 글은 누구를 위한 것인가

  • 상세 페이지가 수천~수만 개로 늘어난 사이트를 운영하는 팀
  • 사이트맵에는 넣었는데 색인이 일부만 되는 상황
  • 상세 페이지 대부분이 목록 페이지에서만 도달 가능한 구조

들어가며

페이지가 100개일 때는 내부 링크를 신경 쓰지 않아도 된다. 홈에서 두 번만 클릭하면 어디든 닿는다. 그런데 4만 개가 되면 상황이 달라진다.

[페이지 수가 늘 때 실제로 일어나는 일]

  100개     홈 → 목록 → 상세          (깊이 2)
  1,000개   홈 → 목록 → 페이지네이션 5 → 상세   (깊이 7)
  40,000개  홈 → 목록 → 페이지네이션 200 → 상세 (깊이 202)

페이지네이션만으로 상세를 연결하면 깊이가 페이지 수에 비례해 늘어난다. 200번째 목록 페이지에 있는 상세 문서는 사실상 아무도 — 크롤러도 — 도달하지 않는다.

Google 문서도 대규모 사이트에서는 크롤 예산 관리를 별도로 다뤄야 한다고 안내한다. 다만 그 문서의 조언 대부분은 "불필요한 URL을 줄여라"에 가깝고, 도달 가능성 자체를 설계하는 부분은 직접 해야 한다.


1. 먼저 측정한다 — 크롤 깊이 분포

감으로 고치기 전에 현재 상태를 숫자로 본다.

[측정할 것]

  · 각 URL의 최단 클릭 깊이 (홈 기준 BFS)
  · 깊이 구간별 페이지 수
  · 내부 링크가 0인 페이지 수 (고아)
  · 내부 링크가 1인 페이지 수 (단일 경로)

전체 크롤 없이도 라우트 정의와 데이터로 계산할 수 있다. 정적 생성 사이트라면 어차피 모든 URL을 알고 있다.

/** 링크 그래프에서 홈 기준 최단 깊이를 구한다 */
function crawlDepth(graph: Map<string, string[]>, root = '/'): Map<string, number> {
  const depth = new Map([[root, 0]]);
  const queue = [root];

  while (queue.length) {
    const cur = queue.shift()!;
    const d = depth.get(cur)!;
    for (const next of graph.get(cur) ?? []) {
      if (depth.has(next)) continue;      // 이미 더 짧은 경로로 도달함
      depth.set(next, d + 1);
      queue.push(next);
    }
  }
  return depth;
}

// 사이트맵에 있으나 그래프에서 도달 못 하는 URL = 고아
const orphans = allUrls.filter(u => !depth.has(u));

고아 페이지의 정의를 사이트맵 기준으로 잡는 것이 중요하다. "사이트맵에는 있는데 어떤 페이지에서도 링크되지 않는" 상태가 가장 흔한 문제이고, 이건 스스로 "이 페이지는 중요하다"고 신고해 놓고 정작 사이트 안에서는 취급하지 않는 모순 신호다.

[결과 해석 기준]

  깊이 0~3     정상
  깊이 4~6     허브 보강 검토
  깊이 7+      사실상 미도달로 간주
  고아         즉시 조치

2. 페이지네이션을 도달 경로로 쓰지 않는다

깊이 폭발의 주범은 페이지네이션이다.

❌ 목록 → 2페이지 → 3페이지 → … → 200페이지 → 상세

✅ 목록 → 지역 허브 → 하위 지역 → 상세     (깊이 3)
   목록 → 유형 허브 → 하위 유형 → 상세     (깊이 3)
   목록 → 니즈 허브 → 상세                 (깊이 2)

여러 축의 허브를 만들어 상세로 가는 짧은 경로를 여러 개 확보하는 것이 해법이다. 하나의 상세 페이지가 지역·유형·니즈 세 축에서 각각 도달 가능하면, 한 축이 깊어져도 다른 축이 받쳐준다.

지역 축은 특히 계층이 자연스럽게 나온다. 풀림의 지역별 색인이 전국을 시·도에서 시·군·구로 내려가는 2단 구조로 묶은 것도 같은 이유다 — 4만 개를 한 화면에 나열할 수 없으니, 두 번의 분기로 수십 개 단위까지 좁히는 것이 목표다.

[허브 계층 설계]

  1단 허브    17개 시·도       각 100~4,000개 담당
  2단 허브    250여 개 시·군·구  각 10~500개 담당
  상세        4만 개

2단에서 상세로 갈 때 항목이 500개를 넘으면 그 안에서 다시 페이지네이션이 필요해진다. 이때는 3단(읍·면·동)을 만들거나, 다른 축(유형)과 교차한 조합 허브를 만든다.


3. 조합 허브의 함정 — 만들수록 좋은 게 아니다

"지역 × 유형" 조합 페이지를 자동 생성하면 도달 경로가 늘어난다. 그런데 이게 역효과를 낸다.

  250개 지역 × 11개 유형 = 2,750개 조합 페이지

  이 중 실제 항목이 있는 조합은?  → 상당수가 0~2개

항목이 거의 없는 조합 페이지는 얇은 콘텐츠(thin content)로 취급될 수 있고, 크롤 예산도 소모한다. 4만 개 상세를 돌려야 하는데 빈 조합 페이지 2,000개를 함께 돌게 만드는 셈이다.

[조합 허브 생성 규칙]

  항목 5개 이상   → 페이지 생성 + 사이트맵 포함 + 허브에서 링크
  항목 1~4개      → 페이지 생성하되 사이트맵 제외, noindex/follow
  항목 0개        → 페이지 자체를 만들지 않음 (404)

noindex, follow가 중간 구간의 답이다. 색인은 안 되지만 그 페이지를 통한 링크 전달은 유지되므로, 소수의 상세 페이지가 고아가 되지 않는다.

export async function generateMetadata({ params }): Promise<Metadata> {
  const { region, type } = await params;
  const count = await countItems(region, type);

  if (count === 0) notFound();

  return {
    robots: count < 5 ? { index: false, follow: true } : undefined,
    alternates: { canonical: `${SITE}/regions/${region}/${type}` },
  };
}

4. 사이트맵 분할

URL이 5만 개를 넘으면 사이트맵 분할이 필수다. 그 미만이어도 분할하는 편이 낫다.

[분할 기준 — 파일 크기가 아니라 의미로]

  ❌ sitemap-1.xml, sitemap-2.xml … (그냥 순번)
  ✅ sitemap-hubs.xml     허브 페이지
     sitemap-places-1.xml 상세 (지역 A~ㅁ)
     sitemap-places-2.xml 상세 (지역 ㅂ~ㅎ)
     sitemap-guides.xml   가이드 문서

의미 단위로 나누면 색인 현황을 그룹별로 읽을 수 있다. Search Console은 사이트맵 파일별로 색인 수를 보여주므로, "허브는 100% 색인, 상세는 40%"처럼 문제 위치가 바로 드러난다. 순번으로 나누면 이 정보를 잃는다.

규격 자체의 상한은 사이트맵 프로토콜에 정의돼 있다 — 파일당 URL 5만 개, 압축 전 50MB.

<!-- sitemap.xml — 인덱스 -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>https://example.com/sitemap-hubs.xml</loc></sitemap>
  <sitemap><loc>https://example.com/sitemap-places-1.xml</loc></sitemap>
</sitemapindex>

lastmod는 실제 콘텐츠 변경 시각이어야 한다. 빌드할 때마다 현재 시각을 넣으면 4만 개가 매번 "방금 수정됨"으로 신고되고, 신호로서의 가치가 사라진다. 이 실수는 정적 생성 사이트에서 특히 흔하다.


5. 중복 판정 피하기

같은 상세 페이지가 여러 축에서 도달 가능하다는 건, URL이 여러 개일 수 있다는 뜻이기도 하다.

  /places/abc-123
  /regions/seoul/gangnam/abc-123
  /types/massage/abc-123

세 URL이 같은 내용을 반환하면 중복 URL 통합 문제가 된다. 어느 것이 대표인지 직접 지정해야 한다.

[권장 구조]

  상세는 단일 URL만 존재      /places/abc-123
  허브는 그 URL로 링크        <a href="/places/abc-123">
  canonical 은 자기 자신      (상세 페이지에서)

허브가 여러 개인 것과 상세 URL이 여러 개인 것은 다르다. 도달 경로는 많아도 되지만 목적지 주소는 하나여야 한다. 경로별로 URL을 다르게 만들면 링크 신호가 분산되고 중복 판정 위험이 생긴다.

필터·정렬 파라미터도 같은 문제다.

  /regions/seoul?sort=name     → canonical: /regions/seoul
  /regions/seoul?page=2        → 자기 자신 (내용이 실제로 다름)

정렬은 내용이 같으므로 정규화 대상이고, 페이지네이션은 내용이 다르므로 각자 canonical이다. 이 둘을 구분하지 않고 전부 1페이지로 canonical을 걸면 2페이지 이후의 상세 링크가 전달되지 않는다.


6. 허브 페이지의 링크 예산

한 페이지에 링크를 무한정 넣을 수는 없다.

[허브 페이지 링크 구성]

  하위 허브 링크    10~50개    다음 계층으로
  대표 상세 링크    10~30개    이 허브의 주요 항목
  인접 허브 링크    5~10개     같은 계층의 형제
  상위 경로         1~3개      브레드크럼

  합계 30~90개 정도가 관리 가능한 범위

"인접 허브 링크"가 자주 빠진다. 서울 강남구 허브에서 서초구·송파구로 가는 링크가 있으면, 크롤러가 형제 허브를 순회하기 쉬워지고 사용자도 인근 지역을 탐색할 수 있다. 상위로 올라갔다 내려오는 경로만 있으면 깊이가 2씩 늘어난다.

[대표 상세를 고르는 기준]

  ✓ 정보가 충실한 항목 (필드 충족률)
  ✓ 최근 갱신된 항목
  ✗ 무작위 — 매 빌드마다 링크가 바뀌면 신호가 불안정
  ✗ 항상 같은 항목 — 나머지가 영원히 노출 안 됨

결정적(deterministic)이되 주기적으로 회전하는 방식이 절충안이다. 항목 ID 해시를 주차(week) 번호와 조합하면, 같은 주에는 항상 같은 결과가 나오면서 주마다 노출 대상이 바뀐다.


7. 배포 후 확인 순서

  1. 사이트맵 파일별 URL 수가 의도대로인가
  2. 고아 페이지 수 = 0 인가
  3. 깊이 7+ 페이지 비율이 줄었는가
  4. (배포 후 2~4주) 사이트맵 그룹별 색인 수 추이

4번은 시간이 걸린다. 4만 페이지 규모에서는 구조를 바꿔도 색인 반영에 수 주가 걸리므로, 일주일 만에 판단하고 되돌리는 것이 가장 나쁜 선택이다.

[색인이 안 늘 때 점검 순서]

  ① 크롤은 되는가        서버 로그에서 크롤러 요청 확인
  ② 응답이 정상인가      5xx·타임아웃 비율
  ③ 렌더링이 되는가      클라이언트 렌더링 의존 여부
  ④ 내용이 충분한가      상세 페이지의 고유 정보량

①이 안 되면 내부 링크 문제, ④가 안 되면 콘텐츠 문제다. 순서를 건너뛰고 ④부터 손대는 경우가 많은데, 크롤 자체가 안 되고 있으면 내용을 아무리 늘려도 소용없다.


8. 정리

  1. 크롤 깊이 분포와 고아 페이지 수를 먼저 측정한다
  2. 페이지네이션은 도달 경로가 아니다 — 다축 허브로 짧은 경로 확보
  3. 조합 허브는 항목 수에 따라 생성/noindex/미생성 3단 분기
  4. 사이트맵은 순번이 아니라 의미 단위로 분할
  5. lastmod에 빌드 시각을 넣지 않는다
  6. 도달 경로는 여러 개, 목적지 URL은 하나
  7. 허브에 인접 허브 링크를 넣어 형제 순회를 가능하게
  8. 구조 변경 효과는 2~4주 단위로 판단

경험상 가장 큰 개선은 2번에서 나온다. 페이지네이션 의존을 끊고 지역·유형 축 허브를 만드는 것만으로 평균 깊이가 절반 이하로 떨어지고, 그 효과는 나머지 항목을 다 합친 것보다 크다.