테마 전환

템플릿 페이지 생성: 프로그래매틱 SEO 기술 구현 방법

Easton editorial illustration: SEO-to-publishing automation line

지난주 한 지인이 프로그래매틱 SEO에 관해 이야기하자며 연락해 왔습니다. 키워드 매트릭스는 이미 준비했고 2천 개가 넘는 롱테일 키워드도 Excel에 정리했지만, 그다음 단계에서 막혔다고 했습니다.

“원리는 알겠는데 막상 시작하려니 문제가 한꺼번에 쏟아져요. Next.js와 Astro 중 뭘 써야 하죠? 데이터베이스는 무엇을 고르고 URL 경로는 어떻게 설계해야 할까요? 그리고 5천 페이지를 한 번에 생성하면 서버가 감당할 수 있을까요?”

저도 2년 전에 같은 문제로 고민했습니다. 당시 변호사 서비스 디렉터리 사이트를 만들면서 프로그래매틱 SEO로 도시별 페이지 3천 개를 만들 계획이었습니다. 결과는 어땠을까요? 첫 버전을 출시하는 데 두 달이 걸렸고, 그때 겪은 시행착오는 지금 떠올려도 머리가 아픕니다.

이 글은 바로 그 시행착오를 줄이기 위해 썼습니다. 정적 생성, 동적 렌더링, 하이브리드 방식이라는 세 가지 기술 경로를 설명합니다. 각 경로에 코드 구현 아이디어, 적합한 상황, 실제 사례를 담았습니다. 다 읽고 나면 바로 시작할 수 있을 것입니다. 적어도 저처럼 처음부터 모든 것을 헤매지는 않을 겁니다.


먼저 확인할 것: 어떤 경로가 적합한가요?

기술 구현은 프레임워크를 무작정 고르는 일이 아닙니다. 먼저 데이터의 특성을 살펴봐야 합니다.

정적 생성(SSG)이 적합한 경우

데이터가 일주일이나 한 달에 한 번처럼 드물게 업데이트된다면 정적 생성이 가장 안정적입니다.

예를 들어 예전에 지인의 여행 가이드 사이트 제작을 도운 적이 있습니다. 도시별 가이드 페이지의 관광지 소개, 교통 정보, 맛집 추천은 거의 고정되어 있었고, 6개월에 한 번 정도만 업데이트했습니다. Astro의 Content Collections를 사용해 도시 2천 곳의 데이터를 JSON 파일로 저장한 뒤 정적 페이지를 일괄 생성했습니다. 빌드에는 약 10분이 걸렸지만, 출시 후 TTFB(첫 바이트 시간)는 약 80ms로 안정적이었고 CDN 캐시 적중률은 95%였습니다.

장점은 분명합니다. 페이지 로딩이 빠르고 SEO에 유리하며 서버 부담이 적습니다. 하지만 한계도 있습니다. 데이터를 업데이트할 때마다 사이트 전체를 다시 빌드해야 하고, 페이지가 5천 개를 넘으면 빌드 시간이 길어집니다.

동적 렌더링(SSR)은 언제 사용하나요?

데이터가 실시간으로 바뀌는 환경에는 정적 생성이 맞지 않습니다.

해외 송금 서비스 Wise가 대표적인 사례입니다. 환율 변환 페이지의 환율은 매분 달라집니다. 정적 생성을 사용하면 사용자가 10분 전 환율을 보게 될 수 있고, 이는 송금 결정에 큰 영향을 줍니다. 그래서 Wise는 Next.js의 SSR(서버 사이드 렌더링)을 사용해 요청이 들어올 때마다 API에서 최신 환율을 가져옵니다.

다만 동적 렌더링에는 서버 부하라는 대가가 따릅니다. Wise에는 매일 수백만 건의 환율 변환 조회가 발생하므로 서버 비용이 낮지 않습니다. TTFB도 다소 느려져 일반적으로 200~500ms 정도입니다.

하이브리드 방식은 절충안입니다

업데이트 빈도가 낮은 데이터와 높은 데이터가 모두 있다면 하이브리드 방식이 가장 적합할 수 있습니다.

Zapier의 통합 페이지가 이 방식을 사용합니다. Zapier에는 ‘App A + App B’ 조합의 통합 페이지가 5천 개 넘게 있습니다. 예를 들면 ‘Slack과 Gmail 통합’ 같은 페이지입니다. 기능 소개와 설정 단계 같은 기본 정보는 정적이지만, 연결 여부와 최근 동기화 시간 같은 사용자의 실제 통합 상태는 동적입니다.

Zapier는 Next.js의 ISR(증분 정적 재생성)을 사용합니다. 처음 로드되는 페이지는 정적이며, 백그라운드 메커니즘이 주기적으로 페이지를 갱신합니다. 따라서 사용자는 빠르면서도 정확한 콘텐츠를 볼 수 있습니다.

제안은 간단합니다. 기술 경로를 선택하기 전에 다음 세 질문에 답해 보세요.

  1. 데이터는 얼마나 자주 업데이트되나요? 매일, 매시간, 아니면 실시간인가요?
  2. 페이지 규모는 어느 정도인가요? 5천 개 미만, 5천~2만 개, 또는 2만 개 이상인가요?
  3. SEO 성능 요구사항은 어느 정도인가요? TTFB가 반드시 100ms 미만이어야 하나요, 아니면 300ms도 허용할 수 있나요?

