Next.js App Router 캐싱 4계층 완전정복
Next.js App Router의 Request Memoization·Data Cache·Full Route Cache·Router Cache 4계층을 무엇을·수명·무효화 기준으로 분리해 설명하고, 동적 렌더 트리거와 함정을 정리한다.

들어가며: 왜 캐싱이 헷갈리는가
App Router의 캐싱은 강력하지만, 처음 보면 "내가 분명히 최신 데이터를 넣었는데 화면엔 옛날 값이 나온다" 또는 반대로 "캐시한다더니 매 요청마다 외부 API를 때린다" 같은 현상에 부딪힌다. 원인은 대개 하나의 캐시가 아니라 서로 다른 4개의 층이 동시에 작동하기 때문이다. 이들은 위치(메모리/디스크/브라우저)도, 수명(요청 1회/배포까지/영속/짧은 세션)도, 무효화 방법도 전부 다르다.
이 글은 그 4개 층을 한 덩어리로 보지 않고 따로 분리해서 정리한다. 각 층마다 "무엇을 캐시하나 · 어디에 사나 · 얼마나 사나 · 어떻게 비우나" 네 가지를 일관되게 짚는다. 요청이 들어와서 응답이 나가기까지 데이터가 통과하는 순서대로 보면 이렇다.
fetch 호출
└─[1] Request Memoization (한 렌더 안에서 중복 fetch 제거)
└─[2] Data Cache (요청을 넘어 fetch 결과를 영속)
└─[3] Full Route Cache (렌더된 HTML+RSC 를 서버에 캐시)
└─[4] Router Cache (브라우저가 RSC 페이로드를 잠시 캐시)
위 두 층(1, 2)은 fetch/데이터 레벨, 아래 두 층(3, 4)은 렌더 결과물 레벨이다. 그리고 4번만 클라이언트, 나머지는 서버에 있다.
1. Request Memoization — 한 렌더 안의 중복 제거
무엇을: 같은 렌더 패스(= 하나의 서버 요청) 안에서 동일한 fetch(url, options) 호출을 여러 번 해도 실제 네트워크 요청은 한 번만 나간다. URL과 옵션이 같으면 첫 호출의 Promise를 재사용한다.
어디서: React가 관리하는 메모리. 디스크가 아니라 그 렌더 동안만 존재하는 임시 맵이다.
수명: 그 요청의 렌더가 끝나면 즉시 사라진다. 요청을 넘기지 않는다.
무효화: 따로 비울 필요가 없다. 렌더가 끝나면 폐기되므로 다음 요청은 처음부터 새로 시작한다.
왜 중요한가: 컴포넌트 트리 여러 곳에서 같은 데이터가 필요할 때, props로 내려주는 대신 각 컴포넌트가 직접 fetch해도 비용이 늘지 않는다. 즉 "데이터를 어디서 가져올지"를 데이터가 필요한 컴포넌트 가까이에 둘 수 있다.
// 이 함수를 레이아웃과 페이지, 그리고 깊은 자식 컴포넌트에서 각각 호출해도
// 한 요청 안에서는 네트워크 호출이 1번만 발생한다.
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}주의할 점: 메모이제이션은 fetch API에만 자동 적용된다. ORM 쿼리나 axios, DB 드라이버 호출에는 자동으로 걸리지 않는다. 그 경우 같은 효과를 원하면 React의 cache()로 감싸면 된다.
import { cache } from "react";
// fetch 가 아닌 DB 접근도 한 렌더 안에서 1회로 합쳐진다.
export const getUserFromDb = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});2. Data Cache — 요청을 넘어 영속되는 데이터
무엇을: 서버에서 일어난 fetch 결과 자체를 저장한다. Request Memoization과 달리 요청 경계를 넘어 유지된다. 즉 사용자 A의 요청에서 캐시된 결과를 사용자 B의 요청이 재사용할 수 있다.
어디서: 서버의 영속 저장소(배포 환경에 따라 디스크 또는 별도 캐시 핸들러). 재배포나 서버 재시작과 무관하게 살아남을 수 있다.
수명: 명시적으로 무효화하거나 revalidate 시간이 지나기 전까지 계속 유효하다. 기본적으로 무기한이 될 수도 있으므로 의도를 명시하는 게 안전하다.
무효화: 시간 기반(revalidate) 또는 온디맨드(revalidateTag / revalidatePath).
fetch 옵션으로 제어
// (a) 강제 캐시: 한 번 가져오면 무효화 전까지 재사용
await fetch(url, { cache: "force-cache" });
// (b) 캐시 안 함: 매 요청마다 새로 가져옴 (Data Cache 우회)
await fetch(url, { cache: "no-store" });
// (c) 시간 기반 재검증: 60초가 지난 뒤 다음 요청에서 백그라운드 갱신
await fetch(url, { next: { revalidate: 60 } });
// (d) 태그 기반: 이 fetch 결과에 'posts' 라벨을 붙여 둔다 → 온디맨드 무효화용
await fetch(url, { next: { tags: ["posts"] } });(c)의 revalidate: 60은 stale-while-revalidate에 가깝다. 60초가 지난 뒤 들어온 첫 요청은 일단 (조금 오래된) 캐시를 받고, 백그라운드에서 새 데이터를 받아와 캐시를 교체한다. 그 다음 요청부터 새 값이 보인다. 즉 "60초마다 무조건 사용자가 기다린다"가 아니라 "60초가 지나면 다음 기회에 비동기로 갱신"이다.
시간 기반 vs 온디맨드. 데이터가 "대략 N초 신선하면 충분"하면 시간 기반(revalidate)이 단순하다. 반대로 "글을 발행/수정한 그 순간 즉시 반영"이 필요하면 온디맨드(태그/경로 무효화)가 맞다. 둘을 섞어도 된다: 평소엔 revalidate: 3600으로 두되, 편집이 일어나면 revalidateTag로 즉시 갈아엎는 식이다.
3. Full Route Cache — 렌더된 라우트 결과 캐시
무엇을: 라우트를 정적으로 렌더한 결과(HTML과 RSC 페이로드)를 통째로 저장한다. 앞의 Data Cache가 "데이터"를 캐시한다면, 이 층은 그 데이터로 렌더까지 끝낸 화면 산출물을 캐시한다.
어디서: 서버. 빌드 시점 또는 재검증 시점에 만들어진다.
수명: 빌드 산출물로 영속되며, 해당 라우트가 의존하는 데이터가 재검증될 때 다시 만들어진다.
무효화: 그 라우트가 사용하는 데이터를 revalidatePath/revalidateTag로 무효화하거나, 라우트가 동적으로 바뀌면 애초에 이 캐시를 만들지 않는다.
정적 vs 동적 — 동적 렌더 트리거
Full Route Cache의 핵심은 그 라우트가 정적이냐 동적이냐다. 정적이면 한 번 렌더해서 캐시하고 모두에게 같은 결과를 준다. 동적이면 매 요청 렌더하므로 이 캐시 자체가 적용되지 않는다.
다음 중 하나라도 쓰면 라우트가 동적이 되어 Full Route Cache에서 빠진다.
import { cookies, headers } from "next/headers";
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
// (1) 요청별 입력을 읽으면 동적이 된다
const cookieStore = await cookies(); // cookies()
const headerList = await headers(); // headers()
const { q } = await searchParams; // searchParams 접근
// (2) no-store fetch 도 그 라우트를 동적으로 만든다
const data = await fetch("https://api.example.com/live", {
cache: "no-store",
}).then((r) => r.json());
return <div>{/* ... */}</div>;
}이게 "왜 내 페이지가 안 캐시되지?"의 흔한 원인이다. 무심코 cookies()를 호출하거나 no-store fetch 하나를 넣으면 라우트 전체가 동적으로 전환된다. 반대로, 라우트를 의도적으로 한쪽으로 고정하고 싶으면 세그먼트 설정으로 명시할 수 있다.
// 이 라우트를 강제로 동적/정적으로 고정
export const dynamic = "force-dynamic"; // 항상 요청 시 렌더
// 또는
export const dynamic = "force-static"; // 항상 정적 (동적 API 는 빈 값 취급)
// 시간 기반 재검증을 라우트 레벨로 지정
export const revalidate = 60;4. Router Cache — 클라이언트의 네비게이션 캐시
무엇을: 사용자가 방문한 라우트들의 RSC 페이로드를 브라우저 메모리에 잠시 담아 둔다. 뒤로 가기나 이미 본 링크로의 이동을 서버 왕복 없이 즉시 처리하기 위한 것이다.
어디서: 브라우저(클라이언트) 메모리. 앞의 세 층은 전부 서버에 있고, 이 층만 클라이언트에 있다.
수명: 세션 동안 유지되며, 페이지를 새로고침(full reload)하면 사라진다. 보이지 않게 라우트 단위로 짧게 살아 있는 임시 캐시다.
무효화: 클라이언트에서 router.refresh()를 호출하거나, 서버 액션에서 revalidatePath/revalidateTag를 호출하면 관련 항목이 무효화된다. 폼 제출처럼 데이터를 바꾸는 서버 액션 뒤에는 보통 이 무효화가 함께 일어난다.
이 층 때문에 가끔 "데이터를 분명히 바꿨는데 클라이언트 네비게이션으로 돌아오니 옛날 화면"인 경우가 생긴다. 새로고침하면 정상으로 보인다면 Router Cache를 의심하고, 변경을 일으킨 액션에서 revalidatePath를 호출했는지 확인한다.
무효화: revalidatePath / revalidateTag
서버 액션이나 Route Handler에서 데이터를 바꾼 뒤, 관련 캐시를 명시적으로 비운다. 이 두 호출은 Data Cache와 Full Route Cache, 그리고 클라이언트 Router Cache까지 연쇄적으로 정리한다.
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { prisma } from "@/lib/db";
export async function updatePost(slug: string, body: string) {
await prisma.post.update({ where: { slug }, data: { body } });
// (1) 특정 경로 무효화 — 그 라우트의 캐시된 렌더를 버린다
revalidatePath(`/posts/${slug}`);
revalidatePath("/posts"); // 목록도 같이
// (2) 태그 무효화 — { next: { tags: ['posts'] } } 로 가져온 모든 fetch 를 한 번에
revalidateTag("posts");
}revalidatePath(path): 경로 단위. "이 페이지(들)를 다음 방문 때 다시 렌더하라." 목록·상세처럼 영향받는 경로를 명시적으로 나열한다.revalidateTag(tag): 데이터 단위. 그 태그가 붙은 fetch 결과를 모두 무효화한다. 어떤 라우트들이 그 데이터를 쓰는지 일일이 몰라도 되므로, 여러 페이지가 공유하는 데이터에 적합하다.
실무에선 "글 1개 수정 → 상세는 revalidatePath, 그 글을 인용하는 여러 위젯/목록은 revalidateTag" 식으로 둘을 함께 쓰는 경우가 많다.
최신 동향: 기본값에 기대지 말 것
버전이 올라가면서 프레임워크의 기본 캐싱 정책은 점점 더 보수적인(덜 공격적으로 캐시하는) 방향으로 움직여 왔다. 예전엔 fetch가 기본적으로 캐시되고 라우트가 적극적으로 정적화되던 시기가 있었고, 이후 "기본은 캐시하지 않고, 캐시하려면 명시하라"는 쪽으로 무게추가 옮겨가는 흐름이 있었다.
여기서 특정 버전의 기본값을 외워 두는 건 위험하다. 핵심 원칙은 이것이다.
기본값에 의존하지 말고, 각 fetch와 각 라우트의 캐싱 의도를 코드로 명시하라.
즉 "이 데이터는 60초 캐시", "이건 매번 새로", "이 라우트는 정적/동적"을 cache/next.revalidate/세그먼트 옵션으로 드러나게 써 두면, 프레임워크 기본값이 버전마다 바뀌어도 동작이 흔들리지 않는다. 기본값에 의존한 코드는 업그레이드 한 번에 조용히 캐싱 동작이 뒤집힐 수 있다.
흔한 함정과 디버깅
(1) 의도치 않은 정적화로 옛 데이터가 박제됨. 빌드 시점에 한 번 렌더되고 그 뒤로 갱신 트리거가 없으면, 데이터가 바뀌어도 화면은 옛날 그대로다. 점검 순서:
- 이 라우트가 정적인가 동적인가? (빌드 로그에서 라우트 옆 표식, 또는
force-dynamic/revalidate를 명시했는지) - 시간 기반(
revalidate)이나 온디맨드(revalidatePath/revalidateTag) 무효화가 실제로 걸려 있는가? - "발행 즉시 반영"이 목표면 시간 기반만으론 부족하다. 변경 액션에서 온디맨드 무효화를 호출해야 한다.
(2) 반대로, 캐시가 전혀 안 걸려 매 요청마다 외부 호출.
의도와 달리 no-store거나 cookies()/headers() 같은 동적 API가 라우트를 동적으로 만들어 캐시를 통째로 비활성화한 경우다. 점검 순서:
- 동적 API(
cookies/headers/searchParams)를 정말 써야 하는 자리인가? 페이지 전체가 아니라 작은 클라이언트 컴포넌트로 격리할 수 있는가? - fetch에 무심코
cache: "no-store"가 들어가 있지 않은가? - 외려 캐시하고 싶은 fetch라면
force-cache또는next.revalidate를 명시했는가?
(3) 클라이언트 네비게이션에서만 옛날 화면.
새로고침하면 최신인데 링크 이동/뒤로가기로 오면 옛날이면 Router Cache다. 변경을 일으킨 서버 액션에서 revalidatePath를 호출했는지, 혹은 클라이언트에서 router.refresh()가 필요한 자리인지 확인한다.
디버깅의 일반 전략: 새로고침과 클라이언트 네비게이션을 나눠서 재현해 보면 클라이언트(4번)와 서버(1~3번) 문제를 빠르게 가른다. 그다음 "이 라우트가 정적/동적인가" → "이 데이터(fetch)의 캐시 옵션이 뭔가" 순으로 위에서 아래로 좁혀 간다.
정리
- 캐싱을 하나로 보지 말고 4개 층으로 분리해서 생각한다. (1) Request Memoization은 한 렌더 안 중복 제거, (2) Data Cache는 요청을 넘는 데이터 영속, (3) Full Route Cache는 렌더 결과물 캐시, (4) Router Cache는 브라우저의 네비게이션 캐시다.
- 위 두 층은 데이터 레벨, 아래 두 층은 렌더 결과 레벨이며, 4번만 클라이언트에 있다.
cookies()/headers()/searchParams/no-store는 라우트를 동적으로 만들어 Full Route Cache를 끈다. 의도한 게 아니라면 동적 API를 작은 컴포넌트로 격리한다.- fetch는
cache와next: { revalidate, tags }로, 라우트는 세그먼트 옵션으로 캐싱을 명시한다. 시간 기반은 단순함, 온디맨드(revalidatePath/revalidateTag)는 즉시성이 강점이다. - 무엇보다 기본값에 기대지 말 것. 버전마다 기본 캐싱이 달라지므로, 각 fetch와 라우트의 의도를 코드로 드러내 두면 업그레이드에도 동작이 흔들리지 않는다.