SPA SEO Gateway

React/Vue/Svelte 같은 SPA 를 검색 봇이 인덱싱할 수 있도록, 헤드리스 Chromium 으로 사전 렌더링해 주는 고성능 다이내믹 렌더링 게이트웨이.

현재 모드
Origin
Uptime
Node
🤖

봇이 들어오면

isbot 의 1,000+ 패턴으로 봇을 자동 식별. Chromium 으로 SPA 를 완전히 렌더한 뒤 캐시에 보관하고 HTML 응답.

캐시 + SWR

Memory LRU + Redis 2-tier · Brotli 압축 · 만료 직후엔 stale 즉시 응답 + 백그라운드 갱신. 대부분의 봇 트래픽은 5ms 이내 캐시 히트.

🛡️

방어선

SSRF DNS 검사, host별 circuit breaker, soft 404 자동 감지, 자동 브라우저 재시작, rate limit, 호스트 화이트리스트.

빠른 시작

  1. 1 좌측 메뉴에서 원하는 작업을 선택. 인증이 필요한 페이지는 우측 상단에 토큰 입력 박스가 표시됩니다.
  2. 2 처음이라면 에서 간단한 URL 한 개를 렌더해 동작 확인.
  3. 3 에서 URL 패턴별 캐시 TTL / waitUntil / ignore 정의.
  4. 4 운영 시작 후 으로 sitemap 으로부터 미리 캐시 채우기.
  5. 5 에서 실시간 처리량/지연/에러를 모니터링.

아키텍처 한눈에

Bot ─→ Edge/CDN  ──→ spa-seo-gateway
              (UA로 분기)        ├─ bot detect (isbot)
                                 ├─ host → site/tenant 매핑
                                 ├─ cache lookup ──→ HIT (5ms)
                                 │                    │
                                 │                    ▼
                                 │                 응답
                                 ├─ cache MISS
                                 ├─ in-flight dedup
                                 ├─ render (puppeteer-cluster)
                                 ├─ quality gate
                                 ├─ cache.set
                                 └─ 응답
Human ──→ Edge/CDN ──→ origin (gateway는 204 또는 proxy)
📊

대시보드 — 게이트웨이 현재 상태

현재 운영 모드, 캐시 설정, 활성 circuit breaker 를 한눈에.

  • mode: render-only(단일) / proxy / cms(다중사이트) / saas(다중테넌트)
  • cache: TTL 만료 후 SWR 윈도우 동안엔 stale 응답 가능
  • Circuit breakers: host 별로 50% 실패율 도달 시 OPEN. 30초 후 HALF-OPEN

Circuit breakers

아직 활동한 host 가 없습니다.
hoststatestats
🛣️

라우트 오버라이드 — URL 패턴별 동작 분기

URL 의 pathname + search 에 정규식이 매칭되면 해당 라우트의 설정이 글로벌 설정을 오버라이드. 위에서 아래로 첫 매칭이 승리합니다.

필드 가이드
  • pattern: 정규식. 예) ^/$ (홈), ^/products/ (제품 하위), ^/(account|cart)(/|$)
  • ttl: 라우트별 캐시 수명 (ms). 빠른 변경(60–600초) vs 정적(24시간)
  • waitUntil: networkidle2 가 안전, 무거운 SPA 는 networkidle0
  • selector: 특정 DOM 요소 등장 대기. [data-product-loaded]
  • waitMs: waitUntil 충족 후 추가 대기
  • ignore: true 면 렌더 스킵, 204 응답
예시 라우트 패턴
^/(account|admin|cart|checkout)(/|$)  ignore=true             ← 인증/결제는 렌더 금지
^/products/[0-9]+                      ttl=21600000  selector=[data-product]  ← 6h
^/blog/                                ttl=86400000             ← 24h
^/                                     ttl=600000               ← 10분 (홈)

💾 저장 (메모리)는 즉시 적용. 저장 + 디스크 영구화seo-gateway.config.json 에 기록.

Route overrides

patternttl(ms)waitUntilselectorwaitMsignore
🗄️

캐시 — 렌더 결과의 보관과 무효화

기본 24h TTL · 1h SWR 윈도우. 봇 트래픽 95%+ 가 캐시에서 즉시 응답.

언제 무효화?
  • 배포 직후 — CI 에 POST /admin/api/cache/clear 추가
  • 특정 페이지 콘텐츠 수정 — URL 입력 + URL 무효화
  • Search Console 에서 인덱스 누락 발견 시

⚠️ 전체 초기화는 다음 봇 트래픽 시 모든 페이지 cold render.

Cache 상태


              
🔥