이 세 가지를 정리하면 기술 선택도 명확해집니다.

<100ms
정적 생성 TTFB
CDN 캐시 적중
200-500ms
동적 렌더링 TTFB
서버 사이드 생성
절충
ISR 방식
성능과 실시간성의 균형
Source: 기술 지표 비교

정적 생성 방식: Astro 구현 방법

정적 생성을 선택했다면 Astro를 권합니다. Astro는 처음부터 정적 사이트를 위해 설계됐고, 특히 Content Collections가 프로그래매틱 SEO에 잘 맞기 때문입니다.

데이터 구조 설계

먼저 데이터 구조를 정의합니다. 도시마다 페이지 하나를 갖는 변호사 서비스 디렉터리를 만든다고 가정해 보겠습니다. 데이터는 다음과 같이 구성할 수 있습니다.

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const lawyersCollection = defineCollection({
  type: 'content',
  schema: z.object({
    city: z.string(),
    citySlug: z.string(),
    province: z.string(),
    lawyerCount: z.number(),
    topFirms: z.array(z.string()),
    avgPrice: z.string(),
    specialties: z.array(z.string()),
  }),
});

export const collections = {
  'lawyers': lawyersCollection,
};

그다음 src/content/lawyers/ 디렉터리에 데이터 파일을 만듭니다. 도시마다 JSON 파일 하나를 둡니다.

// src/content/lawyers/beijing.json
{
  "city": "베이징",
  "citySlug": "beijing",
  "province": "베이징시",
  "lawyerCount": 12500,
  "topFirms": ["King & Wood Mallesons", "Zhong Lun", "Dentons"],
  "avgPrice": "시간당 2,000~5,000위안",
  "specialties": ["형사", "민사", "상사", "지식재산권"]
}

도시가 2천 곳이면 JSON 파일도 2천 개입니다. 번거로워 보이지만 스크립트로 자동 생성할 수 있습니다. 저는 보통 Python이나 Node.js로 데이터베이스에서 데이터를 내보낸 뒤 JSON 파일을 일괄 작성합니다.

동적 라우팅 템플릿

데이터를 준비했다면 다음은 템플릿입니다. Astro의 동적 라우팅은 매우 유연합니다.

// src/pages/[citySlug].astro
---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const lawyers = await getCollection('lawyers');
  return lawyers.map(lawyer => ({
    params: { citySlug: lawyer.data.citySlug },
    props: { lawyer },
  }));
}

const { lawyer } = Astro.props;
---

<!DOCTYPE html>
<html>
<head>
  <title>{lawyer.data.city} 변호사 서비스 안내 | 로펌 추천 및 비용 기준</title>
  <meta name="description" content={`${lawyer.data.city} 변호사 서비스 종합 안내입니다. ${lawyer.data.specialties.join('·')} 등의 분야와 ${lawyer.data.topFirms.join('·')} 등 주요 로펌, 평균 비용 ${lawyer.data.avgPrice}를 다룹니다.`} />
</head>
<body>
  <h1>{lawyer.data.city} 변호사 서비스 안내</h1>

  <section>
    <h2>핵심 데이터</h2>
    <p>변호사 수: {lawyer.data.lawyerCount}명</p>
    <p>주요 분야: {lawyer.data.specialties.join('·')}</p>
    <p>평균 비용: {lawyer.data.avgPrice}</p>
  </section>

  <section>
    <h2>추천 로펌</h2>
    <ul>
      {lawyer.data.topFirms.map(firm => <li>{firm}</li>)}
    </ul>
  </section>

  <!-- 내부 링크: 관련 도시 -->
  <section>
    <h2>인근 도시 변호사 서비스</h2>
    <!-- 성 또는 지리적 위치를 기준으로 추천할 수 있습니다 -->
  </section>
</body>
</html>

getStaticPaths 함수가 모든 도시의 정적 페이지를 자동 생성합니다. 도시가 2천 곳이면 HTML 파일도 2천 개가 만들어집니다.

빌드와 배포

Astro 빌드 명령은 간단합니다.

npm run build

빌드가 끝나면 dist/ 디렉터리에 모든 정적 HTML 파일이 생성됩니다. Cloudflare Pages나 Vercel에 바로 배포하면 CDN이 자동으로 캐시합니다.

앞서 언급한 여행 가이드 사이트는 페이지 2천 개를 빌드하는 데 약 10분이 걸렸습니다. Astro의 빌드 속도는 확실히 빠르고, Next.js의 정적 생성보다 효율이 훨씬 좋았습니다.

최적화 팁: 페이지가 5천 개를 넘으면 분할 빌드를 권합니다. Astro는 증분 빌드를 지원하므로 매번 사이트 전체를 다시 빌드하지 않고 새 데이터에 해당하는 페이지만 생성할 수 있습니다.


동적 렌더링 방식: Next.js SSR 구현

데이터가 실시간으로 바뀐다면 정적 생성은 적합하지 않습니다. 이때는 Next.js의 SSR을 사용합니다.

기본 설정

Next.js SSR의 핵심은 getServerSideProps입니다.

// pages/currency/[pair].tsx
import { GetServerSideProps } from 'next';

