주제 × 지역 조합 페이지의 URL 설계 — 중복 없이 확장하기

프론트엔드

URL 설계canonical정보 구조내부 링크SEO

이 글은 누구를 위한 것인가

  • 카테고리 × 지역처럼 축을 곱해 페이지를 만드는 정보·디렉터리 사이트
  • 페이지는 많은데 색인이 안 되거나, 엉뚱한 페이지가 노출되는 경우
  • 필터를 추가했더니 URL이 무한히 늘어난 것을 발견한 개발자

들어가며

정보 사이트가 커지는 방식은 대체로 정해져 있다.

  1단계  주제별 페이지          /info/tax, /info/health …
  2단계  하위 문서             /info/tax/refund
  3단계  지역 축 추가          /info/area/gangnam
  4단계  주제 × 지역 조합       /info/tax/gangnam ?
                               /area/gangnam/tax ?
                               /info/tax?area=gangnam ?

4단계에서 결정을 미루면 세 형태가 동시에 존재하게 되고, 그때부터 같은 내용이 여러 주소로 흩어진다. 검색엔진은 어느 것이 정본인지 모르고, 내부 링크는 제각각을 가리키며, 분석 도구의 페이지뷰는 쪼개진다.

이 글은 그 결정을 처음에 어떻게 내리는가에 관한 것이다.


1. 조합을 다 만들지 않는다

가장 먼저 정할 것은 어떤 조합에 페이지를 줄 것인가다.

  주제 8개 × 지역 8개 = 64 조합
  여기에 하위 문서 59개까지 곱하면 = 472

  → 이 중 실제로 내용이 다른 것은 몇 개인가?

대부분의 조합은 콘텐츠가 사실상 같다. "강남 지역의 3.3% 환급"과 "부산 지역의 3.3% 환급"은 세법이 같으므로 내용이 거의 동일하다. 이런 조합에 페이지를 만들면 중복 콘텐츠가 대량 생산된다.

[페이지를 만들 조합의 조건]

  ✓ 지역별로 실제 내용이 다르다
      (지역 사무소 정보, 지역 시세, 지역 규정)
  ✓ 해당 조합을 찾는 수요가 실재한다
  ✓ 최소 콘텐츠 분량이 나온다

  ✗ 템플릿에 지역명만 치환된 페이지
  ✗ 결과가 0~2건인 목록

"지역명만 바뀌는 페이지"를 만들지 않는 것이 이 설계의 핵심 원칙이다. 이건 SEO 문제 이전에 사용자에게 가치가 없다.

// 조합 페이지 생성 기준을 코드로 명시
export async function generateStaticParams() {
  const combos = await db.$queryRaw<{ topic: string; area: string }[]>`
    SELECT topic, area
    FROM area_topic_content
    WHERE published = true
      AND has_local_content = true       -- 지역 고유 내용이 있는 것만
      AND word_count >= 400              -- 최소 분량
  `;
  return combos;
}

has_local_content 같은 플래그를 데이터에 두고, 편집자가 지역 고유 내용을 실제로 작성했을 때만 페이지가 생기게 한다. 자동 생성에 맡기면 반드시 껍데기 페이지가 대량으로 생긴다.


2. URL 형태 — 하나를 고르고 나머지는 리다이렉트

세 후보의 성격이 다르다.

[A] /info/tax/gangnam          주제 우선
    · 주제가 상위 개념일 때 자연스러움
    · 지역은 주제의 하위 변형

[B] /area/gangnam/tax          지역 우선
    · 지역 페이지가 허브 역할을 할 때
    · 사용자가 지역으로 먼저 좁히는 경우

[C] /info/tax?area=gangnam     파라미터
    · 페이지가 아니라 필터. 색인 대상이 아님

A와 B 중 하나를 고르는 기준은 "사용자가 어느 축으로 먼저 좁히는가"다. 검색 쿼리를 보면 알 수 있다. "강남 세무" 형태가 많으면 B, "3.3% 환급 강남"이 많으면 A다.

C는 A/B와 함께 쓰되 색인 대상에서 뺀다. 필터 조작으로 생기는 URL은 조합이 무한하므로, 페이지가 아니라 뷰 상태로 다뤄야 한다.

[결정 후]

  정본:      /info/tax/gangnam
  리다이렉트: /area/gangnam/tax  → 308 → /info/tax/gangnam
  canonical: /info/tax?area=gangnam 에서 정본을 가리킴

**리다이렉트는 308(영구)**을 쓴다. 307/302는 임시라는 뜻이라 검색엔진이 원래 주소를 계속 별개로 취급한다.

