사툰사툰

Hydration Error : Minified React Error 해결하기
NEXTJS

Hydration Error : Minified React Error 해결하기

Next.js에서 익숙치 않은 Hydrate 환경에서 데이터를 다루다보면 한 번쯤은 맞닥뜨리는 Minified React Error를 해결해보았습니다.

2023. 11. 9.

2809

🚀 Overview

SSR 환경이 익숙하지 않다면 Hydration Error를 자주 마주칩니다.

Error: This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.
Error : Hydration failed because the initial UI does not match what was rendered on the server.
Text content does not match server-rendered HTML

왠지 모르겠지만 저한테 이 에러는 개발 모드나 콘솔 창에선 잡히지 않는 경우가 많았습니다. 어떻게 이 오류를 해결할까요?

Hydration이란?

hydration_(2).webp

Hydration이란 서버에서 요청한 데이터와 생성한 HTML를 브라우저에서 불러온 후 HTML과 JS를 이어주어 페이지의 기능이 동작하도록 만드는 과정입니다. 우리가 말라가는 나무에 수분을 공급하듯이 HTML이라는 나무 안에 JS라는 수분을 공급하는 상황입니다.

539faac5-992d-4f6f-9a2a-370dbf5285fd.png

저의 블로그는 Remix로 이루어져 있는데요. 현재까지는 Remix의 기조답게 SSR 환경만 지원합니다. 위 사진은 제 블로그를 초기 로드 후 모습인데요. 보시다시피 프리 렌더링을 통해 HTML 마크업만 보내지는 걸 확인하실 수 있습니다.

HTML를 파싱하면서 데이터를 불러오고 있다
HTML를 파싱하면서 데이터를 불러오고 있다

이후에 브라우저는 보내진 HTML를 토대로 DOM Tree를 파싱합니다. 이 때 만나는 <link> 태그로 build된 CSS 및 JS를 읽어온 다음 만들어진 DOM Tree에서 해당되는 DOM 요소를 찾아 UI 적용을 하고 컴포넌트를 매칭합니다.

Untitled.png

HTML 하단에는 <script> 태그 내에 데이터가 미리 전달되어 있습니다. Remix는 LoaderData에, Next.js는 __NEXT_DATA__를 사용하는데요. 이 데이터를 사용하여 화면을 렌더링하고 기능을 동작하도록 만듭니다.

정리

  1. Pre-Rendering : Server에서 Data FetchingHTML Pre-Rendering
  2. Load : Server가 내려준 HTML를 기반으로 JS 및 CSS Load
    (모든 파일을 불러오고 DOM Tree Parsing이 완료되면 페이지가 표시됩니다.)
  3. Execution : Load한 JS 및 CSS 파일 실행
    (Javascript가 실행되어 UI가 업데이트되고 기능이 동작합니다.)
  4. Finish : 브라우저 렌더링 종료

핵심은 서버에서 데이터 패칭과 UI를 완벽하게 체크한다는 것입니다. 이 개념은 사실 Next.js가 아닌 React 자체에서 사용되는 개념이기도 합니다.

적당한 예시

export default function TestPage() {
  return <div>{Math.random()}</div>;
}

다음과 같은 페이지를 구현하여 값을 랜덤으로 렌더링하도록 만들었습니다. 한 번 결과물을 볼까요?

Untitled.png
Server Client
0.56561… 0.0423…

오른쪽은 브라우저, 왼쪽은 서버에서 렌더링된 HTML인데요. 실제로 브라우저에 렌더링한 값은 0.04로 시작되는 반면에 Server에서 내려준 HTML은 0.56으로 시작합니다. Server에서 Math.random()을 실행하여 도출된 값(0.56)으로 HTML을 생성한 후, 다시 JS를 불러와 재실행하여 다른 값인 0.04를 그린 것이죠. 이 예시는 React 공식 문서에서도 확인할 수 있습니다.

그렇다면 왜 에러가 발생할까?

image

