Next.js 캐시 구조 Request Data Full Route Router Cache

··설명 약 10분·작성: 해비·블로그 소개

Next.js에서 router.refresh와 서버 데이터 캐시 재검증은 어떻게 다른가요?

Next.js 서버 데이터 캐시와 브라우저 라우트 결과는 구분해야 합니다. router.refresh는 새 서버 결과를 요청하지만 Data Cache를 무조건 비우지는 않습니다. 본문의 고정 버전·설정에서 데이터 변경과 재검증 결과를 비교합니다.

적용 조건·범위: React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

본문 근거: Next.js 기존 캐시 모델 · Next.js Caching

독립 실습: Router refresh와 서버 재검증을 구분하기

"use client";
import { useRouter } from 'next/navigation';
export default function Refresh() {
  const router = useRouter();
  return <button onClick={() => router.refresh()}>Router 새 요청</button>;
}

기대 결과: 원본 API의 revision만 바꾼 뒤 60초 안에 새 SSR 요청을 보내도 명시적 Data Cache가 fresh이면 이전 revision을 재사용할 수 있습니다. 재검증 요청에서 max 프로필은 먼저 이전 값을 보이고 뒤에서 갱신할 수 있으므로 후속 요청까지 비교하세요. 분리된 cache-lab 검증에서는 origin 직접 읽기의 revision이 증가해도 반복 SSR GET이 같은 값을 유지했고, 실제 버튼 클릭 직후의 이전 값과 새로고침 후의 새 값도 확인했습니다. 원본 독립 예제의 router.refresh 버튼은 별도 확인 대상입니다.

직접 실행: 환경 준비와 전체 파일

React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

별도 터미널에서 node scripts/cache-api.mjs를 실행하고 Next 앱은 npm run build 후 npm run start로 실행합니다. API 값은 PowerShell의 Invoke-RestMethod -Method Post -Uri http://127.0.0.1:4100/public-stats로 바꿀 수 있습니다. 이 글에 고정된 Next.js 16.3.5에서는 force-dynamic과 명시적 next.revalidate:60을 함께 사용한 원본 cache-lab이 요청 간 공개 데이터를 재사용했습니다. 이 설치 구현에서 양의 revalidate:60은 force-dynamic으로 기본 재검증 값을 0으로 정하는 조건에 들어가지 않습니다. 문서의 요약만으로 이 예제의 60초 캐시가 불가능하다고 판단하지 말고, 같은 설치 버전에서 두 버튼의 결과를 비교하세요.

scripts/cache-api.mjs 전체 코드

import { createServer } from "node:http";
let revision = 1;
createServer((req, res) => {
  if (req.url !== "/public-stats") {
    res.writeHead(404).end();
    return;
  }
  if (req.method === "POST") revision++;
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify({ revision, label: "공개 통계 실습" }));
}).listen(4100, "127.0.0.1", () => console.log("공개 통계 실습 API 준비 완료"));

app/practice/page.tsx 전체 코드

import Refresh from "./refresh";
import { revalidateTag } from "next/cache";
export const dynamic = "force-dynamic";
async function refresh() {
  "use server";
  revalidateTag("public-stats", "max");
}
export default async function CacheLab() {
  let data: { revision: number; label: string } | null = null;
  try {
    const response = await fetch("http://127.0.0.1:4100/public-stats", {
      next: { revalidate: 60, tags: ["public-stats"] },
    });
    if (response.ok) data = await response.json();
  } catch {}
  return (
    <main>
      <h1>공개 데이터 캐시 실험</h1><Refresh />
      <p>개인 작업은 캐시하지 않고, 별도 공개 통계만 60초 캐시합니다.</p>
      {data ? (
        <p>공개 통계 버전: {data.revision}</p>
      ) : (
        <p>별도 터미널에서 node scripts/cache-api.mjs를 실행하세요.</p>
      )}
      <form action={refresh}>
        <button>공개 통계 재검증 요청</button>
      </form>
      <p>
        max 프로필은 stale-while-revalidate이므로 다음 요청에서 이전 값이 잠깐
        보일 수 있습니다.
      </p>
    </main>
  );
}

app/practice/refresh.tsx 전체 코드

자가 확인: 새 SSR 요청이 일어나도 공개 데이터가 이전 revision인 이유를 route 결과와 fetch Data Cache로 나누어 설명해 보세요. 이 고정 버전의 실행 결과를 다른 Next.js 버전이나 Cache Components를 켠 구성에 그대로 적용하지 않습니다. 같은 서버 렌더의 중복 GET, 요청을 넘는 데이터, 정적 route 결과, 브라우저 이동 결과도 각각 분류하되 이 코드로 네 캐시를 모두 측정했다고 하지는 않습니다.

1. Request Memoization

한 서버 렌더 과정에서 같은 GET 요청을 여러 컴포넌트가 필요로 할 때 중복 작업을 줄이는 개념입니다.

같은 렌더
Component A → GET /tasks
Component B → GET /tasks

필요한 경우 중복 호출 감소

요청 범위와 적용 조건은 최신 공식 문서를 확인합니다.

2. Data Cache

서버 데이터 요청 결과를 렌더 간 재사용하는 계층입니다.

명시적인 revalidate, tag 정책과 함께 생각합니다.

3. Full Route Cache

정적으로 렌더 가능한 route 결과 자체가 재사용될 수 있습니다.

