Next.js window is not defined 오류 해결

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

Next.js의 window is not defined 오류는 use client만으로 해결되나요?

window·document·localStorage는 브라우저 API이므로 서버 실행 중에 읽으면 오류가 납니다. use client를 붙인 파일도 import나 초기 렌더 시점의 접근을 살펴야 합니다.

적용 조건·범위: 새로고침 시 초기 light 뒤 Effect가 저장된 dark를 반영할 수 있습니다. 저장소가 차단된 환경에서는 코드의 try/catch가 접근 실패를 처리하므로 버튼으로 바꾼 화면 상태가 저장되지 않을 수 있습니다.

본문 근거: Server and Client Components

독립 실습: 브라우저 너비를 클릭 이후 읽기

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를 직접 열고 너비 읽기를 누릅니다. 서버 모듈 최상단에서는 window를 읽지 않습니다.

app/practice/page.tsx 전체 코드

"use client";
import { useState } from 'react';
export default function Page() {
  const [width, setWidth] = useState<number | null>(null);
  return (
    <main>
      <button onClick={() => setWidth(window.innerWidth)}>너비 읽기</button>
      <p>{width === null ? '아직 읽지 않음' : `${width}px`}</p>
    </main>
  );
}

기대 결과: 첫 응답은 아직 읽지 않음이고 클릭 후 현재 브라우저 너비가 표시됩니다.

자가 확인: 창 크기를 바꿔도 버튼을 다시 누르기 전에는 값이 그대로인 이유를 설명합니다.

문제 예제

const theme = localStorage.getItem('theme');

모듈이 서버에서 평가되면 localStorage가 없습니다.

Client Component로 분리

use client는 브라우저 상호작용을 위한 경계를 만들지만, 첫 페이지 요청에서 Client Component의 HTML도 서버에서 미리 렌더링될 수 있습니다. 따라서 window·localStorage를 모듈 최상단이나 초기 렌더에서 읽는 코드는 그대로 안전해지지 않습니다. 화면이 열린 뒤 복원할 값은 Effect에서, 클릭할 때만 필요한 값은 이벤트에서 읽습니다.

'use client';

import { useEffect, useState } from 'react';

export default function ThemeSetting() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const savedTheme =
      localStorage.getItem('theme');

    if (savedTheme) {
      setTheme(savedTheme);
    }
  }, []);

  return <p>현재 테마: {theme}</p>;
}

이벤트 안에서 접근

사용자 클릭 이후에만 필요한 값은 이벤트에서 읽을 수 있습니다.

function handleSave() {
  localStorage.setItem('theme', theme);
}

자주 발생하는 오류

  • Server Component에서 window 접근
  • 파일 최상단에서 localStorage 실행
  • use client만 붙이고 import 시 실행되는 코드 방치
  • 모든 코드를 typeof window 조건으로 감싸 구조 문제를 숨김
선택 실습: 할 일 앱에 적용하기

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

설정의 테마 영역만 Client Component로 분리합니다. localStorage는 Effect와 클릭 이벤트에서 접근하고 저장소 접근이 제한된 환경도 처리합니다.

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

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

TaskFlow Next.js 프로젝트 적용

시작 체크포인트: 이전: Next.js loading error not-found 라우트 상태 처리의 완료 상태에서 이어갑니다.

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

이전 완료본과 달라진 파일: app/(app)/settings/page.tsx, app/ui/theme-setting.tsx, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

직접 확인: 설정의 테마와 저장 결과

독자 질문: 'use client'를 붙였는데도 왜 localStorage 오류가 날까요? import 시점이나 초기 렌더의 접근은 여전히 문제를 만들 수 있습니다. 완료 코드는 app/ui/theme-setting.tsx의 Effect에서 읽고 버튼 이벤트에서 저장합니다.

  1. app/(app)/settings/page.tsx의 ‘설정’ 제목과 ThemeSetting 렌더링을 확인합니다. Client 파일의 초기값 light, useEffect의 localStorage.getItem("taskflow-theme"), 버튼의 setItem을 찾습니다.
  2. /settings에는 ‘현재 테마: light’와 ‘밝게’·’어둡게’ 버튼이 예상됩니다. ‘어둡게’를 누르면 설정 미리보기 영역의 글자·스타일과 저장 키 taskflow-theme가 바뀝니다. 전체 페이지 테마를 바꾸는 단계는 아닙니다.
  3. 새로고침 시 초기 light 뒤 Effect가 저장된 dark를 반영할 수 있습니다. 저장소가 차단된 환경에서는 코드의 try/catch가 접근 실패를 처리하므로 버튼으로 바꾼 화면 상태가 저장되지 않을 수 있습니다.
  4. npm run typecheck, npm run build, 개발·프로덕션의 /settings 결과를 구분해 기록합니다.

오류 점검

오류가 계속되면 Client 파일 또는 import한 패키지의 모듈 최상단 접근, 저장소 권한, taskflow-theme 키 이름을 확인합니다. typeof window만 덧붙여 원인을 가리지 않습니다.

검증 상태: 이번 편집에서 빌드·서버·브라우저·저장소를 실행하지 않았으므로 실제 버튼·저장·초기 화면은 미확인입니다.

다음 학습

13회차에서는 서버 HTML과 브라우저 첫 렌더가 달라지는 hydration 오류를 다룹니다.

완료 코드 ZIP 다운로드 — next-step-12-browser-api-boundary

이전 체크포인트: next-step-11-route-states

이번 회차의 변경

설정의 테마 영역만 Client Component로 분리합니다. localStorage는 Effect와 클릭 이벤트에서 접근하고 저장소 접근이 제한된 환경도 처리합니다. 어두운 테마는 설정 미리보기 영역에 적용됩니다.

변경·추가 파일:

  • app/(app)/settings/page.tsx
  • app/ui/theme-setting.tsx
전체 파일·코드 보기

실행과 검증

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-12-browser-api-boundary

실습 프로젝트 전체 코드

.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.12",
  "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 window is not defined 오류 해결 방법의 원인을 단계별로 점검하는 다이어그램

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

Next.js window is not defined 오류 해결

Next.js · 12 / 38 · 선택 참고 · 오류 해결

Next.js에서는 같은 React 코드라도 서버에서 먼저 실행될 수 있습니다. 브라우저에만 존재하는 window, document, localStorage를 서버에서 읽으면 window is not defined 오류가 발생합니다.

이번 글에서 배우는 내용

  • 서버와 브라우저 실행 환경 차이
  • window/document/localStorage
  • Client Component
  • 이벤트 시점 접근
  • useEffect가 필요한 경우
  • typeof window 임시 회피와 구조적 해결 차이

목차

다음 학습

다음 글에서는 서버 HTML과 브라우저 첫 렌더 결과가 달라질 때 발생하는 hydration failed 오류를 다룹니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기