Next.js 16 캐싱, 감으로 때려잡다 7번 터지고 결국 시스템을 만들었습니다
2026. 6. 4.
Next.js 16 캐싱, 감으로 때려잡다 7번 터지고 결국 시스템을 만들었습니다
프로덕션에서 세 번째 캐싱 장애를 겪고 나면 특유의 기분이 듭니다.
그건 단순한 공황 상태가 아닙니다. 공황보다 더 나쁜, 조용하지만 섬뜩한 깨달음이죠. '지난번 버그는 분명히 제대로 고쳤는데, 다음 버그가 어디 숨어있을지 전혀 모르겠어.'
이전에 제가 썼던 7가지 Next.js 16 캐싱 버그 글에 달린 댓글들을 보면서 한 가지 공통된 패턴을 발견했습니다. 모두가 무엇이 문제인지는 알지만, '그렇다면 정확히 어떤 설정을 해야 하는가?'에 대해서는 명확한 답을 모른다는 점이었죠. 이 글이 바로 그 해답입니다.
이론만 늘어놓는 이야기가 아닙니다. 제가 충분히 데이고 나서야 각 조각이 왜 필요한지 이해하게 되었고, 지금 실제로 사용하고 있는 시스템에 대한 이야기입니다.
첫 번째 문제: 서로 다른 파일에 메모리로 작성된 태그 문자열
Next.js 16에서 발생하는 대부분의 무심한 캐시 버그는 여기서 시작됩니다. 개발자들이 부주의해서가 아닙니다. 두 사람이 같아야 할 두 개의 다른 문자열을 작성하는 것을 막을 수 있는 메커니즘이 없기 때문이죠.
월요일에 개발자 A가 데이터 함수를 작성합니다.
async function getProducts() {
'use cache'
cacheTag('product-list') // 'product-list' 태그
return db.query('SELECT * FROM products')
}
2주 후, 개발자 B는 다른 파일에 데이터 변경(mutation) 함수를 작성합니다.
export async function createProduct(data: ProductData) {
await db.query('INSERT INTO products ...', [...])
revalidateTag('products', 'max') // 'products' 태그
}
product-list와 products. 두 개의 다른 문자열입니다. TypeScript는 아무런 오류를 내지 않고, Next.js도 경고를 주지 않습니다. 새 제품이 생성되어도 제품 목록은 전혀 새로고침 되지 않고, 누군가 두 파일을 동시에 읽어볼 때까지 아무도 그 이유를 모릅니다.
이것은 이전 글의 세 번째 버그입니다. 저는 이 문제에 대해 알고 있었음에도 불구하고, 코드베이스의 여러 부분에서 계속해서 유사한 변형을 겪었습니다. 문제를 아는 것과 문제를 방지하는 시스템을 갖는 것은 완전히 다른 이야기였거든요. 제가 실무에서 이 부분을 처음 맞닥뜨렸을 때, 분명히 고쳤다고 생각했는데 며칠 뒤 다른 팀원이 또 비슷한 실수를 하는 걸 보고 충격받았습니다. 단순한 교육만으로는 해결할 수 없는, 시스템적인 접근이 필요하다는 것을 그때 절실히 깨달았죠.
해결책은 모든 태그 문자열을 관리하는 단 하나의 파일입니다.
// lib/tags.ts
export const tags = {
product: (id: string | number) => `product-${id}`,
user: (id: string | number) => `user-${id}`,
productList: 'products',
userList: 'users',
navigation: 'navigation',
} as const
이제 두 파일 모두 tags 파일에서 태그를 가져옵니다. 오타는 TypeScript 컴파일 오류로 이어지며, 문자열 불일치 버그는 발생할 수 없습니다. 팀의 모든 구성원은 일일이 문자열을 기억하는 대신 자동 완성의 도움을 받게 됩니다.
// 데이터 함수
cacheTag(tags.productList)
// 데이터 변경 함수 - 동일한 import, 동일한 문자열, 완벽 보장
revalidateTag(tags.productList, 'max')
이것은 제 코드베이스에서 가장 많은 버그를 제거한 단 하나의 변경 사항입니다. 새로운 프로젝트에서 캐시 함수를 단 하나라도 작성하기 전에 반드시 이 설정을 먼저 하세요.
두 번째 문제: 캐시를 무효화하는 세 가지 방법, 각각 다른 올바른 API
이 부분에서 저를 포함한 많은 개발자들이 가장 혼란스러워합니다. 어떤 API를 사용할지는 코드를 호출하는 위치와 사용자가 즉시 보아야 하는 내용에 전적으로 달려있습니다. 잘못 사용하면 런타임에 오류를 발생시키거나 사용자에게 오래된 데이터를 조용히 보여주게 되죠.
지금 제가 이 문제를 생각하는 방식은 다음과 같습니다.
사용자가 방금 변경한 내용을 즉시 확인해야 하는 서버 액션 내부:
'use server'
import { updateTag, revalidateTag } from 'next/cache'
export async function updateProductPrice(id: string, newPrice: number) {
await db.query('UPDATE products SET price = $1 WHERE id = $2', [newPrice, id])
updateTag(tags.product(id)) // 작업을 수행한 사용자는 즉시 최신 데이터를 봅니다.
revalidateTag(tags.product(id), 'max') // 다른 모든 사용자는 SWR 업데이트를 받습니다.
revalidateTag(tags.productList, 'max') // 제품 목록도 새로고침 됩니다.
}
여기서 순서가 중요합니다. updateTag가 먼저 실행됩니다. 이것이 관리자가 저장 버튼을 누르고 제품 페이지로 돌아갔을 때 오래된 가격을 보게 되는 것을 방지합니다. 오래된 가격을 보면 저장이 실패한 것처럼 보여서 사용자가 다시 저장 버튼을 누르도록 유도할 수 있습니다. updateTag가 이 문제를 해결합니다.
updateTag는 서버 액션에서만 사용 가능합니다. 다른 곳에서 호출하면 런타임 오류가 발생합니다. 제가 초기에 updateTag와 revalidateTag의 순서를 헷갈려서 한참 삽질했던 기억이 생생합니다. 사용자 경험에 미치는 영향이 생각보다 커서, 이런 디테일이 정말 중요하더라고요.
라우트 핸들러 내부 (웹훅, 외부 서비스):
// app/api/webhooks/stripe/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(req: Request) {
const event = await parseStripeWebhook(req)
if (event.type === 'price.updated') {
revalidateTag(tags.productList, { expire: 0 })
}
return new Response('ok', { status: 200 })
}
updateTag는 라우트 핸들러에서는 사용할 수 없습니다. 여기서 즉시 만료시키는 방법은 { expire: 0 }입니다. 이는 서드파티 시스템이 무언가 변경되었다고 알려주는 웹훅과 같은 시나리오에서 필요합니다.
잠시 오래된 데이터를 보여줘도 괜찮은 백그라운드 업데이트:
revalidateTag(tags.productList, 'max')
Stale-While-Revalidate(SWR) 방식입니다. 사용자는 빠른 캐시 응답을 받지만, 백그라운드에서는 최신 데이터가 로드됩니다. 대부분의 콘텐츠에는 이 방식이 정확합니다. 관리자가 새 게시물을 발행해도 독자가 잠시 동안 이전 목록을 볼 수 있지만, 보통은 허용 가능한 수준입니다.
이 모든 것을 의사 결정 표로 정리하면 다음과 같습니다.
| 상황 | 사용 방법 |
|---|---|
| 사용자가 자신의 데이터를 편집하고 즉시 확인해야 함 | updateTag 다음에 revalidateTag |
| 웹훅이 실행되어 외부 서비스가 즉시 일관성을 필요로 함 | revalidateTag(tag, { expire: 0 }) |
| 백그라운드 새로고침, 잠시 오래된 데이터를 보여줘도 됨 | revalidateTag(tag, 'max') |
이 표를 팀원들이 볼 수 있는 곳에 잘 정리해 두세요. "사용자가 저장 후에도 왜 이전 데이터를 보나요?" 같은 질문을 크게 줄여줄 겁니다.
세 번째 문제: PPR 분할이 기본적으로 보이지 않음
cacheComponents: true 설정으로 Next.js는 부분 사전 렌더링(Partial Prerendering, PPR)을 사용합니다. 페이지는 캐시에서 즉시 렌더링되는 정적 쉘(Static Shell)과 나중에 스트리밍되는 동적 홀(Dynamic Holes)로 구성됩니다. 이로 인한 성능 향상은 분명합니다. 문제는 어떤 부분이 정적 쉘에 포함되고 어떤 부분이 동적 홀로 스트리밍되는지, 무언가 잘못 작동하기 전까지는 명확하지 않다는 것입니다.
cacheLife('seconds')가 적용된 컴포넌트는 조용히 정적 쉘에서 제외됩니다. 캐시 스코프('use cache') 내에서 cookies()를 호출하면 빌드 타임에 "Uncached data was accessed outside of Suspense" 오류가 발생하며, 어떤 컴포넌트인지, 어떤 파일 경로인지 등 유용한 정보는 전혀 제공되지 않습니다. Suspense 경계 없이 동적 컴포넌트를 추가하면 페이지의 일부가 정적 쉘에서 밀려나게 됩니다.
제가 이러한 추측을 멈춘 방법은 컴포넌트 레벨에서 '의도'를 문서화하는 것입니다.
// components/UserCart.tsx
export const boundary = {
name: 'UserCart',
isDynamic: true,
reason: '사용자 세션 쿠키를 읽음 — 사용자마다 다름',
}
그런 다음 이 컴포넌트를 사용하는 페이지에서 그 의도를 명시적으로 참조합니다.
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
// UserCart는 동적 컴포넌트입니다 — 반드시 Suspense로 감싸야 정적 쉘이 깨지지 않습니다.
return (
<div>
<ProductDetails id={id} /> {/* 캐시됨, 정적 쉘의 일부 */}
<RelatedProducts id={id} /> {/* 캐시됨, 정적 쉘의 일부 */}
<Suspense fallback={<CartSkeleton />}>
<UserCart productId={id} /> {/* 동적, 나중에 스트리밍됨 */}
</Suspense>
</div>
)
}
캐시된 컴포넌트는 다음과 같습니다.
async function ProductDetails({ id }: { id: string }) {
'use cache'
cacheLife('hours')
cacheTag(tags.product(id))
const product = await db.query(
'SELECT * FROM products WHERE id = $1', [id]
)
return <article>...</article>
}
동적 컴포넌트에는 'use cache'가 전혀 없습니다.
async function UserCart({ productId }: { productId: string }) {
const cookieStore = await cookies()
const userId = cookieStore.get('user-id')?.value
const cartItem = await db.query(
'SELECT * FROM cart WHERE user_id = $1 AND product_id = $2',
[userId, productId]
)
return cartItem ? <InCartButton /> : <AddToCartButton />
}
정적 쉘은 사용자에게 즉시 로드되고, 장바구니는 나중에 스트리밍됩니다. 이 분할은 알고리즘이 '생존'시킨 결과가 아니라, 의도적이며 문서화된 결과입니다.
한 가지 더 중요한 점: 'use cache' 스코프 내에서 cookies(), headers(), draftMode()를 절대 호출하지 마세요. 대신 외부에서 이 값들을 읽어와 props로 전달하세요. 이 값들은 자동으로 캐시 키의 일부가 되어, 추가 작업 없이 사용자별로 별도의 캐시 항목을 생성합니다.
네 번째 문제: 배포 후 첫 방문자는 항상 콜드 스타트의 고통을 겪는다
이 문제는 버그와는 별개지만 같은 목표와 연결됩니다. 캐싱 설정이 올바르게 되어 있어도, 배포 후 첫 방문자가 페이지에 접속하면 캐시가 비어 있기 때문에 모든 캐시 함수가 순차적으로 처음부터 실행됩니다.
PPR은 캐시가 워밍업되면 빠릅니다. 하지만 배포 후 첫 요청은 그렇지 않습니다.
해결책은 요청 레벨 중복 제거를 위한 React의 cache() 함수입니다. 모든 데이터 페치(fetch)를 페이지 상단에서 컴포넌트가 필요하기 전에 병렬로 실행하세요.
import { cache } from 'react'
import { getProductById, getRelatedProducts } from '@/lib/data'
const prefetch = {
product: cache(getProductById),
related: cache(getRelatedProducts),
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
void prefetch.product(id)
void prefetch.related(id)
return (
<div>
<ProductDetails id={id} />
<RelatedProducts id={id} />
<Suspense fallback={<CartSkeleton />}>
<UserCart productId={id} />
</Suspense>
</div>
)
}
두 페치 모두 즉시 병렬로 실행됩니다. 같은 함수를 호출하는 하위 컴포넌트들은 React의 cache()를 통해 중복 제거된 결과를 받습니다. 사전 페치가 실패해도 조용히 실패하며, 이는 최적화일 뿐 필수 사항은 아닙니다. 하위 컴포넌트에서의 실제 페치도 여전히 작동합니다.
알아두어야 할 중요한 차이점: React의 cache()는 단일 요청 내에서 중복을 제거하고, 'use cache'는 여러 요청에 걸쳐 지속됩니다. 이 둘은 서로 다른 문제를 해결하므로 모두 필요합니다.
완전한 시스템은 어떤 모습일까요?
하나의 태그 파일. 모든 팀원이 이 파일에서 태그를 가져옵니다. 오타는 프로덕션 장애가 아닌 컴파일 오류로 이어집니다.
캐시 무효화 컨텍스트에 대한 명확한 결정: 사용자가 변경 사항을 기다리는 서버 액션에서는 updateTag를 먼저 호출한 다음 revalidateTag를 사용합니다. 라우트 핸들러는 { expire: 0 }와 함께 revalidateTag를 사용합니다. 백그라운드 브로드캐스트는 'max'와 함께 revalidateTag를 사용합니다.
동적 컴포넌트는 문서화하고 항상 Suspense로 감쌉니다. 정적 쉘은 우연히 만들어지는 것이 아니라 명확한 의도를 가지고 구성됩니다.
무거운 페이지 상단에서 병렬로 사전 페치를 실행하여, 배포 후 첫 방문자가 콜드 스타트 비용을 지불하지 않도록 합니다.
이 모든 것은 글로 써두고 나면 복잡하지 않습니다. 가장 어려웠던 부분은 이 모든 것이 필요하다는 것을 깨닫는 것이었고, 이는 충분한 프로덕션 버그를 겪고 나서야 패턴을 파악할 수 있었습니다.
제가 이 시스템에 도달하기까지의 과정은 이 시리즈의 이전 글들에서 다룹니다. 개발 과정이 블랙박스 같았을 때 디버거를 만든 이야기. 컴파일은 되지만 조용히 깨지는 7가지 버그. 빌드 시 아무런 경고도 주지 않고 앱을 망가뜨린 4가지 업그레이드 문제.
완벽한 마이그레이션 참조 자료가 필요하다면, shubhra.dev/tutorials/nextjs-16-cache-components에서 확인하실 수 있습니다.
이러한 엣지 케이스들을 너무 자주 겪다 보니, 결국 이 시스템 전체를 하나의 유틸리티로 만들었습니다. Cache Pro Kit은 이 글의 모든 내용을 프로덕션에 바로 적용할 수 있는 버전입니다. 타입 안전한 태그 레지스트리, 컴파일 타임에 단일 인자 호출을 막는 safeRevalidate, 올바른 순서를 강제하는 serverActionInvalidate, 라우트 핸들러에서 updateTag를 불가능하게 만드는 routeHandlerInvalidate까지. 단 하나의 파일로 lib/에 넣으면 됩니다.
지금 여러분의 캐싱 설정은 어떤가요? 여러분의 프로젝트에서도 이런 문제들을 겪어 보셨나요?
원문: https://dev.to/shubhradev/after-7-nextjs-16-caching-bugs-i-stopped-guessing-and-built-a-system-4ijp 수집일: 2026-06-04 02:28:39