2026년 3월 10일

"use client"와 Hydration Mismatch의 동작 원리

글을 쓰며이 글의 주제와 흐름은 작성자의 생각이며, 본문 작성에 AI의 도움을 받았습니다.

use client 선언과 Hydration 에러

Next.js App Router에서 테마 전환이나 브라우저 저장소 접근을 위해 다음과 같은 컴포넌트를 작성할 때가 있습니다.

"use client";
 
import { useTheme } from "next-themes";
 
export function ThemedCard() {
  const { resolvedTheme } = useTheme();
 
  return (
    <div className={resolvedTheme === "dark" ? "card-dark" : "card-light"}>
      현재 테마: {resolvedTheme}
    </div>
  );
}

하지만 이 코드를 브라우저에서 실행하면 콘솔에 다음과 같은 경고가 발생합니다.

A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.

컴포넌트 상단에 "use client"를 명시했음에도 왜 서버가 렌더링한 HTML과 클라이언트 속성이 일치하지 않는다는 에러가 발생할까요?
Client Component의 렌더링 주기와 Hydration 검증 과정을 통해 그 원리를 살펴보겠습니다.


개념 1: Client Component도 서버에서 사전 렌더링된다

"use client" 지시어는 컴포넌트를 오직 브라우저에서만 실행하라는 명령어가 아닙니다.
React와 Next.js에서 "use client"는 Server Component와 Client Component 사이의 경계(Boundary)를 선언하는 역할을 합니다.1

구분Server ComponentClient Component
실행 환경서버에서만 실행서버와 클라이언트 모두에서 실행
JS 번들브라우저 번들에 포함되지 않음브라우저 번들에 포함됨
상태 및 생명주기사용 불가 (useState, useEffect X)사용 가능 (useState, useEffect O)
브라우저 API사용 불가 (window, localStorage X)사용 가능 (단, 마운트 이후 시점)

Client Component 역시 사용자가 페이지에 처음 접근할 때 서버에서 초기 HTML을 생성(SSR)하는 과정에 함께 참여합니다.2 사용자가 빈 화면 대신 완성된 UI를 빠르게 볼 수 있도록 하기 위함입니다.


개념 2: Hydration은 렌더링 결과를 1:1로 대조하는 과정이다

Hydration은 단순히 정적 HTML에 이벤트 리스너를 바인딩하는 것에 그치지 않습니다.
React는 브라우저에서 컴포넌트를 다시 실행해 보고, 계산된 가상 DOM이 서버에서 내려온 HTML과 일치하는지 1:1로 검증합니다.3

한눈에 보기다이어그램
다이어그램 렌더링 중...

ThemedCard에서 불일치가 발생하는 흐름

  1. 서버 렌더링 시점: 서버에는 브라우저의 localStorage나 시스템 다크 모드 정보가 없으므로 resolvedThemeundefined로 평가되어 <div class="card-light"> HTML이 생성됩니다.
  2. 클라이언트 Hydration 시점: 브라우저에서는 시스템 설정에 따라 resolvedTheme"dark"로 평가되어 <div class="card-dark">로 가상 DOM이 계산됩니다.
  3. 대조 검증: 서버의 card-light와 클라이언트의 card-dark 속성이 달라 불일치(Mismatch) 경고가 발생합니다.

개념 3: 속성 불일치가 하위 트리에 미치는 영향

단순한 클래스명 불일치 외에도, 조건부 렌더링으로 인해 렌더 트리 구조가 달라지면 하위 컴포넌트 전체에 연쇄적인 문제가 발생할 수 있습니다.
대표적인 사례가 useId()를 활용하는 UI 컴포넌트 라이브러리(Radix UI, Headless UI 등)입니다.4

<SandpackProvider theme={resolvedTheme === "dark" ? "dark" : "light"}>
  {/* 내부에서 useId()를 기반으로 tab-panel id와 aria-controls를 자동 생성 */}
</SandpackProvider>

서버와 클라이언트의 조건문 분기가 어긋나면 useId()의 생성 순서와 카운터가 달라져, 접근성(A11y) 식별자가 서로 매칭되지 않는 오류로 이어집니다.

[서버 HTML]      id=":R1:"  <──>  aria-controls=":R1:"
[클라이언트 DOM]  id=":R5:"  <──>  aria-controls=":R5:"

해결 패턴: Mounted 가드를 통한 초기 렌더링 일치

해결의 핵심은 서버 렌더링 결과와 클라이언트의 첫 번째 렌더링(Hydration) 결과를 동일하게 맞추는 것입니다.5
브라우저 환경에 의존하는 값은 Hydration이 완료된 이후(useEffect 실행 시점)에 반영하도록 분리합니다.

"use client";
 
import { useState, useEffect } from "react";
import { useTheme } from "next-themes";
 
export function ThemedCard() {
  const { resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
 
  // useEffect는 서버에서 실행되지 않고 브라우저 마운트 후에만 실행됨
  useEffect(() => {
    setMounted(true);
  }, []);
 
  if (!mounted) {
    // 서버 HTML과 동일한 기본 fallback 반환
    return <div className="card-light" />;
  }
 
  return (
    <div className={resolvedTheme === "dark" ? "card-dark" : "card-light"}>
      현재 테마: {resolvedTheme}
    </div>
  );
}

렌더링 단계별 동작 흐름

  1. 서버 렌더링: mounted의 초기값 false를 기준으로 기본 fallback HTML을 생성합니다.
  2. 클라이언트 Hydration: 클라이언트에서도 useState(false)로 시작하므로 fallback 가상 DOM이 계산되어 서버 HTML과 일치합니다.
  3. 마운트 완료 후: useEffect가 실행되어 setMounted(true)가 호출되고, 실제 브라우저 테마가 반영된 UI로 안전하게 리렌더링됩니다.

Playground로 동작 확인하기

아래 예제에서 useEffect를 통해 초기 Fallback 상태에서 실제 테마 값으로 전환되는 과정을 확인할 수 있습니다.

직접 실행해보기코드를 바꿔볼 수 있어요

참고 자료

React Docs, use client / Next.js Docs, Server and Client Components

React Docs, hydrateRoot

React Docs, useId

KHLogo