
Next.js 캐싱: 프론트엔드가 주도하는 캐싱 전략
Next.js 캐싱: 프론트엔드가 주도하는 캐싱 전략
프론트엔드도 캐싱을 말할 수 있어야 합니다
부하 테스트나 캐싱 전략 회의에 들어가면, 보통 백엔드 엔지니어 분들이 API 캐시나 CDN 캐시 이야기를 먼저 꺼냅니다. 프론트엔드 개발자로서 개인적으로 아쉬움을 느끼는 부분입니다.
클라이언트와 프론트엔드 서버 환경에서도 트래픽을 분산시키고 사용자 경험을 끌어올릴 수 있는 캐싱 기법이 많습니다. 동료들에게 프론트엔드 캐싱이 얼마나 강력한 무기인지 꼭 공유하고 싶었습니다.
우리가 다루는 Next.js는 단순한 화면 렌더링 도구가 아닙니다. 브라우저 메모리부터 프론트엔드 서버 디스크까지 다층 캐시 시스템을 내장하고 있고, 이 구조를 제대로 다루는 것 자체가 프론트엔드 주도의 아키텍처 설계 핵심입니다.
이 글에서는 Next.js 15, 16의 캐싱 메커니즘을 정리한 뒤, 실제 예시 캐싱 아키텍처를 공유합니다. 실제 겪은 요구사항에 가상의 상황을 더해 하나의 예시로 구성했습니다.

프레임워크가 알아서 해준다지만, 캐시를 알아야 하는 이유
Next.js 공식 문서의 Caching 파트를 보면 이런 문구가 있습니다.
“이 페이지는 Next.js의 내부 작동 방식을 이해하는 데 도움이 되지만, Next.js를 생산적으로 사용하는 데 필수적인 지식은 아닙니다. 대부분의 Next.js 캐싱 휴리스틱은 API 사용에 의해 결정되며, 최소한의 구성으로도 최상의 성능을 발휘하도록 기본값이 설정되어 있습니다.” — Next.js 공식 문서 - Caching
핵심 뉘앙스는 “최적의 캐싱을 내장해 뒀으니 비즈니스 로직에 집중하라”는 겁니다.
하지만 실무에서는 이야기가 다릅니다. 단순히 성능 향상만 생각한다면 프레임워크 기본값에 맡겨도 괜찮습니다. 문제는 적절한 타이밍에 최신 데이터를 유저에게 보여줘야 하는 비즈니스 환경입니다.
어떤 캐시가 적용되어 있는지 파악해야 하고, 비즈니스 요구사항을 지키려면 어떻게 코드를 선언하고 무효화할지 알아야 합니다.
이 글에서는 ‘프레임워크에 맡겨도 되는 캐시’와 ‘직접 코드로 통제해야 하는 캐시’로 나누어 각 레이어를 파헤쳐 보겠습니다.
| 캐시 레이어 | 관여 필요도 | 저장 위치 | 무효화 방법 |
|---|---|---|---|
| Request Memoization | 맡겨도 됨 | 서버 메모리 (렌더링 중) | 자동 소멸 |
| Data Cache | 직접 통제 | 서버 디스크 / Redis | revalidateTag, revalidatePath |
| Full Route Cache | 직접 통제 | 서버 디스크 | Data Cache 무효화 시 연쇄 갱신 |
| Router Cache | 직접 통제 | 브라우저 메모리 | router.refresh(), staleTimes 설정 |
프레임워크에 맡기는 영역: Request Memoization
하나의 렌더링 사이클 내에서 동일한 URL + 동일한 옵션의 GET 요청은 자동으로 중복 제거(Dedup)됩니다. 컴포넌트 트리 어디서든 같은 fetch를 호출하면 실제 네트워크 요청은 1번만 나가고, 나머지는 메모리에 캐싱된 응답을 재사용합니다. 렌더링이 끝나면 캐시도 소멸하므로 별도 무효화가 필요 없습니다.
이건 Next.js 고유 기능이 아니라 React가 fetch API를 확장해 제공하는 기능입니다.
Pages Router 시절과의 비교
Pages Router에서는 서버 데이터를 가져오려면 getServerSideProps나 getStaticProps에서 fetch 한 뒤, 페이지 컴포넌트에 props로 내려주거나 Context로 뿌려야 했습니다. 하위 컴포넌트가 같은 데이터를 필요로 해도 root에서 한 번 가져와서 props drilling 하거나, 상태 관리 라이브러리를 붙여야 했죠.
아래 그림을 보면, 컴포넌트 트리의 여러 곳에서 Request A, B, C를 각각 호출하더라도, 최종적으로 Memoized Requests는 A, B, C 각 1번씩만 실행됩니다.

