
웹 개발의 딜레마 - 데이터의 신선도냐, 성능이냐
다음과 같은 근본적인 질문을 던져봅시다. 우리는 왜 캐시를 써야 하며, 잘못 썼을 때 잃게 되는 것은 무엇일까요?
이 점을 이해하기 위해 자정이 되는 순간 파격적인 반값 할인이 시작되는 블랙프라이데이 이벤트의 아키텍처를 생각해봅시다.

시작으로부터 1초 전, 수백만명의 사용자가 미친듯이 키보드를 연타하기 시작합니다.
이 때 시스템 설계자는 2가지 설계의 딜레마에 빠집니다.
A: 사용자가 F5로 새로고침을 할 때 마다 데이터 신선도를 위해 한치의 오차도 없이 DB에 쿼리를 날립니다.
그러나 이 방식은 치명적인 대가를 치뤄야 합니다. 1초에 수많은 DB 연결이 이루어지며
서버의 CPU와 Memory는 임계치를 돌파합니다.
병목 현상으로 인해 화면은 하얗게 멈춰버리고, 결국 시스템 전체가 붕괴하고 맙니다.
B: 극강의 성능을 추구해, 메인 페이지를 한번 완벽하게 그려서 캐시에 얼려두고, 빠른 응답을 처리하는 것입니다.
서버 부하는 0에 가깝게 줄어들고, 랜더링 속도는 빛처럼 빨라집니다.
하지만 이번에는 비즈니스에 치명적인 참사가 벌어지는데, 이미 품절된 상품임에도 과거의 데이터가 랜더링됩니다.
백엔드에서 결제 실패로 인해 사용자의 클레임이 폭주할 것입니다.
결론 : 이것이 컴퓨터 과학을 관통하는 전통의 딜레마인 데이터 신선도와 서버 성능 사이의 트레이드오프 입니다.
과거의 Next.js 프레임워크는 이 딜레마를 해결하기 위해
페이지 전체를 정적으로 구워버리는 SSG나, 일정 시간마다 페이지 전체를 갱신하는 ISR 같은
거시적인 캐싱 기술을 사용했습니다.
하지만, 사용자의 로그인 상태 / 실시간 알림 / 1초마다 변하는 주식 가격 등
복잡하고 동적인 데이터가 하나의 화면에 뒤섞여 있는 현대의 애플리케이션에서는 이 방식으로는 해결이 힘듭니다.
따라서, 프레임워크는 진화해야 했습니다.
그래서 기본적으로는 안전하고 신선한 상태인 동적인 랜더링을 유지하되
개발자가 아키텍트의 시선으로 이 컴포넌트는 변하지 않으니 얼려두고, 이 컴포넌트는 실시간으로 가져오라는 명령을
마치 핀셋으로 집어내듯 통제할 수 있는 권한을 프레임워크에게 넘겨준 것입니다.
무조건적 동적 랜더링과 옵트인 캐싱의 차이
우리가 마주할 첫 번째 충격은 Next.js의 기본값이 완전히 뒤바뀌었다는 사실입니다.
일단 평범한 page.tsx 코드를 주의 깊게 봐주세요.
export default async function ProductDetail() {
const res = await fetch(`https://timeapi.io/api/Time/current/zone?....`, {
cache: 'no-store'
})
const data = await res.json()
return (
<div style={{ padding: '40px' }}>
<h1>상품 상세 페이지</h1>
<p>서버 랜더링 시간 : {data.dataTime}</p>
</div>
)
}Next.js 15+ 부터는 캐시 기본값이 캐싱 안함이라고 배웠는데 굳이 왜 손으로 no-store를 적은 걸까요?
이를 이해하려면 Next.js 프레임워크가 내부적으로 굴리는 2가지의 독립적인 캐시 계층을 이해해야 합니다.
Next.js에는 API에서 나온 데이터를 얼려두는 데이터 캐시, 완성된 화면 껍데기를 얼리는 전체 라우트 캐시가 있습니다.

Next.js 15+ 에서 데이터 캐시의 기본값이 저장 안함으로 바뀐 것은 맞으나
하지만 화면 껍데기를 얼려버리는 전체 라우트 캐시의 정책은 여전히 조건만 맞으면, 무조건 얼림이 기본값입니다.
그러면 프레임워크가 껍데기를 정적으로 얼릴지, 매번 동적으로 새로 그릴지 판단하는 실제 기준은 무엇일까요?
프레임워크 컴파일러는 빌드를 돌릴 때 코드를 쓱 훑어보며 다음 조건들을 검사합니다.
1. 이 페이지 컴포넌트 안에 매번 달라져야 하는 정보 즉 쿠키 / 헤더 같은 동적 함수를 사용했나?
2. 주소창에서 동적으로 넘어오는 searchParams를 읽어오고 있는가?
3. 명시적으로 동적 랜더링을 지시하는 force-dynamic이나 no-store가 존재하는가?
만약 우리가 no-store 옵션을 적지 않고, 기본 fetch API 코드를 달랑 적어둔 채 빌드를 돌리면 어떻게 될까요?
이 페이지는 쿠키도 안쓰고, 파라미터도 없으니 정적인 파일로 박제해버립니다.
그러면 서버 리소스를 아끼기 위해 지금 빌드하는 이 순간에 API를 한 번 딱 호출해서 HTML 껍데기로 박제합니다.
결과적으로 데이터 캐시가 꺼져 있더라도, 페이지 껍데기 자체가 빌드 시점의 시간으로 영구 결빙됩니다.
따라서, no-store를 작성해 허락없이 페이지 껍데기를 정적으로 얼리지 말라는 명령인 것입니다.
이제 프로덕션 환경에서 테스트를 했다면, 새로고침을 누를 때마다 화면의 시간이 바뀌는 결과를 맞이합니다.
이처럼 개발자가 승인한 곳만 캐싱하는 것이 옵트인 패러다임입니다.
하지만 기본값인 동적 랜더링만으로 배포한다면, 트래픽이 몰리는 순간 서버가 터질 것입니다.
이제 아키텍트의 정밀한 핀셋을 꺼내들고, 성능을 끌어올리기 위한 유연한 접근법 즉 선택적 캐싱을 지시합니다.