요청 단위 API나 동적 조건이 있는 route와 구분합니다.

4. Router Cache

브라우저에서 이동했던 route segment 결과를 재사용해 빠른 탐색을 돕는 클라이언트 계층입니다.

왜 구분해야 할까

DB 값 변경
↓
Data Cache 남음?
↓
Route 결과 남음?
↓
브라우저 Router Cache 남음?

화면이 그대로인 원인을 한 계층으로 단정하지 않습니다.

router.refresh와 서버 캐시

router.refresh는 현재 route의 서버 결과를 다시 요청하는 데 도움을 주지만, 서버 데이터 캐시 자체를 무조건 삭제하는 API라고 설명하면 안 됩니다.

자주 발생하는 오류

  • 네 캐시를 모두 같은 캐시라고 설명
  • router.refresh만 하면 DB부터 모든 캐시가 제거된다고 생각
  • Route Handler와 Server Component 요청 동작을 동일하게 가정
  • 버전별 캐시 기본값 차이를 무시

이 글의 네 계층 설명은 Cache Components를 끈 구성입니다. Cache Components를 켠 모델과 옵션을 섞지 마세요.

판단 순서는 원본 API의 현재 값 → 서버에서 재사용한 데이터 → route 결과 → 브라우저 이동 결과입니다. 서버의 데이터를 재사용하는 정책이 원인이라면 클라이언트에서 화면만 다시 요청해도 같은 값이 올 수 있습니다. 이 구분은 Cache Components를 끈 구성에 적용합니다.

원리 확인: Next.js 기존 캐시 모델 (2026-10-12). 아래 원본 cache-lab 파일은 Next.js 16.3.5·cacheComponents:false의 분리된 production 실행 환경에서 반복 GET의 Data Cache 유지와 max 태그 재검증 뒤 후속 갱신을 확인했습니다. 독립 예제의 Router 버튼과 전체 ZIP 앱 실행, 네 캐시 계층 전체의 측정까지 확인했다는 뜻은 아닙니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.

이 과정은 cacheComponents:false 구성에서 기존 캐시 모델을 구분합니다. 개인 작업은 요청 단위 조회, 공개 홈은 정적 결과, 클라이언트 이동은 Router Cache 관점으로 확인합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow Next.js 프로젝트 적용

시작 체크포인트: 이전: Next.js redirects 설정과 URL 이동 처리의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

독자 질문·직접 확인

질문: 작업을 바꿨는데 화면이 낡아 보일 때 어느 캐시부터 볼까요?

경로·데이터 결과: 로그인한 /tasks에서 작업 변경 후 목록을 보고 /cache-lab에서 별도 공개 API의 revision 증가와 재검증을 비교합니다. 공개 홈 /의 정적 결과와 브라우저 경유 이동의 Router Cache도 구분해 적어 보세요. 이 체크포인트는 cacheComponents:false이고 새 실행 코드를 추가하지 않습니다.

오류 점검: router.refresh는 현재 route를 다시 요청하지만 서버 Data Cache를 무조건 삭제하지 않습니다. 개발 모드와 운영 빌드 캐시를 동일시하지 말고, 실제 빌드·서버 결과가 없으면 수명과 상태를 단정하지 않습니다.

빌드·서버·브라우저 실행 검증은 수행하지 않아 미확인입니다.

완료 코드 ZIP 다운로드 — next-step-30-cache-layers

이전 체크포인트: next-step-29-redirects

이번 회차의 변경

이 과정은 cacheComponents:false 구성에서 기존 캐시 모델을 구분합니다. 개인 작업은 요청 단위 조회, 공개 홈은 정적 결과, 클라이언트 이동은 Router Cache 관점으로 확인합니다. 공개 통계는 별도 fetch의 명시적 Data Cache 실험입니다. 아래 cache-lab의 분리 실행에서는 Next.js 16.3.5에서 명시적 fetch Data Cache가 유지됐습니다. 전체 프로젝트의 다른 캐시 계층과 브라우저 이동 결과는 각 경로에서 따로 확인하세요.

이 회차는 이전 프로젝트의 실행 코드를 유지하고 설정·오류·운영 기준을 점검합니다.

전체 파일·코드 보기

실행과 검증

Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.

npm install
npm run dev
npm run build
npm run start

MSW: 개발 시에만 NEXT_PUBLIC_API_MOCKING을 enabled로 설정합니다. npx msw init public/ --save로 worker를 만들 수 있습니다. Mock 데이터와 서버 데이터는 별개이며 17편부터 기본 흐름은 실제 Route Handler입니다.

저장소는 단일 Node 프로세스 메모리입니다. 서버 재시작·프로세스 분리·서버리스에서는 데이터 유지가 보장되지 않습니다. 운영 서비스는 DB 및 트랜잭션 저장소로 교체해야 합니다.

인증: npm run setup-auth로 로컬 학습 계정을 설정한 뒤 서버를 재시작합니다. 비밀번호 입력은 터미널에 보이므로 학습용 값을 사용하세요. 실제 운영 계정 시스템이 아니며 가입·복구·시도 제한 기능은 포함하지 않습니다. .env.local은 공유하지 마세요. Auth.js 5 beta 버전을 고정했습니다.

개인 작업·설정은 로그인 필수입니다. Proxy뿐 아니라 데이터 조회와 Server Action/API가 세션을 검사하고 소유자별로 접근합니다.