export const getServerSideProps: GetServerSideProps = async (context) => {
  const { pair } = context.params;
  const [from, to] = pair.split('-to-');

  // 최신 환율 가져오기
  const exchangeRate = await fetchExchangeRate(from, to);

  return {
    props: {
      from,
      to,
      rate: exchangeRate.rate,
      lastUpdate: exchangeRate.timestamp,
    },
  };
};

export default function CurrencyPage({ from, to, rate, lastUpdate }) {
  return (
    <div>
      <h1>{from}에서 {to}로 환율 변환</h1>
      <p>현재 환율: {rate}</p>
      <p>업데이트 시간: {new Date(lastUpdate).toLocaleString()}</p>

      {/* 환율 계산기 */}
      <input type="number" placeholder="금액 입력" />
      <button>변환</button>

      {/* 과거 추이 차트 */}
      <div>최근 30일 환율 추이</div>
    </div>
  );
}

사용자가 /currency/usd-to-eur에 접속할 때마다 서버가 환율 API에서 최신 데이터를 가져옵니다. 페이지 콘텐츠는 항상 최신 상태입니다.

캐시 전략: 서버가 과부하되지 않게 하기

동적 렌더링의 문제는 서버 부하입니다. Wise에 매일 수백만 건의 조회가 발생하는데, 요청마다 실시간으로 데이터를 가져오면 서버 비용이 급증합니다.

해결책은 캐시입니다. 단, 캐시에도 적절한 전략이 필요합니다.

stale-while-revalidate는 좋은 선택입니다. 사용자가 요청하면 먼저 몇 분 정도 오래됐을 수 있는 캐시 데이터를 반환하고, 동시에 백그라운드에서 조용히 업데이트합니다. 다음 사용자는 새 데이터를 볼 수 있습니다.

Next.js는 이 전략을 지원합니다.

export const getServerSideProps: GetServerSideProps = async (context) => {
  const { pair } = context.params;

  // 캐시 확인
  const cached = await checkCache(pair);

  if (cached && !isExpired(cached)) {
    return { props: cached.data };
  }

  // 캐시가 만료되면 백그라운드에서 업데이트
  fetchExchangeRate(pair).then(data => updateCache(pair, data));

  // 우선 이전 데이터를 반환
  return { props: cached?.data || await fetchExchangeRate(pair) };
};

이렇게 하면 사용자는 매번 콘텐츠를 빠르게 볼 수 있고, 서버도 요청마다 외부 API를 호출할 필요가 없습니다.

구조화 데이터 동적 주입

동적 렌더링 페이지의 구조화 데이터도 동적으로 생성해야 합니다.

// 페이지 컴포넌트에서 JSON-LD 생성
const jsonLd = {
  "@context": "https://schema.org",
  "@type": "FinancialService",
  "name": `${from} to ${to} Currency Conversion`,
  "offers": {
    "@type": "Offer",
    "price": rate,
    "priceCurrency": to,
  },
};

// HTML에 주입
<script type="application/ld+json">
  {JSON.stringify(jsonLd)}
</script>

이렇게 하면 페이지마다 구조화 데이터가 실시간으로 정확하게 유지되고, Google 검색 결과에도 최신 환율을 표시할 수 있습니다.


하이브리드 방식: Next.js ISR 구현

일부는 정적이고 일부는 동적인 환경이라면 ISR(증분 정적 재생성)이 가장 좋은 선택입니다.

ISR의 핵심 원리

ISR의 원리는 이렇습니다. 페이지는 처음에 정적으로 생성되지만 ‘만료 시간’을 설정할 수 있습니다. 시간이 지나면 다음 방문이 백그라운드 재생성을 트리거합니다.

// pages/integrations/[app1]-and-[app2].tsx
export async function getStaticPaths() {
  const integrations = await fetchAllIntegrations();
  return integrations.map(int => ({
    params: { app1: int.app1, app2: int.app2 },
  }));
}

export async function getStaticProps({ params }) {
  const integration = await fetchIntegration(params.app1, params.app2);

  return {
    props: integration,
    revalidate: 3600, // 1시간 후 만료
  };
}

revalidate: 3600은 페이지 생성 후 1시간 동안 모든 방문에 정적 콘텐츠를 반환한다는 뜻입니다. 1시간 뒤 첫 번째 방문자는 여전히 이전 페이지를 보지만, 그와 동시에 백그라운드 재생성이 시작됩니다. 두 번째 방문부터는 새 페이지를 보게 됩니다.

요청 기반 갱신: on-demand revalidation

자연 만료를 기다리기 어려운 상황도 있습니다. 예를 들어 Zapier의 특정 통합에 갑자기 장애가 생겨 사용자가 신고했다면, 1시간을 기다리지 않고 즉시 페이지 상태를 업데이트하고 싶을 것입니다.

Next.js는 요청 기반 갱신을 지원합니다.

// API 경로: 갱신 트리거
// pages/api/revalidate.ts
export default async function handler(req, res) {
  const { app1, app2 } = req.query;

  try {
    await res.revalidate(`/integrations/${app1}-and-${app2}`);
    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).send('Error revalidating');
  }
}

모니터링 스크립트를 두고 통합 상태를 주기적으로 확인할 수 있습니다. 문제가 발견되면 이 API를 호출해 페이지 갱신을 트리거합니다.

ISR이 적합한 범위

ISR이 만능은 아닙니다. 주가나 타임 세일처럼 접속할 때마다 데이터가 반드시 실시간으로 갱신돼야 한다면 여전히 SSR을 사용해야 합니다.

