useSyncExternalStore 훅의 트릭으로 클라이언트 컴포넌트 감지하기
useSyncExternalStore 훅으로 어떻게 클라이언트 컴포넌트 인지 감지를 할 수 있는지
최근 X(트위터)에서 흥미로운 글을 발견했습니다. 한 개발자가 React의 useSyncExternalStore 훅을 "의도치 않은 방법"으로 활용하여 hydration 오류를 회피하는 기법을 소개하고 있었습니다. X 게시글
이 글은 그 포스트에서 영감을 받아 작성되었습니다. "이런 식으로 useSyncExternalStore를 쓰는 건 이상해 보이긴 하는데, hydration 오류를 피하고 클라이언트 전용 코드를 안전하게 실행할 수 있다면 꽤 괜찮은 방법 아닌가요?" 라는 질문에서 시작됩니다.
Hydration 에러
Next.js와 같은 SSR 프레임워크를 사용할 때 종종 겪게 되는 문제 중 하나는 hydration mismatch입니다. 서버에서 렌더링한 HTML과 클라이언트에서 React가 다시 렌더링한 결과가 불일치할 경우, React는 경고 메시지와 함께 클라이언트 코드를 덮어씌우게 됩니다. 특히, window, document, localStorage 등 브라우저 전용 API를 SSR 중에 접근했을 때 이런 문제가 자주 발생하죠.
문제 해결을 위해 많은 사람들은 다음과 같은 방식으로 클라이언트 여부를 감지하고 렌더링을 지연시키곤 했습니다.
'use client';
import { useState, useEffect } from 'react';
function ClientOnly({ children }) {
const [isClient, setIsClient] = useState(false);
useEffect(() => setIsClient(true), []);
if (!isClient) return null;
return <>{children}</>;
}하지만 위 방식은 첫 렌더에서는 null을 반환하고, 그 다음 마운트 이후 다시 렌더를 하게 되므로 사용자 입장에서는 깜빡임(flickering)이 발생합니다. 또한 client-side navigation 시에도 매번 effect가 실행되어야 하는 안좋은 부분도 있었습니다.
Next.js app router 버전 이후 React Server Component(RSC)의 도입으로 인해 서버 컴포넌트와 클라이언트 컴포넌트의 경계가 명확히 나뉘게 되었습니다. 이로 인해 전통적인 CSR 방식과는 다르게 "어떤 코드가 서버에서 실행되고 어떤 코드가 클라이언트에서 실행되는가"에 대한 이해가 필요해졌습니다.
이번 포스트에서는 다음과 같은 내용을 정리합니다:
- 서버 컴포넌트 vs 클라이언트 컴포넌트 구조와 차이
useSyncExternalStore훅이 어떻게 클라이언트 감지를 도와주는지ClientOnly컴포넌트의 동작 원리와 장점window와 같은 브라우저 전역 객체 사용 시 주의사항과 해결 방식
1. 서버 컴포넌트 vs 클라이언트 컴포넌트 구조와 차이
Next.js 13~15의 App Router 환경에서는 서버 컴포넌트(Server Component) 와 클라이언트 컴포넌트(Client Component) 가 명확히 구분됩니다. 단순히 "서버에서 렌더링 되냐, 클라이언트에서 렌더링 되냐"는 것 이상으로, 생명주기, 번들 크기, 기능의 유무에 큰 차이가 존재합니다.
서버 컴포넌트
-
use client 지시어가 없는 컴포넌트는 자동으로 서버 컴포넌트로 인식됩니다.
-
서버에서 실행되어 HTML 결과만 생성되고, 클라이언트에는 JS 번들이 전송되지 않습니다.
-
React 상태 훅 (useState, useEffect, useRef) 사용 불가
-
브라우저 API (window, document, localStorage) 접근 불가
-
마운트(Mount) 개념이 없음 → 클라이언트처럼 componentDidMount나 useEffect 같은 훅이 실행되지 않음
-
비동기 함수(async)로 작성 가능 → 서버에서 직접 DB 조회나 API 호출이 가능
-
번들 크기를 줄이고 성능을 최적화하는데 유용
"마운트 개념이 없다"는 것은 React 컴포넌트가 DOM에 부착되거나 초기화될 필요가 없다는 뜻입니다. 서버에서는 컴포넌트가 단순히 HTML을 생성하기 위한 함수처럼 동작합니다.
클라이언트 컴포넌트
-
"use client"를 파일 상단에 선언하여 명시
-
브라우저 환경에서 실행됨
-
useState, useEffect, useRef 등 상태와 생명주기 훅 사용 가능
-
브라우저 API 접근 가능 (window, navigator, 등)
-
JS로 번들링되어 클라이언트에 전송 및 Hydration 됨
-
사용자 인터랙션, 애니메이션, 상태 관리에 적합
주의할 점
서버 컴포넌트는 클라이언트 컴포넌트를 import할 수 있지만, 반대는 안됩니다. 즉 클라이언트 컴포넌트 안에서는 서버 컴포넌트를 직접 import할 수 없지만 children 으로 서버 컴포넌트를 렌더링하는 것은 가능합니다.
대표적인 예로 <Provider> 컴포넌트가 있습니다.
2. useSyncExternalStore 훅의 원리와 클라이언트 감지
useSyncExternalStore는 React 18에서 도입된 훅으로 외부 저장소의 구독 상태를 감지하여 상태 변화에 따라 컴포넌트를 갱신하는 데 사용됩니다. React는 이 훅을 통해 외부 저장소와 "정합성 있는" 방식으로 상태 동기화를 보장할 수 있게 되었죠.
하지만 이를 단순한 트릭으로 활용하면 "지금 이 렌더링이 브라우저에서 실행 중인지" 여부를 감지할 수 있습니다.
'use client';
import { useSyncExternalStore } from 'react';
const empty = () => {};
export function ClientOnly({
children,
fallback,
}: {
children: React.ReactNode;
fallback: React.ReactNode;
}) {
const value = useSyncExternalStore(
() => empty, // subscribe
() => true, // getSnapshot
() => false // getServerSnapshot
);
if (!value) return fallback;
return <>{children}</>;
}-
subscribe(fn)→ 외부 상태가 변경될 때 실행될 콜백 등록 -
getSnapshot()→ 클라이언트에서 현재 상태 값 반환 -
getServerSnapshot()→ 서버에서 사용할 초기 상태 값 반환
클라이언트 컴포넌트에서 브라우저 자원에 접근하려고 할때
만약 클라이언트 컴포넌트에서 window, localStorage 등 브라우저 자원에 접근하려고 할때 어떻게 될까요?
'use client';
export default function MyClientComponent() {
const theme = window.localStorage.getItem('theme'); // Error: ReferenceError: window is not defined
return <div>{theme}</div>;
}서버 컴포넌트 또는 하이드레이션 이전 시점에 window에 접근하려고 하면 다음과 같은 에러가 발생합니다.
ReferenceError: window is not defined 오류가 발생합니다.
"클라이언트 컴포넌트인데 왜 window에 접근할 수 없지?" 라는 의문이 들 수 있습니다.
하지만 Next.js는 클라이언트 컴포넌트라도 초기 렌더링을 서버에서 HTML로 프리렌더링한다는 점입니다. 즉 ,마운트 전에 HTML을 프리렌더링하기 때문에, 해당 시점에서는 브라우저 환경이 아니며, 브라우저 자원에에 접근하려면 반드시 마운트 이후(useEffect, useLayoutEffect)로 접근을 지연시켜야 합니다.
이 시점에서는 브라우저 환경이 아니기 때문에 window 객체는 존재하지 않고, 따라서 위와 같은 에러가 발생하게 됩니다.
해결방법 1: useEffect,useState로 컴포넌트 마운트 후에 브라우저에서만 실행
이런 문제를 해결하기 위해서 브라우저에서만 실행되는 코드를 작성해야 합니다.
'use client';
import { useEffect, useState } from 'react';
export default function MyComponent() {
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
const saved = window.localStorage.getItem('theme');
setTheme(saved);
}, []);
return <div>{theme}</div>;
}위 코드가 어떻게 에러없이 작동이 되는지 알아보겠습니다. 먼저 클라이언트 컴포넌트에서 리액트 훅은 마운트 된 후에 실행이 되므로 hydration 이후 훅안에서 브라우저 자원에 접근할 수있는겁니다.
useEffect는 클라이언트에서 컴포넌트가 마운트된 후에만 실행되므로, 서버에서는 실행되지 않습니다. 따라서 안전하게 window 객체에 접근할 수 있습니다.
여기서 중요한건 클라이언트 컴포넌트에서 리액트 훅은 마운트 된 후에 실행이 되므로 hydration 이후 훅안에서 브라우저 자원에 접근할 수있는겁니다.
하지만 이 해결방법은 theme의 값을 단순히 클라이언트 컴포넌트에서 렌더링하는목적으로 useState와 useEffect 같은 훅을 매번 선언해야합니다. 따라서 코드가 불필요하게 길어집니다.
또한 첫 렌더에서는 null을 반환하고, useEffect 후 다시 렌더합니다. 깜빡임(flicker) 이 발생할 수 있고 서버에서는 무조건 null이 렌더되므로 사용자에게 초기 로딩 화면을 자연스럽게 보여주는 데 불리합니다.
해결 방법 2: useSyncExternalStore 훅을 통해 트릭으로 클라이언트 컴포넌트 감지
위와같이 useEffect를 사용하는 방법도 있지만, useSyncExternalStore 훅을 사용하는 방법도 있습니다.(useSyncExternalStore 훅은 원래 외부 상태(store) 구독을 위해 만들어졌지만) 이 방법은 보다 선언적으로 작성할 수 있습니다.
먼저 아까와 같은 클라이언트 컴포넌트가 있다고 가정하고 useEffect안에서 사용하지않고 보다 선언적으로 작성하는 방법을 알아보겠습니다.
'use client';
export default function MyClientComponent() {
const theme = window.localStorage.getItem('theme');
return <div>{theme}</div>;
}'use client';
import { useSyncExternalStore } from 'react';
const noop = () => {};
export function ClientOnly({
children,
fallback = <div>Loading...</div>,
}: {
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const isClient = useSyncExternalStore(() => noop, () => true, () => false);
if (!isClient) return fallback;
return <>{children}</>;
}useSyncExternalStore 훅의 메커니즘을 살짝 알아보고 어떻게 클라이언트 컴포넌트인지 감지를 할 수 있는지 알아보겠습니다.
즉, 서버 컴포넌트에서 렌더링 시점
getServerSnapshot()인() => false실행 →isClient = falsegetSnapshot()인() => true실행 →isClient = true
따라서 서버에서는
isClient = false→fallback만 렌더링됨 (예:<div>Loading...</div>)- 클라이언트에서 마운트된 후 →
isClient = true→ 실제children렌더링됨
이렇게 하면 서버에서는 MyClientComponent를 fallback 혹은 null로 렌더링하기 때문에 window 접근 에러가 사라지고, CSR(Client Side Rendering) 로 처리되어 브라우저에서만 안전하게 실행됩니다.
"use client"를 붙이는 것만으로도 클라이언트 컴포넌트를 만들 수 있지만, 서버 컴포넌트가 브라우저가 준비된 이후에만 렌더링되도록 제어하려면 useSyncExternalStore의 트릭을 활용하여 제어 할 수 있습니다.
추가로 fallback을 활용하면 hydration 전까지 Suspense의 느낌으로 로딩 UI 등을 보여줄 수 있고 앱의 SSR 성능을 유지하면서도 클라이언트 전용 기능을 적절히 분리 가능해집니다.
useEffect는 렌더 후 비동기로 실행되지만, useSyncExternalStore는 렌더 시 동기적으로 실행되어 즉시 true/false 값을 반환합니다.
따라서 SSR 중에는 fallback이, 클라이언트에서는 children이 한 번에 정확하게 결정됩니다.
결론
React 공식 문서에서도 useSyncExternalStore는 외부 스토어 구독용으로 소개되어 있습니다. 그렇기 때문에 이 방식은 "권장 사용법"은 아닐 수 있습니다. 하지만 실제로 noop, true/false를 이용한 현재 실행 환경 감지는 부작용 없이 안전하게 동작하며, React 18 이후 hydration 과정의 구조를 이해하고 잘 활용한 예라고 볼 수 있습니다.
댓글 로딩 중...