캐시 실습: 별도 터미널 npm run cache-api 실행 → /cache-lab 접속 → 공개 API에 POST하여 버전 증가 → 재검증 버튼과 다음 요청에서 갱신 확인. 개인 작업은 공유 Data Cache에 넣지 않습니다. Cache Components는 끈 구성입니다.

전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.

완료 체크포인트: next-step-30-cache-layers

실습 프로젝트 전체 코드

.gitignore

node_modules
.next
.env*
!.env.example
*.tsbuildinfo

.env.example

AUTH_SECRET=
DEMO_EMAIL=
DEMO_PASSWORD_SALT=
DEMO_PASSWORD_HASH=
SITE_URL=
NEXT_PUBLIC_API_MOCKING=

app/(app)/@modal/(.)tasks/[id]/page.tsx

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import TaskActions from "@/app/ui/task-actions";
import Modal from "@/app/ui/modal";
export default async function TaskModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  if (!task) notFound();
  return (
    <Modal>
      <h1>{task.title}</h1>
      <p>{task.description}</p>
      <p>상태: {task.completed ? "완료" : "진행 중"}</p>
      <TaskActions id={task.id} completed={task.completed} />
    </Modal>
  );
}

app/(app)/@modal/[…catchAll]/page.tsx

export default function ResetModal() {
  return null;
}

app/(app)/@modal/default.tsx

export default function Default() {
  return null;
}

app/(app)/layout.tsx

import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
import { requireUser } from "@/app/lib/session";
export const metadata = { robots: { index: false, follow: false } };
export default async function AppLayout({
  children,
  modal,
}: {
  children: ReactNode;
  modal: ReactNode;
}) {
  await requireUser();
  return (
    <>
      <SiteHeader />
      {children}
      {modal}
    </>
  );
}

app/(app)/settings/page.tsx

import ThemeSetting from "@/app/ui/theme-setting";
export default function Settings() {
  return (
    <main>
      <h1>설정</h1>
      <ThemeSetting />
    </main>
  );
}

app/(app)/tasks/[id]/page.tsx

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import TaskActions from "@/app/ui/task-actions";
export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  return {
    title: task?.title || "작업 없음",
    robots: { index: false, follow: false },
  };
}
export default async function TaskDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  if (!task) notFound();
  return (
    <main>
      <h1>{task.title}</h1>
      <p>{task.description}</p>
      <p>상태: {task.completed ? "완료" : "진행 중"}</p>
      <TaskActions id={task.id} completed={task.completed} />
      <Link href="/tasks">목록</Link>
    </main>
  );
}

app/(app)/tasks/error.tsx

"use client";
export default function ErrorPage({
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <main>
      <h1>작업을 불러오지 못했습니다.</h1>
      <button onClick={reset}>다시 시도</button>
    </main>
  );
}

app/(app)/tasks/layout.tsx

import { cookies } from "next/headers";
import type { ReactNode } from "react";
export default async function TasksLayout({
  children,
}: {
  children: ReactNode;
}) {
  const cookieStore = await cookies();
  const density =
    cookieStore.get("taskflow-density")?.value === "compact"
      ? "compact"
      : "normal";
  return <section data-density={density}>{children}</section>;
}

app/(app)/tasks/loading.tsx

export default function Loading() {
  return (
    <main>
      <p>작업을 불러오는 중...</p>
    </main>
  );
}

app/(app)/tasks/not-found.tsx

export default function NotFound() {
  return (
    <main>
      <h1>작업을 찾을 수 없습니다.</h1>
    </main>
  );
}

app/(app)/tasks/page.tsx

export const dynamic = "force-dynamic";
import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
import { Suspense } from "react";
import TaskStats from "@/app/ui/task-stats";
import ApiDemo from "@/app/ui/api-demo";
import MswProvider from "@/app/ui/msw-provider";
import ApiStatus from "@/app/ui/api-status";
import TaskForm from "@/app/ui/task-form";
import {
  QueryClient,
  dehydrate,
  HydrationBoundary,
} from "@tanstack/react-query";
import TaskListClient from "@/app/ui/task-list-client";
import { requireUser } from "@/app/lib/session";
export default async function TasksPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string | string[]; status?: string | string[] }>;
}) {
  const tasks = await getTasks();
  const params = await searchParams;
  const q = typeof params.q === "string" ? params.q : "";
  const status = typeof params.status === "string" ? params.status : "all";
  const user = await requireUser();
  const client = new QueryClient();
  await client.prefetchQuery({
    queryKey: ["tasks", user.id],
    queryFn: () => Promise.resolve(tasks),
    staleTime: 60000,
  });
  return (
    <main>
      <h1>작업 목록</h1>
      <Suspense fallback={<p>검색 준비 중...</p>}>
        <TaskSearch />
      </Suspense>
      <TaskForm />
      <HydrationBoundary state={dehydrate(client)}>
        <TaskListClient ownerId={user.id!} q={q} status={status} />
      </HydrationBoundary>
      <Suspense fallback={<p>통계 로딩 중...</p>}>
        <TaskStats />
      </Suspense>
      <MswProvider>
        <ApiDemo />
        <ApiStatus />
      </MswProvider>
      <p>
        <Link href="/cache-lab">공개 데이터 캐시 실험</Link>
      </p>
    </main>
  );
}

app/(app)/template.tsx

import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
  return <>{children}</>;
}

app/(public)/cache-lab/page.tsx

