Next.js 15+ `use cache: remote`와 Upstash Redis로 서버리스 인스턴스 간 캐시 공유 구현하기 — 커스텀 캐시 핸들러 완성 코드 포함
서버리스 환경에서 Next.js 앱을 운영하다 보면 불편한 진실을 마주합니다. 요청마다 새로운 함수 인스턴스가 생성되고, 각각 독립적인 메모리 공간을 가지기 때문에 기본 인메모리 캐시는 사실상 무의미합니다. 10개의 인스턴스가 동시에 떠 있다면 같은 데이터를 10번 DB에서 조회하게 되고, 그 결과로 응답 속도 저하, DB 스파이크, 불필요한 비용 증가가 이어집니다. 트래픽이 높을수록 문제는 더 심각해집니다.
이 글은 Next.js App Router를 프로덕션 서버리스 환경에서 운영하는 개발자를 대상으로 합니다. App Router와 서버리스 배포 기초 경험이 있다고 가정하며, 커스텀 캐시 핸들러의 인터페이스 구조부터 ReadableStream 직렬화의 핵심 난제, 실제 프로덕션에서 바로 사용할 수 있는 완성된 구현체까지 단계적으로 살펴봅니다. use cache: remote와 Upstash Redis를 연결하면 서버리스 환경에서도 모든 인스턴스가 동일한 캐시 풀을 공유하여, 콜드 스타트 이후에도 높은 캐시 히트율을 안정적으로 유지할 수 있습니다.
핵심 개념
서버리스 환경의 캐시 딜레마
서버리스 함수는 요청이 끝나면 메모리가 소멸됩니다. 수평 확장될수록 인스턴스 수도 늘어나는데, 각 인스턴스의 인메모리 캐시는 서로 독립적입니다. "서버리스에서 use cache는 의미가 있는가?"라는 질문은 커뮤니티에서 활발히 논의되고 있는 매우 현실적인 문제입니다.
Next.js는 이 문제를 세 가지 캐시 레이어로 구분하여 해결합니다.
| 디렉티브 | 저장 위치 | 특성 |
|---|---|---|
"use cache" |
인스턴스 인메모리 (LRU) | 속도가 가장 빠르지만 인스턴스 간 공유 불가 |
"use cache: remote" |
외부 원격 저장소 | 인스턴스 간 공유 가능, 네트워크 레이턴시 추가 |
"use cache: private" |
사용자별로 격리된 캐시 | 개인화 데이터용 (캐시 키에 사용자 식별자 포함) |
"use cache: remote"Next.js 15부터 도입된 디렉티브로, 캐시 결과를 외부 저장소에 저장하도록 지시합니다.cacheHandlers.remote로 등록된 핸들러가 실제 저장 로직을 담당합니다.
커스텀 캐시 핸들러 인터페이스
cacheHandlers 옵션으로 등록되는 핸들러는 두 가지 메서드를 구현해야 합니다.
interface CacheHandler {
get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined>
set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>
}
interface CacheEntry {
value: ReadableStream<Uint8Array> // 직렬화가 필요한 스트림
tags: string[]
stale: number
timestamp: number
expire: number // Unix 밀리초 타임스탬프
revalidate: number
}CacheEntry.value가 ReadableStream인 이유는 Next.js가 렌더링 결과를 스트리밍으로 생성하기 때문입니다. 이 설계 덕분에 응답 스트림과 캐시 저장 로직이 비동기로 분리될 수 있지만, Redis에 저장하려면 스트림을 직렬화해야 하는 구현 복잡성이 생깁니다.
softTags는 get 메서드의 두 번째 인수로 전달되는 동적 태그 목록입니다. 현재 요청 컨텍스트에서 생성되며, 이 태그가 revalidateTag로 무효화된 경우 캐시를 반환하지 않아야 합니다. 이 검증 로직을 생략하면 무효화된 캐시가 계속 반환되는 심각한 문제가 발생할 수 있습니다.
cacheHandlers(복수)와 cacheHandler(단수) 구분
혼동하기 쉬운 두 옵션의 역할은 완전히 다릅니다.
| 옵션 | 용도 | 비고 |
|---|---|---|
cacheHandler (단수) |
기존 ISR(Incremental Static Regeneration)/페이지 캐시용 | Next.js 13~14 시절 API |
cacheHandlers (복수) |
"use cache" 디렉티브 전용 |
Next.js 15+ 신규 API |
실전 적용
next.config.js 설정
가장 먼저 next.config.js에서 실험적 기능을 활성화하고 커스텀 핸들러 경로를 등록합니다.
// next.config.js
const nextConfig = {
experimental: {
dynamicIO: true,
},
cacheHandlers: {
// require.resolve()는 Node.js 모듈 해석 시 실행 환경 기준의 절대 경로를
// 보장하기 위해 사용합니다. 상대 경로 문자열을 직접 써도 되지만,
// 모노레포나 빌드 환경에 따라 경로 오류가 발생할 수 있습니다
default: require.resolve('./cache-handlers/in-memory-handler.js'),
remote: require.resolve('./cache-handlers/upstash-handler.js'),
},
}
module.exports = nextConfig| 설정 항목 | 설명 |
|---|---|
dynamicIO: true |
"use cache" 디렉티브를 활성화하는 실험적 플래그 |
cacheHandlers.default |
"use cache" 사용 시 동작할 기본 핸들러 |
cacheHandlers.remote |
"use cache: remote" 사용 시 동작할 원격 핸들러 |
Upstash Redis 원격 핸들러 구현
ReadableStream 직렬화가 포함된 완성된 핸들러 구현입니다.
// cache-handlers/upstash-handler.js
const { Redis } = require('@upstash/redis')
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
// 같은 Redis 인스턴스를 스테이징/프로덕션 또는 여러 프로젝트에서 공유한다면
// 환경 변수로 프리픽스를 분리하여 키 충돌을 방지하는 것을 권장합니다
const CACHE_PREFIX = process.env.CACHE_PREFIX ?? 'nextjs:cache:'
const DEFAULT_TTL = 60 * 60 * 24 // 24시간
module.exports = class UpstashCacheHandler {
// 캐시 조회: Redis에서 가져와 ReadableStream으로 복원
async get(cacheKey, softTags) {
const stored = await redis.get(CACHE_PREFIX + cacheKey)
if (!stored) return undefined
const entry = typeof stored === 'string' ? JSON.parse(stored) : stored
// expire가 0이면 만료 없음, 양수이면 현재 시각과 비교
const now = Date.now()
if (entry.expire !== 0 && now > entry.expire) return undefined
// TODO: softTags 무효화 검증 미구현
// softTags에 포함된 태그가 revalidateTag로 무효화된 경우
// 이 캐시를 반환하면 안 됩니다. 이 로직이 없으면
// 무효화된 데이터가 계속 반환될 수 있습니다.
// 구현 방법(무효화된 태그를 Redis에 저장하고 교차 검증)은 다음 글에서 다룹니다.
// Base64 인코딩된 청크를 Uint8Array로 복원 후 ReadableStream 생성
const chunks = entry.valueChunks.map(
(chunk) => new Uint8Array(Buffer.from(chunk, 'base64'))
)
const value = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk)
controller.close()
},
})
return {
value,
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
}
}
// 캐시 저장: ReadableStream을 직렬화하여 Redis에 저장
async set(cacheKey, pendingEntry) {
try {
// pendingEntry는 Promise이므로 반드시 await
const entry = await pendingEntry
// Next.js는 응답 스트림이 소비된 후 비동기(fire-and-forget)로
// 핸들러를 호출합니다. entry.value를 직접 읽어도 안전합니다
const chunks = []
const reader = entry.value.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(Buffer.from(value).toString('base64'))
}
const serialized = {
valueChunks: chunks,
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
}
// expire는 Unix ms 타임스탬프이므로 Redis EX(초 단위)로 변환
const now = Date.now()
const ttl = entry.expire
? Math.ceil((entry.expire - now) / 1000)
: DEFAULT_TTL
// expire가 이미 지난 항목은 저장하지 않음
if (ttl <= 0) return
await redis.set(
CACHE_PREFIX + cacheKey,
JSON.stringify(serialized),
{ ex: ttl }
)
} catch (err) {
// Redis 저장 실패는 응답에 영향을 주지 않도록 조용히 처리
console.error('[UpstashCacheHandler] set 실패:', err)
}
}
}| 구현 포인트 | 설명 |
|---|---|
await pendingEntry |
set의 두 번째 인수는 Promise<CacheEntry>이므로 반드시 await 필요 |
.tee() 불필요 |
Next.js가 응답 스트림 소비 후 핸들러를 호출하므로 entry.value를 직접 읽어도 안전 |
| Base64 직렬화 | Uint8Array → Buffer.from(value).toString('base64') → JSON 저장 |
| TTL 계산 | expire(ms) → (expire - Date.now()) / 1000으로 초 단위 변환, 음수이면 저장 생략 |
| 에러 처리 | Redis 저장 실패가 Next.js까지 전파되지 않도록 try/catch로 감쌈 |
컴포넌트에서 선언적 사용
핸들러 구성이 완료되면, Server Component와 Server Action에서 선언적으로 원격 캐싱을 활용할 수 있습니다.
// app/products/page.tsx
import { cacheLife, cacheTag } from 'next/cache'
// 이 함수의 결과는 Upstash Redis에 저장됩니다
async function getProducts() {
'use cache: remote'
cacheLife('hours') // stale: 0, revalidate: 3600, expire: 86400
cacheTag('products') // revalidateTag('products')로 무효화 가능
const res = await fetch('https://api.example.com/products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return <ProductList items={products} />
}// app/actions/products.ts
'use server'
import { revalidateTag } from 'next/cache'
// 상품 데이터 변경 시 모든 인스턴스의 원격 캐시를 일괄 무효화
export async function refreshProducts() {
revalidateTag('products')
}cacheLife는 'hours', 'days', 'weeks' 등 사전 정의된 수명 프로필을 사용하거나, next.config.js에서 커스텀 프로필을 등록하는 것도 가능합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 인스턴스 간 캐시 공유 | 수십~수백 개 인스턴스가 동일한 캐시 히트율을 공유합니다 |
| 선언적 캐싱 | 컴포넌트/함수 단위로 캐싱 전략을 명시하여 예측 가능성이 높아집니다 |
| HTTP 기반 Upstash | TCP 커넥션 풀 관리가 불필요하여 엣지·서버리스 어디서든 동작합니다 |
| 태그 기반 세밀한 무효화 | revalidateTag로 관련 캐시만 선택적으로 제거할 수 있습니다 |
| 콜드 스타트 후 히트율 유지 | 인메모리 캐시와 달리 인스턴스 재생성 후에도 캐시가 살아있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 네트워크 레이턴시 | 캐시 조회마다 Upstash API 호출이 추가됩니다 (1~5ms) | 히트율이 높은 데이터에만 선택적으로 적용합니다 |
| 비용 | Upstash는 요청 기반 과금으로 트래픽 급증 시 비용이 증가합니다 | TTL을 적절히 설정하고 불필요한 캐싱을 제한합니다 |
| 직렬화 복잡성 | ReadableStream 직렬화·역직렬화 구현이 필요합니다 |
완성된 핸들러 코드를 재사용하거나 @fortedigital/nextjs-cache-handler를 활용합니다 |
| softTags 무효화 미구현 | 이 글의 구현체는 softTags 검증을 포함하지 않습니다 | 다음 글의 softTags 구현 완료 후 적용합니다 |
| 아직 실험적 | dynamicIO는 Next.js 16에서도 일부 기능이 안정화 진행 중입니다 |
프로덕션 도입 전 Next.js 릴리즈 노트를 확인합니다 |
실무에서 가장 흔한 실수
pendingEntry를 await 없이 처리하는 경우 —set메서드의 두 번째 인수는Promise<CacheEntry>입니다. await 없이.value에 접근하면undefined가 됩니다.softTags검증을 생략하는 경우 —get메서드에서 softTags를 무시하면revalidateTag로 무효화한 태그의 캐시가 계속 반환됩니다. 무효화 로직이 완성되기 전까지는 TODO 주석으로 명확히 표시해 두는 것이 좋습니다.expire필드를 초 단위로 착각하는 경우 —expire는 Unix 밀리초 타임스탬프입니다. Redis의EX옵션에 전달하기 전Math.ceil((entry.expire - Date.now()) / 1000)으로 변환해야 합니다.
마치며
"use cache: remote" + 커스텀 캐시 핸들러는 서버리스의 본질적 한계인 인스턴스 간 상태 공유 문제를 코드 레벨에서 우아하게 해결하는 프로덕션 패턴입니다. 환경과 데이터 특성에 따라 다르지만, DB 직접 조회 대비 수십~수백 ms의 응답 시간 절감과 DB 부하 감소를 기대할 수 있습니다.
지금 바로 시작할 수 있는 3단계:
- Upstash Console에서 Redis DB를 생성하고 환경 변수를 발급합니다. 프리 티어로 시작 가능하며,
UPSTASH_REDIS_REST_URL과UPSTASH_REDIS_REST_TOKEN두 값을.env.local에 추가합니다. pnpm add @upstash/redis후 위 핸들러 코드를cache-handlers/upstash-handler.js로 저장하고next.config.js에 등록합니다.experimental.dynamicIO: true설정도 함께 추가합니다.- 자주 조회되지만 변경 빈도가 낮은 함수 하나에
'use cache: remote'를 선언하고cacheTag를 부여하는 것으로 시작합니다. Upstash Console의 Data Browser에서nextjs:cache:프리픽스로 데이터가 저장되는 것을 직접 확인하면 동작 원리를 빠르게 익힐 수 있습니다.
다음 글:
softTags무효화 로직 완성하기 —revalidateTag호출 시 원격 핸들러에서 동적 태그 만료를 처리하는 패턴
참고 자료
- Directives: use cache: remote | Next.js 공식 문서
- next.config.js: cacheHandlers | Next.js 공식 문서
- Directives: use cache | Next.js 공식 문서
- Functions: cacheLife | Next.js 공식 문서
- Functions: cacheTag | Next.js 공식 문서
- Guides: Self-Hosting | Next.js 공식 문서
- Next.js with Redis | Upstash 공식 문서
- Speed up your Next.js application with Redis | Upstash 블로그
- fortedigital/nextjs-cache-handler | GitHub
- Next.js cache-handler-redis 공식 예제 | GitHub
- Is "use cache" gonna cache anything in a serverless environment? | GitHub Discussion #87842
- "use cache"s interaction with custom cache handlers | GitHub Discussion #76635
- Scaling Next.js with Redis cache handler | DEV Community
- Watt v3.18: Next.js 16 use cache with Redis/Valkey | Platformatic 블로그
- Upgrading: Version 16 | Next.js 공식 문서