Next.js hydration 에러 우아하게 해결하기

thumbnail

 

문제

Next.js App router로 개발을 하다 보면 아주 가끔 억울하게 hydration 에러를 마주할 때가 있습니다. 대표적으로 랜덤한 값에 의존해서 UI를 그릴 때가 있습니다.

ClientComponent.tsx

'use client'; function generate() { return Math.random() * 100; } export function ClientComponent() { const [number, setNumber] = useState(() => generate()); return ( <div> <p>{number}</p> <button onClick={() => setNumber(generate())}>생성하기</button> </div> ); }

이렇게 랜덤하게 생성된 숫자를 화면에 띄우면.. 이미지 hydration 에러를 마주합니다. 이유는 나와있다 시피, 서버에서의 렌더링 결과물과 클라이언트에서의 렌더링 결과물이 일치하지 않아서입니다.

 

해결

1. Server Component 활용?

위 문제는 결국 서버와 클라이언트, 두 번 코드가 실행됐기 때문에 발생한 문제입니다. 서버에서 한 번만 렌더링되는 서버 컴포넌트의 특징을 이용하여, 상위 서버 컴포넌트에서 랜덤한 값을 생성한 뒤에, 해당 값을 prop으로 받아 보여주도록 수정할 수 있습니다.

// page.tsx (server component) export function generate() { return Math.random() * 100; } export default function Page() { return ( <div> <ClientComponent initialNumber={generate()} /> </div> ); } // ClientComponent.tsx (client component) 'use client'; import { generate } from './page'; export function ClientComponent({ initialNumber }: { initialNumber: number }) { const [number, setNumber] = useState(initialNumber); return ( <div> <p>{number}</p> <button onClick={() => setNumber(generate())}>생성하기</button> </div> ); }

원하는 대로 잘 동작합니다. 그런데 조금 찝찝합니다. 이번 사례에서는 큰 문제가 없어 보이는데, 요구사항이 복잡해지면서 ClientComponent를 감싸는 또 다른 클라이언트 컴포넌트, ParentClientComponent가 생겼다고 합시다.

// page.tsx (server component) export function generate() { return Math.random() * 100; } export default function Page() { return ( <div> <ParentClientComponent initialNumber={generate()} /> </div> ); } // ParentClientComponent.tsx (client Component) 'use client'; export function ParentClientComponent({ initialNumber }: { initialNumber: number }) { return ( <div> ... <ClientComponent initialNumber={initialNumber} /> ... </div> ); }

props drilling이 시작되었습니다. ParentClientComponent는, 관심사가 전혀 아닌 initialNumber를 prop으로 받아야합니다. 이는 장기적으로 봤을 때, 페이지 내부 컴포넌트들이 복잡해질 수록 유지보수하기 힘든 코드가 됩니다. 결국 이 방법은 또 다른 문제를 낳게 됩니다.

 

2. useEffect

useEffect는 페인트(paint) 단계 이후에 실행되는 훅이므로 서버에서 실행되지 않습니다. number의 초기값을 useEffect문 내부에서 설정해주면서 서버에서 랜덤값이 렌더링되는 것을 방지할 수 있습니다.

// page.tsx (server component) import { ClientComponent } from './ClientComponent'; export default function Page() { return ( <div> <ClientComponent /> </div> ); } // ClientComponent.tsx (client component) 'use client'; import { useEffect, useState } from 'react'; function generate() { return Math.random() * 100; } export function ClientComponent() { const [number, setNumber] = useState<number | null>(null); useEffect(() => { setNumber(generate()); }, []); return ( <div> <p>{number}</p> <button onClick={() => setNumber(generate())}>생성하기</button> </div> ); }

결과는.... 이미지

prerendering되어 넘어온 html에는 생성된 숫자가 없고, 브라우저에서 코드가 실행 되고, 페인트 단계 이후에 숫자가 생성됩니다. 이 때문에 차마 눈뜨고 보기 힘든 수준의 심각한 layout shift가 발생합니다.

결국 첫 번째 해결방법과 비슷하게, hydration 에러만 피했을 뿐, 해결 과정이 또 다른 문제를 낳아버리고 말았습니다.

 