이유는 간단합니다. 서버에서 생성한 HTML과 React에서 렌더링한 DOM 트리가 서로 일치하지 않기 때문입니다. React에서는 이러한 상황에서 자동으로 고치도록 설계되어 있습니다. 다만 이 과정은 성능 저하 및 잘못된 매칭이 생길 수 있습니다. 따라서 이러한 문제를 감지하고 해결하도록 에러를 표시합니다.

정말 친절하게도 해결 방법 역시 공식문서에 잘 적혀 있습니다. useEffect를 사용하는 방법이 제일 무난해 보이는데요. 하지만 제시한 방법으로 모든 상황이 해결되면 좋겠으나 항상 예외란 존재합니다.

⚒️ Next.js Rewrite Hydration Error

Next.js에서 Rewrite 기능을 사용할 때 Data Fetching이 중복으로 일어나 에러가 발생했습니다. getServerSideProps에서 React-Query를 이용하여 요청하고 있는데요. TanStack 공식 문서에 따르면, 데이터를 Prefetch할 때 Next.js의 Automatic Static Optimization에 의해 두 번 Hydrate가 발생할 수 있다고 합니다.

다음 구조를 확인해 봅시다.

이전 폴더 구조
...
route
  |-- home
  |     |-- index.tsx
  |-- _app.tsx
... etc
async rewrites() {
  return {
    fallback: [
			{
        source: '/home/:path*',
        destination: `/:path*`,
      },
      {
        source: '/:path*',
        destination: `${reactPageUrl}/:path*`,
      },
    ],
  };
},

처음에 메인 페이지를 home 폴더에 라우팅 한 후 불러올 때 / 경로로 이동하도록 rewrite를 설정했습니다. Next.js는 폴더 경로에 따라 라우팅 해주므로 메인 페이지 역시 하나의 폴더에 정리하는 게 더 깔끔하다고 판단했습니다. 하지만 이와 같이 적용한다면, /home 경로와 / 경로를 두 번 거쳐 앞서 말한 에러가 발생합니다. 결국 못마땅하지만 구조를 바꾸었습니다.

현재 폴더 구조
...
route
  |-- _app.tsx
  |-- index.tsx
... etc

⚒️ Suspense Hydration Error

Suspense는 컴포넌트 내 비동기 요청으로 UI 변화가 필요할 때 요청이 끝날 때까지 임시로 fallback UI를 보여주는 방식입니다. 그러나 Hydration 도중에 Suspense를 마주치게 된다면 어떻게 될까요?

우선 서버에서는 모든 비동기 요청을 끝마치고, 최종 HTML을 Return합니다. 즉, Suspense와 상관없이 fallback UI가 생기지 않습니다. 하지만 Client단에서 어떤 이유로 요청이 완료되지 않는 경우 Fallback UI가 렌더링됩니다. 또한, 요청 값이 서버에서 불러온 값과 상이하다면 서로의 HTML 구조도 다를 수 있습니다. 이로 인해 에러가 발생합니다.

Error: This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.

앞서 봤던 에러 문구입니다. 내용 그대로 보면 Hydration 동안 Suspense 내부 비동기 요청에 의해 UI변화가 생겼고 이로 인해 해당 Suspense 경계를 클라이언트 렌더링 모드로 전환한다는 의미입니다. 클라이언트 렌더링으로 인식하는 순간 서버에서 렌더링한 HTML은 사용하지 않습니다. 즉, Hydration 과정이 일어나지 않으므로 이를 에러 메세지를 통해 경고하는 것입니다.

// react-dom 중 일부
function checkForUnmatchedText(serverText, clientText, isConcurrentMode, shouldWarnDev) {
  var normalizedClientText = normalizeMarkupForTextOrAttribute(clientText);
  var normalizedServerText = normalizeMarkupForTextOrAttribute(serverText);

  if (normalizedServerText === normalizedClientText) {
	// Server와 Client의 Text가 같다면 void return = 에러가 없어요!
    return;
  }

  if (shouldWarnDev) {
    {
	// development mode일 때, nextjs에서 메세지를 띄우도록 하는 부분
      if (!didWarnInvalidHydration) {
        didWarnInvalidHydration = true;

        error('Text content did not match. Server: "%s" Client: "%s"', normalizedServerText, normalizedClientText);
      }
    }
  }

  if (isConcurrentMode && enableClientRenderFallbackOnTextMismatch) {
	// ConcurrentMode일 때, Error Return
  // ReactDOM.createRoot()로 render를 한다면 concurrent Mode가 기본으로 적용되어 있다.
    throw new Error('Text content does not match server-rendered HTML.');
  }
}

