Next.js TanStack Query SSR HydrationBoundary

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

Next.js에서 서버 Query 데이터를 HydrationBoundary로 어떻게 이어받나요?

서버에서 조회한 데이터를 브라우저 Query 캐시로 이어주려면 요청별 QueryClient에 준비한 결과를 dehydrate하고 HydrationBoundary로 전달합니다. 받는 useQuery는 같은 queryKey를 사용합니다.

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

본문 근거: TanStack Query Advanced Server Rendering

독립 실습: 외부 API 없는 SSR Query 캐시 전달

export type Item = { id: string; title: string };
export async function getItems(): Promise<Item[]> {
  return [{ id: 'one', title: '독립 Query 실습' }];
}
import { QueryClient, dehydrate } from '@tanstack/react-query';
import { getItems } from './data';
import Client from './client';
export default async function Page() {
  const client = new QueryClient();
  await client.prefetchQuery({ queryKey: ['practice-items'], queryFn: getItems });
  return <Client state={dehydrate(client)} />;
}

기대 결과: 초기 HTML과 브라우저 모두 독립 Query 실습 한 항목입니다. 서버와 Client의 queryKey가 같고 fresh한 60초 동안 마운트 즉시 재조회가 필요하지 않습니다.

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

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

npm install @tanstack/react-query 후 /practice를 직접 열고 응답 HTML과 브라우저 목록을 비교합니다. mock 배열은 공개 데이터라 서버·브라우저가 같은 함수를 import할 수 있습니다. 실제 서버 전용 DB 조회 함수를 Client로 import하는 패턴은 아닙니다.

app/practice/data.ts 전체 코드

app/practice/client.tsx 전체 코드

"use client";
import { QueryClient, QueryClientProvider, HydrationBoundary, useQuery, type DehydratedState } from '@tanstack/react-query';
import { useState } from 'react';
import { getItems } from './data';
function List() {
  const query = useQuery({ queryKey: ['practice-items'], queryFn: getItems, staleTime: 60000 });
  return <ul>{query.data?.map(item => <li key={item.id}>{item.title}</li>)}</ul>;
}
export default function Client({ state }: { state: DehydratedState }) {
  const [client] = useState(() => new QueryClient());
  return <QueryClientProvider client={client}>
    <HydrationBoundary state={state}><List /></HydrationBoundary>
  </QueryClientProvider>;
}

app/practice/page.tsx 전체 코드

자가 확인: Client queryKey만 다른 값으로 바꾸면 캐시를 이어받지 못하는 이유를 설명하고 원래 키로 복구합니다.

설치

npm install @tanstack/react-query

QueryClient 만들기

import {
  QueryClient,
} from '@tanstack/react-query';

export function makeQueryClient() {
  return new QueryClient();
}

요청 사이에 서버 QueryClient를 전역 singleton으로 무심코 공유하지 않도록 주의합니다.

Client Provider

'use client';

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
import {
  useState,
  type ReactNode,
} from 'react';

export default function QueryProvider({
  children,
}: {
  children: ReactNode;
}) {
  const [queryClient] = useState(
    () => new QueryClient(),
  );

  return (
    <QueryClientProvider
      client={queryClient}
    >
      {children}
    </QueryClientProvider>
  );
}

서버 prefetch와 Hydration

서버에서는 새 QueryClient로 queryKey와 queryFn을 지정해 prefetchQuery를 기다린 뒤 dehydrate 결과를 Client에 전달합니다. HydrationBoundary는 전달된 상태를 해당 QueryClient에 연결합니다. 아래 독립 예제는 공개 배열만 사용하며 하단 프로젝트 적용은 사용자별 목록으로 확장합니다.

Client useQuery

Client의 useQuery는 같은 queryKey로 전달된 결과를 찾습니다. staleTime이 0보다 크면 아직 fresh한 결과를 마운트 직후 재조회할 필요가 줄어듭니다. 브라우저 queryFn은 재조회가 필요한 때도 실행할 수 있어야 하며, 하단 프로젝트 적용에서는 인증된 Route Handler를 사용합니다.

캐시 역할을 섞지 않습니다

Next.js
→ 서버 렌더링·route/data 캐시

TanStack Query
→ 클라이언트 서버 상태 캐시
→ refetch·mutation UX

둘을 같은 캐시라고 부르면 디버깅이 어려워집니다.

전달되는 것은 조회 함수나 열린 DB 연결이 아니라 직렬화할 수 있는 Query 상태입니다. 개인 데이터는 서버 요청별 Client와 사용자별 queryKey로 분리하고, 브라우저에서 재조회할 때는 서버 전용 함수를 import하는 대신 인증된 API를 호출합니다.

원리 확인: TanStack Query Advanced Server Rendering (2026-10-12). 아래는 기대 결과이며 이 편집에서 실제 실행은 확인하지 않았습니다.

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

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

요청마다 새 QueryClient에 현재 사용자 목록을 prefetch하고 HydrationBoundary로 전달합니다. queryKey에는 사용자 ID가 포함됩니다.

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

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