3. useServerInsertedHTML

해결해야 하는 문제를 다시 정의 해봅시다.

서버에서 생성한 랜덤 숫자와, 클라이언트에서 생성한 랜덤 숫자가 일치하지 않는다.

그러면 결국 generate를 최초 실행했을 때 생성되는 단 하나의 동일한 결과값을 이용해 서버와 브라우저에서 렌더링하면 되지 않을까요? 서버에서 코드가 먼저 실행되니, 서버에서 랜덤 숫자를 생성하고, 어떤 숫자가 생성되었는지 클라이언트에게 전달하면 됩니다. 그리고 클라이언트에서 렌더링할 때 이 값을 number state의 최초값으로 설정하면 됩니다.

이를 도와주기 위해, next/navigation에서 제공하는 useServerInsertedHTML이라는 훅이 존재합니다.

슬프지만, Next.js에서 이 훅에 대한 자세한 문서를 제공해주지 않고있습니다😢. 다만 활용 예시로, css-in-js를 앱라우터에서 사용하기 위한 수단으로 소개합니다.

'use client' import React, { useState } from 'react' import { useServerInsertedHTML } from 'next/navigation' import { StyleRegistry, createStyleRegistry } from 'styled-jsx' export default function StyledJsxRegistry({ children, }: { children: React.ReactNode }) { // Only create stylesheet once with lazy initial state // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state const [jsxStyleRegistry] = useState(() => createStyleRegistry()) useServerInsertedHTML(() => { const styles = jsxStyleRegistry.styles() jsxStyleRegistry.flush() return <>{styles}</> }) return <StyleRegistry registry={jsxStyleRegistry}>{children}</StyleRegistry> }

export declare function useServerInsertedHTML(callback: () => React.ReactNode): void;

useServerInsertedHTML은, 쉽게 말해 서버에서 렌더링될 때 리턴된 특정 HTML 조각을 삽입(inject)시키는 역할을 합니다. 위 예시에서는, StyleRegistry를 통해 수집된 스타일들을 이용해 스타일 태그를 만들고, 이를 HTML의 루트 head 태그에 삽입해줌으로써 서버에서만 렌더링 되는 서버컴포넌트에서도 스타일이 적용될 수 있도록 도와줍니다.

우리의 사례에 이 기능을 적용해보자면, 서버에서 생성한 랜덤 숫자를 알려주는 코드를 HTML 조각에 삽입하고, 클라이언트에서는 이 정보를 이용하여 최초 number의 값으로 활용하면 좋겠네요. 이를 위한 코드는 아래와 같이 작성해볼 수 있습니다.

const initialNumber=generate(); useServerInsertedHTML(() => { return <script dangerouslySetInnerHTML={{ __html: `window.initialNumber = ${initialNumber};` }} />; });

script 태그를 이용하였고, 브라우저에서 존재할 window 전역 객체에, initialNumber라는 프로퍼티에 랜덤 숫자를 저장하였습니다. 이제 서버에서는 initialNumber값을 이용해 렌더링하고, 브라우저에서는 window객체의 initialNumber 프로퍼티에 접근하여 값을 가져와서 렌더링하면 동일한 결과물을 볼 수 있습니다.

// ClientComponent.tsx (client component) 'use client'; function generate() { return Math.random() * 100; } export function ClientComponent() { const isServer = typeof window === 'undefined'; const initialNumber = generate(); useServerInsertedHTML(() => { return <script dangerouslySetInnerHTML={{ __html: `window.initialNumber = ${initialNumber};` }} />; }); const [number, setNumber] = useState<number | null>(() => { return isServer ? initialNumber : window.initialNumber; }); return ( <div> <p>{number}</p> <button onClick={() => setNumber(generate())}>생성하기</button> </div> ); }

** BUT❗️❗️** 고려해야할 상황이 한 가지 더 있습니다. 클라이언트 컴포넌트는 서버에서 prerendering이 일어나지 않을 수도 있습니다. 클라이언트 측 라우팅을 통해 페이지를 이동하는 경우에는, CSR 처럼 동작하여 prerendering 과정 없이 온전히 브라우저에서의 코드 실행만으로 화면을 그립니다. 즉, 클라이언트 측에서 라우팅을 한 경우에는 브라우저 환경에서 window객체에 initialNumber 프로퍼티가 존재하지 않을 수 있습니다. 이 경우에는 hydration 에러 걱정 없이 단순히 랜덤 숫자 생성해주면 됩니다.

정리하자면, 위 코드에서 isServer가 false일 때도, window 객체에 initialNumber 프로퍼티의 존재를 확인 하고, 없다면 generate 함수를 통해 랜덤 숫자를 생성해 줍니다.

ClientComponent.tsx

// ClientComponent.tsx (client component) 'use client'; function generate() { return Math.random() * 100; } export function ClientComponent() { const isServer = typeof window === 'undefined'; const initialNumber = generate(); useServerInsertedHTML(() => { console.log('init', initialNumber); return <script dangerouslySetInnerHTML={{ __html: `window.initialNumber = ${initialNumber};` }} />; }); // Object.hasOwn 으로 initialNumber 프로퍼티 존재 여부 검사 const [number, setNumber] = useState<number | null>(() => { return isServer || !Object.hasOwn(window, 'initialNumber') ? initialNumber : window.initialNumber; }); return ( <div> <p>{number}</p> <button onClick={() => setNumber(generate())}>생성하기</button> </div> ); }

이를 통해, 우리가 목표했던 바를 layout shift나 코드 부채 없이 이루었습니다.

 

추상화하기

이렇게 억울하게 렌더링 결과물의 미스매치로 인한 hydration 에러는 서비스 개발 중에 생각보다 자주 맞닥뜨립니다. 위와 같이 랜덤 숫자로 화면을 그리는 상황 외에도, 초 단위의 시간까지 화면에 나타내는 경우도 있을 수 있습니다. 서버에서는 55초에 렌더링 됐는데, 클라이언트에서는 56초에 되면 또 비슷한 문제를 겪게 됩니다. 이러한 비슷한 상황들을 모두 해결하기 위해, 위에 작성한 로직을 별도 커스텀 훅으로 추상화를 합시다.

또한, window 객체에 조금 더 안전하게 접근하기 위해 __SYNC_STORAGE__라는 이름의 별도 객체 저장소를 만들어 봅시다.

// useSyncedState.tsx const SYNC_STORAGE_KEY = '__SYNC_STORAGE__'; declare global { interface Window { __SYNC_STORAGE__: Record<string, string>; } } export function useSyncedState<T>(value: T, key: string) { const isServer = typeof window === 'undefined'; useServerInsertedHTML(() => { return ( <script dangerouslySetInnerHTML={{ __html: ` if (Object.hasOwn(window, '${SYNC_STORAGE_KEY}')) { window.${SYNC_STORAGE_KEY}.${key} = ${JSON.stringify(value)}; } else { window.${SYNC_STORAGE_KEY} = { ${key}: ${JSON.stringify(value)} }; } `, }} /> ); }); return useState<T>(() => { return isServer || !Object.hasOwn(window, SYNC_STORAGE_KEY) ? value : (window.__SYNC_STORAGE__[key] as T); }); }

기존 로직과 거의 동일하나, __SYNC_STORAGE__라는 객체의 프로퍼티에 접근하기 위한 코드가 조금 추가되었습니다.

ClientComponent에서는 방금 만든 훅을 useState 대신 사용해주기만 하면 됩니다.

export function ClientComponent() { const [number, setNumber] = useSyncedState(generate(), 'initialNumber'); return ( <div> <p>{number}</p> <button onClick={() => setNumber(generate())}>생성하기</button> </div> ); }

 

최적화하기

여기까지 너무너무 좋지만, 아주 사알짝 쪼끔 더 잘만들 수 있을 것 같습니다. 왜냐하면 생각해보면, window.__SYNC__STORAGE__ 가 존재하는 상황이면, 브라우저에서 generate 함수를 굳이 실행할 필요가 없지 않나요? 그런데 지금 브라우저에서는 무조건 generate 함수가 실행되고 있습니다. 만약 generate 함수가 값비싼 함수였다면 유의미한 성능차이를 낼 수도... 아무튼 불필요한 연산을 굳이 할 필요가 없을 것 같습니다. 이를 위한 코드를 추가해봅시다. 함수를 조건부로 실행시켜야 하기 때문에, useSyncedState의 첫 인자로 값이 아니라 함수를 넘겨야합니다. 그리고, 훅 내부에서 환경에 따라 조건부로 실행하도록 합니다.

const value = isServer || !Object.hasOwn(window, SYNC_STORAGE_KEY) ? callback() : (window.__SYNC_STORAGE__[key] as T);

최종 코드는 아래와 같습니다.

// useSyncedState.tsx export function useSyncedState<T>(callback: () => T, key: string) { const isServer = typeof window === 'undefined'; const value = isServer || !Object.hasOwn(window, SYNC_STORAGE_KEY) ? callback() : (window.__SYNC_STORAGE__[key] as T); useServerInsertedHTML(() => { return ( <script dangerouslySetInnerHTML={{ __html: ` if (Object.hasOwn(window, '${SYNC_STORAGE_KEY}')) { window.${SYNC_STORAGE_KEY}.${key} = ${JSON.stringify(value)}; } else { window.${SYNC_STORAGE_KEY} = { ${key}: ${JSON.stringify(value)} }; } `, }} /> ); }); return useState<T>(value); } // ClientComponent.tsx function generate() { return Math.random() * 100; } export function ClientComponent() { const [number, setNumber] = useSyncedState(generate, 'initialNumber'); return ( <div> <p>{number}</p> <button onClick={() => setNumber(generate())}>생성하기</button> </div> ); }

 

번외

위 패턴이 낯설게 느껴질 수 있습니다. window 객체라는 리액트 바깥의 환경에서 값을 가져오기 때문인데요, 이 경우에는 useSyncExternalStore이라는 리액트 훅을 이용할 수도 있습니다. 리액트 공식문서에 따르면 리액트 바깥의 환경, 즉 브라우저 API를 통해 값을 가져오거나 구독할 때 사용하라고 합니다. 이를 이용하여 useSyncedState 훅을 아래와 같이 구현할 수 있다는 점 참고 바랍니다. 기능상의 차이는 없습니다.

export function useSyncedState<T>(callback: () => T, key: string) { const isServer = typeof window === 'undefined'; const value = useSyncExternalStore( () => () => {}, useCallback(() => { return Object.hasOwn(window, SYNC_STORAGE_KEY) ? (window.__SYNC_STORAGE__[key] as T) : callback(); }, [callback, key]), () => { return isServer || !Object.hasOwn(window, SYNC_STORAGE_KEY) ? callback() : (window.__SYNC_STORAGE__[key] as T); }, ); useServerInsertedHTML(() => { return ( <script dangerouslySetInnerHTML={{ __html: ` if (Object.hasOwn(window, '${SYNC_STORAGE_KEY}')) { window.${SYNC_STORAGE_KEY}.${key} = ${JSON.stringify(value)}; } else { window.${SYNC_STORAGE_KEY} = { ${key}: ${JSON.stringify(value)} }; } `, }} /> ); }); return useState<T>(value); }

이미지

 

마무리

이렇게 렌더링 결과물 미스매치로 인한 hydration 에러를 해결할 수 있는 방법에 대해 알아봤습니다. 물론 제가 소개한 방법도 단점이 없진 않습니다. 보안적으로 민감한 데이터를 다루는 경우에는 사용이 우려될 수 있겠네요. 그리고 이번 기회에 useServerInsertedHTML이라는 훅의 강력함을 볼 수 있었는데, 이 훅을 체계적으로 사용하여 다양한 기능들을 만들 수 있을 것 같습니다. 예를 들어, 제가 예전 글에서 소개해드렸던 React QueryReactQueryStreamedHydration 기능도 정말 비슷한 방식으로 window 객체와 useServerInsertedHTML을 활용하여 구현되었습니다. 이미지 이 처럼 useServerInsertedHTML을 잘 활용한다면 서버-클라이언트 데이터 및 렌더링 결과 동기화 이슈들을 많이 해결할 수 있을 것 같습니다. 감사합니다.