지난해 우리는 137,000개의 리스팅을 보유한 글로벌 디렉토리를 출시했습니다. 프로토타입이 아닙니다. "나중에 최적화하자"는 MVP도 아닙니다. 수백만 건의 페이지뷰를 처리하고, 수천 개의 롱테일 키워드로 순위를 매기며, 부담 없이 온디맨드로 페이지를 재생성하는 프로덕션 시스템입니다. 이는 이 시스템을 구축한 방법과 그것을 가능하게 한 아키텍처 결정에 대한 이야기입니다.

스택: Next.js 14(App Router), Supabase(PostgreSQL + Edge Functions), Vercel(호스팅 + ISR), 그리고 현실적인 판단력의 건강한 복용량입니다. 우리는 실수를 했습니다. 벽에 부딪혔습니다. 이미 완료되었다고 생각했던 것들을 다시 작성했습니다. 하지만 최종 아키텍처는 137,000개 이상의 동적 페이지를 전 세계에서 200ms 미만의 TTFB로 처리하며, Supabase 요금은 월 $100 미만으로 유지됩니다.

비슷한 것을 구축하고 있다면 — 마켓플레이스, 디렉토리, 리스팅 플랫폼 — 이것이 우리가 시작할 때 존재했으면 좋았을 기사입니다.

목차

Building a 137K Listing Global Directory with Next.js, Supabase & Vercel ISR

왜 이 스택인가

Next.js + Supabase + Vercel로 결정하기 전에 많은 옵션을 평가했습니다. 핵심 요구사항은:

  1. 137,000개 이상의 고유 페이지 검색 엔진이 크롤링하고 인덱싱할 수 있는
  2. 전역 서브초 페이지 로드 (40개국 이상의 사용자)
  3. 동적 데이터 — 리스팅은 매일, 일부는 시간 단위로 업데이트됨
  4. 전체 텍스트 검색 패싯 필터링 포함
  5. 예산 의식적 — VC 자금 지원을 받은 moonshot이 아님

우리는 Astro를 고려했습니다(정적 사이트에 훌륭하지만 더 많은 동적 상호작용이 필요했습니다). WordPress + WPEngine을 살펴봤습니다. Algolia를 사용한 순수 SPA를 잠시 고려했습니다.

Next.js는 하나의 킬러 기능 때문에 승리했습니다: 증분 정적 재생성. ISR은 정적 성능과 동적 콘텐츠 사이에서 선택할 필요가 없다는 의미였습니다. 둘 다 가질 수 있었습니다.

Supabase는 PlanetScale과 Neon을 이겼는데 전체 패키지 때문입니다 — 인증, 스토리지, edge functions, 그리고 행 수준 보안이 있는 진정한 좋은 Postgres 구현. 디렉토리의 경우 모두 필요합니다.

Vercel은 ISR이 Vercel에서 가장 잘 작동하기 때문에(당연히) 배포 대상이었습니다. 통합이 기본입니다. 온디맨드 재검증이 그냥 작동합니다.

자체 호스팅은 어떤가요?

Railway에서 자체 호스팅 Next.js 설정을 프로토타입했습니다. 작동했지만, 자체 호스팅 Next.js의 ISR에는 특이한 점이 있습니다. 캐시 무효화 이야기가 더 안 좋습니다. 자신의 CDN 레이어를 관리해야 합니다. 3명의 엔지니어 팀의 경우, 운영 오버헤드가 우리가 절약할 월 $200 가치가 없었습니다.

데이터 레이어: 규모에서의 Supabase

우리의 Supabase 데이터베이스는 137,000개의 리스팅을 보유하고 있으며, 각각 40-60개의 필드를 가지고 있습니다. 카테고리, 위치, 연락처 정보, 풍부한 설명, 이미지, 등급, 영업 시간 — 모두입니다.

스키마 설계

가장 큰 결정은 정규화된 관계형 스키마를 사용할지 아니면 JSONB 열이 있는 더 문서 지향적인 접근 방식을 사용할지였습니다. 우리는 하이브리드로 갔습니다:

