Tech

React의 hydration mismatch 알아보기

2023. 09. 26

React의 hydration mismatch 알아보기

 

 

 

안녕하세요. 화해 프론트엔드 플랫폼 박제훈 입니다. 이번 글에서는 react 프레임워크인 next.js 프로젝트에서 hydration mismatch 이슈를 경험한 내용에 대해 정리하고자 합니다.

 

 


 

시작

 

화해는 에러 트래킹 서비스인 Sentry를 사용하고 있는데요. 지난해 Next.js의 static export로 배포한 이벤트 페이지에서 hydration mismatch 에러가 쌓이기 시작했습니다. 처음엔 동작에 큰 문제를 주지 않기도 했고, 다른 이슈들에 비해 크리티컬하지 않다고 판단했는데요. 어느 날 에러가 급격하게 늘어 sentry 에러의 90% 이상을 차지하면서 문제의 심각성을 느꼈습니다.

 

저와 담당자 한 분이 같이 이슈를 확인했는데, 문제는 이 에러가 어디서 발생하는지 찾는 것 자체가 어렵다는 점이었습니다. 에러 메시지에 단서도 거의 없어 hydration mismatch가 무엇인지부터 다시 조사해야 했습니다.

 

 

 

 

Hydration

 

일반적인 React를 활용한 애플리케이션의 경우 body가 비어있는 html을 다운받고 CSR(Client-Side Render) 방식으로 UI를 그립니다.

 

 

 

 

반면 Next.js는 기본적으로 모든 페이지를 미리 렌더링(pre-render)합니다. 생성된 각 HTML은 해당 페이지에 필요한 최소한의 자바스크립트 코드와 연결되며, 브라우저가 페이지를 로드하면 자바스크립트 코드가 완전히 인터랙티브하게 만들어집니다. 이 과정을 hydration이라고 부릅니다.

 

 

 

 

Hydration을 직역하면 ‘수화(水化)시키다’ 입니다. 사전 렌더링 과정처럼 ‘건조한’ html만을 제공할 경우 javascript(react)로 인터랙션 처리할 수 없어 ‘물’을 주어 인터랙티브하게 만들어 주는 과정을 표현한 것 같습니다.

 

사전 렌더링으로 SEO나 초기 로딩 성능 향상 등의 장점을 취할 수 있는데, 사전 렌더링은 next.js에서 두 가지 방식을 제공합니다.

  • Static Generation (SG)
    • 사전 렌더링 결과를 빌드 타임에 HTML로 생성합니다. Incremental Static Regeneration(ISR) 방식을 활용할 경우에는 배포한 이후에도 HTML을 생성하거나 업데이트할 수 있습니다. 이때도 사전 렌더링입니다.
  • Server Side Rendering (SSR)
    • 페이지를 요청하면 HTML을 생성하고 hydration 합니다.

 

위와 같이 사전 렌더링 방식을 사용 환경에 맞춰서 선택할 수 있는 것이 next.js의 장점 중 하나입니다.

 

 

 

 

Hydration을 해보자

 

아래 코드를 통해 react로 기본적인 hydration 구현 과정을 설명해 보겠습니다. React 공식 문서의 hydrateRoot 예제를 참고하여 작성하였습니다.

 

React 코드를 아래와 같이 작성 합니다.

 

import { useState } from 'react';

export default function App() {
  return (
    <>
      <h1>Hello, hydration!</h1>
      <Counter />
    </>
  );
}

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      You clicked me {count} times
    </button>
  );
}

 

연결할 html은 아래와 같습니다. 보기 편하도록 코드에 줄 바꿈을 하였지만 주석처럼 whitespace(newline)가 존재하면 안 됩니다!

 

<!-- 원래는 안됨 -->
<div id="root">
  <h1>Hello, hydration!</h1>
  <button>You clicked me <!-- -->0<!-- --> times</button>
</div>

<!-- 공백이 없어야됨 -->
<div id="root"><h1>Hello, hydration!</h1><button>You clicked me <!-- -->0<!-- --> times</button></div>

 

index.js에서 hydrateRoot 함수를 활용하여 hydration 시켜줍니다. (React 18 기준이며 이전 버전은 hydrate 함수를 사용하였습니다.)

 

import { hydrateRoot } from 'react-dom/client';
import App from './App.js';

hydrateRoot(
  document.getElementById('root'),
  <App />
);

 

위 hydration을 하기 위해 준비한 html에서 You clicked me <!-- -->0<!-- --> times0을 주목해주세요. React 코드의 useState(0)을 통해 처음 렌더링 될 텍스트가 0으로 렌더링 될 것입니다. 그래서 위와 같이 변경 소지가 있는 html은 react의 초기 렌더링 결과물과 동일해야 합니다. 실제로 next.js로 빌드해보면 react의 예제와 동일한 html이 생성됩니다.

 

