FE/Next.js

[Next.js] App Router 클라이언트 컴포넌트에서 발생하는 window is not defined 에러

unhandled 2025. 5. 24. 10:54

 

(Next.js App Router 15 버전을 기준으로 작성한 포스팅입니다.)

 

window is not defined (윈도우 객체가 정의되지 않았습니다.

⨯ ReferenceError: window is not defined

 

Next.js App Router를 사용해 본 적 있는 개발자라면 무조건 한 번은 마주쳤을 에러라고 생각합니다. 저도 여러 번 마주했지만 그때마다 이 에러의 정확한 발생 원인은 모르고 "window가 undefined이면.." 이런 조건문을 넣어서 해결했던 것으로 기억합니다. 

 

이 에러가 왜 발생하는지 찾아보면 "SSR이 발생하기 때문이다." "리액트 컴포넌트가 마운트 되기 전에 윈도우 객체를 찾으려고 해서 그렇다."라는 이유가 주로 보입니다. 물론 이도 맞는 말이지만 좀 더 정확한 맥락을 파악하고 싶었습니다. 

 

그때의 상황을 재연해서 이유를 찾아보도록 하겠습니다. 

 

상황 재연 테스트

클라이언트 컴포넌트를 하나 만들어줍니다. 

 

Client.tsx

"use client";

import { useEffect, useState } from "react";

type UserName = { name: string };

export default function Client() {
  // 여기
  const browserHeight = window.innerHeight;
  
  const [data, setData] = useState<UserName | null>(null);
  useEffect(() => {
    async function fetchData() {
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
        const result = await res.json();
        setData(result);
      } catch (error) {
        console.log(error);
        return;
      }
    }
    fetchData();
  }, []);

  if (!data) {
    console.log("데이터가 없습니다.");
    return <div>데이터가 없습니다</div>;
  }

  return (
    <div>
      <h2>클라이언트 컴포넌트</h2>
      <p>{(data as UserName).name}</p>
      <p>{browserHeight}</p>
    </div>
  );
}

 

window.innerHeight는 브라우저 화면의 내부 높이값을 반환하는 속성입니다.

 

const browserHeight = window.innerHeight;

 

런타임 테스트

여기서도 지난 포스팅에서처럼 두 가지 접근 방법으로 나누어 확인해 보겠습니다. (각각 실험은 강력 새로고침 및 캐시 삭제 후 진행했습니다.)

 

1. 주소창으로 직접 접근

 

에러가 발생합니다. (하지만 페이지 전체가 404가 뜨지는 않습니다.)

 

 

2. 클라이언트 사이드 내비게이션(Link)으로 접근

 

이 경우는 에러가 발생하지 않습니다.

 

위 테스트 결과는 개발 환경(npm run dev)에서 진행한 것입니다. 해당 페이지(/app/test/page.tsx)가 정적으로 렌더링되는 경우와 동적으로 렌더링되는 경우도 각각 테스트해보았는데 차이는 없었습니다. Next.js App Router의 개발 환경에서는 정적 렌더링 페이지도 동적 렌더링 방식과 유사하게 동작하게 설계되어있으니까요. 이 차이를 분명하게 파악하려면 프로덕션 환경에서 확인해야 합니다.

 

빌드 타임 테스트

 

1. 정적 렌더링 되는 경우

 

현재 컴포넌트 내에는 동적 렌더링을 강제하는 요소가 없습니다. 동적 렌더링을 유발하는 요소가 없다면 기본적으로는 정적 렌더링 된다는 전제하에 이 페이지는 정적 렌더링 됩니다. 

import Client from "./Client";

export default function Page() {
  return (
    <div>
      <Client />
    </div>
  );
}

 

빌드 명령어를 쳐줍니다.

npm run build

 

에러가 발생합니다.

 

아까 브라우저에서도 봤던 에러가 그대로 떴습니다. 

⨯ ReferenceError: window is not defined

 

동적 렌더링 되는 경우에도 빌드 에러가 발생할까요?

 

2. 동적 렌더링 되는 경우

 

부모 페이지 컴포넌트에 force-dynamic를 추가해서 페이지 전체가 동적 렌더링 되게 만듭니다.

export const dynamic = "force-dynamic";

import Client from "./Client";

export default function Page() {
  return (
    <div>
      <Client />
    </div>
  );
}

 