import { revalidateTag } from "next/cache";
export const dynamic = "force-dynamic";
async function refresh() {
  "use server";
  revalidateTag("public-stats", "max");
}
export default async function CacheLab() {
  let data: { revision: number; label: string } | null = null;
  try {
    const response = await fetch("http://127.0.0.1:4100/public-stats", {
      next: { revalidate: 60, tags: ["public-stats"] },
    });
    if (response.ok) data = await response.json();
  } catch {}
  return (
    <main>
      <h1>공개 데이터 캐시 실험</h1>
      <p>개인 작업은 캐시하지 않고, 별도 공개 통계만 60초 캐시합니다.</p>
      {data ? (
        <p>공개 통계 버전: {data.revision}</p>
      ) : (
        <p>별도 터미널에서 npm run cache-api를 실행하세요.</p>
      )}
      <form action={refresh}>
        <button>공개 통계 재검증 요청</button>
      </form>
      <p>
        max 프로필은 stale-while-revalidate이므로 다음 요청에서 이전 값이 잠깐
        보일 수 있습니다.
      </p>
    </main>
  );
}

app/(public)/page.tsx

import ImageExample from "@/app/ui/image-example";
import Link from "next/link";
export default function HomePage() {
  return (
    <main>
      <h1>TaskFlow Next.js</h1>
      <p>App Router 기반 작업 관리 서비스입니다.</p>
      <Link href="/tasks">작업 목록 보기</Link>
      <ImageExample />
    </main>
  );
}

app/_components/SiteHeader.tsx

import Link from "next/link";
import LogoutButton from "@/app/ui/logout-button";
export default function SiteHeader() {
  return (
    <header>
      <nav aria-label="주 메뉴">
        <Link href="/">홈</Link>
        <Link href="/tasks">작업</Link>
        <Link href="/settings">설정</Link>
        <Link href="/login">로그인</Link>
        <LogoutButton />
      </nav>
    </header>
  );
}

app/api/auth/[…nextauth]/route.ts

import { handlers } from "@/auth";
export const { GET, POST } = handlers;

app/api/tasks/route.ts

import { revalidatePath } from "next/cache";
import { listTasks, addTask } from "@/app/lib/store";
import { auth } from "@/auth";
export async function GET() {
  const session = await auth();
  if (!session?.user?.id)
    return Response.json({ message: "로그인이 필요합니다." }, { status: 401 });
  const owner = session.user.id;
  return Response.json(listTasks(owner), {
    headers: { "Cache-Control": "private, no-store" },
  });
}
export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user?.id)
    return Response.json({ message: "로그인이 필요합니다." }, { status: 401 });
  const owner = session.user.id;
  const origin = request.headers.get("origin");
  if (origin && origin !== new URL(request.url).origin)
    return Response.json(
      { message: "허용되지 않은 출처입니다." },
      { status: 403 },
    );
  try {
    const body: unknown = await request.json();
    const title =
      typeof body === "object" && body !== null && "title" in body
        ? body.title
        : undefined;
    const task = addTask(owner, title);
    revalidatePath("/tasks");
    return Response.json(task, { status: 201 });
  } catch {
    return Response.json(
      { message: "올바른 JSON과 2~100자 제목이 필요합니다." },
      { status: 400 },
    );
  }
}

app/globals.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #171717;
  background: #fafafa;
  font-family: Arial, sans-serif;
  line-height: 1.6;
}
header,
main {
  width: min(880px, calc(100% - 32px));
  margin: 24px auto;
}
nav {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
}
a {
  color: inherit;
  text-underline-offset: 3px;
}
button,
input,
select {
  min-height: 44px;
  padding: 8px 12px;
  font: inherit;
  border: 1px solid #aaa;
  background: #fff;
  color: #171717;
  max-width: 100%;
}
button {
  cursor: pointer;
}
button:disabled {
  opacity: 0.6;
  cursor: wait;
}
input {
  display: block;
  width: 100%;
}
label {
  display: block;
}
form,
section,
aside {
  margin: 20px 0;
}
ul {
  padding: 0;
  list-style: none;
}
li {
  padding: 12px;
  margin: 12px 0;
  border: 1px solid #ccc;
  overflow-wrap: anywhere;
}
.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
.controls form {
  margin: 0;
}
.modal {
  width: min(640px, calc(100% - 32px));
  border: 1px solid #777;
  padding: 24px;
  background: #fff;
  color: #171717;
}
.modal::backdrop {
  background: rgb(0 0 0 / 55%);
}
:focus-visible {
  outline: 3px solid #555;
  outline-offset: 3px;
}
[data-theme="dark"] {
  background: #202020;
  color: #fafafa;
  padding: 16px;
}
[data-density="compact"] li {
  padding: 8px;
  margin: 8px 0;
}
img {
  max-width: 100%;
  height: auto;
}

app/layout.tsx

import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
import Providers from "./providers";
export const metadata: Metadata = {
  title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
  description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
  metadataBase: new URL(process.env.SITE_URL || "http://localhost:3000"),
  openGraph: {
    title: "TaskFlow",
    description: "Next.js 학습 프로젝트",
    images: ["/opengraph-image"],
  },
};
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

app/lib/actions.ts