react-dom 일부를 살펴보면 여러 hydration 과정 속에서 unmatch한 경우를 체크합니다. checkForUnmatchedText 함수를 살펴보면 ClientText와 ServerText를 비교하여 일치하는지 확인합니다.

tanstack Query의 useQuery 함수를 사용할 때 suspense : false 조건일 경우, 앞서 말씀드린 오류가 발생합니다. 해결하려면 suspense : true 옵션으로 변경하여 Suspense가 동작하도록 합니다.

⚒️ Minified React Error

Minified React ErrorProduction 환경에서 Build한 후 코드 최적화 과정에서 발생하는 오류입니다. Production 환경이기에 콘솔에 명확한 오류 메시지가 표시되어 정확한 원인을 파악하기 쉽습니다. 저는 주로 다음과 같은 오류를 맞이하였습니다

  • #418 : 클라이언트에서 렌더링한 초기 UI가 서버에서 렌더링한 UI와 일치하지 않을 때 발생
    Hydration failed because the initial UI does not match what was rendered on the server.
  • #425 : 서버와 클라이언트의 HTML 구조가 일치하지 않을 때 발생
    Text content does not match server-rendered HTML.
  • #426 : 동기 입력(synchronous input)으로 인해 특정 컴포넌트의 업데이트가 지연됐고 이에 따라 화면에 상태를 나타낼 수 없을 때 발생
    A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.

#418 Error

Error: Minified React error #418; visit https://reactjs.org/docs/error-decoder.html/?invariant=418 for the full message or use the non-minified dev environment for full

해당 에러는 잘못된 시맨틱 마크업 구조를 사용하거나 컴포넌트 내 렌더링한 값이 서버와 클라이언트가 다를 때 발생합니다. 흔히 제가 하는 실수는 HTML 구조에 맞지 않게 짤 때였습니다.

그 중 가장 제가 흔하게 한 실수는 <p> 태그 안에 <p> 태그를 중복으로 넣었을 때 입니다. <p> 태그에 넣을 수 있는 요소들은 주로 인라인 요소들입니다. <span>, <a>, <strong> 등의 인라인 태그 이외에 블록 레벨 요소의 태그는 집어 넣을 수 없습니다. (이왜진)