ISR은 데이터가 매시간 또는 매일처럼 자주 바뀌지 않지만, 변경 사항은 빠르게 반영돼야 하는 상황에 적합합니다. Zapier의 통합 페이지가 좋은 예입니다. 통합 정보는 몇 달에 한 번 바뀌지만, 바뀐 뒤에는 사용자가 최대한 빨리 새 정보를 보길 기대합니다.


URL 구조 설계: SEO의 토대

기술 방식을 정했다면 다음은 URL 구조입니다. 많은 사람이 이 부분을 소홀히 하지만 URL 설계는 SEO 성과에 직접 영향을 줍니다.

SEO 친화적인 URL의 세 원칙

첫째, 목표 키워드를 포함합니다. URL은 Google 순위 결정 요소 중 하나이며 키워드를 자연스럽게 넣으면 도움이 됩니다.

예를 들어 ‘베이징 이혼 전문 변호사’ 페이지라면 URL을 /beijing/divorce-lawyer로 만들 수 있습니다. ‘베이징’과 ‘이혼 전문 변호사’라는 키워드가 경로에 자연스럽게 들어갑니다.

둘째, 경로는 3단계를 넘기지 않습니다. 너무 깊은 경로는 사용자와 크롤러 모두에게 불편합니다.

/service/legal/lawyer/divorce/beijing처럼 6단계인 경로는 사용자가 한눈에 이해하기 어렵습니다. Google도 이런 페이지를 저품질로 판단할 수 있습니다.

셋째, 매개변수 대신 하이픈으로 구분합니다.

/lawyer?type=divorce&city=beijing 같은 매개변수 URL은 /beijing/divorce-lawyer보다 SEO 효과가 떨어집니다. 크롤러가 동적 페이지로 잘못 판단하기 쉬워 색인 효율도 낮아질 수 있습니다.

자주 사용하는 세 가지 URL 패턴

주로 다음 세 가지 패턴을 사용하며, 각각 적합한 상황이 다릅니다.

패턴 1: 핵심 키워드를 앞에 배치

/lawyer/beijing/divorce

브랜드 중심 사이트에 적합합니다. 핵심 키워드인 ‘lawyer’를 앞에 배치해 브랜드 인식을 강화합니다.

패턴 2: 지역 키워드를 앞에 배치

/beijing/divorce-lawyer

지역 서비스 사이트에 적합합니다. 사용자가 ‘베이징 이혼 전문 변호사’를 검색할 때 URL이 검색어와 정확히 맞아 순위에 유리합니다.

패턴 3: 평면형

/beijing-divorce-lawyer

페이지가 아주 많은 사이트에 적합합니다. 경로가 한 단계뿐이라 빌드와 관리가 간단합니다.

어떤 패턴을 고를지는 사용자가 검색하는 방식에 달려 있습니다. 검색 대부분이 ‘도시 + 서비스’ 형태라면 패턴 2를 사용하고, 검색어가 다양하게 분산돼 있다면 패턴 3이 더 유연합니다.

내부 링크 자동화 구현

URL을 정했다면 다음은 내부 링크입니다. 프로그래매틱 SEO의 장점 중 하나는 내부 링크 네트워크를 자동으로 만들 수 있다는 것입니다.

도시별 페이지 3천 개가 있는 변호사 디렉터리 사이트를 가정해 보겠습니다. 각 페이지는 관련 도시로 연결돼야 합니다. 어떻게 구현할까요?

지리적 계층 기준: 베이징 페이지에서 ‘허베이성 변호사’, ‘톈진 변호사’ 같은 인근 지역으로 연결합니다.

서비스 유형 기준: 베이징 이혼 전문 변호사 페이지에서 ‘베이징 형사 전문 변호사’, ‘베이징 민사 전문 변호사’처럼 같은 도시의 다른 서비스로 연결합니다.

코드 구현은 간단합니다.

---
// 페이지 템플릿에서
const { lawyer } = Astro.props;
const nearbyCities = await getNearbyCities(lawyer.data.province);
const relatedSpecialties = lawyer.data.specialties;
---

<section>
  <h2>인근 도시 변호사</h2>
  {nearbyCities.map(city => (
    <a href={`/${city.slug}/${lawyer.data.specialties[0]}-lawyer`}>
      {city.name} {lawyer.data.specialties[0]} 전문 변호사
    </a>
  ))}
</section>

<section>
  <h2>{lawyer.data.city}의 다른 법률 서비스</h2>
  {relatedSpecialties.map(spec => (
    <a href={`/${lawyer.data.citySlug}/${spec}-lawyer`}>
      {lawyer.data.city} {spec} 전문 변호사
    </a>
  ))}
</section>

이렇게 하면 각 페이지에 수십 개의 내부 링크가 생기고 사이트 전체가 그물형 구조를 이룹니다. 크롤러의 수집 효율이 높아지고 사용자도 관련 콘텐츠를 빠르게 찾을 수 있습니다.


데이터베이스 선택: 너무 오래 고민하지 마세요

데이터 구조를 설계했다면 어디에 저장할지 결정해야 합니다. 많은 사람이 이 문제로 오래 고민하지만 실제로는 그렇게 복잡하지 않습니다.

네 가지 선택지와 적합한 상황

