SEO 콘텐츠를 하나하나 손으로 쓰는 건 느리다. 그렇다고 GPT에게 도시 이름만 바꿔서 100개 글을 뽑아내면 Google은 바로 알아챈다. **Programmatic SEO(pSEO)**는 이 사이 어딘가에 있다 — 구조화된 데이터를 코드로 조합해서, 각 페이지가 고유하면서도 대규모로 생산 가능한 콘텐츠를 만드는 방법이다.
dongyeon.cc(동네견적)는 서울 25개 구의 에어컨 청소 견적을 비교하는 사이트다. 이 글에서는 75개 랜딩 페이지를 코드만으로 만든 구조를 처음부터 끝까지 뜯어본다.
1. pSEO란 무엇인가
Programmatic SEO는 하나의 템플릿과 데이터셋을 결합해서 수십~수천 개의 검색 최적화된 페이지를 자동 생성하는 전략이다. Zapier의 /apps/slack/integrations/google-sheets 같은 URL을 본 적이 있을 것이다 — 앱 A와 앱 B의 모든 조합에 대해 고유한 랜딩 페이지가 존재한다.
핵심 원칙은 세 가지:
- 구조화된 데이터가 존재해야 한다 (지역, 서비스, 가격 등)
- 각 페이지가 유니크한 가치를 제공해야 한다 (단순 변수 치환 금지)
- 내부 링크 그래프가 빽빽해야 한다 (크롤러가 모든 페이지를 발견할 수 있도록)
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" 같은 문자열을 항상 같은 숫자로 변환한다. 랜덤이 아니라 결정적이므로, 빌드할 때마다 같은 결과가 나온다.
템플릿 선택 로직
각 페이지의 콘텐츠 섹션마다 서로 다른 오프셋을 더해서 템플릿을 고른다:
| 섹션 | 오프셋 | 템플릿 수 |
|---|---|---|
| 히어로 서브헤딩 | +3 | 5개 |
| 인트로 텍스트 | +11 | 5개 |
| CTA 헤딩 | +17 | 3개 |
| 클로징 텍스트 | +23 | 3개 |
| 메타 디스크립션 | +29 | 5개 |
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 스키마를 삽입한다.
BreadcrumbList — 모든 페이지
{
"@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 Limiting | IP 기반 인메모리 (5회/시간) |
| 배포 | 정적 빌드 → CDN |
빌드 시점에 102개의 HTML이 생성된다. 런타임 서버 부하는 /api/leads 하나뿐이다.
다음 단계
이 구조는 확장 가능하다:
- 도시 추가:
regions.ts에 부산, 인천 데이터를 넣으면 자동으로 페이지가 생성된다 - 서비스 추가:
services.ts에 새 서비스를 추가하면 25개 × N개로 확장 - 성과 측정: Google Search Console에서 인덱싱 속도와 노출 키워드를 추적
pSEO의 핵심은 코드가 아니다. 어떤 데이터를 어떤 구조로 조합할 것인가를 먼저 결정하고, 코드는 그 결정을 실행하는 도구일 뿐이다.