Sitemap 사전 워밍 — cold path 제거

sitemap.xml 을 파싱해 안의 URL 들을 미리 렌더링. 결과를 캐시에 저장 → 실제 봇 트래픽은 즉시 hit.

언제 실행?
  • 배포 직후 — CI 파이프라인에서 cache/clear 다음 단계
  • 주기적 — cron 으로 매 N시간마다
  • 대규모 콘텐츠 추가 후
파라미터
  • max: 워밍할 최대 URL 수 (기본 1000)
  • concurrency: 동시 렌더 (기본 4, POOL_MAX 보다 작게)

ℹ️ sitemap-index 도 자동 재귀 파싱.

Sitemap pre-warm


            
🧪

렌더 테스트 — 디버깅 도구

URL 을 입력하면 풀에서 페이지를 잡아 즉시 렌더, 결과 HTML 첫 4KB 미리보기. 캐시 우회.

활용 시나리오
  • 새 라우트 추가 후 — waitUntil/selector 동작 확인
  • 모바일 vs 데스크톱 차이 검증 — UA 변경
  • quality gate 트리거 확인 — soft 404 페이지로
  • 응답 헤더 확인 — x-prerender-route, x-prerender-viewport, x-prerender-quality
자주 쓰는 봇 UA (클릭 → 채워넣기)

Render test

status: duration: bytes:
응답 헤더 (x-prerender-*)

                

              
📈

메트릭 — 실시간 운영 상태

/metrics 의 Prometheus exposition 을 파싱해 시각화. 5초마다 자동 갱신.

핵심 지표 보는 법
  • Cache hit ratio: 95%+ 정상. 70% 미만이면 라우트 TTL 늘리거나 워밍 강화
  • Render p95: 5초 미만이 일반. 그 이상이면 waitUntil 완화 or origin 점검
  • Inflight: 풀에 묶여있는 동시 렌더 수. POOL_MAX 에 자주 도달하면 풀 확장
  • Error reasons: timeout/network/crashed/ssrf/circuit-open 별 분류

ℹ️ Prometheus 본격 운영은 /metrics 엔드포인트를 직접 스크레이프하세요. 이 페이지는 빠른 점검용.

렌더 지연 (ms)

데이터 수집 중...
outcome / hostp50p95p99count

에러 분류

에러 없음 ✓
Raw Prometheus exposition

            
🔌

API Explorer — 모든 엔드포인트 한눈에

현재 모드에서 등록된 admin API 엔드포인트들. 각 항목의 [Try it] 버튼으로 바로 호출.

📦

라이브러리로 사용 — npm 패키지 통합

이 게이트웨이는 단일 바이너리로도 쓸 수 있지만, 각 기능이 분리된 npm 패키지라 기존 Fastify 앱에 부분적으로 통합하거나, 커스텀 게이트웨이를 직접 만들 수도 있습니다.

👉 아래는 현재 워크스페이스의 패키지 사용 예시입니다. npm 발행 후엔 동일한 코드가 외부 프로젝트에서도 동작합니다.

⬇️ 설치

필요한 부분만 골라 설치합니다.

패키지 목적 의존
@spa-seo-gateway/core 렌더링 엔진 (HTTP 비의존) puppeteer 24+
@spa-seo-gateway/admin-ui 어드민 UI Fastify 플러그인 (이 페이지) core, fastify 5
@spa-seo-gateway/multi-tenant SaaS 모드 — 다중 테넌트 core, fastify 5
@spa-seo-gateway/cms CMS 모드 — 다중 사이트 core, fastify 5
# pnpm
pnpm add @spa-seo-gateway/core @spa-seo-gateway/admin-ui fastify

# npm
npm i @spa-seo-gateway/core @spa-seo-gateway/admin-ui fastify

# yarn
yarn add @spa-seo-gateway/core @spa-seo-gateway/admin-ui fastify

🛠️ 시나리오 1 — 직접 게이트웨이 구성

현재 apps/gateway 가 하는 일을 직접 구성. 가장 흔한 사용법.

// my-gateway.ts
import Fastify from 'fastify';
import compress from '@fastify/compress';
import cors from '@fastify/cors';
import {
  browserPool,
  config,
  logger,
  shutdownCache,
} from '@spa-seo-gateway/core';
import { registerAdminUI } from '@spa-seo-gateway/admin-ui';

const app = Fastify({
  loggerInstance: logger,
  trustProxy: true,
  bodyLimit: 4 * 1024 * 1024,
});

await app.register(compress, { encodings: ['br', 'gzip'] });
await app.register(cors);