PostgreSQL: 구조화 데이터와 복잡한 쿼리에 적합합니다.

데이터 필드가 고정돼 있고 ‘베이징에서 비용이 3,000위안 미만인 이혼 전문 변호사를 모두 찾기’ 같은 복잡한 쿼리가 필요하다면 PostgreSQL이 가장 안정적입니다. ACID 보장, 트랜잭션, 전문 검색 기능을 모두 활용할 수 있습니다.

MongoDB: 유연한 데이터 구조와 빠른 반복 개발에 적합합니다.

데이터 구조가 계속 바뀌는 단계라면 MongoDB가 더 유연합니다. Schema를 미리 정의하지 않아도 되고 언제든 필드를 추가할 수 있습니다. 저도 프로젝트 초기에는 데이터 구조가 자주 바뀌어서 MongoDB를 많이 사용했습니다.

Airtable/Google Sheets: 소규모 데이터와 협업이 필요한 경우에 적합합니다.

데이터가 수십~수백 건이고 여러 팀원이 함께 작업한다면 Airtable이나 Google Sheets도 충분히 유용합니다. 시각적으로 편집할 수 있고 실시간 협업이 가능해 비개발자도 다룰 수 있습니다. 제 지인의 작은 프로젝트는 200건의 데이터를 Airtable로 관리하는데 유지 비용이 매우 낮습니다.

CSV/JSON 파일: 완전한 정적 생성 환경에 적합합니다.

데이터가 완전히 정적이고 페이지가 1천 개 미만이라면 CSV나 JSON 파일을 바로 사용해도 됩니다. 데이터베이스를 유지할 필요 없이 빌드할 때 파일을 읽으면 됩니다. Astro의 Content Collections도 이런 방식을 위해 설계됐습니다.

제안: 먼저 데이터 규모를 파악하세요

데이터가 1천 건 미만이면 CSV/JSON 파일 또는 Airtable을 사용합니다.
1천~1만 건이면 PostgreSQL 또는 MongoDB를 사용합니다.
1만 건을 넘으면 PostgreSQL과 Redis 캐시를 함께 사용합니다.

너무 오래 고민하지 말고 현재 사용할 수 있는 것을 선택하세요. 나중에 데이터 구조가 바뀌면 마이그레이션하면 되고, 그것도 반드시 큰 작업인 것은 아닙니다.


템플릿 개발의 핵심: 콘텐츠 차별화

기술 아키텍처를 갖춘 뒤에는 템플릿 개발이 진짜 과제입니다. 많은 프로그래매틱 SEO 프로젝트가 템플릿 콘텐츠의 유사성이 너무 높아서 실패합니다.

피해야 할 함정: 키워드만 바꾼 템플릿은 실패합니다

실패 사례를 하나 본 적이 있습니다. 한 사이트가 ‘도시 + 호텔’ 페이지 2만 개를 만들었는데, 각 페이지에서 도시 이름만 바꾸고 나머지 콘텐츠는 완전히 같았습니다. ‘베이징 호텔 추천’, ‘상하이 호텔 추천’, ‘광저우 호텔 추천’의 본문이 모두 같은 템플릿이었습니다.

결국 Google 알고리즘의 불이익을 받아 트래픽이 70% 감소했고, 회복하는 데 8개월이 걸렸습니다.

문제의 근본 원인: 페이지마다 고유한 가치가 없었습니다. ‘베이징 호텔’을 검색한 사용자가 ‘상하이 호텔’ 페이지와 거의 같은 콘텐츠를 보게 된다면 대량 생성된 저품질 콘텐츠라는 사실이 분명합니다.

차별화하는 세 가지 방법

방법 1: 동적 데이터 주입

각 페이지에는 고유한 데이터가 있어야 합니다. 변호사 디렉터리라면 도시별 변호사 수, 로펌 목록, 평균 비용이 서로 다릅니다. 이런 데이터를 데이터베이스에서 직접 가져오면 페이지마다 자연스럽게 차별화됩니다.

방법 2: UGC 통합

사용자 생성 콘텐츠는 가장 좋은 차별화 소재입니다. TripAdvisor의 호텔 페이지에는 호텔마다 고유한 사용자 리뷰가 있습니다. 템플릿이 만든 것이 아니라 실제 사용자가 작성한 콘텐츠입니다.

UGC 데이터가 있다면 반드시 페이지에 통합하세요. 예를 들어 변호사 디렉터리 사이트에는 ‘사용자 후기’ 모듈을 넣을 수 있습니다.

<section>
  <h2>사용자 후기</h2>
  {lawyer.data.reviews.map(review => (
    <div>
      <p>{review.content}</p>
      <span>평점: {review.rating}/5</span>
    </div>
  ))}
</section>

방법 3: AI 보조 확장

동적 데이터나 UGC가 없다면 AI를 활용해 차별화 콘텐츠를 보완할 수 있습니다. 다만 AI가 생성한 콘텐츠는 사람이 검수해야 하며 설명형 문단에만 사용하고 핵심 콘텐츠를 대신하게 해서는 안 됩니다.

저도 한 번 실험한 적이 있습니다. 변호사 디렉터리의 핵심 데이터인 변호사 수와 로펌 목록은 데이터베이스에서 가져오되, 도시별 ‘법률 서비스 특성 소개’는 AI로 초안을 만들었습니다. 예를 들어 ‘베이징 변호사 서비스의 특징: 상사 분쟁 비중이 높고 지식재산권 수요가 많음’ 같은 설명형 문단입니다.