위 과정을 통해 hydration 과정이 완료되었습니다.

 

 

 

 

Hydration mismatch

 

헌데 이 과정에서 유의할 점이 있습니다. 위 예제 코드의 마지막에 언급한 것과 같이 react를 통해 렌더링 될 최초의 결과물이 hydration 할 html과 반드시 일치해야 한다는 것입니다.

 

Next.js의 정적 빌드를 활용하여 아래와 같이 시간을 보여주는 기능을 구현한다고 예를 들어보겠습니다.

 

const getNow = () => {
  const now = new Date();
  const hours = now.getHours();
  const minutes = now.getMinutes();
  const seconds = now.getSeconds();
  const milliseconds = now.getMilliseconds();
  return `${hours}:${minutes}:${seconds}:${milliseconds}`;
}

export default function Hydration() {
  return <span>{getNow()}</span>;
}

 

hydration 할 html이 아래와 같이 next.js로 정적 빌드가 되었습니다.

 

<div id="__next">
  <span>1:6:9:579</span>
</div>

 

위 코드의 1:6:9:579와 같이 next.js는 빌드 타임에 getTime() 함수를 통해 html을 생성하고 hydration을 진행합니다. 배포된 환경에 실제로 유저가 접속하면 어떤 일이 일어날까요?

 

 

 

 

위 이미지처럼 최초 렌더링한 1:6:9:579는 사용자가 접속할 때마다 결과가 달라집니다. 11:12:29:980이나 23:34:19:222 같이 말입니다. react에서는 hydration 할 html과 react 렌더 트리의 불일치에 따른 hydration mismatch 에러 메시지를 발생시킵니다.

 

위의 예제에서는 react가 hydration 에러를 수정하기 때문에 잠깐의 깜박임이 발생하면서 ui가 업데이트 됩니다. 서비스가 멈춘다거나 하는 문제가 발생하지는 않지만, 공식 문서에서는 아래와 같이 언급하고 있는데요. 반드시 고쳐야 하는 오류로 보고, 이를 없애라고 권장합니다.

 

React recovers from some hydration errors, but you must fix them like other bugs. In the best case, they’ll lead to a slowdown; in the worst case, event handlers can get attached to the wrong elements.
리액트는 일부 hydration 에러로부터 복구되지만, 다른 버그와 마찬가지로 반드시 수정해야 한다. 가장 좋은 경우는 속도 저하로 이어지지만, 최악의 경우 이벤트 핸들러가 잘못된 요소에 연결될 수 있다.

 

 

에러 메시지가 발생할 수 있는 예제는 react의 react-dom 패키지에 테스트 케이스로 정리되어 있습니다. hydrateRoot 공식 문서를 참고해 hydration 메시지가 발생할 수 있는 케이스들을 하나씩 알아보겠습니다.

 

 

 

 

대표적인 Mismatch 케이스와 해결 방법들

 

 

루트 노드 내부의 React에서 생성된 HTML 주변에 whitespace가 있는 경우

 

아래 예제처럼 파싱할 html의 root노드에 whitespace(newline)가 있으면 hydration mismatch 에러가 납니다.

 

<!-- hydration error -->
<div id="root">
  <h1>Hello, hydration!</h1>
  <button>You clicked me <!-- -->0<!-- --> times</button>
</div>

 

이 에러는 공백이나 개행을 전부 없애주면 해결됩니다.

 

<!-- correct! -->
<div id="root"><h1>Hello, hydration!</h1><button>You clicked me <!-- -->0<!-- --> times</button></div>

 

next.js와 같은 프레임워크를 사용한다면 이런 부분을 고려해서 html을 생성해 주기 때문에 경험할 확률이 낮습니다. 하지만 프레임워크를 사용하지 않고 CRA(create-react-app) 등으로 직접 react 환경을 구축하여 hydration 로직을 작성하면 만나기 쉬운 에러입니다.

 

 

 

렌더링 로직에 typeof window ≠= ‘undefined’ 같은 client / server 상태 분기

 

렌더링 로직에 windowlocalStorage 같은 브라우저 전용 API를 사용할 때 server와 client 환경의 조건을 다르게 하면 발생합니다.

 

export default function App() {
  const isClient = typeof window !== 'undefined';

  return (
    <h1>
      {isClient ? 'Is Client' : 'Is Server'}
    </h1>
  );
}

 

