ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Next.js 하이드레이션 에러 우아하게 처리하기
    프론트엔드 2026. 6. 22. 16:06

    Next.js가 어떻게 돌아가는지 부터 파악해보기 

    - 프로젝트를 진행하면서 hydration에러가 발생했습니다.React error 418이 Hydration에러가 발생했다라는 뜻이라고 합니다.

     

    - 근데 왜 발생했을까요 

     

    - 우선 Hydration이 뭔지 부터 저는 바로잡고자 다시한번 공부를 했습니다. 

    Hydration? 

    이정환님의 인프런 강의를 참고한 이미지입니다.

    - 이번 Next.js에서 어떻게 렌더링을 하는지에 대해서 봅시다.

    - 유저가 브라우저에서 접속 요청을 하면 서버에서 사전 렌더링이 시작됩니다. 이때 Next.js는 서버 컴포넌트(SC)와 클라이언트 컴포넌트(CC)를 구분해서 처리합니다.

    - 서버 컴포넌트는 서버에서 실행된 뒤 RSC Payload 형태로 직렬화됩니다. 클라이언트 컴포넌트는 서버에서 HTML로 렌더링됩니다. 이 둘을 합쳐 완성된 HTML을 브라우저에 전달하고 유저는 즉시 화면을 볼 수 있습니다.

    🚨 여기서 주의할 점 — SC는 RSC Payload로 클라이언트에 전달될 뿐 추가 작업이 없습니다. 반면 CC는 JS Bundle을 통해 Hydration 과정이 필요합니다.

    - JS Bundle에는 SC가 제외된 CC만 포함되어 있으며 Hydration은 서버에서 받은 HTML에 이벤트 핸들러를 연결하는 과정입니다. 이 과정이 완료되어야 버튼 클릭 상태 변경 같은 상호작용이 가능해집니다.

     

    다시 한번 정리하면 아래 그림으로 정리를 할 수 있습니다. 

    - 유저 접속 요청 -> SC, CC구분 해서 따로 실행 -> 실행된 결과 HTML을 전달 -> 유저가 화면 볼 수 있음 -> JS Bundle(CC만) -> 하이드레이션

    다시 프로젝트로

    - 이제 흐름을 이해했습니다. 

    - 근데 왜 프로젝트에 에러가 발생할까요?? 코드를 봅시다

      const getGenerationStatus = sessionStorage.getItem(
        WRITE_GENERATION_STATUS_KEY
      );
    
      const helperText =
        getGenerationStatus === "generating"
          ? "글 생성 중입니다. 버튼을 눌러 생성 화면으로 이동하세요."
          : getGenerationStatus === "done"
          ? "생성이 완료되었습니다. 버튼을 눌러 결과를 확인하세요."
          : getGenerationStatus === "error" && Boolean(getGenerationStatus)
          ? "생성 중 오류가 발생했습니다. 버튼을 눌러 다시 확인하세요."
          : `저장 가능: 포스트 최대 ${MAX_STORED_POSTS}개까지. 생성 후 자동으로 저장됩니다.`;
    
      const buttonLabel =
        getGenerationStatus === "generating"
          ? "생성 화면으로 이동"
          : getGenerationStatus === "done"
          ? "생성 결과 보기"
          : getGenerationStatus === "error" && Boolean(getGenerationStatus)
          ? "생성 화면으로 이동"
          : "AI 글 생성하기";

    - 코드를 보면 세션 스토리지를 사용합니다. 

    - 여기서 문제가 발생하는데 세션 스토리지는 브라우저에만 존재합니다. 이게 왜 문제가 되는지 살펴보면

    서버에서 렌더링할 때 → sessionStorage 없음 → getGenerationStatus = null

    브라우저에서 Hydration할 때 → sessionStorage 있음 → getGenerationStatus = "generating" (등)

    서버 HTML이랑 클라이언트 HTML이 달라서 → 💥 Hydration 에러

    초기 서버 렌더링 시 sessionStorage가 없어 null로 렌더링되는데 Hydration 과정에서 클라이언트가 sessionStorage 값을 읽어 다른 값을 렌더링하기 때문에 불일치 에러가 발생합니다.

     

    - Next.js 공식문서에도 다음과 같이 나와있습니다. https://nextjs.org/docs/messages/react-hydration-error#common-causes

     

    Text content does not match server-rendered HTML

    Next.js by Vercel is the full-stack React framework for the web.

    nextjs.org

    - Hydration 오류가 발생할 경우중 3번 케이스가 제가 해당하는 케이스입니다.

    - 해결방법도 아래에 나와있습니다. 

    - useEffect가 가장 쉬워보이는데 함정이 있는데 초기설정 값이 나왔다가 다시 실제값이 넣어지는데 이로 인해 깜빡이는 것처럼 보이는 현상이 발생할 수 있습니다. 

    - 코드로 예시를 보면 

    - 아래 코드를 보면 처음 마운트 이후 한번만 값을 읽기 때문에 SessionStorage 값을 변경해도 UI가 자동으로 안바뀝니다.

    const [status, setStatus] = useState<string | null>(null);
    
    useEffect(() => {
      setStatus(sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY));
    }, []);

     

     

    다른 방법 뭐가 있을까? 

    useSyncExternalStore 도입

    - 문서 자료를 찾던중 useSyncExternalStore라는 리액트 훅을 발견했습니다. 
    - useSyncExternalStore는 React 18에서 추가된 훅으로 외부 스토어를 구독하기 위한 공식 API입니다.
    - useSyncExternalStore의 도입 이유는 외부스토어를 구독하고 있어 실시간으로 반영이 가능하기 때문입니다.
     
    - useSyncExternalStore는 인자를 다음과 같이 3개를 받습니다.
    const value = useSyncExternalStore(
      subscribe,          // 스토어 변경 감지 구독 함수
      getSnapshot,        // 클라이언트에서 현재 값 읽기
      getServerSnapshot   // 서버에서 현재 값 읽기 (SSR용)
    );

     

     

    subscribe

    스토어가 변경됐을 때 React에게 알려주는 함수로 React가 내부적으로 callback을 넘겨주고 변경이 생기면 그걸 호출하면 됩니다.

    () => {
      window.addEventListener('storage', callback);
      return () => window.removeEventListener('storage', callback);
    }

    getSnapshot

    클라이언트에서 현재 스토어 값을 읽는 함수로 매 렌더링마다 호출되고 이전 값이랑 다르면 리렌더링 트리거합니다.

    () => sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY)

    getServerSnapshot

    서버 렌더링할 때 사용할 값이고 sessionStorage처럼 서버에 없는 API면 null을 반환합니다.

    () => null

     

    프로젝트에 적용시켜보기

    - 현재 같은 탭 안에서 sessionStorage 변경을 UI에 반영을 해야하기 때문에 커스텀 이벤트로 직접 발행했습니다

     
    export const WRITE_GENERATION_STATUS_KEY = "self:write-generation-status";
    export const GENERATION_STATUS_CHANGE_EVENT = "write-generation-status-change";
    
    export function setGenerationStatus(status: GenerationStatus): void {
      sessionStorage.setItem(WRITE_GENERATION_STATUS_KEY, status); // ① 값 저장
      notifyGenerationStatusChange();                               // ② 알림
    }
    
    function notifyGenerationStatusChange(): void {
      window.dispatchEvent(new Event(GENERATION_STATUS_CHANGE_EVENT));
    }
    
    export function getGenerationStatus(): GenerationStatus | null {
      const raw = sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY);
      ...
    }

     

    - dispatchEvent로 상태변경을 알리고 useGenerationStatus가 subscribe를 통해 그 신호를 읽도록 구현했습니다.

    function subscribe(onStoreChange: () => void) {
      window.addEventListener(GENERATION_STATUS_CHANGE_EVENT, onStoreChange);
      return () => window.removeEventListener(GENERATION_STATUS_CHANGE_EVENT, onStoreChange);
    }
    
    function getSnapshot() {
      return getGenerationStatus(); // 클라이언트: sessionStorage 읽기
    }
    
    function getServerSnapshot() {
      return null; // 서버 + hydration 첫 렌더: 항상 null
    }
    
    export function useGenerationStatus() {
      return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
    }

     

    - 서버와 클라이언트 첫 렌더는 둘 다 null  "AI 글 생성하기"로 하였고 hydration 이후 getSnapshot()이 실제 값을 읽어 UI가 바뀝니다.

     

    마치며

    이번 에러를 통해 Hydration이 단순히 "서버 HTML에 이벤트 연결하는 것" 이상의 의미가 있다는 걸 체감했습니다.

    서버와 클라이언트의 렌더링 결과가 반드시 일치해야 한다는 제약이 있고 sessionStorage처럼 브라우저에만 존재하는 API를 아무 생각 없이 쓰면 바로 터진다라는 것을 깨달았습니다.. 🥹

    useEffect로 빠르게 해결할 수도 있었지만 이번엔 useSyncExternalStore를 써보면서 외부 스토어를 React답게 구독하는 방법도 익혔다. 에러 하나가 꽤 많은 걸 알려주는 좋은 계기가 된거같습니다.

Designed by Tistory.