증분 정적 재생성(ISR)
증분 정적 재생성(ISR)을 사용하면 정적으로 생성된 콘텐츠를 CDN에서 제공하면서 백그라운드에서 주기적으로 재생성할 수 있습니다. 이를 통해 정적 사이트의 성능상 이점과 동적 콘텐츠의 최신성을 모두 얻을 수 있습니다.
TanStack Start에서 ISR이 작동하는 방식
TanStack Start의 ISR 접근 방식은 유연하며 모든 CDN과 호환되는 표준 HTTP 캐시 헤더를 활용합니다. 프레임워크별 ISR 구현과 달리, 이 접근 방식을 사용하면 페이지와 데이터 수준 모두에서 캐싱 동작을 완전히 제어할 수 있습니다.
핵심 개념은 간단합니다:
- 정적 사전 렌더링: 빌드 시 페이지가 생성됩니다
- CDN 캐싱: 캐시 헤더로 CDN이 HTML을 캐시하는 기간을 제어합니다
- 재검증: 캐시가 만료된 후 다음 요청이 재생성을 트리거합니다
- 백그라운드 재검증 중 오래된 콘텐츠 제공: 백그라운드에서 최신 데이터를 가져오는 동안 오래된 콘텐츠를 제공합니다
캐시 헤더 전략
시간 기반 재검증
가장 일반적인 ISR 패턴은 max-age 및 s-maxage 지시어와 함께 Cache-Control 헤더를 사용합니다:
Vite
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
tanstackStart({
prerender: {
routes: ['/blog', '/blog/posts/*'],
crawlLinks: true,
},
}),
],
})
Rsbuild
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'
export default defineConfig({
plugins: [
pluginReact(),
tanstackStart({
prerender: {
routes: ['/blog', '/blog/posts/*'],
crawlLinks: true,
},
}),
],
})
// routes/blog/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/blog/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
headers: () => ({
// Cache at CDN for 1 hour, allow stale content for up to 1 day
'Cache-Control':
'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
}),
})
export default function BlogPost() {
const { post } = Route.useLoaderData()
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}
Cache-Control 지시어 이해하기
public: 응답을 모든 캐시(CDN, 브라우저 등)에 캐시할 수 있습니다max-age=3600: 콘텐츠가 3600초(1시간) 동안 최신 상태로 유지됩니다s-maxage=3600: 공유 캐시(CDN)의 max-age를 재정의합니다stale-while-revalidate=86400: 백그라운드에서 재검증하는 동안 최대 24시간까지 오래된 콘텐츠를 제공합니다immutable: 콘텐츠가 절대 변경되지 않습니다(해시 기반 에셋에 사용합니다)
서버 함수와 함께 ISR 사용하기
서버 함수는 동적 데이터 엔드포인트에도 캐시 헤더를 설정할 수 있습니다:
// routes/api/products/$productId.ts
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/products/$productId')({
server: {
handlers: {
GET: async ({ params, request }) => {
const product = await db.products.findById(params.productId)
return Response.json(
{ product },
{
headers: {
'Cache-Control':
'public, max-age=300, stale-while-revalidate=600',
'CDN-Cache-Control': 'max-age=3600', // Cloudflare-specific
},
},
)
},
},
},
})
캐시 헤더에 미들웨어 사용하기
API 라우트에서는 미들웨어를 사용하여 캐시 헤더를 설정할 수 있습니다:
// routes/api/products/$productId.ts
import { createFileRoute } from '@tanstack/react-router'
import { createMiddleware } from '@tanstack/react-start'
const cacheMiddleware = createMiddleware().server(async ({ next }) => {
const result = await next()
// Add cache headers to the response
result.response.headers.set(
'Cache-Control',
'public, max-age=3600, stale-while-revalidate=86400',
)
return result
})
export const Route = createFileRoute('/api/products/$productId')({
server: {
middleware: [cacheMiddleware],
handlers: {
GET: async ({ params }) => {
const product = await db.products.findById(params.productId)
return Response.json({ product })
},
},
},
})
페이지 라우트에서는 headers 속성을 직접 사용하는 편이 더 간단합니다:
// routes/blog/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/blog/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
headers: () => ({
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
}),
})
온디맨드 재검증
시간 기반 재검증은 대부분의 경우에 효과적이지만, 특정 페이지를 즉시 무효화해야 할 수 있습니다(예: 콘텐츠가 업데이트되는 경우).
// routes/api/revalidate.ts
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/revalidate')({
server: {
handlers: {
POST: async ({ request }) => {
const { path, secret } = await request.json()
// Verify secret token
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid token' }, { status: 401 })
}
// Trigger CDN purge via your CDN's API
await fetch(
`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [`https://yoursite.com${path}`],
}),
},
)
return Response.json({ revalidated: true })
},
},
},
})
CDN별 구성
Cloudflare Workers
Cloudflare는 표준 Cache-Control 헤더를 준수하며 추가 제어 기능을 제공합니다.
export const Route = createFileRoute('/products/$id')({
headers: () => ({
'Cache-Control': 'public, max-age=3600',
// Cloudflare-specific header for finer control
'CDN-Cache-Control': 'max-age=7200',
}),
})
Netlify
Netlify는 Cache-Control 헤더를 사용하며 _headers 파일도 지원합니다.
# public/_headers
/blog/*
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
/api/*
Cache-Control: public, max-age=300
Vercel
Vercel에 배포할 때는 Vercel의 Edge Network 캐시 헤더를 사용합니다.
export const Route = createFileRoute('/posts/$id')({
headers: () => ({
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
}),
})
ISR과 클라이언트 측 캐싱 결합
TanStack Router의 기본 제공 캐시 제어는 CDN 캐싱과 함께 작동합니다.
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
return fetchPost(params.postId)
},
// CDN caching (via headers)
headers: () => ({
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
}),
// Client-side caching (via TanStack Router)
staleTime: 60_000, // Consider data fresh for 60 seconds on client
gcTime: 5 * 60_000, // Keep in memory for 5 minutes
})
이를 통해 다계층 캐싱 전략이 구성됩니다.
- CDN Edge: 1시간 캐시, 24시간 동안 stale-while-revalidate
- 클라이언트: 60초 동안 최신 데이터 유지, 메모리에는 5분 동안 유지
일반적인 ISR 패턴
블로그 게시물
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params }) => fetchPost(params.slug),
headers: () => ({
// Cache for 1 hour, allow stale for 7 days
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=604800',
}),
staleTime: 5 * 60_000, // 5 minutes client-side
})
전자상거래 제품 페이지
export const Route = createFileRoute('/products/$id')({
loader: async ({ params }) => fetchProduct(params.id),
headers: () => ({
// Shorter cache due to inventory changes
'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
}),
staleTime: 30_000, // 30 seconds client-side
})
마케팅 랜딩 페이지
export const Route = createFileRoute('/landing/$campaign')({
loader: async ({ params }) => fetchCampaign(params.campaign),
headers: () => ({
// Long cache for stable content
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
}),
staleTime: 60 * 60_000, // 1 hour client-side
})
사용자별 페이지
export const Route = createFileRoute('/dashboard')({
loader: async () => fetchUserData(),
headers: () => ({
// Private cache, no CDN caching
'Cache-Control': 'private, max-age=60',
}),
staleTime: 30_000,
})
모범 사례
1. 보수적으로 시작합니다
짧은 캐시 시간으로 시작하고 콘텐츠 업데이트 패턴을 파악하면서 늘립니다:
// Start here
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600'
// Then move to
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400'
2. 검증에 ETag 사용
ETag는 CDN이 콘텐츠를 효율적으로 재검증하는 데 도움이 됩니다:
import { createMiddleware } from '@tanstack/react-start'
import crypto from 'crypto'
const etagMiddleware = createMiddleware().server(async ({ next }) => {
const result = await next()
// Generate ETag from response content
const etag = crypto
.createHash('md5')
.update(JSON.stringify(result.data))
.digest('hex')
result.response.headers.set('ETag', `"${etag}"`)
return result
})
3. 쿼리 매개변수에 따라 캐시 구분
콘텐츠가 쿼리 매개변수에 따라 달라지는 경우 캐시 키에 이를 포함합니다:
export const Route = createFileRoute('/search')({
headers: () => ({
'Cache-Control': 'public, max-age=300',
Vary: 'Accept, Accept-Encoding',
}),
})
4. 캐시 적중률 모니터링
캐시 시간을 최적화하려면 CDN 성능을 추적합니다:
const cacheMonitoringMiddleware = createMiddleware().server(
async ({ next }) => {
const result = await next()
// Log cache status (from CDN headers)
console.log('Cache Status:', result.response.headers.get('cf-cache-status'))
return result
},
)
5. 정적 사전 렌더링과 결합
빌드 시 사전 렌더링하여 첫 로드를 즉시 처리한 다음, 업데이트에는 ISR을 사용합니다:
Vite
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
tanstackStart({
prerender: {
routes: ['/blog', '/blog/posts/*'],
crawlLinks: true,
},
}),
],
})
Rsbuild
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'
export default defineConfig({
plugins: [
pluginReact(),
tanstackStart({
prerender: {
routes: ['/blog', '/blog/posts/*'],
crawlLinks: true,
},
}),
],
})
ISR 디버깅
캐시 헤더 확인
브라우저 DevTools 또는 curl을 사용하여 캐시 헤더를 검사합니다:
curl -I https://yoursite.com/blog/my-post
# Look for:
# Cache-Control: public, max-age=3600, stale-while-revalidate=86400
# Age: 1234 (time in cache)
# X-Cache: HIT (from CDN)
재검증 테스트
재생성을 테스트하려면 캐시 미스를 강제로 발생시킵니다:
# Cloudflare: Bypass cache
curl -H "Cache-Control: no-cache" https://yoursite.com/page
# Or use CDN-specific cache purge APIs
성능 모니터링
핵심 지표를 추적합니다:
- 캐시 적중률: 캐시에서 처리된 요청의 비율
- 재검증 시간: 오래된 콘텐츠를 재생성하는 데 걸리는 시간
- 첫 바이트까지의 시간(TTFB): 캐시된 콘텐츠의 경우 짧아야 합니다