hydration Error, 서버에서 useState내부함수가 실행되는 문제,  use hook
🔥

hydration Error, 서버에서 useState내부함수가 실행되는 문제, use hook

작성일
2024년 10월 19일
태그
nextjs
카테고리
nextjs
Last edited time
Last updated November 12, 2024
날짜
 

nexjts hydration 이란??

 
nextjs의“use client”로 선언된 클라이언트 컴포넌트는 클라이언트 환경에서만 실행되는것이 아니라 서버에서 한번 실행 후 클라이언트에 실행하게 됩니다. 정확히 말하면..
🔥
사전 랜더링 시 server component를 먼저 실행시키고, 랜더링 결과만 필요한 client component는 따로 실행해서 서버컴포넌트의 랜더링 결과와 합쳐 최종 랜더링 결과를 만듭니다. client컴포넌트의 랜더링 결과만 필요하기 때문에 서버에서 "실행"되는 것이 아니라, 서버에서 HTML로 "렌더링" 한다고 이해하면 편합니다.
처음에는 server component와 client component를 서버에서 사전 랜더링 후
client component의 자바스크립트 번들을 전송합니다. (app router 기준, page router는 server component 번들도 전달합니다)
notion image
위와같이 사전랜더링시 RSC payload라는 서버컴포넌트들을 실행한후 직렬화한 결과물을 먼저 만든 후 클라이언트 컴포넌트를 실행해서 끼워넣는식.
이때 클라이언트에서는 해당 사전 랜더링 결과를 받아서 브라우저에 그린후 클라이언트 컴포넌트의 js번들을 받아서 실행하는것이
hydation입니다.
notion image
 
hydration error는 주로 다음과 같은 이유로 발생가능합니다.

일반적인 원인

수화 오류는 다음과 같은 이유로 발생할 수 있습니다.
  1. HTML 태그의 잘못된 중첩
    1. <p> 다른 태그 에 중첩됨 <p> 
    2. <div> 태그 에 중첩됨 <p> 
    3. <ul> or <ol> nested in a <p> tag
    4. 1. Interactive Content cannot be nested (<a> nested in a <a> tag, <button> nested in a <button> tag, etc.) 대화형 콘텐츠는 중첩될 수 없습니다(<a>가 <a> 태그에 중첩되거나, <button>이 <button> 태그에 중첩되는 경우).
    5. 잘못된 중첩이 하이드레이션 에러를 유발하는 이유

    6. 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR)의 불일치: SSR에서는 브라우저가 중첩된 a태그를 허용하지 않는 방식으로 HTML을 파싱할 수 있습니다. 예를 들어, 내부 a태그를 무시하거나 외부 a태그를 닫을 수 있습니다. 반면, CSR에서 React는 Virtual DOM을 생성할 때 중첩된 구조를 그대로 유지할 수 있습니다.
    7. React의 재조정(Reconciliation) 과정: 하이드레이션 단계에서 React는 서버에서 생성된 HTML과 클라이언트에서 생성된 Virtual DOM을 비교합니다. 구조가 다르면 React는 이를 불일치로 인식하고 하이드레이션 에러를 발생시킵니다.
    8. 브라우저의 자동 교정: 일부 브라우저는 잘못된 HTML 구조를 자동으로 교정하려고 시도합니다. 이 과정에서 서버에서 생성된 HTML과 클라이언트에서 React가 기대하는 구조 사이에 차이가 발생할 수 있습니다.
    9.  
      // 서버 사이드에서 렌더링된 HTML (브라우저에 의해 자동 교정될 수 있음) <a href="/outer"> Outer Link <a href="/inner">Inner Link</a> </a> // React 컴포넌트 (클라이언트 사이드에서 렌더링) function NestedLinks() { return ( <a href="/outer"> Outer Link <a href="/inner">Inner Link</a> </a> ); }
      이 경우, 서버에서 생성된 HTML은 브라우저에 의해 다음과 같이 해석될 수 있습니다:
      <a href="/outer">Outer Link</a> <a href="/inner">Inner Link</a>
      그러나 React는 원래의 중첩 구조를 기대합니다. 이러한 불일치로 인해 하이드레이션 에러가 발생합니다.
       
  1. typeof window !== 'undefined'
    1. 렌더링 로직과 같은 체크 사용
  1. 렌더링 로직에서 windowlocalStorage
    1. 브라우저 전용 API 사용
  1. Date()
    1. 렌더링 로직의 생성자 와 같은 시간 종속 API 사용
  1. 브라우저 확장 프로그램
    1. HTML 수정하기
  1. 잘못 구성된 CSS-in-JS 라이브러리
    1. 코드가 공식 예제를 따르고 있는지 확인하세요.
  1. Cloudflare 자동 축소 와 같이 html 응답을 수정하려는 잘못 구성된 Edge/CDN
 
 
 