// 어드민 UI 등록 — /admin/ui 에서 접근
await registerAdminUI(app, { prefix: '/admin/ui' });

// 자체 라우트는 직접 등록 (혹은 @spa-seo-gateway/cms / multi-tenant 사용)
import { detectBot, render, cacheSwr, cacheKey } from '@spa-seo-gateway/core';

app.get('/*', async (req, reply) => {
  const isBot = detectBot(req.headers['user-agent'], req.headers, req.query).isBot;
  if (!isBot) { reply.code(204).send(); return; }

  const target = new URL(req.url, config.originUrl).toString();
  const key = cacheKey(target);
  const result = await cacheSwr(key, () =>
    render({ url: target, headers: req.headers })
  );

  reply.code(result.entry.status);
  for (const [k, v] of Object.entries(result.entry.headers)) reply.header(k, v);
  reply.send(result.entry.body);
});

await browserPool.start();
await app.listen({ port: 3000 });

process.on('SIGTERM', async () => {
  await app.close();
  await browserPool.stop();
  await shutdownCache();
  process.exit(0);
});

🏢 시나리오 2 — SaaS 다중 테넌트

FileTenantStore (또는 직접 구현한 store) 와 함께 multi-tenant 플러그인 등록.

import Fastify from 'fastify';
import { browserPool } from '@spa-seo-gateway/core';
import { registerAdminUI } from '@spa-seo-gateway/admin-ui';
import {
  registerMultiTenant,
  FileTenantStore,
  type TenantStore,
} from '@spa-seo-gateway/multi-tenant';

const app = Fastify();
const store: TenantStore = new FileTenantStore('./tenants.json');

await registerMultiTenant(app, {
  store,
  adminToken: process.env.ADMIN_TOKEN,
  // 식별 전략 (기본: ['host', 'apiKey'])
  resolve: ['host', 'apiKey'],
});

await registerAdminUI(app, { prefix: '/admin/ui' });
await browserPool.start();
await app.listen({ port: 3000 });
커스텀 TenantStore (Postgres / Redis 등)
import { type TenantStore, type Tenant } from '@spa-seo-gateway/multi-tenant';
import { sql } from './db.js';

export class PostgresTenantStore implements TenantStore {
  async list() { return sql`SELECT * FROM tenants` as unknown as Tenant[]; }
  async byId(id: string) {
    const r = await sql`SELECT * FROM tenants WHERE id = ${id}`;
    return (r[0] as Tenant) ?? null;
  }
  async byApiKey(key: string) {
    const r = await sql`SELECT * FROM tenants WHERE api_key = ${key}`;
    return (r[0] as Tenant) ?? null;
  }
  async byHost(host: string) {
    const r = await sql`SELECT * FROM tenants WHERE origin LIKE ${'%' + host + '%'}`;
    return (r[0] as Tenant) ?? null;
  }
  async upsert(t: Tenant) {
    await sql`INSERT INTO tenants ... ON CONFLICT (id) DO UPDATE ...`;
    return t;
  }
  async remove(id: string) {
    const r = await sql`DELETE FROM tenants WHERE id = ${id}`;
    return r.count > 0;
  }
}

🌐 시나리오 3 — CMS 다중 사이트

같은 조직이 여러 사이트(마케팅 / 블로그 / 도큐먼트) 를 운영할 때.

import Fastify from 'fastify';
import { browserPool } from '@spa-seo-gateway/core';
import { registerCms, FileSiteStore } from '@spa-seo-gateway/cms';
import { registerAdminUI } from '@spa-seo-gateway/admin-ui';

const app = Fastify();
const store = new FileSiteStore('./sites.json');

await registerCms(app, { store, adminToken: process.env.ADMIN_TOKEN });
await registerAdminUI(app, { prefix: '/admin/ui' });
await browserPool.start();
await app.listen({ port: 3000 });

// 사이트 추가는 admin API 로
// POST /admin/api/sites { id, name, origin, routes: [...] }

🔧 시나리오 4 — 기존 Fastify 앱에 임베드

이미 Fastify 백엔드가 있다면, 봇 트래픽만 SEO 렌더로 분기하는 미들웨어로 사용.

import {
  detectBot,
  render,
  cacheSwr,
  cacheKey,
  browserPool,
} from '@spa-seo-gateway/core';

// 앱 시작 시 한 번
await browserPool.start();