"use server";
import { requireUser } from "./session";
import { addTask, changeTask } from "./store";
import { revalidatePath } from "next/cache";
export async function createTask(formData: FormData) {
  const user = await requireUser();
  try {
    addTask(user.id!, formData.get("title"));
  } catch {
    return { ok: false, message: "제목은 2~100자로 입력해 주세요." };
  }
  revalidatePath("/tasks");
  return { ok: true, message: "작업을 추가했습니다." };
}
export async function updateTask(id: string, operation: "toggle" | "delete") {
  const user = await requireUser();
  if (operation !== "toggle" && operation !== "delete")
    throw new Error("잘못된 작업입니다.");
  changeTask(user.id!, id, operation);
  revalidatePath("/tasks");
}

app/lib/axios-client.ts

import axios from "axios";
export const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? "",
  timeout: 5000,
});

app/lib/data.ts

import "server-only";
import { requireUser } from "./session";
import { listTasks } from "./store";
export async function getTasks() {
  const user = await requireUser();
  return listTasks(user.id!);
}
export async function getTask(id: string) {
  return (await getTasks()).find((t) => t.id === id);
}
export async function getTaskStats() {
  const tasks = await getTasks();
  return {
    total: tasks.length,
    completed: tasks.filter((t) => t.completed).length,
  };
}

app/lib/session.ts

import "server-only";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export async function requireUser() {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");
  return session.user;
}

app/lib/store.ts

import "server-only";
import type { Task } from "./types";
const globalStore = globalThis as typeof globalThis & {
  taskflowStore?: Map<string, Task[]>;
};
const store: Map<string, Task[]> = (globalStore.taskflowStore ??= new Map<
  string,
  Task[]
>());
export function listTasks(ownerId: string) {
  if (!store.has(ownerId))
    store.set(ownerId, [
      {
        id: "task-1",
        title: "App Router 공부",
        description: "서버와 브라우저의 역할을 구분합니다.",
        completed: false,
        ownerId,
      },
      {
        id: "task-2",
        title: "TaskFlow 만들기",
        description: "누적 실습을 완성합니다.",
        completed: true,
        ownerId,
      },
    ]);
  return store.get(ownerId)!.map((task) => ({ ...task }));
}
export function validateTitle(value: unknown) {
  if (
    typeof value !== "string" ||
    value.trim().length < 2 ||
    value.trim().length > 100
  )
    throw new Error("제목은 2~100자로 입력해 주세요.");
  return value.trim();
}
export function addTask(ownerId: string, value: unknown) {
  const title = validateTitle(value);
  const task: Task = {
    id: crypto.randomUUID(),
    title,
    description: "학습 중 추가한 작업",
    completed: false,
    ownerId,
  };
  store.set(ownerId, [task, ...listTasks(ownerId)]);
  return task;
}
export function changeTask(
  ownerId: string,
  id: string,
  operation: "toggle" | "delete",
) {
  const tasks = listTasks(ownerId);
  const task = tasks.find((t) => t.id === id && t.ownerId === ownerId);
  if (!task) throw new Error("작업이 없거나 권한이 없습니다.");
  store.set(
    ownerId,
    operation === "delete"
      ? tasks.filter((t) => t.id !== id)
      : tasks.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)),
  );
}

app/lib/types.ts

export type Task = {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  ownerId: string;
};

app/login/page.tsx

import { signIn } from "@/auth";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
export const metadata = {
  title: "로그인",
  robots: { index: false, follow: false },
};
export default async function Login({
  searchParams,
}: {
  searchParams: Promise<{ error?: string }>;
}) {
  const { error } = await searchParams;
  async function login(data: FormData) {
    "use server";
    try {
      await signIn("credentials", {
        email: data.get("email"),
        password: data.get("password"),
        redirectTo: "/tasks",
      });
    } catch (error) {
      if (error instanceof AuthError) redirect("/login?error=credentials");
      throw error;
    }
  }
  return (
    <main>
      <h1>로그인</h1>
      <p>README의 로컬 계정 설정을 먼저 완료하세요.</p>
      {error ? (
        <p role="alert">로그인 정보 또는 로컬 인증 설정을 확인해 주세요.</p>
      ) : null}
      <form action={login}>
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          name="email"
          type="email"
          autoComplete="username"
          required
        />
        <label htmlFor="password">비밀번호</label>
        <input
          id="password"
          name="password"
          type="password"
          autoComplete="current-password"
          required
        />
        <button>로그인</button>
      </form>
    </main>
  );
}

app/opengraph-image.tsx

import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
  return new ImageResponse(
    <div
      style={{
        display: "flex",
        width: "100%",
        height: "100%",
        alignItems: "center",
        justifyContent: "center",
        background: "#181818",
        color: "#fff",
        fontSize: 72,
      }}
    >
      TaskFlow Next.js
    </div>,
    size,
  );
}

app/providers.tsx

"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export default function Providers({ children }: { children: ReactNode }) {
  const [client] = useState(() => new QueryClient());
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

app/ui/api-demo.tsx