위에서 봤던 page.tsx 코드에서 fetch API의 두번째 인자로 cache: 'force-cache'로 수정해주세요.
export default async function ProductDetail() {
const res = await fetch(`https://timeapi.io/api/Time/current/zone?....`, {
cache: 'force-cache'
})
const data = await res.json()
return (
<div style={{ padding: '40px' }}>
<h1>상품 상세 페이지</h1>
<p>서버 랜더링 시간 : {data.dataTime}</p>
</div>
)
}이 옵션이 적용되면 프레임워크는 최초 1회만 진짜 서버에 다녀오고
그 결과를 Next.js의 거대한 데이터 캐시 창고에 영구적으로 얼려 버립니다.
그 다음부터는 진짜 서버는 쳐다보지도 않고 무조건 창고에서 얼어있는 데이터를 꺼내옵니다.
이것의 결과값은 브라우저에서 아무리 새로 고침을 해도 시간이 완벽하게 멈추게 됩니다.
이렇게 함으로 가장 비용이 많이 드는 API 호출 결과를 통제한 것입니다.
수백만명의 접속자가 몰려와도 DB는 타격이 없고, 얼려둔 스냅샷만 0.001s 만에 던져주는 극강의 성능을 확보했습니다.
과거에는 페이지 전체를 통째로 얼리거나 무식하게 갱신해야 했지만
현대에는 결제나 재고 데이터는 실시간 동적 랜더링으로 남겨두고, 절대 변하지 않는 데이터는 force-cache로 얼립니다.
이는 컴포넌트 단위(fetch 요청 단위)로 아키텍처를 설계한 것입니다.
시간 기반 캐시 갱신 전략(Time-based Revalidation)
비즈니스 세계에서는 영원한 데이터는 없으며 끊임없이 변화됩니다.
하루에 한번씩 업데이트되는 주식 및 환율 정보의 경우를 생각해보세요.
매번 서버 부하를 감당하며, 동적 랜더링을 할 수도 없고, 그렇다고 force-cache로 과거의 데이터만 보여줄 수도 없습니다.
이 때 시스템 설계자는 데이터 신선도와 극강의 성능이라는 딜레마 속에서 타협점을 찾아야 합니다.

평소에는 캐시 창고에서 빠르게 꺼내주어, 서버 부하를 0으로 만들어 성능을 극대화하되
딱 60초에 한번씩만 몰래 진짜 서버에 다녀와서 캐시를 새로운 데이터로 갈아 끼웁니다.
이 전략이 바로 시간을 기준으로 유효성을 다시 검증하는 Time-based Revalidation 입니다.

이를 컴퓨터 과학 용어로는 Incremental Static Regeneration (ISR) 이라고 부릅니다.
과거처럼 수만개의 상품 페이지를 최신화하기 위해 사이트 전체를 처음부터 다시 조립, 빌드하는 것이 아니라
방문자가 발생한 특정 페이지만 백그라운드에서 점진적으로 다시 생성해내는 혁명적인 아키텍처입니다.
이것을 지난 번에 작성한 page.tsx 코드로 다시 작성해보겠습니다.
먼저, fetch의 두번째 옵션에 next: { revalidate: 60 } 옵션을 집어넣습니다.
const res = await fetch(`https://timeapi.io/api/Time/.....`, { next: { revalidate: 60 }})여기서 next 옵션은 오직 Next.js 프레임워크가 자사의 거대한 서버 캐싱 엔진과 통신하기 위해
독자적으로 확장한 특수 객체입니다.

그 안에 revalidate 속성은 데이터에 일정 시간 만큼의 TTL(Time to Live)를 부여한 것입니다.
즉 데이터의 수명과 유효 기간을 정해준 것입니다.
이 옵션의 원리를 시간에 따라 아키텍트의 시선으로 해부해봅시다.
아무도 접속하지 않았던 최초의 순간, 0s의 상황에서는 프레임워크는 캐시 창고가 텅 비어있음을 확인합니다.
즉시 진짜 서버로 가져가 데이터를 가져온 뒤 신선한 데이터를 캐시 창고에 넣으며 60초 타이머를 작동합니다.
이 순간 서버와 통신하는 만큼은 살짝 기다려야 합니다.

이제 1s ~ 59s의 시간대에서는 이벤트가 터져 수만개의 트래픽이 동시에 몰려오지만
프레임워크는 진짜 서버 접속 없이 캐시 데이터를 반환합니다. TTL이 아직 끝나지 않았기 때문입니다.

드디어 약속된 60s가 지나면, 캐시의 유통기한이 끝난 바로 이 시점에 누군가 페이지를 새로고침합니다.
일단 유통기한이 지나 상해버린 낡은 데이터를 전송합니다. 따라서 사용자는 화면 지연 시간을 겪지 않습니다.
하지만 이와 동시에 프레임워크는 백그라운드에서 몰래 진짜 서버로 달려갑니다.
최신 데이터를 가지고 와서 기존의 창고에 낡은 데이터를 신선한 데이터로 갈아끼우고
타이머를 다시 60s로 리셋합니다.

이 매커니즘을 컴퓨터 과학 용어로 SWR(Stale - While - Revalidate)이라고 부릅니다.
즉, 낡은 데이터를 보여주는 동안 재검증한다는 의미입니다.