이제 빌드 명령어를 쳐줍니다. 

npm run build

 

이 경우에는 빌드가 무사히 완료됩니다. 

 

정적 렌더링 시 에러가 발생한 이유

 

빌드타임에 해당 경로에 대응되는 test.html 파일을 생성합니다. 이 test.html 파일에는 Client.tsx도 포함되어 있습니다. 빌드서버에서 이 클라이언트 컴포넌트에 대해 프리렌더링이 발생합니다. 프리렌더링 과정에서 초기 html을 위해서 클라이언트 컴포넌트의 일부 자바스크립트를 실행합니다.

 

+) 프리렌더링 과정에서 실행되는 "일부 자바스크립트"는?

 

이 "일부 자바스크립트"란 useEffect와 같은 컴포넌트 생명주기와 관련된 기능, 유저 인터렉션 기능(이벤트 핸들러), 브라우저 전용 API를 제외한 부분입니다. 즉 초기화면을 그리는 렌더링 로직이 실행되는 것입니다. (그리고 이 초기 화면은 클라이언트에서 하이드레이션 됩니다.)

 

 

Client.tsx의 코드를 다시 확인해 보겠습니다.

보시다시피 window.innerHeight 속성을 useEffect 밖에서 사용하고 있습니다. 

 

Client.tsx

"use client";

import { useEffect, useState } from "react";

type UserName = { name: string };

export default function Client() {
  // 여기
  const browserHeight = window.innerHeight;
  const [data, setData] = useState<UserName | null>(null);
  
  useEffect(() => {
    async function fetchData() {
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
        const result = await res.json();
        setData(result);
      } catch (error) {
        console.log(error);
        return;
      }
    }
    fetchData();
  }, []);

  if (!data) {
    console.log("데이터가 없습니다.");
    return <div>데이터가 없습니다</div>;
  }

  return (
    <div>
      <h2>클라이언트 컴포넌트</h2>
      <p>{(data as UserName).name}</p>
      <p>{browserHeight}</p>
    </div>
  );
}

 

그렇다는 것은 곧 프리렌더링 과정에서 저 부분도 실행한다는 의미입니다. 그런데 widow.innerHeight는 현재 브라우저의 뷰포트 높이를 가져오는 웹 API입니다. 즉, 브라우저가 아닌 환경에서는 사용할 수 없습니다. window is not defined는 저 웹 API를 웹 브라우저가 아닌 서버(빌드 서버)에서 실행시키려고 해서 발생한 에러인 것입니다. 

 

동적 렌더링 시 에러가 발생하지 않은 이유

동적 렌더링 시에는 초기 HTML 생성을 위한 프리렌더링을 빌드 서버에서 진행하지 않습니다. 런타임에 온디맨드 형식으로 실시간으로 HTML을 작성해서 보내주죠. 실제로 프로덕션 환경에서 접속하면 위와 같은 현상(에러)이 그대로 발생할 것입니다. 

 

프로덕션 환경 확인

다음 명령어를 터미널에 쳐줍니다.

npm start

 

여전히 /test 경로에 주소창으로 접근하면 에러가 발생합니다. 다만 에러는 개발 환경에서처럼 화면 좌측 하단에 표시되지는 않고 브라우저 콘솔에만 찍힙니다.

 

 Link로 접근한다면 에러가 발생하지 않습니다. 

 

해결법

이 문제에 대한 해결 방법은 사실 매우 간단합니다.

 

1. 조건문 추가 (비추천)

 

저도 예전 프로젝트 당시에는 이 방법으로 해결했던 것 같습니다.

 

이렇게 수정하면 정상작동하는 듯 하나 이 코드는 잠재적인 문제가 있습니다.

"use client";

import { useEffect, useState } from "react";

type UserName = { name: string };

export default function Client() {
  const [data, setData] = useState<UserName | null>(null);
  //여기 추가
  const [browserHeight, setBrowserHeight] = useState<number>(0);

  //여기 추가
  if (typeof window !== "undefined" && browserHeight === 0) {
    setBrowserHeight(window.innerHeight);
  }

  useEffect(() => {
    async function fetchData() {
      try {
        const res = await fetch(
          "https://jsonplaceholder.typicode.com/users/1",
        );
        const result = await res.json();
        setData(result);
      } catch (error) {
        console.log(error);
        return;
      }
    }
    fetchData();
  }, []);

  if (!data) {
    console.log("데이터가 없습니다.");
    return <div>데이터가 없습니다</div>;
  }

  return (
    <div>
      <h2>클라이언트 컴포넌트</h2>
      <p>{(data as UserName).name}</p>
      <p>{browserHeight}</p>
    </div>
  );
}

 