// 미들웨어처럼 사용
app.addHook('onRequest', async (req, reply) => {
  const detection = detectBot(
    req.headers['user-agent'],
    req.headers,
    req.query,
  );
  if (!detection.isBot) return; // 사람은 다음 핸들러로

  const target = new URL(req.url, 'https://your-spa.example.com').toString();
  const key = cacheKey(target);
  const result = await cacheSwr(key, () =>
    render({ url: target, headers: req.headers }),
  );

  reply.code(result.entry.status);
  for (const [k, v] of Object.entries(result.entry.headers)) reply.header(k, v);
  return reply.send(result.entry.body);
});

🖥️ 시나리오 5 — 단발 렌더 CLI / 스크립트

서버 없이 빌드 단계 / cron 으로 단발 렌더링.

import { browserPool, render } from '@spa-seo-gateway/core';
import { writeFileSync } from 'node:fs';

await browserPool.start();
try {
  const entry = await render({
    url: 'https://www.example.com/posts/123',
    headers: { 'user-agent': 'Googlebot/2.1' },
  });
  writeFileSync('out.html', entry.body, 'utf8');
  console.log(`status=${entry.status} bytes=${entry.body.length}`);
} finally {
  await browserPool.stop();
}

📚 핵심 API 레퍼런스 (@spa-seo-gateway/core)

함수설명
render(input)URL → 렌더된 HTML CacheEntry. SSRF/breaker/품질 게이트 포함
cacheSwr(key, fetcher, ttlMs?)캐시 + SWR + dedup. miss 면 fetcher 호출
cacheKey(url, locale?, namespace?)정규화된 URL → 안정적 캐시 키
detectBot(ua, headers, query)isbot + force/bypass 헤더 처리
warmFromSitemap(url, opts)sitemap.xml/sitemap-index 재귀 파싱 + 동시 워밍
matchRoute(url)현재 등록된 라우트 중 매칭되는 첫 항목
setRoutes(routes[])런타임 라우트 교체
persistRoutesToFile(path?)현재 라우트를 JSON 파일에 영구화
browserPool.start() / stop() / withPage(fn)풀 라이프사이클 + 페이지 수동 획득
registry.metrics()Prometheus exposition (라우트에 직접 마운트)
shutdownCache()Redis 연결 종료 (graceful 종료 시)

전체 export 는 packages/core/src/index.ts 참고.

⚙️ 라이브러리 모드에서의 설정

@spa-seo-gateway/core 는 모듈 로드 시점에 환경변수와 seo-gateway.config.json 을 자동 로드합니다. 기존 환경변수 그대로 (POOL_MAX, WAIT_UNTIL, ADMIN_TOKEN 등) 동작합니다.

# .env (라이브러리로 쓰는 외부 프로젝트도 동일)
POOL_MIN=2
POOL_MAX=8
WAIT_UNTIL=networkidle2
BLOCK_RESOURCE_TYPES=image,media,font
MEMORY_CACHE_TTL_MS=86400000
SWR_WINDOW_MS=3600000
ADMIN_TOKEN=secret
REDIS_CACHE_ENABLED=true
REDIS_URL=redis://localhost:6379

자세한 설정은 CONFIGURATION.md.

💡 라이브러리 사용 시 팁

  • 풀 시작 / 종료 직접 관리browserPool.start() 를 앱 시작 시, browserPool.stop() + shutdownCache() 를 SIGTERM 처리에 호출.
  • 다른 풀 라이프사이클과 독립 — 한 프로세스에서 여러 게이트웨이를 띄우는 건 지원하지 않습니다 (모듈 싱글톤). 필요하면 별도 프로세스.
  • Redis 활성 권장 — 멀티 노드 + 재시작 안전성. REDIS_CACHE_ENABLED=true
  • 커스텀 store 구현TenantStore/SiteStore 인터페이스만 만족하면 Postgres/SQLite/DynamoDB 어떤 백엔드든 OK.
  • 메트릭 노출app.get('/metrics', async (_req, reply) => { reply.header('content-type', registry.contentType); return registry.metrics(); })
  • 품질 게이트 비활성 — 자체 검증 로직이 있다면 QUALITY_CHECK=false
  • 로그 라우팅logger 는 pino 인스턴스. 앱 자체 로거에 통합하려면 logger.child({ ... }) 또는 transport 추가
📢

npm 발행 상태

위 패키지들은 현재 워크스페이스 내부에서 사용 가능. 외부 npm 발행은 진행 중이며 발행 후 동일 코드로 외부 프로젝트에서 import 가능합니다. 중간에는 git clone 후 pnpm install 또는 npm install file:../path/to/package 로 사용.

👀 릴리즈 노트 에서 발행 시점 확인.

도움말 — 자주 묻는 질문 / 트러블슈팅

처음 접하는 분들이 흔히 만나는 상황과 해결법.