이 SWR 패턴 덕분에 사용자는 언제나 빛의 속도로 화면을 보게 되고
서버는 수 만명이 몰려와도 60s에 한번만 데이터베이스를 조회하면 되는 평화가 찾아옵니다.
컴포넌트 단위로 캐싱하는 마이크로 캐싱(Micro Caching)
fetch API 기반이 아니라, DB 클라이언트로 직접 조회할 때는 어떻게 해야할까요?
예를 들어 Prisma나 typeORM을 사용한다면?
이 근본적인 해결책이 마이크로 캐싱(Micro Caching) 입니다.
과거 Next.js 14 이전의 시대를 지배했던 핵심 패러다임은 매크로 캐싱(Macro Caching) 이었습니다.
이는 페이지 단위의 거시적이고 투박한 캐싱입니다.
다음과 같은 화면 구조를 예를 생각해봅시다.

화면의 일부에는 변하지 않는 정적 데이터가, 또 일부에는 초마다 변해야 하는 동적 데이터가 있습니다.
매크로 캐싱에서는 서버 부하를 줄이기 위해 페이지 전체를 캐싱해서 얼려버린다면
상품설명은 0.01s 만에 뜨겠지만, 장바구니 아이템과 실시간 재고까지 통채로 얼어버립니다.
반대로, 실시간 정보를 보여주기 위해 페이지 전체를 동적 랜더링해서 사용한다면
특히 사용자의 로그인 상태를 알기 위해 cookies() 함수를 하나라도 적는 순간 기존의 캐시를 무효화 시킵니다.
1초마다 변하는 단 10%의 데이터 때문에 똑같은 90% 데이터까지 매번 서버 CPU에게 쿼리를 날려야 합니다.
이를 위해 Next.js 15+ 에서는 캐싱의 단위를 페이지 전체에서 컴포넌트와 함수 단위로 쪼갰습니다.
이것이 바로 마이크로 캐싱(Micro Caching) 기술입니다.
이제 코드를 통해 구조를 파악해보겠습니다.
먼저 전체 틀을 잡아주는 부모 컴포넌트인 page.tsx를 살펴보겠습니다.
export default function ProductPage() {
return (
<div>
<LiveShoppingCart /> { /* A 컴포넌트 */ }
<LiveStockCount /> { /* B 컴포넌트 */ }
<HeavyProductDescription /> { /* C 컴포넌트 */ }
</div>
)
}그 안에는 3개의 자식 부품이 조립되어 있는데
A, B 컴포넌트는 매번 동적 랜더링되야 하고, C 컴포넌트만 단독으로 얼려 있습니다.
과거였다면 부모 컴포넌트 전체로 통채로 녹이거나 얼려야 했지만 마이크로 캐싱에서는 다릅니다.
이제 다음 HeavyProductDescription 서버 컴포넌트를 봅시다.
async function HeavyProductDescription() {
'use cache'
const data = await db.product.getExtensiveDescrption()
return <article>{data}</article>
}무거운 DB 조회를 수행하는 컴포넌트 최상단에 use cache라는 지시어가 적혀 있는데
부모인 ProductPage와 그 이외의 자식 컴포넌트들이 매번 랜더링하며 뛰어 놀아도
프레임워크는 use cache가 선언된 자식 컴포넌트를 만나는 순간 무거운 DB 연산을 생략합니다.
대신 미리 메모리에 꽁꽁 얼려 두었던 HTML 조각을 0.01s 만에 꺼내어 부모에게 전달합니다.
그리고 나머지 컴포넌트들과 합쳐 완벽한 하이브리드 화면을 만들어냅니다.

이제 이 use cache 지시어를 사용해 마이크로 캐싱 실전 코딩을 진행해보겠습니다.
오늘 사용할 use cache라는 지시어는 과거에는 dynamicIO라는 불안전한 실험적 기능이었으나
이제 최신 버젼의 프레임워크에서는 정식 아키텍처로 당당히 승격되었습니다.
이를 위해 환경 설정이 필요합니다. next.config.ts 파일에 다음과 같이 작성해주세요.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
logging: {
fetches: {
fullUrl: true
}
},
cacheComponents: true
};
export default nextConfig;설정 객체 상단에 cacheComponents라는 속성을 추가하고, 그 값을 true로 설정하면
과거의 복잡함은 버리고 이 단 하나의 직관적인 스위치로 프레임워크에게 선언하는 것입니다.
이제부터 이 프로젝트의 모든 데이터 IO는 기본적으로 실시간으로 처리되며
오직 내가 use cache로 허락한 영역만 캐시 창고에 보관하겠다는 의미입니다.
이 스위치를 킴으로써 비로써 유연하고 다채로운 랜더링 전략이 해방됩니다.
본격적으로, UI 컴포넌트를 작성할텐데, A 존에는 1초마다 변하는 장바구니가 있고
B 존에는 무거운 연산이 필요하지만 한번 계산하면 더 이상 계산할 필요가 없는 상품 상세 설명 영역이 섞여 있습니다.
먼저 부모가 되는 전체 페이지 컴포넌트를 src/app/product/[id]에 page.tsx로 작성합니다.
ProductPage라는 이름의 함수 컴포넌트는 기본적으로 동적 랜더링으로 작동합니다.
export default async function ProductPage({ params } : { params: Promise<{ id: string }>})
{ .... }앞선 포스팅에서도 살펴봤듯이 params를 받을 때는 비동기로 받습니다.
const resolvedParams = await params실시간 데이터를 받기 위해 currentTime 변수에 실시간 시간 데이터를 가져옵니다.
const currentTime = new Date().toLocaleTimeString()이것을 사용하는 UI는 새로고침을 누를 때마다 계속해서 새로운 시간으로 업데이트되는 살아 숨쉬는 공간입니다.
반대로 하단의 HeavyProductDescription 컴포넌트는 다른 전략을 사용할 것입니다.
return (
<div style= {{ padding: '20px' }}>
<h1>프리미엄 무선 헤드폰</h1>
<div style={{ color: 'blue', marginBottom: '20px' }}>
<p>실시간 장바구니 현황 (동적 랜더링)</p>
<p>현재 접속 시간: {currentTime}</p>
</div>
<hr />
<HeavyProductDescription productId={resolvedParms.id} />
</div>
)HeavyProductDescription 파일을 살펴보면, 상단에 use cache 지시어를 통해 마이크로 캐시의 사령관 역할을 담당합니다.
이는 데이터와 데이터를 사용하는 UI(HTML 태그)를 통채로 얼립니다.
'use cache'그리고 테스트를 위해 delay라는 가짜 함수를 만듭니다.
const delay = (ms: number) => new Promise(res => setTimeout(res, ms))그리고 본격적으로 HeavyProductDescription 컴포넌트 함수를 만듭니다.
export default async function HeavyProductDescription({ productId } : { productId: string })
{ .... }의도적으로 2초에 딜레이를 주어 가짜로 시스템에 부하를 넣습니다.
await delay(2000)부모 컴포넌트와 동일하게 cachedTime 이라는 변수에 랜더링된 시간을 담습니다.
const cachedTime = new Date().toLocaleTimeString()화면 하단에 텍스트로 출력하도록 만듭니다.
return (
<div style={{ border: '1px solid gray', padding: '20px' }}>
<h2> 상품 상세 설명 </h2>
<p> 상품 ID ({productId}) 영역은 'use cache'에 의해 0.001초만에 로드됩니다</p>
<p style= {{ color: 'red', fontWeight: 'bold' }}>
설명 랜더링 시간 (얼어있음) : { cachedTime }
</p>
</div>
)코드를 저장하고 F5를 연타해보겠습니다.