위 코드는 렌더링 도중 setState를 호출합니다. 렌더링 중에 상태가 변경되는 것은 다시 리렌더링을 유발할 수 있고, 리렌더링이 되었을 때 또 상태가 변경되고.. 이를 반복한다면 무한 루프 에러가 발생할 수 있습니다. 

 

useState가 아닌 변수의 상태가 변경돼도 리렌더링을 유발하지 않는 useRef 훅을 사용하는 것이 좀 더 나은 방법입니다. 

"use client";
// 여기 추가
import { useEffect, useState, useRef } from "react";

type UserName = { name: string };

export default function Client() {
  const [data, setData] = useState<UserName | null>(null);
  // 여기 추가
  const browserHeightRef = useRef<number | null>(null);
  // 여기 추가
  if (typeof window !== "undefined" && browserHeightRef.current === null) {
    browserHeightRef.current = window.innerHeight;
  }

  useEffect(() => {
    async function fetchData() {
      try {
        const res = await fetch(
          "https://jsonplaceholder.typicode.com/users/1",
        );
        const result = await res.json();
        setData(result);
      } catch (error) {
        console.log(error);
        return;
      }
    }
    fetchData();
  }, []);

  if (!data) {
    console.log("데이터가 없습니다.");
    return <div>데이터가 없습니다</div>;
  }

  return (
    <div>
      <h2>클라이언트 컴포넌트</h2>
      <p>{(data as UserName).name}</p>
      //여기 추가
      <p>{browserHeightRef.current}</p>
    </div>
  );
}

 

하지만 이 또한 렌더링 도중 상태변경이 발생한다는 것은 동일합니다. 그리고 리액트는 렌더링 중 이러한 사이드 이펙트(상태 변경)가 발생할 수 있는 방식을 권장하지 않습니다. 

 

그러므로 가장 추천하는 방법은 다음 방법입니다. 

 

2. useEffect 훅 안에 넣기 (추천)

 

가장 좋은 해결책입니다. 

 

Client.tsx

"use client";

import { useEffect, useState } from "react";

type UserName = { name: string };

export default function Client() {
  const [data, setData] = useState<UserName | null>(null);
  // 여기 추가
  const [browserHeight, setBrowserHeight] = useState<number>(0);

  useEffect(() => {
    // 여기 추가
    setBrowserHeight(window.innerHeight);
    async function fetchData() {
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
        const result = await res.json();
        setData(result);
      } catch (error) {
        console.log(error);
        return;
      }
    }

    fetchData();
  }, []);

  if (!data) {
    console.log("데이터가 없습니다.");
    return <div>데이터가 없습니다</div>;
  }

  return (
    <div>
      <h2>클라이언트 컴포넌트</h2>
      <p>{(data as UserName).name}</p>
      <p>{browserHeight}</p>
    </div>
  );
}

 

이렇게 한다면 더 이상 주소창으로 접근 시나 새로고침시 에러가 발생하지 않습니다.

 

 

3. 컴포넌트의 SSR을 끄고 가져오기 (비추천)

 

클라이언트 컴포넌트가 서버에서의 프리렌더링 과정에서 자바스크립트 코드를 실행시켜서 문제가 되는 것이라면, 그러한 서버에서 자바스크립트가 실행되지 않게끔 하여 해결할 수도 있을 것 같습니다.

 

Next.js가 제공하는 동적 임포트(Dynamic Import) 기능인 next/dynamic을 사용해서 컴포넌트를 import 해옵니다.

 

그리고 { ssr: false } 옵션도 추가해 줍니다. 

//여기 추가
import dynamic from "next/dynamic";

//여기 추가
const Client = dynamic(() => import("./Client"), { ssr: false });

export default function Page() {
  return (
    <div>
      <Client />
    </div>
  );
}

 

그리고 해당 페이지에 주소창으로 바로 접속해 보면... 이런! 에러가 발생했습니다! 

 

 

방금은 개발 환경에서 접근한 것인데 빌드 시에도 동일한 에러가 발생합니다.

 

 