핵심은 AI가 보조 수단일 뿐 대체 수단이 아니라는 점입니다. 페이지마다 실제 고유 데이터가 있어야 하며 AI는 이를 보완하는 역할만 해야 합니다.

구조화 데이터 자동화

모든 페이지에는 JSON-LD 구조화 데이터가 필요합니다. 이 과정은 완전히 자동화할 수 있습니다.

const jsonLd = {
  "@context": "https://schema.org",
  "@type": "LegalService",
  "name": `${lawyer.data.city} 변호사 서비스`,
  "areaServed": {
    "@type": "City",
    "name": lawyer.data.city,
  },
  "provider": lawyer.data.topFirms.map(firm => ({
    "@type": "Organization",
    "name": firm,
  })),
};

페이지마다 실제 데이터를 기반으로 구조화 데이터를 생성하면 Google 검색 결과에 로펌 목록과 도시 정보를 표시할 수 있어 클릭률을 높이는 데 도움이 됩니다.


성능 최적화: 사용자를 기다리게 하지 마세요

프로그래매틱 SEO는 페이지 수가 많으므로 성능 최적화를 무시할 수 없습니다.

세 가지 핵심 지표

TTFB(첫 바이트 시간): 서버 응답 속도입니다.

정적 생성은 보통 < 100ms, 동적 렌더링은 200~500ms입니다. TTFB가 500ms를 넘으면 사용자가 콘텐츠를 보기도 전에 페이지를 닫을 수 있습니다.

LCP(최대 콘텐츠 렌더링 시간): 주요 콘텐츠가 표시되는 시간입니다.

목표는 2.5초 미만입니다. 페이지에 이미지나 복잡한 컴포넌트가 많으면 LCP가 느려질 수 있습니다.

FID(최초 입력 지연): 상호작용 응답 속도입니다.

목표는 100ms 미만입니다. 페이지의 JavaScript가 너무 많으면 사용자가 버튼을 눌러도 반응이 늦을 수 있습니다.

CDN 설정: 정적 페이지를 빠르게 전달하기

정적 생성 페이지에서는 CDN 캐시가 중요합니다. Cloudflare Pages와 Vercel은 CDN을 기본 제공하고 설정도 간단합니다.

// astro.config.mjs
export default defineConfig({
  output: 'static',
  build: {
    assets: 'assets/',
  },
  vite: {
    build: {
      rollupOptions: {
        output: {
          assetFileNames: 'assets/[hash][extname]',
        },
      },
    },
  },
});

이렇게 빌드하면 모든 정적 파일에 고유한 hash가 붙어 CDN 캐시 효율이 높아집니다.

이미지 최적화: 이미지가 페이지 속도를 늦추지 않게 하기

페이지마다 있는 hero 이미지가 원본 JPG라면 용량이 몇 MB에 이를 수 있습니다. 그러면 로딩 시간이 길어집니다.

Astro에는 이미지 최적화 기능이 내장되어 있습니다.

---
import { Image } from 'astro:assets';
import heroImage from '../images/lawyer-hero.jpg';
---

<Image src={heroImage} alt="변호사 서비스" width={1200} height={675} />

Astro가 이미지를 WebP 형식으로 자동 변환하고 적절한 크기로 압축합니다. 원래 2MB였던 이미지가 최적화 후에는 200KB 정도로 줄어들 수 있습니다.

크롤링 예산 관리: 페이지가 많을 때 반드시 해야 할 일

페이지가 5천 개를 넘으면 Google 크롤러가 모두 수집하지 못할 수 있습니다. 이를 ‘크롤링 예산 낭비’라고 합니다.

해결 방법은 다음과 같습니다.

첫째, sitemap.xml을 분할합니다. 5천 페이지를 하나의 sitemap에 모두 넣지 말고 여러 파일로 나눕니다.

// sitemap-index.xml
<sitemapindex>
  <sitemap><loc>https://example.com/sitemap-1.xml</loc></sitemap>
  <sitemap><loc>https://example.com/sitemap-2.xml</loc></sitemap>
  <sitemap><loc>https://example.com/sitemap-3.xml</loc></sitemap>
</sitemapindex>

sitemap 파일 하나당 페이지를 최대 500개만 넣으면 크롤러의 수집 효율을 높일 수 있습니다.

둘째, 내부 링크 우선순위를 조정합니다. 검색량이 많은 도시 같은 중요 페이지에는 홈페이지 추천이나 내비게이션 노출 등 더 많은 내부 링크 진입점을 제공합니다. 덜 중요한 페이지의 내부 링크는 줄이면 크롤러가 자연스럽게 중요 페이지부터 수집합니다.


실제 사례 분석: 다른 서비스는 어떻게 구현했을까요?

이론을 살펴봤으니 실제 사례를 보겠습니다. 다음 사이트의 기술 아키텍처에서 참고할 점이 많습니다.

TripAdvisor: 하이브리드 아키텍처의 모범 사례

TripAdvisor에는 수백만 개의 호텔 페이지가 있습니다. 어떻게 구현했을까요?

아키텍처: 정적 생성과 동적 업데이트를 결합했습니다.