TaskFlow Next.js 프로젝트 적용

시작 체크포인트: 이전: Next.js fetch 캐시와 재검증의 완료 상태에서 이어갑니다.

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

이전 완료본과 달라진 파일: app/(app)/tasks/page.tsx, app/layout.tsx, app/providers.tsx, app/ui/api-demo.tsx, app/ui/task-actions.tsx, app/ui/task-form.tsx, app/ui/task-list-client.tsx, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

개념 설명에서 사용한 프로젝트 부분 코드

다음 공개 목록 설명 조각의 getTasks·getTasksClient는 생략된 조회 함수 자리이며 그대로 실행할 파일이 아닙니다. 개인 작업을 구현할 때는 아래 완료본처럼 사용자 ID가 든 queryKey와 인증된 API를 사용합니다. 상단 공개 fixture의 단독 예제와 혼합하지 않습니다.

import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query';

export default async function Page() {
  const queryClient =
    new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ['tasks'],
    queryFn: getTasks,
  });

  return (
    <HydrationBoundary
      state={dehydrate(queryClient)}
    >
      <TaskListClient />
    </HydrationBoundary>
  );
}
'use client';

import {
  useQuery,
} from '@tanstack/react-query';

export default function TaskListClient() {
  const { data = [] } = useQuery({
    queryKey: ['tasks'],
    queryFn: getTasksClient,
  });

  return (
    <ul>
      {data.map((task) => (
        <li key={task.id}>
          {task.title}
        </li>
      ))}
    </ul>
  );
}

독자 질문·직접 확인

질문: 서버에서 가져온 작업 목록이 브라우저 Query 캐시에 어떻게 이어지나요?

경로·데이터 결과: 로그인 후 /tasks의 초기 목록을 확인합니다. app/(app)/tasks/page.tsx가 사용자 ID를 포함한 queryKey로 현재 사용자 작업을 prefetch하고 app/ui/task-list-client.tsx가 동일 키로 이어받습니다. 작업을 추가·완료·삭제한 뒤 목록이 갱신되는지 보고 네트워크 탭에서 인증된 /api/tasks 요청을 확인하세요.

오류 점검: 사용자 ID를 queryKey에서 빼거나 서버 QueryClient를 전역 공유하면 사용자 간 데이터 혼동 위험이 있습니다. MSW가 켜져 있으면 실제 Route Handler 응답과 구분하고, 메모리 저장소의 재시작 초기화도 감안합니다.

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

완료 코드 ZIP 다운로드 — next-step-23-query-hydration

이전 체크포인트: next-step-22-cache

이번 회차의 변경

요청마다 새 QueryClient에 현재 사용자 목록을 prefetch하고 HydrationBoundary로 전달합니다. queryKey에는 사용자 ID가 포함됩니다. 브라우저 조회는 인증된 /api/tasks를 사용하며 작업 추가·완료·삭제 후 Query 캐시 무효화와 RSC 갱신을 함께 수행합니다. 검색·상세·통계 기능을 유지합니다.

변경·추가 파일:

  • app/layout.tsx
  • app/(app)/tasks/page.tsx
  • app/ui/api-demo.tsx
  • app/ui/task-form.tsx
  • app/ui/task-actions.tsx
  • app/providers.tsx
  • app/ui/task-list-client.tsx
전체 파일·코드 보기

실행과 검증

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-23-query-hydration

실습 프로젝트 전체 코드

.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 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/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 Link from "next/link";
export default function HomePage() {
  return (
    <main>
      <h1>TaskFlow Next.js</h1>
      <p>App Router 기반 작업 관리 서비스입니다.</p>
      <Link href="/tasks">작업 목록 보기</Link>
    </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 학습 프로젝트",
};
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/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/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" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";


// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

next.config.ts

import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;

package.json

{
  "name": "taskflow-next",
  "version": "1.0.23",
  "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",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

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

Next.js TanStack Query SSR HydrationBoundary 학습 표지

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

Next.js TanStack Query SSR HydrationBoundary

Next.js · 23 / 38 · 필수 실무 · TanStack Query SSR·Hydration

서버의 Query 결과를 브라우저 캐시에 어떻게 이어줄까요? 요청별 QueryClient에 prefetch한 결과를 dehydrate하고 HydrationBoundary로 전달한 뒤 같은 queryKey의 useQuery가 읽게 합니다. 서버 초기 HTML과 Client 목록이 같은 공개 fixture를 사용하는 독립 예제로 전달 경계를 확인합니다.

이번 글에서 배우는 내용

  • TanStack Query 설치
  • QueryClientProvider
  • 서버 prefetchQuery
  • dehydrate
  • HydrationBoundary
  • Next.js 데이터 캐시와 Query 캐시 역할 구분

목차

다음 학습

다음 글에서는 cookies와 headers처럼 요청 단위 정보가 렌더링·캐시 경계에 어떤 영향을 주는지 확인합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기