서버 컴포넌트에서 동적 임포트(next/dynamic)를 사용하면서 ssr:false 옵션을 줄 수 없다고 합니다.

 

이를 해결하려면 해당 클라이언트 컴포넌트를 import 해오는 부모 컴포넌트를 클라이언트 컴포넌트로 만들거나 

//여기 추가
"use client";

import dynamic from "next/dynamic";

const Client = dynamic(() => import("./Client"), { ssr: false });

export default function Page() {
  return (
    <div>
      <Client />
    </div>
  );
}

 

새로운 클라이언트 컴포넌트를 만들어서 거기에 Client.tsx를 ssr:false 옵션을 준 동적 임포트로 가져오고, 새로 만든 클라이언트 컴포넌트를 page.tsx에 그냥 import 해오면 됩니다. 

 

 

왜 부모 컴포넌트가 서버 컴포넌트이면 자식 컴포넌트는 ssr:false 옵션으로 가져올 수 없는 걸까요?

 

서버 컴포넌트는 서버에서 완전히 렌더링 되어야 하고 이 과정에서 모든 자식 컴포넌트를 해석합니다. 그런데 ssr: false 옵션을 주면 클라이언트 컴포넌트인 자식 컴포넌트는 서버에서의 프리렌더링이 아예 발생하지 않게 되므로 서버 컴포넌트는 자식 컴포넌트의 내용을 알 수 없게(해석할 수 없게) 되기 때문입니다.

 

번외) 순수 리액트 테스트

사실 이 에러는 RSC나 SSR를 사용하지 않는 순수 리액트 프로젝트에서는 거의 발생할 일이 없습니다. 

리액트 프로젝트(vite로)를 얼른 만들어서 확인해 보았는데요.

 

App.tsx

import { Routes, Route, Link } from "react-router-dom";
import Test from "./pages/Test";
import "./App.css";

function App() {
  return (
    <div>
      <nav>
        <Link to="/test">Test</Link>
      </nav>
      <Routes>
        <Route path="/test" element={<Test />} />
      </Routes>
    </div>
  );
}

export default App;

 

Test.tsx

export default function Test() {
  const browserHeight = window.innerHeight;

  return (
    <div>
      <h2>순수한 리액트 프로젝트</h2>
      <p>browserHeight : {browserHeight}</p>
    </div>
  );
}

 

리액트 프로젝트(vite) 결과

 

주소창 접근 시에도 에러가 발생하지 않습니다. useEffect를 사용하지 않았는데도 말입니다. 순수 리액트는 서버 환경에서 자바스크립트 코드를 실행할 일이 없습니다. 이는 서버에서 웹 API 실행 또한 시도하는 경우가 없다는 것을 의미하므로 에러가 발생하지 않습니다. 

 

물론 이 경우에도 useEffect 내부에서 browserHeight 속성을 가져오는 것이 가장 적절한 방법입니다. 

 

마무리

사실 오늘 내용은 바로 지난 글인 "클라이언트 컴포넌트의 자바스크립트도 서버에서 실행될 수도 있다"을 작성할 때 실험해 본 것이었습니다. 클라이언트 컴포넌트의 자바스크립트도 서버에서 실행된다는 것을 증명하기 위해 진행해 본 테스트였는데요. 정리하고 보니 다른 글로 분리하는 게 나을 것 같아서 이렇게 별도의 포스팅으로 작성합니다. 

 

물론 이 실험의 결과 또한 클라이언트 컴포넌트의 일부 자바스크립트 코드가 서버에서 실행된다는 증거가 되어줄 수 있습니다. window.innerHeight는 브라우저의 높이를 반환하는 웹 API 속성이지만, 웹 API를 동작시키려 했다는 것은 곧 자바스크립트가 실행된다는 것을 의미하기 때문입니다. 다만 웹 API는 웹 브라우저 환경에서만 동작할 수 있으니 브라우저가 아닌 노드 환경인 서버에서는 웹 API 사용이 불가능합니다. 당연히 window 객체를 찾을 수 없겠지요. 그래서 이와 같은 에러가 발생했던 것입니다. 그러니까 이 에러는 서버에서 자바스크립트를 실행하려고 했기에 발생한 문제가 아니라 그 실행시키려는 자바스크립트 코드가 웹 API를 사용하고 있었기에 발생한 문제인 것입니다.