호텔 이름, 주소, 편의시설 같은 기본 정보는 정적으로 생성합니다. 사용자 리뷰와 평점은 동적으로 불러옵니다. 호텔 페이지마다 데이터 소스가 두 개이며, 정적 데이터는 데이터베이스 내보내기에서 가져오고 동적 데이터는 리뷰 API에서 가져옵니다.

URL 구조: /hotel/[city]/[hotel-name]

예를 들면 /hotel/beijing/grand-hyatt입니다. 3단계 경로로 SEO에 유리합니다.

핵심 기술:

  • 사용자 리뷰 실시간 업데이트(UGC 통합)
  • 가격 비교 동적 로딩(API 호출)
  • 구조화 데이터 자동화(Review schema)

TripAdvisor 성공의 핵심은 UGC입니다. 각 호텔 페이지에는 실제 사용자가 쓴 고유한 리뷰가 수백 개씩 있어 콘텐츠가 자연스럽게 차별화됩니다. UGC 없이 템플릿만으로 생성한 페이지로는 이런 순위를 얻기 어렵습니다.

Zapier: 대표적인 ISR 활용 사례

Zapier에는 ‘App A + App B’ 조합의 통합 페이지가 5천 개 넘게 있습니다. 예를 들어 ‘Slack과 Gmail 통합’, ‘Notion과 Google Calendar 통합’ 같은 페이지입니다.

아키텍처: Next.js ISR입니다.

기능 소개와 설정 단계 같은 통합 기본 정보는 정적이지만, 연결 여부와 최근 동기화 시간 같은 사용자의 실제 통합 상태는 동적입니다. ISR 메커니즘 덕분에 페이지는 빠르면서도 정확합니다.

URL 구조: /integrations/[app1]/[app2]

예를 들면 /integrations/slack/gmail입니다. 2단계 경로라 간결하고 명확합니다.

핵심 기술:

  • on-demand revalidation: 통합에 문제가 생기면 즉시 페이지 갱신
  • 자동화 테스트 범위: Playwright로 모든 통합 페이지 테스트
  • 내부 링크 네트워크: app hub 페이지와 관련 통합 추천

Zapier의 ISR 구현은 참고할 가치가 큽니다. 엔지니어링 팀이 ISR로 5천 페이지를 관리하는 방법을 블로그에 썼으니 읽어보길 권합니다.

Wise: 동적 렌더링과 캐시 전략

Wise의 환율 변환 페이지는 환율이 매분 바뀌기 때문에 완전한 정적 생성으로는 구현하기 어렵습니다.

아키텍처: Next.js SSR과 stale-while-revalidate 캐시를 결합했습니다.

사용자가 /currency/usd-to-eur에 접속할 때마다 서버는 먼저 캐시를 확인합니다. 캐시가 5분 이내처럼 아직 만료되지 않았다면 기존 데이터를 바로 반환하고, 백그라운드에서 환율을 조용히 업데이트합니다. 다음 사용자는 새 데이터를 보게 됩니다.

URL 구조: /currency/[from]-to-[to]

예를 들면 /currency/usd-to-eur입니다. 키워드가 자연스럽게 포함됩니다.

핵심 기술:

  • stale-while-revalidate 캐시 전략
  • CDN edge caching: 전 세계 노드에 캐시
  • 구조화 데이터 동적 주입: JSON-LD에 실시간 환율 표시

Wise의 캐시 전략은 정교합니다. 데이터의 실시간성을 보장하면서도 서버 비용을 제어합니다. 실시간 데이터 서비스를 만든다면 참고할 만합니다.


시행착오를 줄이기 위한 주의사항

성공 사례를 살펴봤으니 실패에서 얻은 교훈도 이야기해 보겠습니다. 제가 직접 겪은 문제들이니 같은 시행착오를 피하는 데 도움이 되길 바랍니다.

문제 1: 혼란스러운 URL 구조

초기에 만든 변호사 디렉터리 사이트의 URL은 /service?id=lawyer&city=beijing&type=divorce 형태였습니다.

유연해 보이지만 문제가 많았습니다. Google 크롤러는 매개변수 URL을 효율적으로 처리하기 어렵고 색인 효율도 낮습니다. 사용자도 이런 URL만 보고는 페이지 내용을 알기 어렵습니다.

교훈: URL 구조는 개발을 시작하기 전에 확정해야 합니다. 출시 후 URL 구조를 바꾸려면 모든 내부 링크, 외부 링크, sitemap을 수정해야 하므로 비용이 큽니다.

문제 2: 중복되는 템플릿 콘텐츠

페이지 2만 개에서 도시 이름만 바꾸고 본문은 완전히 같았던 사이트를 본 적이 있습니다. ‘베이징’만 ‘상하이’로 바꾼 수준이었습니다.

Google의 불이익을 받아 트래픽이 70% 감소했습니다.

교훈: 페이지마다 고유한 데이터가 있어야 합니다. 고유 데이터가 없다면 페이지를 만들지 마세요. 저품질 페이지 1만 개보다 고품질 페이지 1천 개가 낫습니다.

문제 3: 성능 병목

페이지 5천 개를 동적 렌더링으로 제공하면 서버 부하가 큽니다. 초기에 PHP로 동적 생성했을 때 서버가 자주 다운됐습니다.

교훈: 페이지가 5천 개를 넘으면 정적 생성이나 ISR을 사용해야 합니다. 완전한 동적 렌더링만으로는 감당하기 어렵습니다.

