- Published on
Next.js RSC에서 window is not defined 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(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가 새는지 찾는 법
- 스택 트레이스의 첫 번째 외부 모듈을 확인한다
- 내 코드가 아니라 라이브러리 내부에서
window를 참조하는 경우가 많음
- 내 코드가 아니라 라이브러리 내부에서
- 해당 모듈이 Server Component에서 import되는지 확인한다
- import 경로에 배럴 파일이 끼어 있는지 확인한다
- 해결 우선순위
- (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 전환 이후 가장 흔한 런타임 에러를 상당 부분 제거할 수 있습니다.