---
title: "Next.js App Router 캐싱 4계층 완전정복"
description: "Next.js App Router의 Request Memoization·Data Cache·Full Route Cache·Router Cache 4계층을 무엇을·수명·무효화 기준으로 분리해 설명하고, 동적 렌더 트리거와 함정을 정리한다."
date: 2026-06-07
updated: 2026-06-07T09:00:00.000Z
tags: [nextjs, react, performance, frontend]
canonical: https://blog.wooncloud.com/posts/nextjs-app-router-caching
---

![Next.js App Router 캐싱 4계층 완전정복](/images/posts/nextjs-app-router-caching/ce88212f-841e-46a3-b2ff-60c96bda790d.webp)

## 들어가며: 왜 캐싱이 헷갈리는가

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해도 비용이 늘지 않는다. 즉 "데이터를 어디서 가져올지"를 데이터가 필요한 컴포넌트 가까이에 둘 수 있다.

```tsx
// 이 함수를 레이아웃과 페이지, 그리고 깊은 자식 컴포넌트에서 각각 호출해도
// 한 요청 안에서는 네트워크 호출이 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()`로 감싸면 된다.

```ts
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 옵션으로 제어

```ts
// (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에서 빠진다.

```tsx
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 하나를 넣으면 라우트 전체가 동적으로 전환된다. 반대로, 라우트를 의도적으로 한쪽으로 고정하고 싶으면 세그먼트 설정으로 명시할 수 있다.

```ts
// 이 라우트를 강제로 동적/정적으로 고정
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까지** 연쇄적으로 정리한다.

```ts
"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와 라우트의 의도를 코드로 드러내 두면 업그레이드에도 동작이 흔들리지 않는다.