화면의 상단 파란색 접속 시간은 F5를 누를 때마다 즉각적으로 갱신되지만
화면의 하단 빨간색 설명 랜더링 시간은 최초의 접속 시간에 완벽하게 멈춰 있습니다.
캐시 TTL & 동적 데이터 스트리밍(PPR)
비즈니스 요구사항이 바뀌어도 영구적으로 얼어붙은 상품 설명 컴포넌트는
개발자가 수정해서 서버를 재시동하지 않는 이상 영원히 변하기 전 설명만을 보게 될 것입니다.

이 치명적인 문제를 해결하기 위해 시스템 설계자는 메모리에 꽁꽁 얼려둔 컴포넌트에
수명을 부여해야만 합니다. 즉 이것이 TTL, 캐시 데이터의 유효기간입니다.
이 유효기간이 만료되면, 사용자 모르게 백그라운드에서 데이터를 가져옵니다.

그리고, 기존의 낡은 부품을 떼어내고 신선한 새 부품으로 감쪽같이 교체해 버리죠.
과거 Next.js 14 이하 시절에는 이 유통기한을 페이지 전체에만 설정할 수 있었습니다.
즉, 장바구니 하나 때문에 상품 설명까지 유효 기간이 짧아지는 비효율이 발생한 것입니다.
하지만, Next.js 15+의 마이크로 캐시 생태계에서는 수십개의 레고 부품마다 각기 다른 정밀한 TTL 수명을 부여해
캐시 생태계를 완벽하게 통제할 수 있게 되었습니다.
그러나 이 상황에도 딜레마는 있는데 만약 어떤 컴포넌트의 TTL이 만료되던 순간에 어떤 사용자가 접속했다고 가정합시다.
서버는 만료된 데이터를 갱신하기 위해 무거운 DB 쿼리를 시작하고, 트래픽이 몰려 이 쿼리가 3s나 걸린다면 어떠할까요?
이것이 웹 개발의 블로킹 현상입니다. 단 하나의 동적인 부품이 늦게 도착한다는 이유로 나머지 화면들까지 도달하지 못합니다.
이 병목 현상을 해결하기 위해 등장한 것이 부분 사전 랜더링(Partial Pre Rendering)과 동적 데이터 스트리밍입니다.

PPR 아키텍처의 핵심 철학은 서버가 완벽한 화면을 그릴 때까지 기다리지 말고, 준비된 것부터 던지라는 것입니다.
사용자가 접속하는 순간 화면은 무거운 쿼리를 기다리지 않습니다. 먼저 정적 데이터는 0.01s 만에 브라우저로 보여주고
나머지 쿼리 작업 중인 컴포넌트는 로딩 스피너나, 로딩 스켈레톤을 통해 구멍으로 남겨놓습니다.
그리고 데이터가 완성되는 순간 서버는 이미 브라우저에 띄워진 화면을 새로고침하지 않고 네트워크 연결 통로를 통해
방금 완성된 데이터만 흘려보냅니다. 스트리밍 기술을 HTML 랜더링에 적용한 것입니다.

브라우저는 이 조각을 받아, 아까 남겨두었던 구멍을 통해 끼워넣기만 합니다.
이처럼 PPR과 스트리밍 기술을 통해 병목 현상을 제거하는 것입니다.
본격적인 PPR 아키텍처 설계를 위해 실습으로 알아보겠습니다.
현재 수백만명이 몰리는 상품 상세 페이지를 만들 것입니다.
이 페이지 안에는 자주 업데이트 되므로, 1년 내내 얼려둘 수 없는 Review 컴포넌트가 있습니다.
이에 따라, 먼저 ProductReview.tsx 컴포넌트를 작성하겠습니다.
코드의 최상단에 use cache 지시어를 통해 이 부품을 캐싱하겠다고 선언합니다.
'use cache'next/cache로부터 cacheLife 함수를 불러옵니다.
import { cacheLife } from 'next/cache'가상의 3초짜리 데이터베이스 지연 함수를 정의합니다.
const delay = (ms: number) => new Promise(res => setTimeout(res, ms))본격적으로 ProductReview 서버 컴포넌트를 작성합니다. props로 productId를 가져옵니다.
export default async function ProductReview({ productId } : { productId: string }) { .... }함수의 내부 로직을 본격적으로 살펴보겠습니다. 먼저 cacheLife() 함수를 사용해 프로필을 적용합니다.
cacheLife('minutes')여기서 컴퓨터 과학의 관점에서 cacheLife가 제어하는 3가지 핵심 생명 주기를 짚고 넘어가봅니다.
1. Stale : 클라이언트 라우터 캐시로 자신이 가지고 있는 캐시를 즉각적으로 보여줍니다.

