SPA SEO Gateway
React/Vue/Svelte 가 만드는 동적 콘텐츠를 봇 요청 시점에 헤드리스 Chromium 으로 실시간 렌더링 해, 봇에겐 완성된 HTML, 사람에겐 원본 SPA 를 전달하는 게이트웨이. 캐시·SWR·자동 워밍은 처리량을 높이는 최적화일 뿐 — 본질은 on-demand 렌더링.
봇이 들어오면 그 자리에서 렌더
isbot 의 1,000+ 패턴으로 봇 자동 식별 → 그 시점에 Chromium 으로 SPA 를 실행해 데이터 fetch / state 적용 / DOM 완성. 최종 HTML 을 봇에게 응답하고 동시에 캐시에 보관.
캐시 + SWR
Memory LRU + Redis 2-tier · Brotli 압축 · 만료 직후엔 stale 즉시 응답 + 백그라운드 갱신. 대부분의 봇 트래픽은 5ms 이내 캐시 히트.
방어선
SSRF DNS 검사, host별 circuit breaker, soft 404 자동 감지, 자동 브라우저 재시작, rate limit, 호스트 화이트리스트.
빠른 시작
- 1 좌측 메뉴에서 원하는 작업을 선택. 인증이 필요한 페이지는 우측 상단에 토큰 입력 박스가 표시됩니다.
- 2 처음이라면 에서 간단한 URL 한 개를 렌더해 동작 확인.
- 3 에서 URL 패턴별 캐시 TTL / waitUntil / ignore 정의.
- 4 운영 시작 후 으로 sitemap 으로부터 미리 캐시 채우기.
- 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)
바로가기
- 📚 설치 가이드
- ⚙️ 전체 설정 레퍼런스
- 🏢 SaaS 모드 (다중 테넌트)
- 🌐 CMS 모드 (다중 사이트)
- 🚀 배포 가이드 (Docker/K8s/CDN)
- 🏗️ 아키텍처
대시보드 — 게이트웨이 현재 상태
현재 운영 모드, 캐시 설정, 활성 circuit breaker 를 한눈에.
- mode: render-only(단일) / proxy / cms(다중사이트) / saas(다중테넌트)
- cache: TTL 만료 후 SWR 윈도우 동안엔 stale 응답 가능
- Circuit breakers: host 별로 50% 실패율 도달 시 OPEN. 30초 후 HALF-OPEN
Circuit breakers
| host | state | stats |
|---|---|---|
라우트 오버라이드 — 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
| pattern | ttl(ms) | waitUntil | selector | waitMs | ignore | ||
|---|---|---|---|---|---|---|---|
| ⋮⋮ |
캐시 — 렌더 결과의 보관과 무효화
기본 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
응답 헤더 (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 / host | p50 | p95 | p99 | count |
|---|---|---|---|---|
에러 분류
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 로 사용.
👀 릴리즈 노트 에서 발행 시점 확인.
도움말 — 자주 묻는 질문 / 트러블슈팅
처음 접하는 분들이 흔히 만나는 상황과 해결법.
더 자세한 가이드
- 📚 5분 시작 가이드
- ⚙️ 전체 설정 레퍼런스
- 🚀 배포 (Docker / K8s / CDN)
- 🏢 SaaS 모드 (다중 테넌트)
- 🌐 CMS 모드 (다중 사이트)
- 🏗️ 내부 아키텍처
- ⚡ 동시성 모델
- 📊 벤치마크 시나리오