Published on

Next.js RSC에서 window is not defined 해결법

Authors

서버 컴포넌트(React Server Components, RSC) 기반의 Next.js(App Router)를 쓰다 보면, 기존 React SPA에서는 한 번도 못 보던 에러를 쉽게 만나게 됩니다. 대표가 바로 ReferenceError: window is not defined 입니다.

이 에러는 단순히 “SSR에서 window가 없다” 수준을 넘어, RSC의 실행 모델(서버/클라이언트 경계), 번들링, 모듈 평가 시점과 맞물려 재발하기 쉽습니다. 이 글에서는 왜 RSC에서 특히 자주 발생하는지, 그리고 실무에서 재사용 가능한 해결 패턴을 코드 중심으로 정리합니다.

에러의 본질: RSC는 기본이 서버 실행이다

App Router에서 컴포넌트는 기본적으로 Server Component입니다. Server Component는 다음 특징이 있습니다.

  • Node.js(또는 Edge Runtime)에서 실행됨
  • 렌더링 시점에 DOM/브라우저 API가 없음
  • window, document, localStorage, navigator 등 접근 불가

즉, 아래 코드가 Server Component에서 실행되면 즉시 터집니다.

// app/page.tsx (기본은 Server Component)
export default function Page() {
  console.log(window.location.href); // ❌ ReferenceError
  return <div>Hello</div>;
}

하지만 실무에서는 “내 코드에서 window를 안 썼는데도” 터지는 경우가 많습니다. 그 이유는 보통 의존 라이브러리(analytics, map SDK, editor, chart 등)가 모듈 로드 시점에 window를 참조하기 때문입니다.

흔한 발생 케이스 4가지

1) 모듈 최상단(top-level)에서 window 접근

// lib/track.ts
const ua = window.navigator.userAgent; // ❌ import 되는 순간 평가

export function track(event: string) {
  // ...
}

이 모듈이 Server Component에서 import 되면 렌더링 이전에 이미 실패합니다.

2) Server Component가 Client-only 라이브러리를 직접 import

// app/page.tsx
import Chart from "some-chart-lib"; // 내부에서 window 사용

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

Server Component는 서버에서 번들 평가가 일어나므로, 라이브러리의 window 참조가 그대로 문제를 일으킵니다.

3) Server Action/route handler에서 브라우저 API 사용

// app/api/foo/route.ts
export async function GET() {
  return Response.json({ href: window.location.href }); // ❌
}

API 라우트/서버 액션은 100% 서버입니다.

4) "use client"를 붙였는데도 터지는 케이스

"use client"는 해당 파일을 Client Component로 만들지만, 그 파일이 import하는 모듈의 평가 시점Next의 번들링/트리쉐이킹 결과에 따라 여전히 서버 번들 경로에 섞이는 경우가 있습니다(특히 공용 유틸/배럴 export 패턴에서 자주 발생).

해결 전략 1: Client Component로 경계를 분리한다(정석)

가장 권장되는 방식은 window가 필요한 부분만 Client Component로 분리하고, Server Component는 데이터 패칭/레이아웃만 담당하게 만드는 것입니다.

예제: 서버 페이지 + 클라이언트 위젯 분리

// app/page.tsx (Server Component)
import ClientWidget from "./client-widget";

export default async function Page() {
  // 서버에서 데이터 패칭 가능
  const data = { message: "hello" };

  return (
    <main>
      <h1>RSC Page</h1>
      <ClientWidget initialMessage={data.message} />
    </main>
  );
}
// app/client-widget.tsx (Client Component)
"use client";

import { useEffect, useState } from "react";

export default function ClientWidget({ initialMessage }: { initialMessage: string }) {
  const [href, setHref] = useState<string>("");

  useEffect(() => {
    // ✅ 브라우저에서만 실행
    setHref(window.location.href);
  }, []);

  return (
    <section>
      <p>{initialMessage}</p>
      <p>current url: {href}</p>
    </section>
  );
}

핵심은 단순합니다.

  • Server Component에서는 window를 절대 만지지 않는다
  • window가 필요한 UI/로직은 Client Component로 격리한다

해결 전략 2: 동적 import로 SSR을 끈다(라이브러리 대응)

차트/지도/에디터처럼 “클라이언트에서만 의미 있는” 컴포넌트는 next/dynamic으로 SSR을 꺼서 해결할 수 있습니다.

// app/chart-section.tsx
"use client";

import dynamic from "next/dynamic";

const Chart = dynamic(() => import("./chart"), {
  ssr: false,
  loading: () => <div>Loading chart...</div>,
});

export default function ChartSection() {
  return (
    <div>
      <h2>Chart</h2>
      <Chart />
    </div>
  );
}

이 패턴은 다음 상황에서 특히 유용합니다.

  • 외부 라이브러리가 모듈 로드시 window를 참조해서 고치기 어려움
  • 초기 렌더는 서버에서 하고, 특정 섹션만 클라이언트에서 늦게 로드해도 UX 문제가 없음