CREATE TABLE listings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug TEXT UNIQUE NOT NULL,
  title TEXT NOT NULL,
  description TEXT,
  category_id UUID REFERENCES categories(id),
  city_id UUID REFERENCES cities(id),
  country_code TEXT NOT NULL,
  coordinates GEOGRAPHY(POINT, 4326),
  contact JSONB DEFAULT '{}',
  attributes JSONB DEFAULT '{}',
  media JSONB DEFAULT '[]',
  rating_avg NUMERIC(3,2) DEFAULT 0,
  rating_count INTEGER DEFAULT 0,
  status TEXT DEFAULT 'active',
  published_at TIMESTAMPTZ,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  search_vector TSVECTOR
);

CREATE INDEX idx_listings_category ON listings(category_id) WHERE status = 'active';
CREATE INDEX idx_listings_city ON listings(city_id) WHERE status = 'active';
CREATE INDEX idx_listings_country ON listings(country_code) WHERE status = 'active';
CREATE INDEX idx_listings_coordinates ON listings USING GIST(coordinates);
CREATE INDEX idx_listings_search ON listings USING GIN(search_vector);
CREATE INDEX idx_listings_slug ON listings(slug);

필터링하는 것(카테고리, 도시, 국가)에 대한 구조화된 관계형 데이터. 리스팅마다 다양한 반구조화된 항목(연락 방법, 사용자 정의 속성, 미디어 배열)에 대한 JSONB. 이것은 우리에게 양쪽의 최선을 주었습니다 — 관계형 열에 대한 빠른 인덱싱된 쿼리와 나머지에 대한 유연성.

검색 벡터

search_vector 열은 중요합니다. 우리는 트리거로 채웁니다:

CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
    setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
    setweight(to_tsvector('english', COALESCE(NEW.attributes->>'keywords', '')), 'C');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

이는 모든 리스팅이 Postgres 자체를 통해 전체 텍스트 검색 가능하다는 의미입니다. 처음 100K 리스팅에는 외부 검색 서비스가 필요하지 않습니다. 이것이 언제 해제되는지는 나중에 이야기합니다.

연결 풀링

Supabase는 연결 풀링을 위해 PgBouncer를 사용합니다. ISR을 사용하면 서버리스 함수 호출의 버스트를 얻습니다 — 각각 데이터베이스 연결이 필요합니다. 풀링 없으면 몇 분 안에 연결이 소진됩니다.

우리는 모든 서버리스 컨텍스트에 풀링된 연결 문자열(port 6543)을 사용하고 마이그레이션 및 관리 작업에만 직접 연결(port 5432)을 사용합니다. 이것은 명확해 보이지만 사람들을 잡는 것 중 하나입니다.

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // Server-side only
  {
    db: { schema: 'public' },
    auth: { persistSession: false }
  }
)

페이지 생성 전략: ISR, SSG, 그리고 137K 문제

이것은 흥미로워지는 부분입니다. 그리고 우리가 가장 큰 초기 실수를 한 곳입니다.

순진한 접근 방식(이렇게 하지 마세요)

우리의 첫 번째 시도: generateStaticParams를 사용하여 빌드 시간에 모든 137,000개 페이지를 생성합니다. 빌드는 4시간 22분이 걸렸습니다. Vercel의 무료 티어는 45분 빌드 제한이 있습니다. Pro 티어도 6시간으로 제한됩니다. 하지만 실제 문제는 타임아웃이 아니었습니다 — 피드백 루프였습니다. 모든 배포는 반나절을 기다렸습니다. 그것은 작동하지 않습니다.

ISR 접근 방식(실제로 작동하는 것)

배포된 전략은:

  1. 빌드 시간: 상위 5,000개 페이지(트래픽별)를 정적으로 생성
  2. 첫 요청: 남은 페이지를 온디맨드로 생성하고 캐시
  3. 재검증: 시간 기반(3600초마다) + 웹훅을 통한 온디맨드