문제 4: 모니터링 체계 부재

출시 후 모니터링하지 않아 문제를 너무 늦게 발견했습니다. 한 프로젝트는 출시 3개월 뒤에야 페이지 절반이 Google에 색인되지 않았다는 사실을 알았습니다. 원인은 sitemap 설정 오류였습니다.

교훈: 출시 첫 주부터 모니터링해야 합니다. Google Search Console에서 색인 상태를 확인하고, Screaming Frog로 기술 문제를 검사하며, Ahrefs로 순위 변화를 추적합니다.


다음 단계: 고민만 하지 말고 작게 시작하세요

여기까지 읽고도 복잡하게 느껴질 수 있습니다. 그럴 때는 작은 실험부터 시작하세요.

1단계: 작은 범위 선택

처음부터 5천 페이지를 만들지 마세요. 도시 페이지 50개처럼 관리 가능한 범위를 선택합니다.

2단계: 기술 스택 결정

데이터 업데이트 빈도가 낮다면 Astro를 사용하고, 데이터가 실시간으로 바뀐다면 Next.js SSR을 사용합니다.

3단계: 데이터 구조와 URL 설계

반나절 정도 시간을 들여 데이터 필드와 URL 경로를 정리하세요. 중요한 단계이므로 생략하면 안 됩니다.

4단계: 템플릿 개발과 테스트

먼저 페이지 3개로 템플릿을 만들고 콘텐츠의 차별화 정도를 사람이 직접 확인합니다. 문제가 없으면 일괄 생성합니다.

5단계: 소규모 출시

페이지 50개를 먼저 출시하고 일주일간 지켜봅니다. 색인율, 이탈률, 체류 시간을 확인한 뒤 지표가 정상이라면 500개로 확대합니다.

6단계: 반복 개선

데이터 피드백에 따라 템플릿, 내부 링크, URL을 조정합니다. 프로그래매틱 SEO는 한 번 끝내는 작업이 아니라 계속 개선하는 과정입니다.


FAQ

정적 생성, 동적 렌더링, 하이브리드 방식 중 무엇을 선택해야 하나요?
데이터 업데이트 빈도와 페이지 규모를 기준으로 선택합니다. 데이터가 매주 또는 매월처럼 드물게 업데이트되고 페이지가 5천 개 미만이면 정적 생성(Astro)이 적합합니다. 데이터가 실시간으로 바뀌면 동적 렌더링(Next.js SSR)을 사용합니다. 그 중간이라면 ISR 하이브리드 방식을 선택합니다.
프로그래매틱 SEO 페이지 수에 상한이 있나요?
절대적인 상한은 없지만 크롤링 예산을 고려해야 합니다. 5천 페이지 이내라면 Google이 대체로 모두 크롤링할 수 있습니다. 5천~2만 페이지는 sitemap 분할과 내부 링크 최적화가 필요합니다. 2만 페이지를 넘으면 검색 수요가 있는 페이지만 생성하도록 엄격한 품질 선별 체계를 갖춰야 합니다.
템플릿으로 만든 페이지는 Google의 불이익을 받나요?
페이지마다 실질적인 가치가 있다면 그렇지 않습니다. 각 페이지에 키워드만 바꾼 것이 아닌 고유 데이터가 있고, UGC나 상호작용 같은 사용자 참여 요소가 있으며, 사람의 검수 단계가 있어야 합니다. 키워드만 바꾼 템플릿 페이지가 문제입니다.
데이터베이스는 PostgreSQL과 MongoDB 중 무엇을 선택해야 하나요?
데이터 구조가 고정되어 있다면 ACID 보장과 복잡한 쿼리가 안정적인 PostgreSQL을 선택합니다. 데이터 구조가 계속 바뀌는 단계라면 Schema가 유연한 MongoDB가 적합합니다. 수백 건 규모의 작은 프로젝트라면 Airtable이나 JSON 파일로도 충분합니다.
URL 경로는 최대 몇 단계가 적당한가요?
3단계를 넘기지 않는 것이 좋습니다. 예를 들어 `/city/service/type` 정도가 한계입니다. 너무 깊은 경로는 사용자와 크롤러 모두에게 불편합니다. 페이지가 아주 많은 사이트에는 1단계의 평면형 URL이 적합합니다.
내부 링크 자동화는 어떻게 구현하나요?
지리적 계층을 기준으로 도시 페이지에서 인근 도시를 연결하거나, 주제 클러스터를 기준으로 같은 유형의 서비스 페이지를 서로 연결할 수 있습니다. 코드에서는 페이지 템플릿이 tags 또는 location 필드로 관련 페이지를 매칭해 링크 목록을 자동 생성하도록 구현합니다.
Astro와 Next.js 중 프로그래매틱 SEO에 더 적합한 것은 무엇인가요?
완전한 정적 환경에는 Astro가 적합합니다. Content Collections가 대량 페이지 생성을 위해 설계되었고 빌드도 빠릅니다. 동적 렌더링이 필요하면 SSR과 ISR을 잘 지원하는 Next.js를 선택합니다. 두 프레임워크 모두 프로그래매틱 SEO에 사용할 수 있으며, 핵심은 데이터 업데이트 빈도에 맞추는 것입니다.

4분 읽기 · 게시일: 2026년 4월 4일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog