Next.js와 Vercel ISR을 이용한 137K 리스팅 디렉토리 플랫폼 구축

지난해 우리는 디렉토리 플랫폼을 출시했습니다. 137,000개의 리스팅. 이것은 사소한 작업이 아니었습니다. 모든 리스팅이 자체 SEO 최적화 페이지를 가진 완전히 구현된 플랫폼이었습니다. 검색은 빨라야 했고, 물론 호스팅도 저렴해야 했습니다. 그러면 Next.js, Vercel, 그리고 Incremental Static Regeneration(ISR)로 어떻게 했을까요? 준비하세요. 여기 그 이야기가 있습니다. 힘든 부분도 포함해서요.

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR

목차

왜 디렉토리 플랫폼이 생각보다 어려운가

디렉토리 사이트는 간단해 보일 수 있습니다. 목록 페이지, 상세 페이지, 몇 가지 필터를 추가하면 완성이라고 생각할 수 있습니다. 하지만 수천 개의 리스팅을 넘어가면 모든 것이 복잡해집니다.

실제로는 이런 일들이 일어나고 있습니다:

  • 137,000개 이상의 고유 페이지는 각각 크롤 가능하고 색인 가능해야 함
  • 패싯 검색 (위치, 카테고리 등)
  • 낡은 데이터 관리 -- 리스팅은 끊임없이 업데이트되고 삭제됨
  • SEO 요구사항 은 클라이언트 측 렌더링만으로는 안 됨을 의미
  • 저렴한 호스팅 은 빌드 시간에 모든 페이지를 생성하는 것을 배제

많은 방법을 검토한 후, 우리는 Next.js와 ISR을 선택했습니다. Astro도 고려했습니다(우리의 다른 프로젝트에서 사용됨--Astro 개발 작업 참조). 결국 ISR을 사용한 Next.js의 동적 기능이 명백한 선택이었습니다.

아키텍처 개요

우리 아키텍처는 다음과 같습니다:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Vercel      │────▶│  Next.js App │────▶│  PostgreSQL  │
│   Edge CDN    │     │  (ISR)       │     │  (Neon)      │
└──────────────┘     └──────────────┘     └──────────────┘
                            │                     │
                            ▼                     ▼
                     ┌──────────────┐     ┌──────────────┐
                     │  Redis       │     │  Meilisearch │
                     │  (Upstash)   │     │  (Cloud)     │
                     └──────────────┘     └──────────────┘

스택

구성 기술 이유
프레임워크 Next.js 14 (App Router) ISR 지원, React Server Components, 라우트 핸들러
호스팅 Vercel Pro Edge CDN, ISR 인프라, 분석
데이터베이스 Neon PostgreSQL 서버리스 Postgres, 미리보기 브랜치
검색 Meilisearch Cloud 오타 허용, 패싯 검색, 빠른 인덱싱
캐시 Upstash Redis 요청 제한, 세션 캐시, ISR 조정
CMS (관리) Custom admin + Payload CMS 리스팅 관리, 대량 작업
CDN/이미지 Vercel Image Optimization + Cloudinary 여러 breakpoints에서 리스팅 사진

이것은 본질적으로 Next.js 개발 프로젝트이며, ISR이 우리의 큰 판매점이었습니다.

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR - architecture

규모에서 실제로 작동하는 ISR 전략

직설적으로 말하자면: 137,000개의 페이지를 빌드 시간에 정적으로 생성하려고 하면 문제를 자초하는 것입니다. 정말로, 그런 골칫거리를 초대하지 마세요. Next.js의 병렬 생성으로도 빌드는 45분을 넘을 수 있어 모든 배포가 악몽이 됩니다.

ISR을 사용하면 필요에 따라 페이지를 생성하고 엣지에서 캐시합니다. 기본 ISR은 좋지만, 우리의 경우 몇 가지 조정이 필수였습니다.

3단계 페이지 전략

우리는 리스팅을 3개 단계로 나누었습니다:

// app/listing/[slug]/page.tsx

export async function generateStaticParams() {
  // 1단계: 트래픽이 가장 높은 상위 2,000개 리스팅을 미리 생성
  const topListings = await db.listing.findMany({
    where: { tier: 'premium' },
    orderBy: { monthlyViews: 'desc' },
    take: 2000,
    select: { slug: true },
  });

  return topListings.map((listing) => ({
    slug: listing.slug,
  }));
}

// 2단계 & 3단계: ISR을 통해 필요에 따라 생성
export const revalidate = 3600; // 대부분의 리스팅은 1시간

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const listing = await getListingBySlug(params.slug);

  if (!listing) {
    notFound();
  }

  // 리스팅 단계별 동적 재검증
  // Premium 리스팅은 10분마다 재검증
  // 표준 리스팅은 1시간마다
  // 보관된 리스팅은 24시간마다

  return <ListingDetail listing={listing} />;
}

1단계 (2,000개 페이지): 이 높은 트래픽 리스팅들은 빌드 시간에 미리 생성됩니다. 대부분의 오가닉 검색 트래픽을 담당합니다. 항상 준비되어 있습니다.

2단계 (35,000개 페이지): 첫 요청 시 생성되어 1시간 동안 캐시됩니다. 이 리스팅들은 꾸준한 트래픽을 가지므로, 캐시 만료 후 첫 방문자는 서버 렌더링되지만 빠른 페이지를 받습니다. 다른 모든 사용자는 캐시된 버전을 받습니다.

3단계 (100,000개 페이지): 첫 요청 시 생성되어 24시간 동안 캐시됩니다. 이 리스팅들은 거의 트래픽이 없으므로 리소스를 낭비할 필요가 없습니다.

실시간 업데이트를 위한 온디맨드 재검증

대부분의 경우는 시간 기반 재검증으로 커버되지만, 레스토랑 주인이 방금 영업 시간을 업데이트한 경우는 어떻게 할까요? 좋아요, 우리는 라우트 핸들러를 사용한 Next.js의 온디맨드 재검증을 구현했습니다:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidation-secret');

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }

  const { slug, type } = await request.json();

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`);
    revalidateTag(`listing-${slug}`);
  } else if (type === 'category') {
    revalidateTag(`category-${slug}`);
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

우리의 관리 패널과 웹훅이 이 엔드포인트와 통신하므로, 리스팅을 변경하는 사람은 누구나 다음 요청에서 신선한 페이지를 받습니다. 빠르죠?

137K 페이지를 빌드 시간 폭증 없이 처리하기

빌드 시간은 정말 우리를 겁먹게 했어요! 우리가 발견한 것은:

전략 빌드 시간 첫 요청 레이턴시 캐시 히트 레이턴시
Full SSG (모든 137K 페이지) ~52분 ~40ms ~40ms
ISR (2K 미리 빌드) ~3.5분 ~180ms (cold) ~40ms
Full SSR (캐싱 없음) ~45초 ~250ms N/A
우리의 하이브리드 접근 ~3.5분 ~150ms (cold) ~35ms

우리의 ISR 접근 방식은 빌드 시간을 고통스러운 1시간에서 4분 미만으로 단축했습니다. 그것은 배포를 두려워하는 것과 글쎄, 커피를 마시면서 실행되기를 기다리는 것의 차이입니다.

`dynamicParams` 설정

여기 중요한 팁이 있습니다: generateStaticParams 외부의 페이지를 생성하기 위해 ISR을 허용하도록 dynamicParams = true를 유지하세요. 당연한 것처럼 들리지만, 이것이 얼마나 자주 간과되는지 봤으면 놀랐을 겁니다.

export const dynamicParams = true; // 온디맨드 생성 허용

병렬 라우트 세그먼트

카테고리 및 위치가 있는 페이지의 경우, 필터와 리스팅 그리드가 독립적으로 로드될 수 있도록 병렬 라우트 세그먼트를 활용했습니다:

// app/directory/[category]/layout.tsx
export default function CategoryLayout({
  children,
  filters,
}: {
  children: React.ReactNode;
  filters: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[280px_1fr] gap-6">
      <aside>{filters}</aside>
      <main>{children}</main>
    </div>
  );
}

이것은 필터를 자체적으로 캐시할 수 있음을 의미합니다. 필터를 변경하면 리스팅 그리드만 다시 렌더링됩니다. 빠릅니다!

데이터베이스 및 검색 레이어

Neon의 PostgreSQL

우리는 확장 및 미리보기 브랜치와 같은 서버리스 이점을 위해 Neon을 선택했습니다. 우리의 삶을 더 쉽게 만들어주는 종류의 것들입니다.

우리의 리스팅 테이블은 간단하지만 인덱싱에 크게 의존합니다:

CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_location ON listings USING GIST(location);
CREATE INDEX idx_listings_rating ON listings(avg_rating DESC);
CREATE INDEX idx_listings_slug ON listings(slug);
CREATE INDEX idx_listings_status_tier ON listings(status, tier);

위치의 GiST 인덱스가 뭔가요? 정확한 지리공간 쿼리에 관한 것입니다. "내 근처 커피숍"은 흥미로운 말이 아니라 실제 계산입니다.

검색을 위한 Meilisearch

리스트가 우리처럼 팽창하면, PostgreSQL의 텍스트 검색으로는 부족합니다. 이것은 Meilisearch가 들어오는 곳입니다. 주로 가격($30/월 vs $200+)과 인상적인 오타 허용 때문에 Algolia를 이겼습니다.

// lib/search.ts
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_API_KEY!,
});

export async function searchListings(query: string, filters: FilterParams) {
  const index = client.index('listings');

  return index.search(query, {
    filter: buildFilterString(filters),
    facets: ['category', 'city', 'priceRange', 'rating'],
    limit: 24,
    offset: filters.page * 24,
    attributesToHighlight: ['name', 'description'],
  });
}

5분마다 리스팅은 작업과 동기화됩니다. 주간 전체 재인덱싱도 진행합니다. 안전이 최고죠?

규모에서의 SEO: 사이트맵, 구조화된 데이터, 크롤 예산

137,000개의 페이지가 있는 플랫폼의 경우 SEO는 선택사항이 아니라 생명입니다. 우리가 어떻게 했는지 알아봅시다:

동적 사이트맵

137,000개의 URL을 모두 하나의 사이트맵 파일에 덤프할 수 없습니다. 사양에 따르면 제한은 50,000개의 URL입니다. 그럼 뭘 합니까? 분할된 부분을 가리키는 사이트맵 색인을 생성합니다:

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // 이것은 사이트맵 색인을 생성합니다
  const totalListings = await db.listing.count({ where: { status: 'active' } });
  const sitemapCount = Math.ceil(totalListings / 10000);

  const sitemaps = [];

  for (let i = 0; i < sitemapCount; i++) {
    sitemaps.push({
      url: `${process.env.NEXT_PUBLIC_URL}/sitemap/${i}.xml`,
      lastModified: new Date(),
    });
  }

  return sitemaps;
}

분할된 사이트맵은 각각 10,000개의 리스팅을 가지고 있으며 타임스탬프를 포함합니다. Google은 하루에 약 8,000-12,000개의 페이지를 크롤합니다.

구조화된 데이터

모든 리스팅 페이지에는 LocalBusiness 스키마 마크업이 포함됩니다:

function generateStructuredData(listing: Listing) {
  return {
    '@context': 'https://schema.org',
    '@type': 'LocalBusiness',
    name: listing.name,
    description: listing.description,
    address: {
      '@type': 'PostalAddress',
      streetAddress: listing.address,
      addressLocality: listing.city,
      addressRegion: listing.state,
      postalCode: listing.zip,
    },
    aggregateRating: listing.reviewCount > 0 ? {
      '@type': 'AggregateRating',
      ratingValue: listing.avgRating,
      reviewCount: listing.reviewCount,
    } : undefined,
    geo: {
      '@type': 'GeoCoordinates',
      latitude: listing.lat,
      longitude: listing.lng,
    },
  };
}

이런 구조화된 데이터는 우리의 순위를 급상승시켰으며, Google은 그러한 정확한 정보에 큰 선호도를 보입니다.

성능 벤치마크

2025년 초 라이브 사이트의 실제 메트릭:

메트릭 목표
Largest Contentful Paint (LCP) 1.1s (p75) < 2.5s
First Input Delay (FID) 12ms (p75) < 100ms
Cumulative Layout Shift (CLS) 0.02 (p75) < 0.1
Time to First Byte (TTFB) 85ms (캐시됨) / 190ms (cold ISR) < 200ms
Lighthouse 성능 점수 94-98 > 90
빌드 시간 3분 22초 < 5분
캐시 히트율 94.7% > 90%

그 높은 캐시 히트율? 맞습니다, 우리 페이지의 엄청난 94.7%는 Vercel의 엣지 CDN에서 바로 나옵니다. 추가 컴퓨팅이 필요 없습니다. 속도와 비용 모두에 승리입니다.

Vercel 비용 내역

달러와 센트에 대해 이야기해봅시다. 좋은 거래를 좋아하지 않는 사람이 있나요?

서비스 월간 비용 (2025) 참고사항
Vercel Pro $20/seat Pro 수준의 기능과 제한
Vercel 대역폭 ~$55 ISR 캐싱으로 ~600GB/월
Vercel 서버리스 함수 ~$40 ISR 작업 + API 작업용
Neon PostgreSQL $19 (Scale plan) 10GB 저장소, 확장 가능 계산
Meilisearch Cloud $30 500K 문서, 전용 인스턴스
Upstash Redis $10 평균 10K 명령/일
Cloudinary $25 이미지 저장소 및 변환
합계 ~$199/월 137K 페이지, ~월 200K 방문자

137,000개의 페이지를 가진 거대한 프로젝트를 월 $200 미만으로 운영할 수 있습니다. 기존 서버 설정과 비교하면? VM, 관리형 DB, CDN, 그리고 모두를 보살핍니다 위해 전일제 DevOps로 인해 돈이 새어나갑니다.

이 규모에서 플레이하고 있고 대화를 원한다면 contact us하거나 our pricing을 확인하세요.

우리가 한 실수와 바뀔 점

실수 1: 처음부터 온디맨드 재검증을 설정하지 않음

우리는 처음에 시간 기반 재검증에만 의존했습니다. 형편없는 결정이었다고 말해 봅시다. 리스팅 소유자들은 정보를 즉시 수정하고 확인하고 싶어 했습니다. 낡은 데이터를 봤다면? 자신감 증진용이 아닙니다. 재검증은 MVP여야 했습니다.

실수 2: 사이트맵 복잡성을 과소평가함

우리의 첫 사이트맵 시도는 모든 것을 하나의 서버리스 함수에 집어넣었습니다. 타임아웃이 나타났습니다. Vercel은 타임아웃 전에 10초(Pro에서 60초)를 제공합니다. 우리는 배웠습니다. 그것들을 분할하세요.

실수 3: 이미지 최적화 비용을 과소평가함

처음에는 Vercel이 모든 리스팅 사진 최적화를 처리했습니다. 엄청난 양의 이미지는 심한 비용을 의미했습니다. 우리는 Cloudinary와 그 의무를 분담했으며, Vercel의 마법을 UI 필수사항용으로 예약했습니다.

실수 4: React Server Components를 충분히 공격적으로 사용하지 않음

일부 초기 페이지는 너무 많은 'use client' 명령으로 가득했습니다. 결과? 너무 많은 JavaScript가 배송되었습니다. Server Components에 다시 초점을 맞추니 우리의 JavaScript 번들이 깃털처럼 가벼워졌습니다 (62% 감소!).

다음에 할 것들

다음 번에는 절대적으로 Next.js와 Payload CMS 같은 것을 처음부터 짝을 지어서, 처음부터 관리 패널을 해킹하는 대신 사용할 겁니다. 그것은 얼마나 시간을 절약했을 텐데요!

또한 Vercel의 최신 unstable_cache (또는 지금 cache)를 표준 ISR 캐싱을 넘어서는 쿼리 결과를 위해 신중하게 고려할 겁니다.

FAQ

Next.js ISR은 정말 수십만 개의 페이지를 처리할 수 있습니까?


절대적으로. 우리는 실제로 했습니다. generateStaticParams를 사용하여 상위 트래픽 페이지(일반적으로 1-5%)를 미리 생성하고 ISR에 나머지를 맡기세요. Vercel의 엣지가 전 세계적으로 빠른 로드 시간을 보장합니다.

Vercel에서 대규모 디렉토리 사이트를 운영하는 데 얼마나 들 수 있습니까?


우리의 경우, 월 200,000명의 방문자와 137K 리스팅으로 약 $199/월입니다. 비용은 변할 것이지만, 캐싱 스윗 스팟에 도달하고, ISR은 당신에게 큰 돈을 절약할 수 있습니다.

디렉토리 사이트의 ISR과 SSR의 차이는 뭡니까?


ISR은 재검증 간격마다 페이지를 생성하고 캐시하는 반면, SSR은 모든 요청에서 페이지를 새로 생성합니다. ISR은 데이터가 매분마다 변하지 않는 리스팅에 더 효율적입니다.

정적으로 생성된 디렉토리에서 검색을 어떻게 처리합니까?


검색 상호작용은 API 호출로 Meilisearch로 직접 이동합니다. 검색 결과는 클라이언트 측에서 렌더링되는 반면 리스팅 페이지는 ISR 백업입니다. 정적과 동적의 최고의 혼합입니다.

디렉토리 사이트의 ISR에서 어떤 재검증 간격을 사용해야 합니까?


변경 빈도에 따라 다릅니다. 우리는 계층형 접근을 사용합니다: Premium용 10분, 표준용 1시간, 조용한 리스팅용 24시간. 즉각적인 변경을 위해 온디맨드 재검증을 섞습니다.

타임아웃 없이 137,000개 페이지의 사이트맵을 생성하려면 어떻게 해야 합니까?


분할이 최고의 친구입니다. 10,000개씩 자르세요. 사이트맵 색인을 통해 라우트하세요. 각 청크는 타임아웃 제한 내에 편하게 유지되어야 합니다.

Next.js는 디렉토리 플랫폼 구축에 최적의 프레임워크입니까?


예, 무거운 작업의 경우 -- 특히 ISR과 함께. 극도로 간단하고 거의 변하지 않는 목록의 경우? Astro는 경량 옵션이 될 수 있습니다. 우리는 둘 다 만들었습니다; 선택은 작업 부하와 필요에 달려 있습니다.

ISR로 사용자 경험이 낡은 데이터로 인해 손상되지 않도록 어떻게 방지합니까?


시간 기반 재검증과 온디맨드 재검증을 혼합하면 도움이 됩니다. 이를 클라이언트 측 SWR 또는 React Query와 짝지어 극도로 신선한 데이터를 위해. ISR이 쉘에 공급하면서 실시간이 선택적으로 빛납니다.