문제상황

 
“use client” 선언을 해서 클라이언트 사이드 렌더링을 했음에도 서버사이드에서 한번 실행하는 과정에서 리액트 훅이 실행되는 문제.
"use client" import React, { createContext, useContext, useEffect, useState } from 'react'; import dayjs from 'dayjs'; const SearchContext = createContext(); export const SearchProvider = ({ children }) => { const initialState = () => { let savedData = null; if (typeof window !== 'undefined' && window.sessionStorage) { // <- 이부분입니다. savedData = sessionStorage.getItem('searchData'); } return JSON.parse(savedData) }; const [searchData, setSearchData] = useState(initialState); useEffect(() => { sessionStorage.setItem('searchData', JSON.stringify(searchData)); console.log(searchData); console.log(sessionStorage.getItem('searchData')); }, [searchData]); return ( <SearchContext.Provider value={{ searchData, setSearchData }}> {children} </SearchContext.Provider> ); }; export const useSearch = () => useContext(SearchContext);
해당 로직은 Provider에서 브라우저 세션을 설정하고 검색창의 데이터를 저장하는 로직입니다.
 
 
처음 initalState를 설정하는 부분에서 (typeof window !== 'undefined' && window.sessionStorage)
이부분을 삭제하게되면 서버에서 실행될 떄 sessionStorage를 찾을 수 없다는 에러가 터미널에뜨고 이부분을 유지하게되면 서버에서의 sate가 null로 유지되면서
서버에서의 랜더링과 클라이언트의 랜더링이 불일치하게 되면서
Hydration error가 뜨게됩니다.
 
이전 해결방안.

시도해본것

 
 
다이나믹 라우트를 사용해서 해당 nav를 클라이언트에서만 랜더링 ⇒ 동작하지 않았음.
const MainNav = dynamic(() => import("@/components/mainNav/MainNav"), { ssr: false, });
 
 
mount 되었는지 확인
const [hasMounted, setHasMounted] = useState(false); useEffect(() => { setHasMounted(true); }, []); if (hasMounted) { return [storedValue, setValue] as const; }
해당 로직을 context에서 사용했는데 이것 또한 잘 되지는 않음
 

해결

useEffect를 사용해서 따로 컴포넌트가 마운트된 후 값을 넣음
useEffect(() => { sessionStorage.setItem('searchData', JSON.stringify(searchData)); console.log(searchData); console.log(sessionStorage.getItem('searchData')); }, [searchData]);
위의 provider에서 초기 로딩될때 값이 없다고 서버에서 판단해서 지워버리기 때문에
아래방식으로 해결함
 
const [searchData, setSearchData] = useState(null); useEffect(() => { if (!searchData) return; sessionStorage.setItem('searchData', JSON.stringify(searchData)); console.log(searchData); console.log(sessionStorage.getItem('searchData')); }, [searchData]);
null일때는 값을 변하지 않게하고
 
export default function Home() { const { searchData, setSearchData } = useSearch(); useEffect(() => { const initialState = () => { let savedData = null; if (typeof window !== "undefined" && window.sessionStorage) { savedData = sessionStorage.getItem("searchData"); } return JSON.parse(savedData); }; setSearchData(initialState); }, []); console.log("searchData", searchData); const { data, status } = useSession();
초기 컴포넌트 로드시 값 지정했음.
 
 
문제사항을 다시 분석해보았을 때
use client를 선언한 클라이언트 컴포넌트에서 input값을 초기에 null로설정 후
클라이언트에서 해당 값을 바꾸려했기때문에 발생한 문제.
 
지금이었다면 이런 데이터는 queryStaring으로 값을 설정할것같다.
굳이 브라우저 세션과 constext로 전역데이터를 유지하고싶다면
  1. 애초에 클라이언트 사이드에서만 실행되는 코드인데 useEffect 밑의 로직을 검증할 필요가없음 만일 서버에서 해당 코드가 실행된다면 클라이언트사이드에만 실행되도록 구조를 변경할 것
f (typeof window !== 'undefined' && window.sessionStorage) { // <- 이부분입니다. savedData = sessionStorage.getItem('searchData'); }
 
  1. 서버사이드 랜더링시 리엑트 훅이 절대 실행되지 않는건아님. 정확히 말하면 useState훅 안의 함수는 실행된다.
const getSessionBrowser = () => sessionStorage.getItem('sessionId'); export default function ClientC() { const [sessionId, setSessionId] = useState<null | string>(getSessionBrowser()); return <div>홈 페이지 {sessionId}</div>; }
훅이라고 무조건 클라이언트사이드에서 실행된다는 안일한 방심은 금물!
✓ Compiled in 936ms (725 modules) ⨯ src\app\(after-login)\test\components\ClientC.tsx (4:48) @ getItem ⨯ ReferenceError: sessionStorage is not defined at getSessionBrowser (test/components/ClientC.tsx:11:46) at ClientC (test/components/ClientC.tsx:13:87) digest: "4268536241" 2 | import { useEffect, useState } from 'react'; 3 | > 4 | const getSessionBrowser = () => sessionStorage.getItem('sessionId'); | ^
 
  • 왜 실행되는가??
export default function ClientC() { const [sessionId, setSessionId] = useState<null | string>('text'); useEffect(() => { setTimeout(() => { setSessionId('업데이트된 텍스트'); }, 2000); }, []); return ( <div> <h1>홈 페이지</h1> <div> {/* sessionId가 업데이트될 때마다 DOM에 변경 사항이 반영됨 */} <p>현재 세션 ID: {sessionId}</p> </div> </div> ); }
위의 간단한 예제를 확인했을 때
 
notion image
nextjs는 client compoent를 서버사이드에서 랜더링 할 때 useState내의 기본값을 먼저 랜더링해주려고 한다. 그렇기에 useState내부의 기본값을 랜더링 해주려고 함수가 실행되는것이다. 해결방법: 그냥 useEffect를 사용하면된다.
 

하지만..useEffect를 사용하면 화면깜박임이 발생할 수 밖에 없는 구조

서버 사이드 랜더링 후 브라우저에서 세션을 가져와 바인딩해주기 때문에 어쩔 수 없이 깜박임이 발생
( 위 이미지_ 현재 세션 ID: text 부분에서 text가 브라우저 세션으로 바뀌면서 깜박임 )
 
흔히 이를 해결하기 위해 사용되는 useLayoutEffect는 당연히 SSR에서는 무용지물이다. 이미 html을 랜더링했는데 랜더링 전에 상태를 업데이트해줘봐야 무슨 소용인가.. 이때는 단순히 컴포넌트를 로딩처리해주자!
 

어떻게 로딩처리를 하나요..?

 
 
2024-11-11 이전 해결법 (use를 사용해 해결)
<Suspense fallback={<div>loading</div>}> <ClientC /> </Suspense>
nextjs 에서는 Suspense를 이용해서 클라이언트 컴포넌트의 비동기 로직을 간편하게 로딩처리 할 수 있습니다.
간단한 딜레이 함수를 만들어 Promise를 반환시켜 해당 컴포넌트를 Suspense를 트리거 할 수 있게 만들어주자
 
const delay = () => { return new Promise<string>(resolve => { setTimeout(() => { resolve('업데이트된 텍스트'); }, 2000); }); };
이후 useEffect에 해당 로직을 실행시키면…?
export default function ClientC() { const [sessionId, setSessionId] = useState<null | string>(null); useEffect(() => { (async () => { const result = await delay(); setSessionId(result); })(); }, []); return ( <div> <h1>홈 페이지</h1> <div> <p>현재 세션 ID: {sessionId}</p> </div> </div> ); }
 

Suspense는 트리거되지 않는다!

 
Suspense는 컴포넌트 랜더링 시 promise를 반환받고 이를 토대로 로딩상태를 결정함
⇒ useEffect내의 비동기함수는 Suspense를 트리거 하지 못한다. 이미 자식 컴포넌트는 반환되었기 때문에 이를 이미 composite 한 상태임 이후 useEffect가 실행되기때문에 Suspense의 fallback이 끼어들 여지가 없음
notion image
 

해결방법

여러 방법이 존재하겠지만 결국 proimse상태를 반환해주면 해결되는 문제이다. 이때 쉽게 쓸 수 있는 방법은 react의 최신 훅인 use를 사용하는것이다.
 

use란?

  • use 함수의 동작 방식:
    • 'use' 함수는 Promise를 받아 그 결과를 동기적으로 반환하는 것처럼 보이게 합니다.
    • 내부적으로, 'use'는 Promise가 해결될 때까지 컴포넌트의 렌더링을 일시 중단합니다.
    • 이 과정에서 Suspense의 fallback이 표시됩니다.
    • Promise가 해결되면, 컴포넌트는 결과와 함께 다시 렌더링됩니다.
  • Suspense와의 상호작용:
    • 'use'로 감싼 비동기 작업이 진행 중일 때, React는 가장 가까운 Suspense 경계로 “throw함".
    • Suspense는 이 "던져진" 상태를 감지하고 fallback을 표시합니다.
    • 데이터가 준비되면 Suspense는 fallback을 실제 컴포넌트로 교체합니다
 
// server component <Suspense fallback={<div>loading</div>}> <ClientC /> </Suspense> // -------- // client component 'use client'; const delay = () => { return new Promise<string>(resolve => { setTimeout(() => { resolve('업데이트된 텍스트'); }, 2000); }); }; export default function ClientC() { const sessionId = use(delay()); // const [sessionId, setSessionId] = useState<null | string>(null); return ( <div> <h1>홈 페이지</h1> <div> <p>현재 세션 ID: {sessionId}</p> </div> </div> ); }
위처럼 use를 사용하면 마치 nextjs 의 서버사이드 랜더링 방식처럼
동기적인 느낌으로 컴포넌트를 랜더링 가능하게된다. use는 이외에도 context를if문 내에서 실행시킬 수 있게 해준다.
아래는 리액트 공식 use 사용법
notion image
 

주의점!

💡
use Hook은 React의 Experimental 및 Canary 채널에 포함되어 있습니다.
  • Canary Channel: 가장 최신의 개발 버전으로, 새로운 기능과 변경 사항이 즉시 반영됨
 
실험적인 기능은 API가 변경될 수 있으며, 예상치 못한 버그나 문제가 발생할 수 있습니다.
또한 향후 업데이트에서 기능의 변경이나 제거가 있을 수 있으므로, 코드 수정이 필요할 수 있습니다.
 
nextjs15 버전이 업데이트 됨에 따라 위의 클라이언트에서 promise를 생성해서 use를 사용해 suspense를 트리거하는 방법은 권장하지 않는 방법이 되었다.
notion image
위와 같이 클라이언트 컴포넌트에서 Promise를 생성하지 말라는 에러가 발생한다. (기능은 정상적으로 동작한다.)
또한 서버를 하나 만들어 패치해보니.. 계속해서 패치를 시도하는 무한 패치 오류가 발생했다..
 
 
아래 코드처럼 server컴포넌트에서 promise를 생성하고 클라이언트 컴포넌트에 전달해주는 방법과 (server에서 promise가 실행되지는 않는다)
// server 컴포넌트입니다. const fetchFnc = async () => { return await new Promise<string>(resolve => { setTimeout(() => { resolve('업데이트된 텍스트'); }, 2000); }); }; export default async function Card() { return ( <Suspense fallback={<div>loading</div>}> <ClientComp promise={fetchFnc()} /> </Suspense> ); }
 
'use client'; import { use } from 'react'; export default function ClientComp({ promise }: { promise: Promise<any> }) { const text = use(promise); return ( <div> 클라이언트 컴포넌트입니다: {text} </div> ); }
 
위의 방식이 props driling과 nextjs의 철학인 필요한곳에서 패치하라는 철학에 반하는것 같아
아래 방법으로 해결하는것이 좋아보인다.
tnastack query의 useSuspenseQuery를 사용해주면 Suspense를 트리거할 수 있다.
'use client'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { use } from 'react'; export default function ClientC() { const { data } = useSuspenseQuery({ queryKey: ['someData'], queryFn: () => new Promise<string>(resolve => setTimeout(() => resolve('업데이트된 텍스트'), 2000)), }); // ...
 
굳이 use를 사용하지 않더라도 useEffect와 useState로 로딩상태와 패치를 처리할 수 있지만
위의 방법이 너무나 깔끔해서 가능하면 해당 기능을 이용하고싶다.
Suspense는 세밀한 로딩을 제어하기는 힘들지만 많은 경우에
깨끗한 코드를 만드는데 좋은 방법이 될 것 같다.