SEO 콘텐츠를 하나하나 손으로 쓰는 건 느리다. 그렇다고 GPT에게 도시 이름만 바꿔서 100개 글을 뽑아내면 Google은 바로 알아챈다. **Programmatic SEO(pSEO)**는 이 사이 어딘가에 있다 — 구조화된 데이터를 코드로 조합해서, 각 페이지가 고유하면서도 대규모로 생산 가능한 콘텐츠를 만드는 방법이다.

dongyeon.cc(동네견적)는 서울 25개 구의 에어컨 청소 견적을 비교하는 사이트다. 이 글에서는 75개 랜딩 페이지를 코드만으로 만든 구조를 처음부터 끝까지 뜯어본다.


1. pSEO란 무엇인가

Programmatic SEO는 하나의 템플릿과 데이터셋을 결합해서 수십~수천 개의 검색 최적화된 페이지를 자동 생성하는 전략이다. Zapier의 /apps/slack/integrations/google-sheets 같은 URL을 본 적이 있을 것이다 — 앱 A와 앱 B의 모든 조합에 대해 고유한 랜딩 페이지가 존재한다.

핵심 원칙은 세 가지:

  1. 구조화된 데이터가 존재해야 한다 (지역, 서비스, 가격 등)
  2. 각 페이지가 유니크한 가치를 제공해야 한다 (단순 변수 치환 금지)
  3. 내부 링크 그래프가 빽빽해야 한다 (크롤러가 모든 페이지를 발견할 수 있도록)

2. URL 설계: Hub-and-Spoke 패턴

사이트 구조는 4단계로 구성된다:

/                           ← 홈 (도시 선택)
/seoul                      ← 서울 25개 구 목록
/seoul/gangnam              ← 강남구 서비스 선택
/seoul/gangnam/wall-aircon  ← 강남구 벽걸이 에어컨 청소 (전환 페이지)

진짜 허브는 구(district) 페이지다. 여기서 3개 서비스 스포크가 뻗어나간다. 이 구조가 중요한 이유는:

  • Google이 계층 구조를 이해한다 (BreadcrumbList 스키마와 일치)
  • 사용자가 URL만 보고 어떤 페이지인지 안다
  • 각 스포크가 "강남구 벽걸이 에어컨 청소" 같은 롱테일 키워드를 정확히 타겟한다

3. 데이터 레이어: 두 개의 배열이 전부다

전체 사이트의 데이터 소스는 TypeScript 파일 두 개뿐이다.

regions.ts — 25개 자치구

{
  districtName: "강남구",
  districtSlug: "gangnam",
  landmarks: ["강남역", "테헤란로", "코엑스"],
  targets: ["사무실", "병원", "학원"],
  citySlug: "seoul"
}

각 구마다 3개의 랜드마크와 3개의 타겟 업종이 정의되어 있다. 이 데이터가 나중에 콘텐츠 유니크화의 핵심 재료가 된다.

services.ts — 3개 서비스 타입

{
  name: "벽걸이 에어컨 청소",
  slug: "wall-aircon-cleaning",
  priceMin: 50000,
  priceMax: 80000,
  priceUnit: "대",
  benefits: [...],  // 4개
  faqs: [...]       // 5개
}

25개 구 × 3개 서비스 = 75개 랜딩 페이지. 여기에 홈, 도시 페이지, 25개 구 페이지를 합치면 총 102페이지다.


4. 콘텐츠 유니크화: DJB2 해시의 마법

75개 페이지가 모두 같은 문구를 쓰면 Google은 이를 “thin content”로 분류한다. 그렇다고 75개를 수작업으로 쓸 수도 없다. 해결책은 결정적(deterministic) 해시 함수다.

DJB2 해시 함수

function deterministicHash(value: string): number {
  let hash = 5381;
  for (let i = 0; i < value.length; i++) {
    hash = (hash * 33) ^ value.charCodeAt(i);
  }
  return hash >>> 0;
}

이 함수는 "seoul:gangnam:wall-aircon-cleaning" 같은 문자열을 항상 같은 숫자로 변환한다. 랜덤이 아니라 결정적이므로, 빌드할 때마다 같은 결과가 나온다.

템플릿 선택 로직

각 페이지의 콘텐츠 섹션마다 서로 다른 오프셋을 더해서 템플릿을 고른다:

섹션오프셋템플릿 수
히어로 서브헤딩+35개
인트로 텍스트+115개
CTA 헤딩+173개
클로징 텍스트+233개
메타 디스크립션+295개
const seed = deterministicHash("seoul:gangnam:wall-aircon-cleaning");
const heroIndex = (seed + 3) % 5;   // 히어로 템플릿 #2
const introIndex = (seed + 11) % 5; // 인트로 템플릿 #4
const ctaIndex = (seed + 17) % 3;   // CTA 템플릿 #1

결과적으로 강남구 벽걸이 페이지와 서초구 벽걸이 페이지는 다른 히어로, 다른 인트로, 다른 CTA를 갖게 된다. 거기에 각 구의 랜드마크와 타겟 업종이 삽입되므로 문맥도 달라진다.

변수 보간 예시

"{districtName} {serviceName}는 {landmarks[0]}·{landmarks[1]} 주변
{targets[0]}과 {targets[1]} 현장 기준으로 {priceMin}~{priceMax}원
범위의 합리적인 비교 견적을 안내합니다."