"use client";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
export default function ApiDemo() {
  const router = useRouter();
  const query = useQueryClient();
  const [message, setMessage] = useState("API 응답 확인 전");
  const [busy, setBusy] = useState(false);
  async function request(method: "GET" | "POST") {
    setBusy(true);
    try {
      const response = await fetch("/api/tasks", {
        method,
        headers: { "Content-Type": "application/json" },
        ...(method === "POST"
          ? { body: JSON.stringify({ title: "API 실습 작업" }) }
          : {}),
      });
      const data = await response.json();
      if (response.ok && method === "POST") {
        await query.invalidateQueries({ queryKey: ["tasks"] });
        router.refresh();
      }
      setMessage(
        response.ok
          ? `${response.status}: ${Array.isArray(data) ? data.length + "개 작업" : data.title}`
          : `${response.status}: ${data.message}`,
      );
    } catch {
      setMessage("API 요청 실패");
    } finally {
      setBusy(false);
    }
  }
  return (
    <section>
      <h2>API 실습</h2>
      <p>
        Mock 저장소와 서버 저장소는 별개입니다. 17편부터 실제 Route Handler를
        사용합니다.
      </p>
      <div className="controls">
        <button disabled={busy} onClick={() => request("GET")}>
          목록 API 조회
        </button>
        <button disabled={busy} onClick={() => request("POST")}>
          API 작업 추가
        </button>
      </div>
      <p role="status">{message}</p>
    </section>
  );
}

app/ui/api-status.tsx

"use client";
import { useState } from "react";
import { apiClient } from "../lib/axios-client";
export default function ApiStatus() {
  const [message, setMessage] = useState("확인 전");
  const handleCheck = async () => {
    try {
      await apiClient.get("/api/tasks");
      setMessage("API 연결 성공");
    } catch {
      setMessage("API 연결 실패");
    }
  };
  return (
    <section>
      <button onClick={handleCheck}>API 상태 확인</button>
      <p>{message}</p>
    </section>
  );
}

app/ui/image-example.tsx

import Image from "next/image";
export default function ImageExample() {
  return (
    <figure>
      <Image
        src="https://images.unsplash.com/photo-1499750310107-5fef28a66643"
        alt="책상 위 학습 도구"
        width={800}
        height={533}
        sizes="(max-width: 880px) 100vw, 880px"
      />
      <figcaption>외부 이미지 요청은 네트워크가 필요합니다.</figcaption>
    </figure>
  );
}

app/ui/logout-button.tsx

import { signOut } from "@/auth";
export default function LogoutButton() {
  return (
    <form
      action={async () => {
        "use server";
        await signOut({
          redirectTo: "/",
        });
      }}
    >
      <button>로그아웃</button>
    </form>
  );
}

app/ui/modal.tsx

"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDialogElement>(null);
  const router = useRouter();
  useEffect(() => {
    const dialog = ref.current;
    if (dialog && !dialog.open) dialog.showModal();
    return () => {
      dialog?.close();
    };
  }, []);
  return (
    <dialog
      className="modal"
      ref={ref}
      aria-label="작업 상세"
      onCancel={(event) => {
        event.preventDefault();
        router.back();
      }}
    >
      <button type="button" onClick={() => router.back()}>
        닫기
      </button>
      {children}
    </dialog>
  );
}

app/ui/msw-provider.tsx

"use client";
import { useState, useEffect, type ReactNode } from "react";
export default function MswProvider({ children }: { children: ReactNode }) {
  const enabled =
    process.env.NODE_ENV === "development" &&
    process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
  const [ready, setReady] = useState(!enabled);
  const [error, setError] = useState("");
  useEffect(() => {
    if (!enabled) return;
    let active = true;
    import("@/src/mocks/browser")
      .then(({ startMockWorker }) => startMockWorker())
      .then(() => {
        if (active) setReady(true);
      })
      .catch(() => {
        if (active)
          setError("Mock API 초기화에 실패했습니다. worker 파일을 확인하세요.");
      });
    return () => {
      active = false;
    };
  }, [enabled]);
  return error ? (
    <p role="alert">{error}</p>
  ) : ready ? (
    <>{children}</>
  ) : (
    <p>Mock API 준비 중...</p>
  );
}

app/ui/task-actions.tsx

"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateTask } from "@/app/lib/actions";
export default function TaskActions({
  id,
  completed,
}: {
  id: string;
  completed: boolean;
}) {
  const router = useRouter();
  const query = useQueryClient();
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  async function run(op: "toggle" | "delete") {
    setBusy(true);
    try {
      await updateTask(id, op);
      await query.invalidateQueries({ queryKey: ["tasks"] });
      if (op === "delete") router.replace("/tasks");
      router.refresh();
    } catch {
      setError("변경에 실패했습니다. 권한과 연결을 확인하세요.");
    } finally {
      setBusy(false);
    }
  }
  return (
    <section>
      <div className="controls">
        <button disabled={busy} onClick={() => run("toggle")}>
          {completed ? "진행 중으로 변경" : "완료로 변경"}
        </button>
        <button disabled={busy} onClick={() => run("delete")}>
          삭제
        </button>
      </div>
      <p role="alert">{error}</p>
    </section>
  );
}

app/ui/task-form.tsx

"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { createTask } from "@/app/lib/actions";
export default function TaskForm() {
  const router = useRouter();
  const query = useQueryClient();
  const [message, setMessage] = useState("");
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<{ title: string }>();
  return (
    <form
      onSubmit={handleSubmit(async ({ title }) => {
        const data = new FormData();
        data.set("title", title);
        try {
          const result = await createTask(data);
          setMessage(result.message);
          if (result.ok) {
            reset();
            await query.invalidateQueries({ queryKey: ["tasks"] });
            router.refresh();
          }
        } catch {
          setMessage("저장에 실패했습니다. 다시 시도해 주세요.");
        }
      })}
    >
      <label htmlFor="task-title">새 작업</label>
      <input
        id="task-title"
        aria-invalid={!!errors.title}
        aria-describedby="task-error"
        {...register("title", {
          setValueAs: (v) => v.trim(),
          required: "제목이 필요합니다.",
          minLength: { value: 2, message: "2자 이상 입력해 주세요." },
          maxLength: { value: 100, message: "100자 이하로 입력해 주세요." },
        })}
      />
      <p id="task-error" role="alert">
        {errors.title?.message}
      </p>
      <button disabled={isSubmitting}>작업 추가</button>
      <p role="status">{message}</p>
    </form>
  );
}