지역 축을 별도 섹션으로 두는 구조는 실제로 흔하다. 토닥의 지역 정보처럼 /info/area/{지역} 형태로 지역 인덱스를 두고 그 아래에 지역별 문서를 배치하면, 주제 축(/info/tax)과 지역 축이 각자 독립적인 진입점을 갖는다. 두 축이 교차하는 지점에서만 조합 페이지를 만들고, 나머지는 각 축의 인덱스로 흘려보내는 것이 관리 가능한 구조다.


3. canonical — 언제 자기 자신이 아닌가

<link rel="canonical">은 "이 페이지의 정본은 여기다"를 선언한다. 대부분의 페이지는 자기 자신을 가리키지만, 자기 자신이 아니어야 하는 경우를 정확히 알아야 한다.

[자기 자신 (self-canonical)]

  /info/tax/gangnam            → 자기 자신
  /info/tax                    → 자기 자신

[다른 곳을 가리켜야 함]

  /info/tax?area=gangnam       → /info/tax/gangnam
  /info/tax?page=2&sort=new    → /info/tax?page=2   (정렬만 제거)
  /info/tax/gangnam?utm_source=x → /info/tax/gangnam

페이지네이션은 통합하지 않는다. ?page=2의 canonical을 1페이지로 지정하면 2페이지 이후의 콘텐츠가 색인에서 사라진다. 페이지 번호는 유지하고 정렬·뷰 옵션 같은 표현용 파라미터만 제거한다.

const INDEXABLE_PARAMS = new Set(['page']);   // 이것만 canonical에 남긴다

export function buildCanonical(pathname: string, sp: URLSearchParams): string {
  const kept = new URLSearchParams();
  for (const [k, v] of sp) {
    if (INDEXABLE_PARAMS.has(k)) kept.set(k, v);
  }
  const qs = kept.toString();
  return `${SITE_URL}${pathname}${qs ? `?${qs}` : ''}`;
}

허용 목록(allowlist) 방식을 쓴다. 차단 목록으로 하면 새 파라미터가 추가될 때마다 누락이 생기고, 그때부터 조용히 중복이 쌓인다.

정규화 규칙과 canonical의 동작은 중복 URL 통합 가이드에 정리돼 있다. 참고로 canonical은 지시가 아니라 힌트다. 검색엔진이 다른 판단을 할 수 있으므로, 정본이 아닌 URL은 애초에 내부 링크에서 쓰지 않는 것이 확실하다.


4. 내부 링크는 항상 정본으로

canonical을 걸어두고 내부 링크는 파라미터 URL을 쓰는 구현이 흔하다. 모순된 신호를 보내는 것이다.

  ❌ 목록 화면의 링크가 /info/tax?area=gangnam
     상세 화면의 canonical은 /info/tax/gangnam
     → 사이트 자신이 정본을 안 쓰고 있음

  ✅ 링크도 canonical도 /info/tax/gangnam

URL 생성을 한 곳으로 모으는 것이 이 문제의 근본 해법이다.

// lib/routes.ts — URL은 여기서만 만든다
export const routes = {
  topic: (topic: string) => `/info/${topic}`,
  topicDoc: (topic: string, slug: string) => `/info/${topic}/${slug}`,
  area: (area: string) => `/info/area/${area}`,
  topicArea: (topic: string, area: string) => `/info/${topic}/${area}`,
} as const;

문자열 결합으로 URL을 만드는 코드가 여기저기 흩어져 있으면, 구조를 바꿀 때 반드시 몇 군데를 빠뜨린다. 정본 URL 생성 함수를 강제하는 것만으로 3절의 문제 대부분이 예방된다.


5. 빵부스러기 — 두 축을 다 표현할 수 없다

조합 페이지의 빵부스러기(breadcrumb)는 답이 하나가 아니다.

  /info/tax/gangnam 의 경로는?

  [A] 정보 > 세금 > 강남
  [B] 정보 > 지역 > 강남 > 세금

URL 구조와 일치하는 쪽(A)을 쓴다. 마크업 요건은 빵부스러기 구조화 데이터 가이드BreadcrumbList 스키마에 정의돼 있다. 빵부스러기가 URL과 다르면 사용자가 위치를 잘못 인식하고, 구조화 데이터로 내보낼 때도 어긋난다.

다른 축으로 가는 경로는 빵부스러기가 아니라 본문 내 관련 링크로 제공한다.

  빵부스러기:  정보 > 세금 > 강남

  본문 하단:
    같은 주제 다른 지역:  홍대 · 부산 · 대구
    이 지역의 다른 주제:  보험 · 건강 · 권리