강남구: “강남구 벽걸이 에어컨 청소는 강남역·테헤란로 주변 사무실과 병원 현장 기준으로…” 마포구: “마포구 벽걸이 에어컨 청소는 홍대입구·연남동 주변 카페와 공유오피스 현장 기준으로…”

같은 템플릿이지만, 삽입되는 데이터가 다르니 다른 페이지가 된다.


5. SEO: Schema Markup 삼중주

Google이 페이지를 이해하도록 세 가지 JSON-LD 스키마를 삽입한다.

{
  "@type": "BreadcrumbList",
  "itemListElement": [
    { "position": 1, "name": "홈", "item": "https://dongyeon.cc/" },
    { "position": 2, "name": "서울특별시", "item": "https://dongyeon.cc/seoul" },
    { "position": 3, "name": "강남구", "item": "https://dongyeon.cc/seoul/gangnam" },
    { "position": 4, "name": "벽걸이 에어컨 청소" }
  ]
}

SERP에서 dongyeon.cc > 서울특별시 > 강남구 > 벽걸이 에어컨 청소 형태의 경로가 표시된다.

Service — 랜딩 페이지

{
  "@type": "Service",
  "name": "강남구 벽걸이 에어컨 청소",
  "areaServed": { "@type": "AdministrativeArea", "name": "강남구, 서울특별시" },
  "offers": { "lowPrice": "50000", "highPrice": "80000", "priceCurrency": "KRW" }
}

Google이 이 페이지가 특정 지역의 특정 서비스에 대한 것임을 명확히 인식한다.

FAQPage — 리치 스니펫

각 서비스에 5개의 FAQ가 있다. FAQPage 스키마를 적용하면 Google 검색 결과에서 아코디언 형태의 FAQ가 직접 노출될 수 있다.


6. 전환: LeadForm → Telegram 알림

SEO로 트래픽을 모아도 전환이 없으면 의미가 없다. 각 랜딩 페이지에는 견적 요청 폼이 있다.

폼 필드

  • 연락처 (필수)
  • 평수 (10평 ~ 100평 드롭다운)
  • 에어컨 대수
  • 희망 날짜
  • 메시지

서버 처리 흐름

[폼 제출] → POST /api/leads
         → IP 기반 Rate Limit (5회/시간)
         → Zod 스키마 검증
         → Telegram Bot API로 알림 전송

Telegram 메시지 형태:

🔔 새 문의 접수!
📍 지역: 강남구
🔧 서비스: 벽걸이 에어컨 청소
📱 연락처: 010-****-****
📐 평수: 30평 / 2대
📅 희망일: 2026-02-20

DB가 필요 없다. 알림이 Telegram으로 바로 오므로 즉시 대응할 수 있고, 채팅 기록이 자연스럽게 리드 로그가 된다.


7. 내부 링크 전략: 2차원 그래프

pSEO에서 내부 링크는 생명줄이다. 102페이지를 Google이 빠짐없이 크롤하려면 페이지 간 연결이 촘촘해야 한다.

같은 지역, 다른 서비스

강남구 벽걸이 에어컨 페이지에서:

  • → 강남구 시스템 에어컨 청소
  • → 강남구 스탠드 에어컨 청소

같은 지역에서 서비스를 바꿔볼 수 있게 유도한다.

같은 서비스, 인접 지역

강남구 벽걸이 에어컨 페이지에서:

  • 서초구 벽걸이 에어컨 청소
  • 송파구 벽걸이 에어컨 청소
  • 마포구 벽걸이 에어컨 청소
  • 강서구 벽걸이 에어컨 청소

인접 구 4개를 자동으로 선택하는 알고리즘이 있다. 현재 구의 인덱스를 기준으로 ±1, ±2 오프셋을 교대로 적용해서 가장 가까운 구 4개를 뽑는다.

결과

각 랜딩 페이지에서 최소 6개의 내부 링크(같은 지역 2 + 인접 지역 4)가 생성된다. 75개 페이지 × 6개 링크 = 450개의 내부 링크. Google 크롤러가 아무 페이지에서 시작해도 전체 사이트를 순회할 수 있다.


8. 기술 스택 정리

항목선택
프레임워크Next.js (App Router)
렌더링Static Generation (generateStaticParams)
데이터 소스TypeScript 상수 (CMS 없음)
콘텐츠 유니크화DJB2 해시 + 템플릿 변형
스키마 마크업BreadcrumbList + Service + FAQPage
폼 검증react-hook-form + Zod (클라이언트 + 서버)
알림Telegram Bot API
Rate LimitingIP 기반 인메모리 (5회/시간)
배포정적 빌드 → CDN

빌드 시점에 102개의 HTML이 생성된다. 런타임 서버 부하는 /api/leads 하나뿐이다.


다음 단계

이 구조는 확장 가능하다:

  • 도시 추가: regions.ts에 부산, 인천 데이터를 넣으면 자동으로 페이지가 생성된다
  • 서비스 추가: services.ts에 새 서비스를 추가하면 25개 × N개로 확장
  • 성과 측정: Google Search Console에서 인덱싱 속도와 노출 키워드를 추적

pSEO의 핵심은 코드가 아니다. 어떤 데이터를 어떤 구조로 조합할 것인가를 먼저 결정하고, 코드는 그 결정을 실행하는 도구일 뿐이다.