app/ui/task-list-client.tsx

"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import type { Task } from "@/app/lib/types";
export default function TaskListClient({
  ownerId,
  q,
  status,
}: {
  ownerId: string;
  q: string;
  status: string;
}) {
  const {
    data = [],
    isError,
    isFetching,
    refetch,
  } = useQuery<Task[]>({
    queryKey: ["tasks", ownerId],
    queryFn: async () => {
      const response = await fetch("/api/tasks", { cache: "no-store" });
      if (!response.ok) throw new Error("조회 실패");
      return response.json();
    },
    staleTime: 60000,
  });
  const tasks = data.filter(
    (t) =>
      t.title.toLowerCase().includes(q.toLowerCase()) &&
      (status === "done"
        ? t.completed
        : status === "active"
          ? !t.completed
          : true),
  );
  return (
    <section>
      {isError ? <p role="alert">목록 조회에 실패했습니다.</p> : null}
      <button disabled={isFetching} onClick={() => void refetch()}>
        목록 새로 조회
      </button>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link href={"/tasks/" + task.id}>{task.title}</Link> ·{" "}
            {task.completed ? "완료" : "진행 중"}
          </li>
        ))}
      </ul>
      {tasks.length === 0 ? <p>조건에 맞는 작업이 없습니다.</p> : null}
    </section>
  );
}

app/ui/task-search.tsx

"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function TaskSearch() {
  const search = useSearchParams();
  const router = useRouter();
  const path = usePathname();
  return (
    <form
      key={search.toString()}
      onSubmit={(event) => {
        event.preventDefault();
        const data = new FormData(event.currentTarget);
        const params = new URLSearchParams(search);
        for (const key of ["q", "status"]) {
          const value = String(data.get(key) || "");
          if (value) params.set(key, value);
          else params.delete(key);
        }
        router.replace(path + "?" + params.toString());
      }}
    >
      <label htmlFor="q">작업 검색</label>
      <input id="q" name="q" defaultValue={search.get("q") || ""} />
      <label htmlFor="status">상태</label>
      <select
        id="status"
        name="status"
        defaultValue={search.get("status") || "all"}
      >
        <option value="all">전체</option>
        <option value="active">진행 중</option>
        <option value="done">완료</option>
      </select>
      <button>검색 적용</button>
    </form>
  );
}

app/ui/task-stats.tsx

import { getTaskStats } from "@/app/lib/data";
export default async function TaskStats() {
  await new Promise((resolve) => setTimeout(resolve, 600));
  const s = await getTaskStats();
  return (
    <aside aria-label="작업 통계">
      <p>
        전체: {s.total} · 완료: {s.completed}
      </p>
    </aside>
  );
}

app/ui/theme-setting.tsx

"use client";
import { useState, useEffect } from "react";
export default function ThemeSetting() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  useEffect(() => {
    try {
      const saved = localStorage.getItem("taskflow-theme");
      if (saved === "light" || saved === "dark") setTheme(saved);
    } catch {}
  }, []);
  function change(value: "light" | "dark") {
    setTheme(value);
    try {
      localStorage.setItem("taskflow-theme", value);
    } catch {}
  }
  return (
    <section data-theme={theme}>
      <p>현재 테마: {theme}</p>
      <div className="controls">
        <button onClick={() => change("light")}>밝게</button>
        <button onClick={() => change("dark")}>어둡게</button>
      </div>
    </section>
  );
}

auth.ts

import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { scryptSync, timingSafeEqual } from "node:crypto";
export const { handlers, auth, signIn, signOut } = NextAuth({
  pages: { signIn: "/login" },
  session: { strategy: "jwt" },
  providers: [
    Credentials({
      credentials: { email: { type: "email" }, password: { type: "password" } },
      async authorize(input) {
        const email = process.env.DEMO_EMAIL;
        const salt = process.env.DEMO_PASSWORD_SALT;
        const hash = process.env.DEMO_PASSWORD_HASH;
        if (
          !email ||
          !salt ||
          !hash ||
          typeof input.email !== "string" ||
          typeof input.password !== "string" ||
          input.password.length > 256 ||
          input.email !== email
        )
          return null;
        const expected = Buffer.from(hash, "hex");
        const actual = scryptSync(input.password, salt, 64);
        if (
          expected.length !== actual.length ||
          !timingSafeEqual(expected, actual)
        )
          return null;
        return { id: "demo", name: "TaskFlow 학습자", email };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) token.sub = user.id;
      return token;
    },
    session({ session, token }) {
      if (session.user && token.sub) session.user.id = token.sub;
      return session;
    },
  },
});

next-env.d.ts

/// <reference types="next" />
/// <reference types="next/image-types/global" />

next.config.ts

import type { NextConfig } from "next";
const config: NextConfig = {
  async redirects() {
    return [{ source: "/todo", destination: "/tasks", permanent: true }];
  },
  cacheComponents: false,
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
    ],
  },
};
export default config;