2. Revalidate : 서버 백그라운드 갱신으로, 일단 기존의 낡은 것을 주고 백그라운드에선 새 캐시를 갱신합니다.

3. Expire: 캐시 폐기로써, 이 시간동안 아무도 페이지 찾지 않을 시 보관되던 캐시는 폐기됩니다.
이 때 다음 사용자는 서버가 화면을 그릴 때까지 기다려야 합니다.

위에서 적은 'minutes' 프로필은 stale 5분, revalidate 1분, expire 1시간으로 우아하게 셋팅합니다.
다음으로, 강제 딜레이를 주고, 시간 데이터를 랜더링하겠습니다.
await delay(3000)
const renderTime = new Date().toLocaleTimeString()
return ( .... )여기서 부모 컴포넌트에서 위 컴포넌트를 끼워넣겠습니다.
export default async function ProductPage({ params } : { params: Promise<{ id: string }> })
{
const resolvedParams = await params
return (
<div style={{ padding: '20px' }}>
<h1>프리미엄 무선 헤드폰</h1>
<p>1년 내내 변하지 않는 아주 빠른 상품 기본 정보입니다.</p>
<hr />
<Suspense fallback={<p style={{ color: 'red' }}>리뷰 데이터를 불러오는 중입니다</p>}>
<ProductReview productId={resolvedParams.id} />
</Suspense>
</div>
)
}Suspense 컴포넌트로 이 자식 컴포넌트를 감싸주었습니다.
내부에 use cache가 선언된 컴포넌트를 Suspense로 감싸는 순간 정적 껍데기의 뚫어놓은 구멍임을 이해하고
백그라운드에서 PPR 마법을 가동합니다.
이를 적용 시, Suspense를 제외한 정적 껍데기는 즉시 튀어나오고, 구멍에는 3초동안 빨간 로딩 글씨가 보여집니다.
3초 뒤 브라우저 깜빡임 없이 물 흐르듯 스트리밍 됩니다.
빌드 콘솔 화면에 Prerendered Shell 아이콘이 생긴것을 확인할 수 있습니다.

캐시 무효화의 난제 - Cache Stale Issue
현실의 비즈니스 세계는 우리의 통제를 언제나 벗어납니다.
어떤 사용자가 상품에 대해 최악의 악성 리뷰를 했다고 가정해봅시다.
이 리뷰는 cacheLife에 따라 0.01s 만에 메모리 창고에 얼어붙게 됩니다.
관리자가 어드민 페이지에서 삭제 버튼을 누르지만 (DB에서는 삭제됨), Next.js 웹 캐시 창고에는 남아있습니다.
이것이 바로 Cache Stale Issue 입니다. 데이터베이스의 원본과 동기화가 끊어진 과거의 데이터를 보여주는 것입니다.

시스템 설계자는 정해진 시간을 기다리는 수동적인 태도를 버리고, 데이터베이스가 변경되는 즉시
아키텍트의 권한으로 개입해서 얼어있는 기존 데이터를 강제로 부숴야 합니다.
이것이 컴퓨터 과학에서 어렵다고 손꼽히는 난제, On-Demand Cache Invalidation (캐시 무효화) 입니다.
하지만 리뷰 하나가 지워졌다고 전체 페이지의 캐시를 날려서도 안될 일입니다.
Next.js 16+은 불가능에 가까운 난제를 우아하게 해결할 수 있도록 3자루의 무기를 제공합니다.

첫번째 무기는 넓은 범위를 한번에 정리하는 revalidatePath 입니다.
특정 URL 주소에 묶여있는 화면 전체의 캐시를 날려버리는 가장 직관적인 무기입니다.
실무에서는 회사의 소개 페이지나, 블로그 게시글을 수정했을 때 활용하기 좋습니다.
블로그 글을 수정했다면, 복잡하게 태그 나눌 필요 없이 주소 전체를 새로고침하는 것이 좋기 때문입니다.
단일 페이지 업데이트에 최고의 효율을 냅니다.
두번째 무기는 부드럽고 우아한 백그라운드 갱신을 담당하는 revalidateTag 입니다.
즉시 캐시를 파괴하는 대신, 이 캐시는 낡았다고 마킹을 해둡니다.
그리고 유저가 다음번에 방문할 때 화면이 멈추는 일 없이 백그라운드에서 부드럽게 최신 데이터로 교체합니다.
단 방아쇠를 당길 때 반드시 max 같은 생명주기 설정이 있어야 합니다.
실무에서는 전체 공지사항 배너나, 글로벌 네비게이션 메뉴를 갱신할 때 완벽하게 쓰입니다.
이런 부품들은 쇼핑몰의 수많은 페이지에 동시에 떠 있으므로, 배너 하나하나에 revalidatePath 처리를 할 수는 없을 것입니다.
그대신 데이터에 global_banner라고 이름을 붙이고, revalidateTag를 처리하면 모든 것이 해결됩니다.
마지막 세번째는 단 1초의 지연도 허락하지 않는 updateTag 입니다.
관리자가 삭제 버튼을 누르는 즉시 서버 캐시를 완전히 파괴하고, 현재 화면을 보고 있는 클라이언트의 브라우저 화면까지
즉각적으로 다시 그리도록 강제합니다.
하지만 너무 강력해서 오직 Server Action 이라는 엄격한 벙커 내부에서만 쏠 수 있다는 제약이 있습니다.
실무에서는 내 리뷰 즉시 삭제나 장바구니 담기 등 즉각적 반응이 필요할 때 유용합니다.
실무 - Next.js의 첫번째 캐시 통제 도구 revalidatePath
revalidatePath는 이름 그대로 특정 경로를 타겟으로 삼아
그 주소에 얽혀있는 풀 라우트 캐시와 데이터 캐시를 한번에 싹 날려버리는 직관적인 도구입니다.
기본 문법은 초기화하고 싶은 주소를 문자열로 적어주면 됩니다.