// app/listing/[slug]/page.tsx
import { supabase } from '@/lib/supabase'
import { notFound } from 'next/navigation'

export async function generateStaticParams() {
  // Only pre-generate top listings by traffic
  const { data } = await supabase
    .from('listings')
    .select('slug')
    .eq('status', 'active')
    .order('rating_count', { ascending: false })
    .limit(5000)

  return (data || []).map((listing) => ({
    slug: listing.slug,
  }))
}

export const revalidate = 3600 // Revalidate every hour

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const { data: listing, error } = await supabase
    .from('listings')
    .select(`
      *,
      category:categories(*),
      city:cities(*, country:countries(*))
    `)
    .eq('slug', params.slug)
    .eq('status', 'active')
    .single()

  if (!listing || error) notFound()

  return <ListingDetail listing={listing} />
}

온디맨드 재검증

리스팅 소유자가 데이터를 업데이트할 때, 페이지가 새로 고쳐질 때까지 1시간을 기다리고 싶지 않습니다. Supabase 웹훅은 Next.js API 경로를 트리거합니다:

// app/api/revalidate/route.ts
import { revalidatePath } 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: 'Unauthorized' }, { status: 401 })
  }

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

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`)
    revalidatePath(`/`) // Revalidate homepage too
  }

  return NextResponse.json({ revalidated: true })
}

이것은 우리에게 둘 다의 최선을 제공합니다: 동적 사이트 신선도를 가진 정적 사이트 성능. 빌드는 8분 이내에 완료됩니다. 사전 생성되지 않은 페이지는 첫 방문 시 생성되고 엣지에서 캐시됩니다.

수치

메트릭 전체 SSG(순진한) ISR(프로덕션)
빌드 시간 4h 22m 7m 40s
배포 시 페이지 137,000 5,000
첫 방문(캐시되지 않음) N/A ~800ms
후속 방문 ~120ms ~120ms
재검증 레이턴시 전체 재배포 < 2초
월간 빌드 분 한도 초과 ~230분

Building a 137K Listing Global Directory with Next.js, Supabase & Vercel ISR - architecture

URL 아키텍처 및 규모의 SEO

137,000개의 페이지가 있으면 URL 구조는 사후 생각이 아닙니다 — 아키텍처입니다. 모든 URL은 순위 기회입니다.

URL 계층 구조

/                                    → 홈페이지
/categories/[category-slug]          → 카테고리 페이지(48개 카테고리)
/locations/[country]/[city]          → 위치 페이지
/listing/[listing-slug]              → 개별 리스팅
/search?q=...&category=...&city=...  → 검색 결과(noindex)

카테고리 + 위치 교차 페이지는 실제 SEO 금광입니다:

/categories/restaurants/us/new-york   → "뉴욕의 레스토랑"
/categories/hotels/uk/london          → "런던의 호텔"

이러한 교차 페이지는 ISR을 통해 동적으로 생성됩니다. 대략 12,000개의 유효한 조합이 있습니다. 각각은 특정 롱테일 키워드를 대상으로 합니다.

사이트맵 생성

137K URL을 사용하면 사이트맵 인덱스 파일이 필요합니다. Google의 제한은 사이트맵당 50,000개 URL입니다.

// app/sitemap/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
  const page = parseInt(params.id)
  const perPage = 45000 // Stay under the 50K limit
  const offset = page * perPage

  const { data: listings } = await supabase
    .from('listings')
    .select('slug, updated_at')
    .eq('status', 'active')
    .order('id')
    .range(offset, offset + perPage - 1)

  const xml = generateSitemapXml(listings)
  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml' },
  })
}

우리는 4개의 사이트맵으로 분할합니다: sitemap-0.xml부터 sitemap-3.xml까지, 사이트맵 인덱스로 참조됩니다. Google Search Console은 제출된 URL의 98%를 6주 이내에 인덱싱했습니다.

구조화된 데이터

모든 리스팅 페이지에는 JSON-LD 구조화된 데이터가 포함됩니다. 디렉토리의 경우 LocalBusiness 스키마가 중요합니다:

const structuredData = {
  '@context': 'https://schema.org',
  '@type': 'LocalBusiness',
  name: listing.title,
  description: listing.description,
  address: {
    '@type': 'PostalAddress',
    addressLocality: listing.city.name,
    addressCountry: listing.city.country.code,
  },
  geo: {
    '@type': 'GeoCoordinates',
    latitude: listing.coordinates?.lat,
    longitude: listing.coordinates?.lng,
  },
  aggregateRating: listing.rating_count > 0 ? {
    '@type': 'AggregateRating',
    ratingValue: listing.rating_avg,
    reviewCount: listing.rating_count,
  } : undefined,
}

검색 및 필터링: 어려운 부분

검색은 항상 어려운 부분입니다. 항상.

1단계: Postgres 전체 텍스트 검색

초기 출시의 경우 Postgres tsvector 검색이 모든 것을 처리했습니다. GIN 인덱스를 사용하면 137K 행에 충분히 빠릅니다. 쿼리 시간 평균 40-80ms.

const { data } = await supabase
  .from('listings')
  .select('id, slug, title, description, category:categories(name)')
  .textSearch('search_vector', query, { type: 'websearch' })
  .eq('status', 'active')
  .eq('country_code', countryFilter)
  .order('rating_avg', { ascending: false })
  .range(0, 19)

2단계: Postgres가 충분하지 않을 때

약 80,000개의 리스팅에서 복잡한 패싯 검색(카테고리 + 위치 + 텍스트 + 정렬)이 300-500ms를 시작했습니다. 대부분의 앱에는 허용되지만 우리 사용자는 즉각적인 결과를 기대했습니다.

우리는 Typesense를 검색 레이어로 추가했습니다. Algolia가 아님(우리 규모에서는 너무 비쌈 — 월 $500 이상을 지불하게 될 것입니다). Meilisearch가 아님(훌륭하지만 Typesense의 지오 검색이 우리 사용 사례에 더 좋았습니다).

Typesense는 단일 $48/월 Hetzner 인스턴스에서 실행됩니다. Supabase에서 야간 전체 재인덱싱 + 실시간 웹훅 업데이트를 통해 동기화합니다. 검색 쿼리는 이제 평균 8-15ms입니다.

검색 솔루션 쿼리 시간(p50) 쿼리 시간(p99) 월간 비용 패싯 검색
Postgres FTS 45ms 320ms $0(포함) 제한됨
Typesense 9ms 28ms $48 우수
Algolia ~5ms ~15ms $500+ 우수
Meilisearch ~8ms ~22ms $48(자체호스팅) 좋음

성능 예산 및 엣지 캐싱

우리는 처음부터 공격적인 성능 목표를 설정했습니다:

  • TTFB: < 200ms(전역 p75)
  • LCP: < 1.5초
  • CLS: < 0.05
  • 총 페이지 무게: < 300KB(초기 로드)

Vercel Edge Network

ISR 페이지는 Vercel의 엣지 네트워크에 캐시됩니다 — 100개 이상의 PoP(존재 지점) 전 세계에. 페이지가 생성되고 캐시되면, 가장 가까운 엣지 위치에서 제공됩니다. 이것이 바로 동남아시아 또는 남미의 사용자에게도 TTFB가 200ms 미만으로 유지되는 이유입니다.

이미지 최적화

각 리스팅에는 1-8개의 이미지가 있습니다. 이는 잠재적으로 100만 개 이상의 이미지입니다. 우리는 next/image와 함께 Vercel의 내장 이미지 최적화를 사용합니다:

<Image
  src={listing.media[0]?.url}
  alt={listing.title}
  width={800}
  height={600}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  loading={index === 0 ? 'eager' : 'lazy'}
  quality={75}
/>

이미지는 Supabase Storage에 저장되고 Vercel의 이미지 CDN을 통해 제공됩니다. 원본 이미지는 종종 2-5MB입니다. 최적화 후 40-120KB입니다. 이것만 대역폭의 약 80%를 절약했습니다.

프로덕션의 모니터링 및 관찰성

모니터링 없이 137K 페이지를 프로덕션에서 실행하는 것은 눈을 가리고 운전하는 것과 같습니다. 우리 스택은:

  • Vercel Analytics: 핵심 웹 바이탈, 실제 사용자 모니터링
  • Sentry: 오류 추적(우리는 매일 ~50개 오류를 캐치합니다, 주로 봇이 보내는 쓰레기)
  • Supabase Dashboard: 데이터베이스 성능, 쿼리 분석
  • Checkly: 합성 모니터링, 중요 경로에 5분 간격
  • Google Search Console: 인덱스 커버리지, 크롤 통계

우리가 설정한 가장 가치 있는 모니터링은 인덱싱된 페이지 vs. 총 활성 리스팅을 계산하는 일일 Supabase 쿼리였습니다. 비율이 95% 아래로 떨어지면 경고를 받습니다. 이것은 나쁜 배포를 24시간 이내에 발견했습니다.

비용 분석: 실제로 비용이 얼마나 드는가

사람들은 항상 비용에 대해 묻습니다. 2026년 Q1 기준 실제 월간 지출은 다음과 같습니다:

서비스 플랜 월간 비용
Vercel Pro $20
Vercel 대역폭(초과) 사용량 기반 ~$35
Supabase Pro $25
Supabase 데이터베이스(컴퓨팅) 소형 인스턴스 $48
Typesense(Hetzner) CX31 $48
Checkly 스타터 $7
Sentry $26
도메인 + DNS(Cloudflare) 무료 티어 $0
합계 ~$209/월

수백만 건의 월간 페이지뷰로 137,000개 페이지를 월 약 $200으로 제공합니다. 전통적인 WordPress를 실행하는 서버 설정으로 그렇게 해보세요.

비슷한 프로젝트를 고려 중이고 이와 같은 아키텍처가 예산에 어떻게 매핑되는지 이해하고 싶다면, 우리의 가격 페이지에서 일반적으로 디렉토리 및 마켓플레이스 프로젝트의 범위를 정하는 방법을 분석합니다.

다르게 했을 것들

처음부터 ISR을 사용했을 것입니다. 우리는 전체 SSG를 작동시키려고 2주를 낭비했는데 수학이 맞지 않는다는 것을 받아들이기 전이었습니다.

처음부터 Typesense를 사용했을 것입니다. Postgres FTS는 초기에는 괜찮았지만 프로젝트 중간에 검색을 마이그레이션하는 것은 방해가 되었습니다. 월 $48은 출시부터의 가치가 있었을 것입니다.

더 일찍 데이터 검증에 투자했을 것입니다. 다양한 소스에서 가져온 137K 리스팅을 사용하면 데이터 품질이 악몽이었습니다. 첫 번째 임포트 후가 아닌 첫 번째 임포트 전에 엄격한 Zod 스키마와 검증 파이프라인을 구축했어야 합니다.

스테이징에서 더 일찍 현실적인 데이터 볼륨으로 테스트했을 것입니다. 우리의 스테이징 환경에는 500개의 리스팅이 있었습니다. 500행에서 훌륭하게 작동하던 쿼리는 137K에서 해체되었습니다. 우리는 이제 프로덕션 데이터의 20% 무작위 샘플로 스테이징을 시드합니다.

비슷한 디렉토리 또는 마켓플레이스 빌드를 계획 중이고 이러한 동일한 함정을 피하고 싶다면, 당신의 팀에 연락하세요. 우리는 이것을 충분히 몇 번 했으므로 지뢰가 어디에 있는지 알고 있습니다.

FAQ

100K 이상의 리스팅 디렉토리를 Next.js로 구축하는 데 얼마나 오래 걸립니까?

우리 팀의 경우, 초기 아키텍처와 핵심 기능은 약 10주가 걸렸습니다. 데이터 임포트, 정리 및 검증은 추가 3-4주가 걸렸습니다. 킥오프부터 프로덕션 출시까지 총 약 14주였습니다. 이를 이전에 한 Next.js 개발 팀과 함께 작업하면 2-3주를 단축할 수 있습니다.

Supabase가 디렉토리용 100,000개 이상의 행을 처리할 수 있습니까?

절대로. Supabase는 Postgres 위에서 실행되며, 백만 개의 행을 문제없이 처리합니다. 핵심은 적절한 인덱싱입니다 — 가장 자주 쿼리하는 열의 인덱스 없이는 성능이 빠르게 저하됩니다. 위에서 설명한 인덱스를 사용하면 137K 행의 쿼리는 단일 레코드 조회에 일관되게 50ms 미만에서 반환됩니다.

ISR과 SSG의 차이점은 무엇입니까(대규모 사이트의 경우)?

SSG(정적 사이트 생성)는 배포할 때마다 모든 페이지를 빌드합니다. ISR(증분 정적 재생성)은 배포할 때 일부를 빌드하고 나머지는 온디맨드로 생성합니다. 10,000개 이상의 페이지가 있는 사이트의 경우 ISR이 사실상 필수입니다 — 전체 SSG 빌드는 합리적인 배포 주기에 너무 느립니다.

137,000개의 동적으로 생성된 페이지에 대해 SEO를 어떻게 처리합니까?

세 가지가 가장 중요합니다: 여러 파일에 걸쳐 분할된 적절한 사이트맵 생성, 모든 리스팅 페이지에 대한 고유한 구조화된 데이터(JSON-LD), 그리고 ISR 생성 페이지가 적절한 HTTP 200 상태 코드를 반환하도록 보장(소프트 404가 아님). 우리는 또한 리스팅 데이터를 사용하여 페이지당 고유한 메타 제목과 설명을 생성합니다 — 중복 메타 콘텐츠 없습니다.

Vercel ISR은 규모에서 프로덕션에 신뢰할 수 있습니까?

우리의 경험상 예. 우리는 8개월 이상 99.98% 가동 시간으로 이 설정을 실행해왔습니다. 유일한 사건은 자체 초래된 것 — 재검증 웹훅을 끊은 나쁜 배포, 그리고 Supabase 유지보수 창으로 인한 15분의 성능 저하. Vercel의 엣지 캐시는 견고합니다.

대규모 디렉토리의 경우 Algolia 또는 Typesense를 사용해야 합니까?

예산에 따라 다릅니다. Algolia는 최고의 개발자 경험을 갖춘 업계 표준이지만, 100K 레코드를 지나면 비싸집니다 — 월 $500-1000+를 예상합니다. Typesense는 자체 호스팅할 때 기능의 90%를 비용의 일부로 제공합니다. 우리는 Typesense를 선택했으며 후회하지 않습니다.

137,000개의 리스팅을 최신 상태로 유지하려면 어떻게 합니까?

우리는 혼합 접근 방식을 사용합니다: 개별 리스팅이 변경될 때 Supabase 웹훅으로 트리거된 온디맨드 재검증, 안전망으로의 시간 기반 ISR 재검증(매시간), 그리고 오래된 데이터를 확인하고 대량 재검증을 트리거하는 야간 배치 작업. 리스팅 소유자는 대시보드를 통해 페이지 새로 고침을 수동으로 요청할 수도 있습니다.

이 아키텍처가 Supabase 대신 헤드리스 CMS에서 작동할 수 있습니까?

예, 절충안이 있습니다. 헤드리스 CMS 설정 Sanity 또는 Contentful과 같은 콘텐츠 관리 측면에서는 잘 작동하지만, 검색 및 복잡한 쿼리를 위해 여전히 데이터베이스가 필요할 수 있습니다. 우리는 헤드리스 CMS에 편집 콘텐츠가 있고 Postgres에 리스팅 데이터가 있는 디렉토리 프로젝트를 빌드했습니다 — 유효한 하이브리드 접근 방식입니다.