package.json

{
  "name": "taskflow-next",
  "version": "1.0.30",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start",
    "typecheck": "tsc --noEmit",
    "setup-auth": "node scripts/setup-auth.mjs",
    "cache-api": "node scripts/cache-api.mjs"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "axios": "1.20.0",
    "server-only": "0.0.1",
    "react-hook-form": "7.88.0",
    "next-auth": "5.0.0-beta.32",
    "@tanstack/react-query": "5.103.1"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "24.13.6",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "msw": "2.15.0"
  }
}

proxy.ts

import { auth } from "@/auth";
import { NextResponse } from "next/server";
export const proxy = auth((request) => {
  if (!request.auth?.user)
    return NextResponse.redirect(new URL("/login", request.url));
  return NextResponse.next();
});
export const config = { matcher: ["/tasks/:path*", "/settings/:path*"] };

scripts/cache-api.mjs

import { createServer } from "node:http";
let revision = 1;
createServer((req, res) => {
  if (req.url !== "/public-stats") {
    res.writeHead(404).end();
    return;
  }
  if (req.method === "POST") revision++;
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify({ revision, label: "공개 통계 실습" }));
}).listen(4100, "127.0.0.1", () => console.log("공개 통계 실습 API 준비 완료"));

scripts/setup-auth.mjs

import { randomBytes, scryptSync } from "node:crypto";
import { writeFile, access } from "node:fs/promises";
import { createInterface } from "node:readline/promises";
try {
  await access(".env.local");
  throw new Error(".env.local이 이미 있습니다. 기존 설정을 보존합니다.");
} catch (error) {
  if (error.code !== "ENOENT") throw error;
}
const input = createInterface({ input: process.stdin, output: process.stdout });
const email = (await input.question("학습용 이메일: ")).trim();
const password = await input.question(
  "학습용 비밀번호(입력 내용이 화면에 보입니다): ",
);
input.close();
if (!email.includes("@") || password.length < 12)
  throw new Error("이메일과 12자 이상 학습용 비밀번호가 필요합니다.");
const salt = randomBytes(16).toString("hex");
const values = {
  AUTH_SECRET: randomBytes(32).toString("hex"),
  DEMO_EMAIL: email,
  DEMO_PASSWORD_SALT: salt,
  DEMO_PASSWORD_HASH: scryptSync(password, salt, 64).toString("hex"),
};
await writeFile(
  ".env.local",
  Object.entries(values)
    .map(([key, value]) => key + "=" + JSON.stringify(value))
    .join("\n") + "\n",
  { mode: 0o600 },
);
console.log("로컬 인증 설정을 저장했습니다. 서버를 재시작하세요.");

src/mocks/browser.ts

import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);

let startPromise: ReturnType<typeof worker.start> | undefined;

export function startMockWorker() {
  if (!startPromise) {
    startPromise = worker.start({ onUnhandledRequest: "bypass" }).catch((error) => {
      startPromise = undefined;
      throw error;
    });
  }
  return startPromise;
}

src/mocks/handlers.ts

import { http, HttpResponse } from "msw";
let tasks = [
  {
    id: "task-1",
    title: "Mock API 연결",
    completed: false,
  },
];
export const handlers = [
  http.get("/api/tasks", () => {
    return HttpResponse.json(tasks);
  }),
  http.post("/api/tasks", async ({ request }) => {
    const body = (await request.json()) as {
      title?: string;
    };
    if (typeof body.title !== "string" || body.title.trim().length < 2) {
      return HttpResponse.json(
        {
          message: "제목을 입력해 주세요.",
        },
        {
          status: 400,
        },
      );
    }
    const newTask = {
      id: crypto.randomUUID(),
      title: body.title.trim(),
      completed: false,
    };
    tasks = [newTask, ...tasks];
    return HttpResponse.json(newTask, {
      status: 201,
    });
  }),
];

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

글 소개·학습 준비·전체 목차

Next.js 캐시 구조 Request Data Full Route Router Cache 학습 표지

Next.js · 30/38 · TaskFlow 누적 실습

Next.js 캐시 구조 Request Data Full Route Router Cache

Next.js · 30 / 38 · 고급 필수 · 캐시 구조

데이터를 바꿨는데 화면이 그대로라면 어느 캐시를 확인할까요? 같은 서버 렌더의 중복 요청, 서버 데이터, 정적 route 결과, 브라우저 route 결과를 각각 구분합니다. router.refresh는 새 서버 결과를 요청하지만 Data Cache를 무조건 지우지 않습니다. 아래 공개 revision 예제에서 서버 새 요청과 태그 재검증의 결과를 따로 비교합니다.

이 글에서는 대표적인 캐시 계층을 서로 분리해서 이해합니다. 세부 기본값과 Cache Components 관련 동작은 Next.js 버전에 따라 달라질 수 있으므로 사용 중인 버전과 공식 문서를 함께 확인합니다.

이번 글에서 배우는 내용

  • Request Memoization
  • Data Cache
  • Full Route Cache
  • Router Cache
  • 정적/동적 렌더링과 캐시
  • 재검증 API 역할

목차

다음 학습

다음 글에서는 metadata, 초기 HTML, canonical, OG 이미지 등 Next.js SEO를 배포 전 체크리스트로 점검합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 39개 · 필수 38개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기