Next.js hydration failed 오류 해결

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

Next.js hydration failed 경고는 서버와 브라우저의 어떤 차이에서 발생하나요?

Hydration은 서버 HTML에 브라우저의 React 상태와 이벤트를 연결하는 과정입니다. 서버 결과와 브라우저 첫 렌더가 다르면 hydration failed 경고가 날 수 있습니다.

적용 조건·범위: 테마 버튼을 삭제하지 않고 유지합니다. 서버와 브라우저 첫 렌더는 light로 일치시키고 Effect에서 검증된 저장값만 반영합니다.

본문 근거: Hydration Error

독립 실습: 서버와 첫 브라우저 렌더 맞추기

마운트 후 Effect가 기록한 현재 시각을 확인하세요.

예제 크게 보기 (새 탭)

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

npm run dev 후 /practice를 직접 새로고침하며 응답 HTML과 Console을 확인합니다. 초기 렌더에서는 시간 생성 대신 동일 문구를 사용합니다.

app/practice/page.tsx 전체 코드

"use client";
import { useEffect, useState } from 'react';
export default function Page() {
  const [time, setTime] = useState<string | null>(null);
  useEffect(() => setTime(new Date().toISOString()), []);
  return <p>{time ?? '시간 준비 중'}</p>;
}

기대 결과: 서버와 첫 렌더는 시간 준비 중이고 Effect 이후 ISO 시간이 나타납니다. 이 후속 변경은 초기 불일치와 다릅니다.

자가 확인: useEffect 대신 JSX에서 new Date().toISOString()을 직접 출력하는 경우 첫 결과가 달라질 수 있는 이유를 설명합니다. 경고 유무는 실제 새로고침으로 기록합니다.

비결정적 값

잘못된 예:

export default function Clock() {
  return <p>{Date.now()}</p>;
}

서버와 브라우저가 서로 다른 값을 만들 수 있습니다.

브라우저 저장값을 초기 렌더에 바로 사용

const [theme] = useState(
  localStorage.getItem('theme') ?? 'light',
);

서버에는 localStorage가 없고 첫 결과도 달라질 수 있습니다.

안전한 기본값으로 시작한 뒤 Effect에서 브라우저 값을 반영합니다.

HTML 구조도 확인

<p>
  <div>잘못된 중첩</div>
</p>

브라우저가 HTML 구조를 보정하면 React가 예상한 DOM과 달라질 수 있습니다.

자주 발생하는 오류

  • 경고를 suppressHydrationWarning으로 바로 숨김
  • 서버/클라이언트 초기값 차이를 무시
  • 렌더 중 random 값 사용
  • localStorage 값을 첫 렌더 원본으로 사용
선택 실습: 할 일 앱에 적용하기

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

서버·브라우저 첫 테마를 light로 맞추고 Effect에서 저장된 테마를 읽어 hydration 불일치를 구분합니다.

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

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

TaskFlow Next.js 프로젝트 적용

시작 체크포인트: 이전: Next.js window is not defined 오류 해결의 완료 상태에서 이어갑니다.

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

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

직접 확인: 첫 렌더와 저장값 복원

독자 질문: dark를 저장했는데 새로고침 직후 light가 잠깐 보이면 hydration 오류일까요? TaskFlow는 첫 상태를 light로 만들고 Effect에서 저장된 dark를 반영합니다. 이후 상태 변경 자체는 첫 렌더 불일치가 아닙니다.

  1. 12·13회차 완료 코드의 app/ui/theme-setting.tsx를 비교합니다. useState("light")와 useEffect의 저장값 읽기를 확인합니다. 기획에 언급됐던 현재 시각 표시 컴포넌트는 완료 코드에 없습니다.
  2. /settings에서 ‘어둡게’를 눌러 taskflow-theme=dark를 확인합니다. 새로고침 후 첫 light와 Effect 이후 미리보기 영역의 dark를 관찰하고 브라우저 콘솔의 hydration 경고와 구분합니다.
  3. Date.now() 렌더 및 <p><div> 중첩은 독립적인 오류 예제입니다. TaskFlow 완료 코드가 이 예제를 실행하지는 않습니다.
  4. npm run typecheck, npm run build와 브라우저 콘솔 결과를 따로 기록합니다.