주의할 점은, ssr: false는 해당 컴포넌트를 완전히 CSR로 돌리는 선택이므로 SEO/초기 페인트에 민감한 영역에는 남발하면 안 됩니다.

해결 전략 3: typeof window 가드(최후의 보루)

유틸 함수에서 브라우저 API를 쓰되, 서버에서도 import 될 수밖에 없다면 가드 코드로 방어할 수 있습니다.

// lib/storage.ts
export function getLocalStorageItem(key: string): string | null {
  if (typeof window === "undefined") return null; // ✅ 서버에서는 안전
  return window.localStorage.getItem(key);
}

하지만 이 방식은 한계가 있습니다.

  • top-level에서 window를 쓰면 가드가 의미 없음
  • 서버/클라이언트 결과가 달라져 hydration mismatch를 유발할 수 있음

따라서 “서버에서도 import 되는 공용 유틸”에만 제한적으로 쓰고, UI는 가능하면 Client Component로 분리하는 쪽이 안전합니다.

해결 전략 4: top-level window 사용을 제거하고 effect로 미룬다

라이브러리를 직접 고칠 수 있거나 래핑할 수 있다면, 모듈 평가 시점에 window를 읽지 않게 바꾸는 게 가장 확실합니다.

"use client";

import { useEffect, useState } from "react";

export function UserAgent() {
  const [ua, setUa] = useState<string>("");

  useEffect(() => {
    setUa(window.navigator.userAgent);
  }, []);

  return <pre>{ua}</pre>;
}

이렇게 하면 서버 렌더링 시점에는 빈 값으로 렌더되고, 클라이언트 마운트 후 값이 채워집니다.

실무에서 자주 밟는 지뢰: 배럴 export와 공용 모듈

다음 구조는 RSC에서 특히 위험합니다.

  • components/index.ts 같은 배럴 파일에서 Client 컴포넌트를 re-export
  • Server Component가 그 배럴을 import
  • 의도치 않게 Client 전용 모듈이 서버 번들 경로에 섞임

예시:

// components/index.ts
export * from "./ClientOnlyWidget"; // "use client" 포함
export * from "./ServerFriendly";
// app/page.tsx (Server)
import { ServerFriendly } from "@/components"; // 배럴 import
// 내부적으로 ClientOnlyWidget도 함께 평가/분석될 수 있음

권장 패턴:

  • Server에서 쓸 컴포넌트/유틸과 Client 전용 컴포넌트를 엔트리 포인트부터 분리
  • components/server/*, components/client/*처럼 디렉터리로 경계를 드러내기

디버깅 체크리스트: 어디서 window가 새는지 찾는 법

  1. 스택 트레이스의 첫 번째 외부 모듈을 확인한다
    • 내 코드가 아니라 라이브러리 내부에서 window를 참조하는 경우가 많음
  2. 해당 모듈이 Server Component에서 import되는지 확인한다
  3. import 경로에 배럴 파일이 끼어 있는지 확인한다
  4. 해결 우선순위
    • (1) Client Component로 격리
    • (2) dynamic(..., { ssr:false })
    • (3) typeof window 가드

이런 식의 “원인 파악 → 격리/우회 → 재발 방지” 흐름은 장애 대응에서도 동일하게 중요합니다. 타임아웃/게이트웨이 계열 이슈를 체계적으로 다루는 글로는 Cloud Run 504 Timeout 원인·해결 9가지, 분산 호출에서 타임아웃 설계를 정리한 gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계도 함께 참고할 만합니다.

패턴 모음: 상황별 추천 처방

A) 페이지 대부분은 서버, 일부만 브라우저 기능 필요

  • Server Page + Client Widget 분리(전략 1)

B) 서드파티 라이브러리가 window를 top-level에서 사용

  • dynamic import + ssr:false(전략 2)

C) 공용 유틸이 서버/클라 모두에서 import됨

  • typeof window 가드 + 호출 시점 지연(전략 3/4)

D) hydration mismatch가 같이 발생

  • 서버와 클라이언트 렌더 결과가 달라지는지 확인
  • 브라우저 값(window 기반)을 초기 렌더에 바로 반영하지 말고 effect 이후에 반영

결론: RSC에서는 “window를 쓰는 위치”가 설계다

RSC 환경에서 window is not defined는 단순 실수가 아니라, 컴포넌트 경계를 어떻게 설계했는지를 드러내는 신호에 가깝습니다.

  • window가 필요하면 Client Component로 격리
  • 라이브러리가 문제면 SSR을 끄거나(동적 import) 래핑
  • 공용 코드에서는 top-level window 접근을 금지하고 가드/지연 평가

이 3가지만 팀 규칙으로 정해도, App Router 전환 이후 가장 흔한 런타임 에러를 상당 부분 제거할 수 있습니다.