본문으로 건너뛰기

증분 정적 재생성(ISR)

증분 정적 재생성(ISR)을 사용하면 정적으로 생성된 콘텐츠를 CDN에서 제공하면서 백그라운드에서 주기적으로 재생성할 수 있습니다. 이를 통해 정적 사이트의 성능상 이점과 동적 콘텐츠의 최신성을 모두 얻을 수 있습니다.

TanStack Start에서 ISR이 작동하는 방식

TanStack Start의 ISR 접근 방식은 유연하며 모든 CDN과 호환되는 표준 HTTP 캐시 헤더를 활용합니다. 프레임워크별 ISR 구현과 달리, 이 접근 방식을 사용하면 페이지와 데이터 수준 모두에서 캐싱 동작을 완전히 제어할 수 있습니다.

핵심 개념은 간단합니다:

  1. 정적 사전 렌더링: 빌드 시 페이지가 생성됩니다
  2. CDN 캐싱: 캐시 헤더로 CDN이 HTML을 캐시하는 기간을 제어합니다
  3. 재검증: 캐시가 만료된 후 다음 요청이 재생성을 트리거합니다
  4. 백그라운드 재검증 중 오래된 콘텐츠 제공: 백그라운드에서 최신 데이터를 가져오는 동안 오래된 콘텐츠를 제공합니다

캐시 헤더 전략

시간 기반 재검증

가장 일반적인 ISR 패턴은 max-ages-maxage 지시어와 함께 Cache-Control 헤더를 사용합니다:

Vite

vite.config.ts
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

rsbuild.config.ts
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
})

이를 통해 다계층 캐싱 전략이 구성됩니다.

  1. CDN Edge: 1시간 캐시, 24시간 동안 stale-while-revalidate
  2. 클라이언트: 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

vite.config.ts
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

rsbuild.config.ts
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): 캐시된 콘텐츠의 경우 짧아야 합니다