오류 점검

경고가 발생하면 서버 HTML과 첫 브라우저 렌더의 값·태그 구조를 비교합니다. 시간·랜덤값·저장소 접근·잘못된 중첩을 점검한 뒤에 수정하며 suppressHydrationWarning으로 먼저 숨기지 않습니다.

검증 상태: 이번 편집에서는 빌드·브라우저·콘솔을 실행하지 않아 경고 부재와 화면 전환 결과는 미확인입니다.

다음 학습

14회차는 서버 데이터 조회와 Suspense 경계입니다.

완료 코드 ZIP 다운로드 — next-step-13-hydration-fix

이전 체크포인트: next-step-12-browser-api-boundary

이번 회차의 변경

테마 버튼을 삭제하지 않고 유지합니다. 서버와 브라우저 첫 렌더는 light로 일치시키고 Effect에서 검증된 저장값만 반영합니다.

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

전체 파일·코드 보기

실행과 검증

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

npm install
npm run dev
npm run build
npm run start

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

완료 체크포인트: next-step-13-hydration-fix

실습 프로젝트 전체 코드

.gitignore

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

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

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
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>
    </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";
export default async function AppLayout({
  children,
  modal,
}: {
  children: ReactNode;
  modal: ReactNode;
}) {
  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";
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>
      <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

import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
export default async function TasksPage() {
  const tasks = await getTasks();
  return (
    <main>
      <h1>작업 목록</h1>
      <TaskSearch />
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link href={"/tasks/" + task.id}>{task.title}</Link>
            <span> · {task.completed ? "완료" : "진행 중"}</span>
          </li>
        ))}
      </ul>
    </main>
  );
}

app/(app)/template.tsx

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

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";
export default function SiteHeader() {
  return (
    <header>
      <nav aria-label="주 메뉴">
        <Link href="/">홈</Link>
        <Link href="/tasks">작업</Link>
        <Link href="/settings">설정</Link>
      </nav>
    </header>
  );
}

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";
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>{children}</body>
    </html>
  );
}

app/lib/data.ts

import type { Task } from "./types";
const tasks: Task[] = [
  {
    id: "task-1",
    title: "App Router 공부",
    description: "서버와 브라우저의 역할을 구분합니다.",
    completed: false,
    ownerId: "demo",
  },
  {
    id: "task-2",
    title: "TaskFlow 만들기",
    description: "누적 실습을 완성합니다.",
    completed: true,
    ownerId: "demo",
  },
];
export async function getTasks() {
  return tasks;
}
export async function getTask(id: string) {
  return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
  const ts = await getTasks();
  return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}

app/lib/types.ts

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

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/task-search.tsx

"use client";
import { useState } from "react";
export default function TaskSearch() {
  const [keyword, setKeyword] = useState("");
  return (
    <label>
      작업 검색
      <input
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
        placeholder="검색어 입력"
      />
    </label>
  );
}

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>
  );
}

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.13",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "24.13.6",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}

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 서버 출력과 React 첫 렌더 사이의 hydration mismatch 진단 흐름

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

Next.js hydration failed 오류 해결

Next.js · 13 / 38 · 선택 참고 · hydration 오류

Hydration은 서버에서 만든 HTML에 React가 브라우저 이벤트와 상태를 연결하는 과정입니다. 서버 HTML과 클라이언트의 첫 렌더 결과가 다르면 hydration failed 경고가 발생할 수 있습니다.

이번 글에서 배우는 내용

  • hydration
  • 서버 HTML과 첫 클라이언트 렌더 일치
  • Date.now, Math.random 같은 비결정적 값
  • localStorage 초기값 문제
  • 잘못된 HTML 중첩
  • suppressHydrationWarning 사용 기준

목차

다음 학습

다음 글에서는 Server Component에서 직접 데이터를 조회하고 Suspense를 이용해 느린 영역을 Streaming합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기