위 코드로 next.js의 SSR을 활용하면 서버에서 만들어 준 html과 react로 hydration을 진행할 때 문제가 생깁니다. next.js의 SSR 사전 렌더링에 관해 설명한 것처럼 사용자가 요청할 때 html을 만들기 때문에 처음 생성하는 html은 서버 환경에서 만들게 됩니다.

 

<!-- 서버에서 만든 html -->
<html>
  <h1>Is Server</h1>
</html>

<!-- react 트리가 첫 렌더링 때 기대하는 html -->
<html>
  <h1>Is Client</h1>
</html>

 

실제로 렌더링해 보면 Is Server 텍스트가 찰나의 순간에 보였다가 Is Client로 변경되는 것을 확인할 수 있습니다. 레이턴시가 길 경우 보이는 구간이 길어집니다.

 

이러한 mismatch를 해결하기 위한 여러 접근법이 있습니다. 첫 번째로 useEffect를 활용해서 첫 렌더링 때 의도적으로 원하는 상태를 업데이트하지 않게 하는 방법이 있습니다. 위 예제의 경우 아래처럼 isClient의 첫 상태를 false로 동일하게 맞출 수 있습니다.

 

const [isClient, setIsClient] = useState(false);

useEffect(() => {
  setIsClient(true);
}, []);

 

다만 위 방법은 공식 문서에도 언급된 것처럼 컴포넌트가 무조건 두 번은 렌더링하기 때문에 화면이 느린 것 같은 사용자 경험이 있을 수 있습니다. 또한 Next.js에서는 dynamically import의 { ssr: false } 옵션으로 특정 컴포넌트의 SSR을 비활성화할 수 있습니다.

 

// no-ssr.jsx
const NoSSR = () => {
  return <h1>Is Client</h1>
}

// index.js
const NoSSR = dynamic(() => import('../no-ssr'), { ssr: false });

export default function App() {

  return (
    <div>
      <NoSSR />
    </div>
  );
}

 

 

 

불가피하게 발생하는 mismatch

 

가장 처음 소개해 드린 time stamp 예제처럼 빌드 결과물이 매번 달라 반드시 hydration mismatch가 발생하는 경우 suppressHydrationWarning 옵션으로 mismatch 경고를 받지 않을 수 있습니다.

 

export default function Hydration() {
  return <span suppressHydrationWarning>{getNow()}</span>;
}

 

가장 확실하게 처리 가능한 속성이지만 이 문서에서 언급한 여러 케이스의 해결 방법을 확인하면서 사용할지를 판단하는 게 좋겠습니다.

 

 

 

 

그 외 hydration mismatch를 발생시키는 케이스

 

hydration mismatch 에러는 해결 방법이 명확한 것도 있지만 그렇지 않은 것도 있습니다. 위 케이스를 다 확인해도 여전히 에러가 발생한다면 공식 문서 외에 별도로 수집된 케이스를 확인해 보세요.

 

 

 

Browser extension을 통해 HTML이 변경되는 경우

블로그 작성일 기준, 문서 전체가 리렌더링 되었어도 문제는 발생하지 않는 것으로 보입니다. 당장 어떤 해결 방법이 있다기보다는 browser extension이 hydration 이슈를 발생시킬 수 있다는 것을 인지해야겠습니다.

 

 

 

vaildateDOMNesting Hydration failed

HTML 태그에 잘못된 중첩이 있는 경우 hydration mismatch가 발생합니다. 아래는 잘못된 HTML 코드 중 하나입니다.

  • <p> 내부에 <p>가 있는 경우 </p> </p>
  • <p> 내부에 <div>가 있는 경우 </div> </p>

 

HTML5 명세에 따르면 각 태그들을 특성 기반의 content 모델로 분류하고, 특성을 공유하는 요소끼리 묶어 content category로 정의하고 있습니다. Content category를 기반으로 p 태그는 flow content이며 palpable content입니다. 그리고 phrasing content를 포함할 수 있습니다. div 태그는 flow content이며 palpable content입니다. 그리고 flow content를 포함할 수 있습니다.

 

코드로 예를 들어보겠습니다. 아래의 html은 잘못된 dom 포함 관계로 작성되었습니다.

 

<p> nesting <div> div </div> </p>

 

p 태그는 phrasing content만 포함할 수 있기 때문에 flow, palpable content인 div를 포함할 수 없습니다. 보통 브라우저는 태그를 HTML 표준에 맞게 아래와 같은 형태로 수정할 겁니다.

 

<p>nesting</p> <div> div </div> <p></p>

 

헌데 react로 hydration을 진행하면 브라우저가 파싱할 거란 예상과 달리 수정되지 않은 코드 그대로 렌더링 시키고 hydration mismatch 경고를 남깁니다.

 

<p>nesting</p> <div> div </div> <p></p>

 