만약 상품과 관련된 동적 라우팅으로 된 페이지를 모두 날리려면 어떻게 해야 할까요?
이 때는 2번째 인자로 layout 옵션을 추가합니다.

먼저 Server Actions 코드를 actions.ts에 작성하겠습니다.
'use server'
import { revalidatePath } from 'next/cache'
let mockReviews = [
{ id: '1', productId: '123', content: '정말 훌륭한 상품입니다.', author: '김유저' },
{ id: '2', productId: '123', content: '정말 쓰레기 같네요!', author: '악플러' }
]
export async function getReviews(productId: string) {
await new Promise(res => setTimeout(res, 1000))
return mockReviews.filter(r => r.productId === productId)
}
export async function deleteReviewAction(reviewId: string, productId: string)
{
try {
mockReviews = mockReviews.filter(r => r.id !== reviewId)
console.log(`[DB 갱신] 리뷰 ${reviewId} 영구 삭제`)
revalidatePath(`/product/${productId}`)
console.log(`[캐시 파괴] /product/${productId} 최신화 완료`)
return { success: true }
} catch (e) {
console.error(`삭제 실패`)
return { success: false, message: "삭제에 실패했습니다." }
}
}가장 먼저 mockReviews라는 배열을 만들어 임시 데이터베이스 역할을 맡겼습니다.
그 아래 getReviews 함수는 실제 데이터베이스 조회를 흉내내기 위해 1초의 의도적인 네트워크 지연시간을 줍니다.
가장 핵심은 deleteReviewAction이라는 서버 액션 함수인데, reviewId와 productId를 전달받아 백엔드에서 조용히 실행됩니다.
첫번째로 filter 함수를 이용해 임시 데이터베이스에서 악성 댓글을 필터링합니다.
두번째로, revalidatePath 문법을 이용해 캐시 창고에 단단하게 얼어있던 과거의 데이터를 즉시 파괴해버립니다.
지금 우리는 상품 상세페이지 단 하나의 페이지를 파괴하는 것이므로, layout 옵션을 추가로 적지 않습니다.
마지막으로, 성공 여부 및 메시지를 반환하여 프론트엔드와의 소통 창구를 열어둡니다.
이제 프론트엔드 파일을 작성할텐데, 아키텍처 유연성을 위해 리뷰 목록을 독립된 컴포넌트 파일로 분리합니다.
참고로 src/app/products/[id] 안에 page.tsx와 ReviewList.tsx 파일을 동시에 위치합니다.
이렇게 되면 상품 상세 페이지 전용 컴포넌트라는 것을 시스템 구조상으로 알리게 됩니다.
ReviewList.tsx의 코드를 직접 살펴봅니다.
'use cache'
import { getReviews, deleteReviewAction } from '@/app/actions'
export default async function ReviewList(
{ paramsPromise } :
{ paramsPromise : Promise<{ id: string }> }
) {
const resolvedParams = await paramPromise
const productId = resolvedParams.id
const reviews = await getReviews(productId)
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{reviews.map((review) => {
const handleDelete = deleteReviewAction.bind(
null, review.id, productId
) as (formData: FormData) => Promise<void>
return (
<li
key={review.id}
style={{ marginBottom: '15px' }}
>
<strong>{review.author}</strong>
<form action={handleDelete}>
<button
type="submit"
>
리뷰 삭제
</button>
</form>
</li>
)
})}
</ul>
)
}우선 파라미터를 받아올때 paramsPromise를 통해 Next.js 최신의 비동기 파라미터 방식을 따랐습니다.
await paramsPromise로 상품의 ID를 먼저 풀어냅니다. 그리고 가짜 데이터베이스에서 리뷰 목록을 조회합니다.
가장 중요한 handleDelete에서 deleteReviewAction.bind() 통해 Server Actions를 준비하는 과정에서
as formData 타입스크립트 코드를 덧붙입니다.
백엔드 로직은 성공여부를 반환하도록 확장성을 열어두었지만 프론트엔드의 폼 태그는 아무것도 반환하지 않는
순수한 함수를 기대하기 때문에 타입스크립트 에러가 발생할 것입니다. 따라서 as 키워드를 통해
폼이 기대하는 타입에 맞게 다리를 놓아준 것입니다.
이제 page.tsx에서 UI를 보여주겠습니다.
export default function ProductPage({ params } : { params: Promise<{ id: string }> }) {
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h1>프리미엄 헤드폰 상세페이지</h1>
<hr style={{ margin: '20px 0' }} />
<h3>고객 리뷰</h3>
<Suspense fallback={<p style={{ color: 'red', fontWeight: 'bold' }}>Loading..</p>}>
<ReviewList paramsPromise={params} />
</Suspense>
</div>
)
}대규모 CDN의 캐시 태그 - revalidateTag
위에서 말한 악성 댓글이 여러 페이지, 서로 다른 주소에 노출되어 있다면 어떠할까요?

우리는 revalidatePath를 4번이나 하드 코딩하여 적어넣어야 할까요?

이러한 방식은 URL 기준으로 캐시를 관리하는 자체가 과거 2000년대의 웹 아키텍처 패러다임이었습니다.
그러나, 현대의 웹은 동일한 데이터가 수십 개의 컴포넌트로 흩어지는 블록 구조입니다.
패러다임을 전환하여 주소가 아닌 데이터 자체를 바라봐야 합니다.