App Router에서는 이 Memoization 덕분에 각 컴포넌트가 필요한 데이터를 직접 fetch 해도 됩니다. 같은 요청이면 어차피 한 번만 나갑니다.
코드로 보면
// api.ts
export async function getUser(id: string) {
// React가 동일한 URL의 요청을 가로채어 한 번만 실행합니다.
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
// Layout.tsx (최상위)
export default async function Layout() {
const user = await getUser('123'); // API 실제 호출 (1회)
return <Profile />;
}
// Profile.tsx (하위 컴포넌트)
export default async function Profile() {
const user = await getUser('123'); // 메모리에서 즉시 반환
return <div>{user.name}</div>;
}
각 하위 컴포넌트가 동일한 fetch를 독립적으로 선언해도 괜찮으니 컴포넌트 응집도가 올라갑니다.
직접 통제해야 하는 영역 1: Data Cache
한 번 불러온 데이터를 서버(디스크/Redis)에 저장해 두고, 사용자 요청 전반에 걸쳐 재사용하는 영역입니다. 프론트엔드 서버 부하를 줄이는 1차 방어선입니다.
v15/16의 철학적 변화
과거 v14에서는 fetch 호출 시 자동으로 캐싱(force-cache)이 적용됐습니다. v15부터는 기본값이 no-store(Dynamic by Default)로 변경됐습니다. 명시적으로 옵션을 선언하지 않으면 매번 새로운 데이터를 가져옵니다.
캐시 선언 방법
fetch 옵션의 next 객체로 캐싱 기간과 태그를 선언합니다.
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: {
revalidate: 3600, // Time-based: 1시간마다 백그라운드에서 최신화
tags: ['post-list'] // On-demand: 수동으로 캐시를 날리기 위한 태그
}
});
return res.json();
}
캐시 무효화 방법
게시글이 새로 작성되거나 수정됐을 때, Server Actions나 API Routes에서 무효화 코드를 호출합니다.
'use server'
import { revalidateTag, revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.insert(...);
// Data Cache 무효화 트리거
revalidateTag('post-list'); // 'post-list' 태그로 묶은 캐시 일괄 삭제
// revalidatePath('/posts'); // 특정 라우트 경로 단위로 삭제할 때
}
다중 서버 환경에서의 주의사항
Vercel이라면 프레임워크가 전역 캐시 무효화를 처리하지만, 자체 인프라(AWS ECS, EKS 등)에서 여러 대의 프론트엔드 서버를 운영할 때는 주의가 필요합니다.
기본적으로 Next.js Data Cache는 각 서버의 로컬 디스크에 저장됩니다. A 서버에서 revalidateTag를 호출해도, B 서버나 C 서버의 로컬 캐시는 그대로 남아있습니다. 로드밸런싱에 따라 유저마다 다른 과거 데이터를 보게 되는 문제가 발생합니다.
해결책은 Custom Cache Handler를 구현해 Redis 같은 중앙 저장소를 사용하는 것입니다. 모든 인스턴스가 동일하게 캐시를 공유하고 일괄 무효화가 가능해집니다.