p 태그 안에 p 태그를 삽입한 경우
// ❌ <p> 태그 안에는 오직 인라인 요소만 넣을 수 없습니다
export default function Component({isRendering, dataA, dataB}) {
	return (
		<p>
			{dataA}
			{iRendering && (
			  <p>dataB</p>
			)}
		</p>
	)

이로 인해 중첩된 <p>를 사용해야 하는 상황이 있다면, 다음과 같이 분리하여 사용하였습니다

export default function Component({isRendering, dataA, dataB}) {
	return (
		<div>
			<p>
				{dataA}
			</p>
			{iRendering && (
			<p>dataB</p>
			)}
		</div>
	)

하지만, 그럼에도 불구하고 어디에선가 같은 #418 Hydration Error가 반복해서 발생하였죠.

Untitled.png

발생한 에러를 해결하기 위해 개발자 모드의 소스 디버깅을 통해 파헤쳐봤습니다. 개발자 모드에서 소스 탭에서 Breaking Point를 체크 후 DOMContentLoaded 과 같은 DOM 변형 Event 중단점을 통해 어느 시점에서 에러가 발생하는지 차근차근 확인해보았습니다.

fc0df53f-f18e-4a85-89bb-c252bd30d6ae.png

그러던 도중 숨겨져 있는 Hydration Error을 발견했습니다! 며칠 동안 쩔쩔 매면서 어디서 발생하는지 코드만 뒤져보고 있었는데 마침내 디버깅으로 에러를 찾아 감격의 눈물을 흘렸습니다… 😭

Untitled.png

위 사진을 보시면 className을 확인하실 수 있는데요. 프로젝트 내에선 CSS Module을 사용중이라 className으로 컴포넌트를 유추할 수 있었습니다. 이를 통해 날짜와 관련된 부분이 문제가 있다는 걸 파악했습니다.

// recoil/calendar.ts
// new Date()에 의해 에러 발생
import { atom } from 'recoil';

const date = new Date();

export const selectCalendarYear = atom<number>({
    key: 'selectCalendarYear',
    default: date.getFullYear(),
});

export const selectCalendarMonth = atom<number>({
    key: 'selectCalendarMonth',
    default: date.getMonth() + 1,

현재 코드에서는 Recoil을 사용하여 전역 상태를 관리하고 있습니다. 코드는 new Date()를 사용하여 현재 날짜를 생성하고, Recoil atom을 사용하여 상태로 가져와 필요한 부분을 렌더링하고 있습니다. 그러나 이 방식을 사용하면 프리 렌더링 도중에는 서버에서의 new Date() 값과 Hydration 도중에는 클라이언트에서의 new Date() 값이 달라서 에러가 발생합니다.

이러한 이유로 처음에는 단순히 new Date()를 직접 사용해서 데이터가 달라진다고 판단했습니다. 그래서 같은 데이터를 사용하려면 이를 서버에게 주도권을 주면 되겠다고 생각하여 다음과 같이 수정하였습니다.

recoil/calendar.ts

import { requestRoute } from 'utils/axios';

const getYear = async (): Promise<number> => {
    const res = await requestRoute('GET', '/date');
    return res.year;
};

const getMonth = async (): Promise<number> => {
    const res = await requestRoute('GET', '/date');
    return res.month;
};

export const selectCalendarYear = atom<number>({
    key: 'selectCalendarYear',
    default: getYear().then((res) => res),
});

export const selectCalendarMonth = atom<number>({
    key: 'selectCalendarMonth',
    default: getMonth().then((res) => res),
});

pages/api/date/index.ts

import type { NextApiRequest, NextApiResponse } from 'next';

export type ResponseDate = {
    message: string;
    year: number;
    month: number;
};

export default function handler(req: NextApiRequest, res: NextApiResponse<ResponseDate>) {
    if (req.method !== 'GET') {
        res.status(500).json({ year: -1, month: -1, message: '잘못된 요청입니다' });
    }

    const date = new Date();
    const year = date.getFullYear();
    const month = date.getMonth() + 1;

    res.status(201).json({ year, month, message: '성공' });
}

그러나 이 방식도 마찬가지로 같은 결과를 초래합니다. 어디서 요청하든 new Date()는 항상 변하기 때문이죠. 또한 에러가 발생하면 HttpStatus 등 조건을 따로 고려해야 합니다. 또한, 무의미한 요청을 만드는 것 같아 다시 롤백 하였습니다. 궁극적인 원인은 데이터의 변동성입니다. 데이터가 변하지 않도록 하려면 생성한 데이터를 그대로 사용해야 합니다. 그래서 getServerSideProps 내에 날짜 데이터를 생성한 후 이를 props로 내려주는 방식으로 수정했습니다.

pages/index.tsx

export const getServerSideProps: GetServerSideProps = async (
    context: GetServerSidePropsContext
) => {
   ...
    const today = new Date();
    const year = today.getFullYear();
    const month = today.getMonth() + 1;

    return {
        props: {
            year,
            month
        },
    };
};

interface HomeProps {
    year: number;
    month: number;
}

export default function Home(props: HomeProps) {
    const { refetch } = useCalendarQuery(props.year, props.month);

그렇게 에러가 사라졌고 골칫거리를 해결한 것 같아 뿌듯했습니다 😁

#425 Error

Untitled.png

그런데 또 이 이후 디버깅에 에러가 잡힌 것입니다. (아니 하나가 아니었다고?!)

얼마나 안일하게 만들고 있었는지 자책한 순간이었습니다. (숙연)

달력 부분에서 불러오는 날짜마다 #425 Error가 발생하였습니다.

#425 Error동적으로 생성되는 HTML 태그에 주의해야 합니다. 특히, 스크립트 내부에서 map 함수를 사용하여 컴포넌트를 생성하거나 조건에 따라 다른 HTML 태그를 렌더링할 때 주로 발생합니다.

Server HTML 구조
<div>
  <a>링크</a>
</div>
Client HTML 구조
<div>
	<a><span>링크</span></a>
</div>
loading1.gif

현재의 달력 구현은 각 달에 대한 데이터 요청 후에 Suspense를 이용하여 로딩 UI를 표시하고 있습니다. 이 과정에서 각 달의 일 수를 계산하고 해당 일에 대한 정보를 칸에 배치하고 있습니다. 그러나 매 달마다 일 수를 계산하는 과정에서 new Date()를 사용하여 에러가 발생하고 있습니다.

기존 달력 일 수 생성 로직
// 기존 달력의 일별 칸을 만드는 로직
export const makeDay = ({ year, month }: { year: number; month: number }) => {
    const firstDay = new Date(year, month - 1, 1);
    const lastDay = new Date(year, month, 0);
    const prevMonthLastDay = new Date(year, month - 1, 0).getDate();

    const firstDayOfMonth = firstDay.getDay();
    const lastDateOfMonth = lastDay.getDate();

    const days = [];

    let prevMonthDay = prevMonthLastDay - firstDayOfMonth + 1;

    for (let i = 0; i < firstDayOfMonth; i++)
        days.push({ day: prevMonthDay++, isCurrentMonth: false });

    for (let i = 1; i <= lastDateOfMonth; i++) days.push({ day: i, isCurrentMonth: true });

    let nextMonthDay = 1;
    while (days.length % 7 !== 0) days.push({ day: nextMonthDay++, isCurrentMonth: false });

    return days;
};

매번 일 수를 계산하는 과정은 사용자 클릭에 따라 브라우저에서 달력의 일을 업데이트해야 하기 때문에 반드시 필요합니다. 이로 인해 HTML 태그가 동적으로 변하게 되는데, 이를 해결하기 위해 서버에서 해당 컴포넌트를 렌더링하지 않도록 코드를 분할하여 dynamic import를 사용하였습니다.

...
// dynamic import를 통해 컴포넌트 로드
const CalendarBody = dynamic(() => import('...'), {
  ssr: false,
});
...

#426 Error

그런데 달을 바꾸는 과정에서 #426 Error가 발생했습니다. Suspense로 로딩 컴포넌트를 띄웠음에도 발생했죠. 파헤쳐 본 결과 이 에러의 핵심은 비동기 작업이 동기 입력(synchronous input)으로 인해 진행이 어긋날 때 발생한다는 것을 알게 되었습니다.

간단한 예시를 들어보겠습니다.

  1. 비동기 요청을 통해 컴포넌트를 업데이트할 때 Suspense가 없으면, 업데이트 중에 보여줄 UI가 없어서 에러가 발생합니다.
  2. 코드 분할을 통해 동적으로 컴포넌트를 불러올 때 Suspense가 없으면, 컴포넌트를 가져오는 동안 보여줄 UI가 없어서 에러가 발생합니다.

이러한 상황은 모두 비동기적인 작업입니다. 그러나 화면 렌더링은 동기적으로 일어난다는 점을 기억해야 합니다.

조금 더 짚어보겠습니다. 우리가 보통 setter 함수인 setState를 사용하여 상태를 업데이트 합니다. 상태를 업데이트할 때 사용한 setter 함수는 유기적으로 리액트 내에 연결됩니다 (Linked-list와 비슷). 이러한 구조를 기반으로 리액트는 여러 번의 setState 호출을 하나의 리렌더링으로 묶어 처리합니다. 한 번에 모든 상태를 계산해야 하므로, 상태 업데이트는 비동기로 관리합니다. (병렬적으로 상태 처리를 하기 위해) 하지만 상태를 업데이트 후 실제 DOM 업데이트는 동기적으로 이루어집니다. 만약, 화면을 비동기적으로 반영한다면 여러 상태 업데이트가 얽혀 화면 자체에 혼란을 야기할 수 있습니다.

따라서 비동기 작업 중에 동기적인 상태 업데이트가 발생하면, React는 이를 즉시 처리하려고 합니다. 이 때 작업이 완료될 때까지 Suspense를 통해 화면에 보여줄 UI가 필요하다는 것입니다. 만약 Suspense가 없다면 UI가 어느 스냅샷을 기준으로 렌더링이 될지 불분명하고, 혹은 Suspense 가 있어도 동작하지 않을 수도 있습니다. 이로 인해 에러가 발생합니다.

그렇다면 아예 Suspense를 사용하지 않는 법은 어떨까요?

해결 방법은 크게 두 가지가 있습니다. useEffect를 사용해 컴포넌트가 마운트된 후에 클라이언트 측에서 요청하거나, startTransition을 사용해 우선순위를 낮춰 UI 업데이트를 지연하도록 만드는 방법이 있습니다.

저는 Suspense와 잘 맞물리는 startTransition을 사용해 UI 업데이트가 지연되도록 만들었습니다. 또한, #426 Error 메세지에서도 권장하는 방법입니다.

// 상태를 저장할 때, startTransition으로 감싸주면 업데이트가 지연된다.
// startTransition 내부 함수는 브라우저가 유휴 상태일 때 업데이트한다.
startTransition(() => setState(...);
...
after_calendar.gif

startTransition을 적용하니 스피너의 깜빡임과 상대 업데이트로 인한 잔상 현상이 크게 줄어들었습니다. 훨씬 더 깔끔해진 모습입니다. 😃

sentry의 알람이 눈에 띄게 줄었다
sentry의 알람이 눈에 띄게 줄었다

그렇게 며칠 동안이나 이 에러와 씨름하였고 알람이 눈에 띠게 줄어드는 성과를 이루었습니다. 이럴 때마다 뿌듯함을 많이 느낍니다! :)

마치며

앞서 말씀 드린 에러는 suppressHydrationWarning 의 값을 true로 부여한다면, 에러가 발생하지 않습니다.

// 공식 문서 중 일부
// 이 방법으로 에러가 발생하지 않는다
export default function App() {
  return (
    <h1 suppressHydrationWarning={true}>
      Current Date: {new Date().toLocaleDateString()}
    </h1>
  );
}

즉, timestamp 와 같은 어쩔 수 없이 값이 바뀌는 부분은 이러한 방법으로도 쉽게 해결 가능합니다. 다만, 이 방법은 강제로 에러가 발생하지 않도록 하는 방법이기에 단순히 밑 빠진 독에 물 붓기라고 생각합니다.

지금도 간간히 에러가 발생하고 있습니다. 특히 웹 브라우저 환경에선 일어나지 않는 에러가 모바일 환경(Safari, Crosswalk)에서 잡히는 경우가 꽤 있습니다. (간헐적으로 Sentry에 잡혀서 답답한 상황) 하지만 에러 발생의 궁극적인 원인은 바뀌지 않으니 이를 기반으로 어느 순간에 발생하는지 쭈욱 파악하는 습관을 길들여 보려 합니다.

참고

hydrateRoot – React

ReactDOM – React (reactjs.org)

Building Your Application: Rendering | Next.js (nextjs.org)

Text content does not match server-rendered HTML | Next.js (nextjs.org)

React 18 Concurrent Rendering (velog.io)

React의 hydration mismatch 알아보기 – 화해 블로그 | 기술 블로그 (hwahae.co.kr)

#Minified React Error#Hydration Error#425#426#418#startTransition#new Date()