이러한 추적과 동기화 아키텍처를 완벽하게 구현해낸 것이 바로 Fastly, Cloudflare 같은 CDN 기업들입니다.
이러한 글로벌 캐시 동기화에서 캐싱의 최종 진화 형태가 Surrogate Key 기술입니다.

여기서 Surrogate는 본래 대리인, 대체물을 뜻하는데, 데이터베이스와 캐시 아키텍처에서는
원본 데이터를 대신하여, 시스템 전반에서 해당 데이터를 식별하고 추적하게 해주는 대리 키로 사용됩니다.
즉, 물리적인 주소에 얽매이지 않고, 원본 데이터가 입고 있는 가상 이름표 / 식별자 역할을 합니다.

예를 들어, 쇼핑몰에 Oliver라는 유저가 456번 리뷰 ID에 악성 리뷰를 적었다고 가정해봅시다.
서버는 이 456번 리뷰 컴포넌트를 캐시 창고에 얼려둘 때,
내부적으로 HTTP 헤더에 보이지 않는 특별한 식별표를 함께 발급합니다.
캐시 엔진은 이 식별표들을 모아, 마치 책에 맨 뒤에 있는 색인처럼 거대한 장부를 만듭니다.
review-456이 포함된 곳은 Home, MyPage, Detail이라고 꼼꼼히 기록해둡니다.