위 그림처럼 공유 캐시가 없으면 Instance A, B, C 각각이 독립적으로 백엔드에 요청하고 로컬에 저장합니다. A에서 캐시를 무효화해도 B, C에는 여전히 과거 데이터가 남아있는 구조입니다.
이 문제를 해결하려면 인스턴스 사이에 Redis를 두고, Pub/Sub 방식으로 revalidate 이벤트를 발행 → 각 인스턴스가 구독하여 일괄 캐시 무효화하는 형태가 이상적입니다. 다만 Redis 인프라 추가, Pub/Sub 구독 관리, 장애 시 fallback 처리 등 운영 복잡성이 올라가는 트레이드오프가 있습니다. 현재 우리 환경에서는 이 복잡성을 감수하기보다, TTL 기반으로 충분히 방어 가능한 구간에서 운영하고 있습니다.
직접 통제해야 하는 영역 2: Full Route Cache
Data Cache가 ‘데이터 조각’을 캐싱한다면, Full Route Cache는 ‘페이지 렌더링 결과물(HTML + RSC Payload)’ 전체를 서버 디스크에 저장합니다. Pages Router 시절의 SSG가 진화한 형태입니다.
SSG와 뭐가 다른가
RSC Payload의 존재: HTML뿐만 아니라 React Server Component Payload를 함께 캐싱합니다. 클라이언트에서 링크 이동 시 전체 페이지 로드 없이 이 페이로드만 받아와 부드럽게 부분 렌더링을 수행합니다.
PPR과의 결합: 페이지 전체를 정적으로 굳힐 필요가 없습니다. 변하지 않는 레이아웃 셸만 캐싱하고, 유저 장바구니 같은 동적 영역만 요청 시점에 끼워 넣습니다.
Data Cache와의 연결: Data Cache가 revalidateTag로 무효화되면, 해당 데이터를 참조하던 Full Route Cache도 서버에서 즉시 다시 빌드됩니다.
Full Route Cache가 깨지는 순간 (Auto Opt-out)
Next.js는 빌드 시점에 최대한 많은 페이지를 정적으로 렌더링해 Full Route Cache에 담으려 합니다. 하지만 개발자의 코드 한 줄이 이 캐시를 조용히 무력화할 수 있습니다.
Dynamic API 사용의 함정: 페이지 컴포넌트나 하위 컴포넌트 어디서든 cookies(), headers()를 호출하거나 searchParams prop을 읽는 순간, Next.js는 “이 페이지는 요청마다 데이터가 달라진다”고 판단해 Full Route Cache를 포기합니다.
동적 라우트의 누락된 Params: App Router의 동적 라우트(app/posts/[id]/page.tsx)에서 generateStaticParams를 선언하지 않으면 기본적으로 요청 시점에 동적 렌더링합니다.
코드 리뷰가 필수인 이유: 정적으로 잘 빌드되던 초고속 페이지가, 동료가 추가한 ‘현재 로그인 유저 쿠키 확인’ 로직 하나로 캐시가 깨지는 일이 실무에서 빈번하게 일어납니다. “이 랜딩 페이지는 무조건 정적 캐시를 타야 한다”는 합의가 있다면, 리뷰 과정에서 Dynamic API가 섞여 들어오지 않는지 추적해야 합니다.
직접 통제해야 하는 영역 3: Router Cache (클라이언트 메모리)
무엇을 캐싱하는가
Router Cache는 브라우저 메모리에 RSC Payload를 저장합니다. 유저가 <Link>를 클릭해 다른 페이지로 이동하면, 서버에서 받아온 React Server Component Payload(해당 페이지의 렌더링 결과물)를 브라우저 메모리에 보관합니다. 이후 같은 페이지를 다시 방문하거나 뒤로 가기를 누르면, 서버에 재요청하지 않고 메모리에 저장된 Payload를 꺼내 즉시 화면을 그립니다.
쉽게 말해, “이미 방문한 페이지의 렌더링 결과를 브라우저가 기억하고 있다가 재활용하는 캐시”입니다.
왜 문제가 되는가
실무에서 “수정 완료를 눌렀는데 이전 화면이 나와요!”라는 버그 리포트의 대부분은 이 Router Cache 때문입니다. 서버에서 아무리 revalidateTag로 Data Cache를 날려도, 브라우저 메모리에 이전 RSC Payload가 남아있으면 유저에게는 옛날 화면이 보입니다. 서버는 이미 최신 데이터인데, 브라우저가 캐시된 이전 렌더링 결과를 보여주는 겁니다.
staleTimes: 얼마나 오래 캐싱되는가
Router Cache가 저장된 RSC Payload를 얼마나 유지할지는 페이지 유형에 따라 다릅니다:
- Static Routes (정적 페이지): 5분(300초) 동안 캐시 유지. 5분 안에 재방문하면 서버 요청 없이 즉시 표시
- Dynamic Routes (동적 페이지): v14에서는 30초였으나, v15/16부터는 0초로 변경. 매번 서버에서 최신 Payload를 받아옴
여기서 말하는 Static/Dynamic은 Next.js가 빌드 시점에 판단한 페이지 유형입니다. generateStaticParams로 미리 빌드된 페이지는 Static, cookies()나 searchParams를 사용하는 페이지는 Dynamic으로 분류됩니다.
prefetch와의 관계
<Link> 컴포넌트는 뷰포트에 보이면 자동으로 해당 페이지의 RSC Payload를 미리 가져옵니다(prefetch). 이 prefetch된 데이터도 Router Cache에 저장됩니다. <Link prefetch={true}>를 명시하면 Dynamic 페이지도 강제로 prefetch되어 캐시에 들어가므로, 최신 데이터가 중요한 페이지에서는 주의해야 합니다.
캐시 무효화 방법
클라이언트 컴포넌트 내에서 수동으로 화면을 갱신할 때 사용합니다.
'use client'
import { useRouter } from 'next/navigation';
export default function RefreshButton() {
const router = useRouter();
const handleRefresh = () => {
// 브라우저의 Router Cache(RSC Payload)를 버리고 서버에 최신 데이터를 재요청
// React state는 유지되면서 서버 데이터만 갱신됨
router.refresh();
};
return <button onClick={handleRefresh}>최신 데이터 불러오기</button>
}
참고로 Server Action에서 revalidatePath를 호출하면, Next.js 서버가 클라이언트 브라우저에게도 Router Cache를 지우라는 시그널을 보냅니다.
실무 적용 예시: 다국어 이벤트 페이지 캐싱 설계
여기서부터가 본론입니다. 앞에서 정리한 캐시 레이어를 실제 이벤트 페이지에 어떻게 적용할지 설계해봤습니다. 실제 겪은 요구사항에 가상의 상황을 더한 예시입니다.
요구사항
- 트래픽: 이벤트 오픈 시 초당 수천 건 접속
- BO(Back Office) 변경 빈도: 운영팀이 하루에도 수십 번 이벤트 배너, 상품 목록, 당첨자 정보를 수정
- 다국어 지원: 한국어, 일본어, 영어 등 최소 3개 언어. URL 구조가
/ja/events/[id],/ko/events/[id]형태 - 개인화 영역: 유저별 참여 현황, 쿠폰 적용 상태, 응모 횟수
- 인프라 제약: AWS ECS 다중 Pod 운영, 중앙 캐시 저장소(Redis 등) 미도입 상태