이에 대해서는 react 저장소에올라온 적도 있었는데, validateDOMNesting 코드 내에 아래와 같은 주석이 있습니다.

Note: this does not catch all invalid nesting, nor does it try to (as it’s not clear what practical benefit doing so provides). Instead, we warn only for cases where the parser will give a parse tree differing from what React intended. For example, ‘b’ is invalid but we don’t warn because it still parses correctly. we do warn for other cases like nested p tags where the beginning of the second element implicitly closes the first. causing a confusing mess.

 

 

의도를 해석해 보면 react는 잘못된 표준을 확인하지도 않지만, 잘못된 표준이 문제를 일으킬 수 있으므로 사용하지 않도록 개발/운영 환경에 경고를 남기는 것으로 보입니다. 또한 hydration을 진행할 때 잘못된 태그가 있으면 react 트리를 전부 수정하는 데 큰 비용이 들 수 있어 에러로 남겨놓은 것이 아닌가 하는 생각도 듭니다. 더 알게 되는 내용이 있다면 보완하겠습니다.

 

이 과정에서 hydration mismatch 에러 추적의 어려움을 인지한 next.js 개발팀은 dev overlay가 hydration mismatch 에러를 조금 더 자세하게 표현하도록 개선했습니다. 아래는 몇 달 전 next.js의 hydration mismatch DX 개선 건 중 하나입니다.

 

// https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js used as a reference
const knownHydrationWarnings = new Set([
  'Warning: Text content did not match. Server: "%s" Client: "%s"%s',
  'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
  'Warning: Expected server HTML to contain a matching text node for "%s" in <%s>.%s',
  'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s',
  'Warning: Did not expect server HTML to contain the text node "%s" in <%s>.%s',
])

export function patchConsoleError() {
  const prev = console.error
  console.error = function (msg, serverContent, clientContent, componentStack) {
    if (knownHydrationWarnings.has(msg)) {
      hydrationErrorWarning = msg
        .replace('%s', serverContent)
        .replace('%s', clientContent)
        .replace('%s', '')
      hydrationErrorComponentStack = componentStack
    }

    // @ts-expect-error argument is defined
    prev.apply(console, arguments)
  }
}

 

 

>참고 PR: https://github.com/vercel/next.js/pull/46677

 

 

정리하면 위 에러는 html 명세를 정확하게 따름으로써 hydration mismatch 문제를 해결할 수 있습니다. 예를 들어 p 태그를 div 태그로 변경하면 위 문제가 해결됩니다.

 

<div> nesting <div> div </div> </div>

 

 

 

iOS의 safari에서 발생하는 hydration mismatch

 

이슈와 같이 iOS에서는 전화번호 포맷을 자동으로 전화번호로 파싱하는 문제가 있습니다. 이는 meta 태그를 추가하면 해결됩니다.

 

<meta
  name="format-detection"
  content="telephone=no, date=no, email=no, address=no"
/>

 

그 외 cloudflare의 Auto Minify와 같은 html 응답 수정을 시도하는 잘못 구성된 Edge/CDN도 언급되어 있습니다. 저희는 최종적으로 next.js의 dynamically import의 { ssr: false } 옵션과 suppressHydrationWarning 속성의 조합으로 문제를 해결하였습니다.

 

 

 

 

결론

 

이번 기회에 react의 hydration 과정을 다시 정리하게 되었고, 가볍게 넘어간 next.js 공식 문서도 다시 정독하면서 공부가 많이 되었습니다. 또한 next.js가 비교적 최근까지 DX(Developer Experience)를 위해 hydration mismatch 이슈에 대해 꾸준히 개선하거나 해결 방안을 문서로 정리해 업데이트하는 걸 알게 되면서 참 좋은 조직이라는 생각이 들었습니다. 저도 조직이란 무엇인지에 대해서도 다시 한번 생각하게 되었구요.

 

사실 이 이슈의 원인과 해결 방법을 찾아가는 과정에서 직접적으로 관련 없는 분들도 여러 방면으로 도움을 주셔서 많은 힘이 되었습니다. 도움을 주신 분들께 다시 한번 감사하다고 인사하며 글을 마무리합니다.

 

 

 

 

참조

 

React의 hydration mismatch 알아보기 React의 hydration mismatch 알아보기


React의 hydration mismatch 알아보기

이 글이 마음에 드셨다면 다른 콘텐츠도 확인해 보세요!
Git Internal API를 활용한 .git 탐험
Mocking으로 프론트엔드 DX를 높여보자

  • mismatch
  • React
  • hydration
  • 프론트엔드
  • next.js
avatar image

박제훈 | Front-end Developer

화해팀에서 Front-end 리딩을 맡고 있습니다.

연관 아티클