누군가 데이터를 삭제하면, 엔진은 주소를 찾는 대신 이 거대한 색인 장부를 펴서
해당 태그가 묻어있는 모든 얼음 조각(캐시)을 순식간에 찾아내 일제히 파괴합니다.
Next.js 16은 이 아키텍처를 도입하면서 과거 Next.js 15까지 있던 파편화된 규칙들을 모두 버리고
정교한 헤더 생성과 색인 기록 과정을 내부로 숨기고, 아키텍트가 직관적으로 태그만 선언하면 되는
강력한 cacheTag라는 공식 API를 새롭게 탄생시켰습니다.
다음과 같이 cacheTag로 대리 키(Surrogate Key)를 붙여 이름표를 부여합니다.
import { cacheTag } from 'next/cache'
export async function getReview(id: string) {
'use cache'
cacheTag(`review-${id}`, `product-123-reviews`)
const res = await fetch(`https://api.example.com/reviews/${id}`)
return res.json()
}이제 이 컴포넌트가 메인 화면에 쓰이든 마이페이지에 쓰이든 상관없이
이 캐시가 복사되어 쓰이는 모든 곳에는 이 대리 키가 평생 따라다니게 됩니다.
여기서 이름표를 무더기로 붙이는 것보다 더 중요한 것은 대리 키의 명명규칙을 사전에 체계적으로 설계하는 것입니다.
수백 개의 API와 수만 개의 컴포넌트가 얽히는 엔터프라이즈 환경에서 언제 누가 코드를 보더라도
직관적으로 타겟 지점을 찾을 수 있도록 규칙을 세우는 것이 시스템 아키텍트의 중요한 역량입니다.
이제 Next.js 16이 제공하는 cacheTag로 태깅을 하고 revalidateTag, updateTag로 정밀하게 제어해보겠습니다.
이것은 기기의 성능이나, 네트워크 환경에 구애받지 않고 모든 사용자에게 속도와 매끄러운 경험을 제공합니다.
먼저 revalidateTag는 부드러운 백그라운드 동기화를 담당하는데 데이터를 즉각적으로 파괴하지 않습니다.
대신 시스템 중앙 통제실에 이 태그가 붙은 데이터는 이제 낡았다고 조용히 보고만 올립니다.
이후 누군가 그 데이터를 요청하면, 일단 낡은 캐시를 보여주고, 시스템이 알아서 백그라운드에서 데이터를 교체합니다.
이 revalidateTag의 문법을 살펴보면, 반드시 2개의 인자를 받게 됩니다.
첫번째 인자로는 우리가 타겟으로 삼을 tag의 이름을 문자열 형태로 넣고
두번째 인자로는 캐시가 어떤 생명주기를 따를지 결정하는 profile 이름을 문자열 형태로 넣습니다.
보통 max 프로필을 작성하는데, 프레임워크가 기본적으로 제공하는 사전 정의된 캐시 프로필입니다.
캐시를 최대한 길게 유지하면서, 백그라운드 갱신을 처리하겠다는 강력한 생명주기 단위입니다.
max는 SWR(Stale-While-Revalidate) 패턴을 따르는 캐시 프로필입니다.
이러한 경우는 글로벌 공지사항 배너처럼 사용자가 몇 초 늦게 반영된 데이터를 보더라도
문제 없는 비동기적이고 범용적인 데이터 갱신 상황에 아주 탁월하게 쓰입니다.
반면, 1초의 지연도 허용하지 않는 즉각 동기화를 원한다면 updateTag를 사용해야 합니다.
함수가 호출되는 즉시, 서버의 캐시를 산산조각 내고, 클라이언트에게 최신 데이터를 다시 그리라고 강제합니다.
코드를 살펴보면, 인자로 태그 이름만 직관적으로 전달받습니다.
이 코드는 오직 서버 액션 내부에서만 사용할 수 있다는 엄격한 제약이 따릅니다.
이 도구가 적절한 상황은 장바구니에 물건 담기나, 내 리뷰 즉시 삭제하기 처럼
사용자가 버튼을 누른 즉시 화면에 완벽하게 반영해야 하는 반응형 인터렉션에 필수적입니다.
이제 revalidateTag, updateTag의 예시를 살펴보겠습니다.
다양한 이름의 사용자의 데이터가 담긴 임시 데이터베이스를 tagAction.ts 파일에 구성하겠습니다.
'use server'
let mockReviews = [
{
id: '1',
content: '디자인이 정말 아름답네요. 문제 해결의 정수가 담겨있습니다!',
author: 'stella'
},
{
id: '2',
content: '음질이 끔찍하네요! 당장 환불해주세요! (악성리뷰)',
author: 'Oliver'
},
{
id: '3',
content: '배송도 빠르고 기술력이 돋보입니다!',
author: 'Chloe'
},
{
id: '4',
content: '최고의 사용자 경험입니다.',
author: 'Noah'
}
]
export async function getReviews() {
await new Promise(res => setTimeout(res, 500))
return mockReviews
}최상단에 선언된 'use server'는 클라이언트 브라우저와 백엔드 서버 사이의 절대적인 실행 경계를 긋는
강력한 지시어로써, 이 구문이 파일 맨 위에 선언되는 순간 파일 내에서 export 되는 모든 함수는
즉시 server actions로 변환됩니다.
자세한 원리는 프레임워크가 빌드 시점에 이 함수를 브라우저용 자바스크립트 번들에서 완전히 제외시킵니다.
그리고, 클라이언트가 이 서버 함수를 호출할 수 있도록 보이지 않는 뒷단에 자동으로 API 엔드포인트를 생성해줍니다.
이 선언의 목적은 복잡한 API 경로 설계 없이 브라우저에서 서버의 데이터베이스나 내부 보안 로직을
안전하게 실행하는 로직을 만들어주는 것입니다.
그리고, 그 아래에 mockReviews라는 배열을 만들어, 서버가 켜져있는 동안 데이터를 유지하는
임시 데이터베이스 역할을 부여했습니다.
그리고, 그 아래에 getReviews() 함수는 데이터를 가져오는 역할로, 내부에 0.5s의 네트워크 지연을 구성했습니다.
이를 통해 캐시가 적용되었을 때와 그렇지 않았을 때 성능 차이를 체험할 수 있게 됩니다.
이제 데이터베이스 정보를 화면에 보여주는 프론트엔드 UI 컴포넌트를 작성하겠습니다.
page.tsx 파일입니다. 여기서 데이터에 대리 키를 부여하는 작업이 진행됩니다.
'use cache'
import { getReviews } from '@/app/actions/tagAction'
import { cacheTag } from 'next/cache'
import DeleteButton from './DeleteButton'
export default async function ProductPage() {
cacheTag('global-reviews')
const reviews = await getReviews()
return (
<div style={{ padding: '20px' }}>
<h1>프리미엄 헤드폰 상세 페이지</h1>
<hr />
<h3>전 세계 고객 리뷰</h3>
<ul>
{reviews.map(review => (
<li key={review.id} style={{ marginBottom: '15px' }}>
<strong>{review.author}</strong>은 다음과 같이 평가합니다 : {review.content}
<DeleteButton reviewId={review.id} />
</li>
))}
</ul>
</div>
)
}최상단에 문자열 형태로 선언된 'use cache'는 컴포넌트나 함수의 반환값을
통채로 캐싱하겠다는 명시적이고 절대적인 선언입니다.
구체적인 동작 원리를 살펴보면, 이 컴포넌트를 매 사용자의 요청마다 무겁게 새로 랜더링하지 않습니다.
대신 최초의 요청 시점에 단 한번만 코드를 실행하고, 그 결과를 서버에 영구적인 캐시 스토리지에 저장해둡니다.
이후에 모든 사용자 요청은 내부 로직을 다시 실행하지 않고, 이 얼어붙은 캐시를 즉시 반환 받습니다.
무거운 동적 랜더링이 마치 초고속 정적 파일처럼 서비스됩니다.
그리고 컴포넌트 내부에 첫 줄에 작성된 cacheTag() 라는 코드가 바로 이름표 부여 작업입니다.
프레임워크는 이 얼음조각을 저장할 때 겉면에 global-reviews 라는 대리 키를 단단히 붙여두어
나중에 화면 어디서든 이 캐시를 쉽게 찾아내 제어할 수 있도록 색인 장부에 꼼꼼히 기록해둡니다.
이제 tagActions.ts로 돌아가 삭제하는 2가지 방식의 서버 액션을 작성합니다.
import { revalidateTag, updateTag } from 'next/cache'
export async function deleteReviewSoftAction(reviewId: string) {
mockReviews = mockReviews.filter(r => r.id !== reviewId)
revalidateTag('global-reviews', 'max')
}
export async function deleteReviewInstantAction(reviewId: string) {
mockReviews = mockReviews.filter(r => r.id !== reviewId)
updateTag('global-reviews')
}두 함수 모두, 임시 데이터베이스인 mockReviews 배열에서 filter() 함수를 이용해 id와 일치하는 악성 리뷰를 도려냅니다.
차이는 그 직후에 캐시를 다루는 방식에 있습니다.
deleteReviewSoftAction() 함수에는 revalidateTag() 함수를 호출하여 global-reviews 태그와 max 프로필을 전달했습니다.
이는 당장 캐시를 지우는 것이 아니라 조용히 낡은 캐시라고 표시만 해두는 방식입니다.
반면 deleteReviewInstantAction() 함수에는 updateTag()에 오직 global-reviews 태그 하나만 전달하여 호출했습니다.
이는 데이터베이스 삭제 직후 관련된 모든 캐시를 물리적으로 즉시 붕괴시키는 파괴적인 명령입니다.
우리는 리뷰 삭제가 화면에 즉각적으로 삭제해야 하므로, updateTag() 방식이 아키텍처 설계상 올바릅니다.
이제 클라이언트에서 서버 액션을 호출하는 DeleteButton 컴포넌트를 만들어보겠습니다.
'use client'
import { deleteReviewInstantAction } from '@/app/actions/tagAction'
export default function DeleteButton({ reviewId } : { reviewId: string }) {
return (
<button
onClick={() => deleteReviewInstantAction(reviewId)}
style={{ marginLeft: '10px', color: 'red', cursor: 'pointer' }}
>
리뷰 즉시 삭제
</button>
)
}