이 두 줄이 조합 페이지의 내부 링크 구조를 만든다. 조합 페이지끼리 서로 연결되지 않으면 각 페이지가 고립되고, 크롤러가 도달하기 어려워진다.

// 구조화 데이터는 URL 경로와 동일하게
const breadcrumb = {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: [
    { '@type': 'ListItem', position: 1, name: '정보', item: `${SITE}/info` },
    { '@type': 'ListItem', position: 2, name: '세금', item: `${SITE}/info/tax` },
    { '@type': 'ListItem', position: 3, name: '강남', item: `${SITE}/info/tax/gangnam` },
  ],
};

6. 목록 페이지의 필터 — 색인에서 빼는 방법

필터 조작으로 생기는 URL은 크롤 예산을 가장 많이 낭비하는 요인이다.

  /info/tax?sort=new&view=grid&open=1&page=3&region=all
  → 파라미터 5개면 조합이 수백 개

세 가지 수단을 겹쳐 쓴다.

  1. canonical 로 정본 지정        (3절)
  2. robots meta 로 색인 제외      필터가 걸린 상태만
  3. 링크에 rel="nofollow" 없이도  → 애초에 필터 URL을 <a href>로 만들지 않기

3번이 가장 효과적이다. 필터를 URL 변경 없이 클라이언트 상태로 처리하면 크롤러가 발견할 URL 자체가 생기지 않는다. 다만 공유·뒤로가기를 위해 URL 반영이 필요하다면, history.replaceState로 링크 없이 주소만 갱신하는 절충이 가능하다.

// 필터 변경 — 링크 이동이 아니라 주소만 갱신
function applyFilter(next: FilterState) {
  const sp = new URLSearchParams(serialize(next));
  window.history.replaceState(null, '', `${location.pathname}?${sp}`);
  setFilter(next);        // 데이터는 클라이언트에서 갱신
}

색인 제외가 필요한 경우는 서버에서 판단한다. Next.js라면 generateMetadata에서 robotsalternates.canonical을 함께 정한다.

export async function generateMetadata({ searchParams }): Promise<Metadata> {
  const sp = await searchParams;
  const hasFilter = Object.keys(sp).some(k => !INDEXABLE_PARAMS.has(k));
  return {
    robots: hasFilter ? { index: false, follow: true } : undefined,
    alternates: { canonical: buildCanonical(pathname, new URLSearchParams(sp)) },
  };
}

follow: true를 유지하는 게 중요하다. 색인은 안 하되 링크는 따라가게 해야, 필터 페이지를 통해서만 도달 가능한 콘텐츠가 고립되지 않는다.


7. 확장할 때 무너지지 않으려면

축이 하나 더 늘어나는 순간(예: 연도, 대상별)을 대비해야 한다.

[축 추가 시 원칙]

  · 세 번째 축은 경로가 아니라 파라미터로 시작
      /info/tax/gangnam?year=2026
  · 실제 수요가 확인된 조합만 경로로 승격
  · 경로 승격 시 기존 파라미터 URL에서 308 리다이렉트

  ✗ 축이 늘 때마다 경로 깊이를 늘리는 것
      /info/tax/gangnam/2026/freelancer  ← 조합 폭발

경로 깊이는 3단계를 넘기지 않는 것이 관리 가능한 상한이다. 그 이상이 필요하다면 축을 줄이거나, 하나를 필터로 되돌려야 한다는 신호다.

[사이트맵 반영]

  경로로 존재하는 조합만 사이트맵에 포함
  파라미터 URL은 제외
  lastmod 는 해당 조합의 콘텐츠 수정일 (배포 시각 아님)

8. 정리

  1. 지역명만 치환된 페이지는 만들지 않는다 (has_local_content 플래그)
  2. 주제 우선 / 지역 우선 중 하나를 고르고 나머지는 308
  3. canonical 파라미터는 허용 목록 방식, 페이지네이션은 통합 금지
  4. URL 생성 함수를 한 곳에 두고 내부 링크는 항상 정본
  5. 빵부스러기는 URL 구조와 일치, 다른 축은 관련 링크로
  6. 필터는 링크가 아니라 replaceState + noindex,follow
  7. 세 번째 축은 파라미터로 시작해 수요 확인 후 승격
  8. 사이트맵에는 경로 조합만, lastmod는 콘텐츠 수정일

가장 효과가 큰 건 1번과 4번이다. 만들지 말았어야 할 페이지를 만들지 않는 것이 나머지 최적화를 전부 합친 것보다 크고, URL 생성을 한 곳으로 모으면 중복 문제의 재발 자체가 막힌다.