설계 결론부터
위 조건에서 내린 결론:
- Full Route Cache → 포기. 다국어 × 이벤트 수 조합이 너무 많고 BO 변경이 잦아 빌드 시점 정적 생성이 비현실적
- On-demand Revalidation → 신뢰 불가. Redis 없이 다중 Pod 환경에서는 특정 Pod만 무효화되고 나머지는 stale
- Data Cache의 Time-based TTL → 주축. 30초 TTL로 모든 Pod에서 균일하게 갱신, 백엔드 호출 50~60% 감소
- 개인화 영역 → Suspense로 분리. 캐시 가능한 정적 영역을 먼저 렌더링하고, 유저별 데이터는 스트리밍
이 결론에 도달한 과정을 풀어보겠습니다.
페이지를 정적/동적으로 쪼개기
왼쪽 정적 영역(배너, 이벤트 설명, 상품 목록)은 서버 캐시가 가능하고 BO 변경 시에만 갱신하면 됩니다. 오른쪽 동적 영역(유저 참여 현황, 쿠폰 적용 상태, 실시간 재고/응모)은 유저마다 다르므로 캐싱이 불가하고 매번 API를 호출합니다.
이 구분을 코드 레벨에서 어떻게 반영하는지가 설계의 핵심입니다.
왜 Full Route Cache를 포기했는가
이벤트 상세 페이지는 얼핏 Full Route Cache의 좋은 후보입니다. generateStaticParams로 이벤트 ID를 미리 빌드하면 되니까요.
다국어 환경에서는 사정이 다릅니다:
locale × eventId모든 조합을 빌드해야 함 → 이벤트가 누적될수록 빌드 시간 폭증- BO에서 수시로 변경되는 이벤트 정보 → 빌드 직후 이미 stale
- 유저 개인화 영역이 섞여 있어
cookies()등 Dynamic API 호출 불가피 → 자동으로 Dynamic Rendering 전환
// app/[locale]/events/[id]/page.tsx
export default async function EventPage({ params }: Props) {
const { locale, id } = await params;
// locale을 기반으로 Accept-Language 헤더나 쿠키를 참조하는 순간
// → Dynamic Rendering으로 전환됨
const event = await getEvent(id, locale);
// ...
}
Full Route Cache는 이 페이지에서 사용하기 어렵습니다.
왜 On-demand Revalidation을 믿을 수 없는가
이상적인 시나리오라면 BO 수정 → 웹훅 → revalidateTag → 즉시 반영입니다. 우리 환경에서는 이게 안 됩니다:
- 웹훅이 로드밸런서를 거쳐 Pod 하나에만 도달 → 나머지 Pod의 캐시는 그대로
- 모든 Pod에 개별 웹훅을 보내는 방법도 있지만, 오토스케일링으로 Pod 수가 유동적이면 타겟 관리가 복잡
- Custom Cache Handler + Redis 도입 전까지는 근본 해결이 어려움
그래도 태그는 미리 걸어둡니다. 향후 Redis를 도입하면 코드 변경 없이 On-demand가 활성화되기 때문입니다.
남은 선택지: Time-based TTL 중심 설계
소거법으로 남은 건 Data Cache의 Time-based Revalidation입니다. 데이터의 변경 빈도와 비즈니스 허용 지연을 기준으로 TTL을 나눕니다.
| 항목 | revalidate | 이유 |
|---|---|---|
| 이벤트 기본 정보(제목, 배너, 상품 목록) | 30초 | BO 변경 빈도 높음, 최대 30초 지연 SLA |
| 댓글·후기 | 300초 | 변경 빈도 낮음, 5분 지연 허용 가능 |
| 재고·응모 수량 | no-store | 실시간 정확성 필수 |
| 유저 개인화(참여 현황, 쿠폰) | no-store | 유저별 상이, 캐싱 불가 |
코드로 보면:
// lib/api/event.ts
export async function getEvent(id: string, locale: string) {
const res = await fetch(`${API_BASE}/events/${id}?lang=${locale}`, {
next: {
revalidate: 30, // 30초 TTL
tags: [`event-${id}`, `event-${id}-${locale}`] // 향후 Redis 도입 시 활용
}
});
return res.json();
}
export async function getEventComments(id: string, locale: string) {
const res = await fetch(`${API_BASE}/events/${id}/comments?lang=${locale}`, {
next: {
revalidate: 300, // 5분 TTL
tags: [`event-comments-${id}`]
}
});
return res.json();
}
export async function getEventStock(id: string) {
// 재고는 캐싱하지 않음
const res = await fetch(`${API_BASE}/events/${id}/stock`, {
cache: 'no-store'
});
return res.json();
}
TTL 30초의 의미:
- 초당 3,000 요청 기준, 30초간 백엔드 호출은 Pod당 1번. 89,999건은 캐시 응답
- BO 수정 반영 최대 지연: 30초 (모든 Pod 동일)
- 트레이드오프: TTL을 줄일수록 백엔드 호출 빈도 증가
개인화 영역은 Suspense로 분리
정적 영역은 Data Cache에서 즉시 응답하고, 동적 영역은 스트리밍으로 나중에 채웁니다.
// app/[locale]/events/[id]/page.tsx
import { Suspense } from 'react';
export default async function EventPage({ params }: Props) {
const { locale, id } = await params;
const event = await getEvent(id, locale); // Data Cache 히트 (30초 TTL)
return (
<main>
{/* 정적 영역 - 캐시에서 즉시 렌더링 */}
<EventBanner event={event} />
<EventDescription description={event.description} />
{/* 동적 영역 - 스트리밍으로 나중에 도착 */}
<Suspense fallback={<StockSkeleton />}>
<EventStock eventId={id} />
</Suspense>
<Suspense fallback={<ParticipationSkeleton />}>
<ParticipationStatus eventId={id} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<EventComments eventId={id} locale={locale} />
</Suspense>
</main>
);
}
유저 입장에서는 배너와 설명이 먼저 보이고, 남은 수량이나 참여 현황은 살짝 뒤에 채워집니다. 체감 로딩이 빨라지면서 캐시 가능한 영역은 확실히 캐시를 탑니다.
백엔드에서 캐싱하는 거랑 뭐가 다른가?
“백엔드에서 Redis로 캐싱해도 결과는 같지 않나?”라는 의문이 들 수 있습니다. 데이터 신선도만 놓고 보면 비슷하지만, 프론트엔드 Data Cache는 다른 이점이 있습니다.
1. 네트워크 왕복이 사라진다 — 백엔드가 Redis에서 1ms 캐시 히트해도, 프론트엔드 → 백엔드 간 요청(DNS, TCP, 직렬화, 전송)은 매번 수 ms ~ 수십 ms 소요됩니다. 프론트엔드 로컬 캐시는 이 왕복이 0ms입니다.
2. 백엔드 커넥션 부하가 줄어든다 — 초당 수천 건의 TCP 커넥션을 받는 것 자체가 비용입니다. 프론트엔드에서 막아주면 백엔드는 진짜 동적 요청(재고 확인, 응모 처리)에 리소스를 몰아줄 수 있습니다.
3. 렌더링 비용도 절약된다 — 백엔드 캐시는 원본 데이터만 캐싱합니다. 프론트엔드 Data Cache는 fetch 응답 캐싱과 동시에 RSC 렌더링 결과도 재활용합니다.
4. 소비 맥락별 TTL 분리가 가능하다 — 같은 API라도 이벤트 메인에서는 30초, 목록 페이지에서는 5분처럼 호출하는 곳에 따라 TTL을 다르게 줄 수 있습니다.
5. 백엔드 장애 시 버퍼가 된다 — 백엔드가 다운되어도 TTL 내 캐시가 남아있으면 유저에게 정상 응답을 줍니다.
프론트엔드 캐싱은 백엔드 캐싱의 대체제가 아니라 앞단에 하나 더 쌓는 방어막입니다.
실제 서버 부담 변화
캐싱 미적용 시
- 초당 3,000 요청 × API 3개 = 백엔드에 초당 9,000 호출
- 프론트엔드 서버도 매번 RSC 렌더링 → CPU 부하 높음
Data Cache 적용 후 (Pod 3대 기준)
- 이벤트 기본정보: 30초 TTL → Pod당 30초에 1번. 3대면 30초간 총 3번
- 댓글: 300초 TTL → Pod당 5분에 1번
- 재고·수량: 여전히 매번 호출
- 결과: 백엔드 API 호출량 약 50~60% 감소
Pod별 로컬 캐시라 Pod 수만큼 중복 호출이 생깁니다. 그래도 초당 3,000건이 30초간 3번으로 줄어드는 효과는 충분하고, 향후 Redis 도입 시 이 중복마저 1번으로 합칩니다.
정리
Next.js를 사용하는 환경이라면, 브라우저(Router Cache)부터 프론트엔드 서버(Data Cache)까지 직접 통제할 수 있는 캐시 레이어가 다양합니다. 다국어 이벤트 페이지처럼 Full Route Cache를 활용하기 어렵고, Redis도 없어 On-demand Revalidation마저 제한된 환경에서도, Time-based Data Cache + Suspense 스트리밍 조합만으로 백엔드 부하의 50% 이상을 프론트엔드 단에서 흡수할 수 있습니다.
물론 한계도 명확합니다. 현재 환경에서는 BO 수정 반영까지 최대 TTL만큼의 지연이 발생하고, Pod 수만큼 캐시가 중복됩니다. 이건 향후 Custom Cache Handler + Redis 도입으로 해결할 과제입니다.
중요한 건, Redis 없이도 프론트엔드 캐싱만으로 이미 상당한 트래픽 방어가 가능하다는 점입니다. 캐싱 시스템을 이해하고 적절히 설계하면, 성능과 유저 경험을 동시에 챙길 수 있습니다.
댓글
새 글 알림 받기
실무에서 바로 써먹을 수 있는 개발 팁과 경험담을 받아보세요
개인정보는 뉴스레터 발송 목적으로만 사용되며, 언제든 구독을 해지할 수 있습니다.