[Next.js] Text content does not match server-rendered HTML 에러 해결

김방울·2024년 7월 7일
0

Next.js

목록 보기
7/7
post-thumbnail

Next.js 14 버전으로 개인 포트폴리오 사이트를 작업하던 중, 콘솔창에 4~5개의 에러가 뜨는 것을 확인했습니다. 🤔


에러 메세지에서 전체 메세지를 확인하기 위해서는 링크를 방문하라고 친절하게 안내해 주는데, 숫자는 다르지만 전체적으로 React Hydration Error 와 관련된 내용임을 확인할 수 있었습니다.

Hydration Error?

빈 HTML을 내려주고 자바스크립트를 이용해서 클라이언트 측에서 렌더링을 하는 React와 다르게, Next.js는 기본적으로 서버에서 페이지를 미리 렌더링(Pre-render)한 뒤 클라이언트 측에서 이 결과물을 다시 파싱하고 이벤트 리스너를 실제 DOM에 연결해 주는 작업을 거칩니다. (원래 있던 DOM 트리를 찾아 정해진 자바스크립트 속성들을 적용!🐳)
이 작업을 Hydration 이라고 하는데, 이때 서버에서 렌더링한 React 트리와 클라이언트 측에서 렌더링한 React 트리가 다르면 DOM과의 동기화에 실패할 수 있으며 의도하지 않은 컨텐츠가 화면에 표시될 수 있습니다. 이를 Hydration Error 를 통해 경고해 주는 것입니다.

원인/해결(공식 문서)

Next.js 공식 문서에 따르면, 일반적인 원인을 다음과 같이 설명하고 있었습니다.

  1. 잘못된 마크업 구성
  • p 태그가 다른 p 태그에 중첩됨
  • p 태그 안에 div 요소 사용
  • ul 또는 ol 태그에 li가 아닌 태그를 사용
  • a 태그에 a 태그가 중첩되거나, button 태그 안에 button 태그를 쓰는 등 대화형 컨텐츠가 중첩
  1. typeof window !== 'undefined' 와 같은 로직을 사용(window 객체는 브라우저에서만 사용할 수 있다!)
  2. 렌더링 로직에서 localStorage 와 같은 브라우저 전용 API 사용
    4.new Date() 와 같은 시간 종속 API 사용
  3. 잘못 구성된 CSS-In-JS 라이브러리(Styled-components, emotion과 같은)
  4. 브라우저 확장 프로그램/Cloudflare Auto Minify 등이 HTML을 수정하는 경우

또한 다음 솔루션들을 제시하였습니다.

useEffect() 를 사용하여 클라이언트 측에서만 렌더링

import { useState, useEffect } from 'react'
 
export default function App() {
  const [isClient, setIsClient] = useState(false)
 
  useEffect(() => {
    setIsClient(true)
  }, [])
 
  return <h1>{isClient ? 'This is never prerendered' : 'Prerendered'}</h1>
}

특정 구성 요소에서 SSR(서버 사이드 렌더링) 비활성화

import dynamic from 'next/dynamic'
 
const NoSSR = dynamic(() => import('../components/no-ssr'), { ssr: false })
 
export default function Page() {
  return (
    <div>
      <NoSSR />
    </div>
  )
}

suppressHydrationWarning 을 사용하여 경고 무시하기

<time datetime="2016-10-25" suppressHydrationWarning />

나의 해결법

이전에도 개발 과정에서 Hydration Error를 마주한 적이 있었는데, Styled-components 설정과 관련된 문제였고 로컬 개발 모드에서도 해당 에러를 확인할 수 있었기에 디버깅에 큰 어려움이 없었습니다.
이번 문제의 경우 특이하게도 로컬 개발모드에서는 에러가 출력되지 않아 어디서 문제가 발생한건지 파악이 힘들었고.. scss로 작업했기 때문에 CSS-in-JS의 문제도 아니었습니다.🤔 window 객체에 접근하는 로직도 useEffect 안에서 실행했기 때문에 해당 문제도 아닌 것으로 보였는데..
Next.js 공식 문서를 확인하고 나서 4.new Date() 와 같은 시간 종속 API 사용이 원인이라는 실마리를 잡았습니다.

저의 경우 nav 요소에서 현재 한국 시간을 표시하는 로직이 있었는데, 해당 로직에서 new Date() 를 사용하고 있었습니다.

// getKoreaTime.ts
// FUNCTION 한국 시각 반환
export const getKoreaTime = () => {
  const now = new Date();
  const utc = now.getTime() + now.getTimezoneOffset() * 60 * 1000;
  const koreaTimeDiff = 9 * 60 * 60 * 1000;
  const korNow = new Date(utc + koreaTimeDiff);
  const time = {
    hour: korNow.getHours(),
    min: korNow.getMinutes(),
    second: korNow.getSeconds(),
  };
  return time;
};
// useTime.ts
import { getKoreaTime } from '@/utils';
import { useEffect, useMemo, useRef, useState } from 'react';

export const getFormattedTime = (time: number) => {
  if (time < 10) {
    return `0${time}`;
  } else {
    return `${time}`;
  }
};
const useTime = () => {
  const timer = useRef<NodeJS.Timeout>();
  const [hour, setHour] = useState(getKoreaTime().hour);
  const [min, setMin] = useState(getKoreaTime().min);
  useEffect(() => {
    timer.current = setInterval(() => {
      setHour(getKoreaTime().hour);
      setMin(getKoreaTime().min);
    }, 1000);
    return () => clearInterval(timer.current);
  });
  const amPm = useMemo(() => {
    if (hour >= 12) return 'PM';
    else return 'AM';
  }, [hour]);
  const formattedHour = useMemo(() => (hour <= 12 ? hour : hour - 12), [hour]);
  return { hour, min, amPm, formattedHour };
};
export default useTime;

그리고, 로직을 커스텀 hook 형태로 불러와 사용하고 있었습니다.

// Header.tsx
'use client'
import useTime, { getFormattedTime } from '@/hooks/useTime';

const Header = () => {
  const time = useTime();
  
  ...
  
   <div className='header__clock pc-only'>
    Seoul, Korea
    <time dateTime={`${time.hour}:${time.min}`}>
	...

처음엔 단순히 useTime.ts 상단에 "use client"를 추가해 주면 해결될 줄 알았는데, 해당 방법으로는 해결되지 않았고

  const [hour, setHour] = useState(getKoreaTime().hour);
  const [min, setMin] = useState(getKoreaTime().min);

useState의 초기값으로 new Date() API에 의존하는 값이 들어가 있어서 오류가 발생하고 있었습니다.

// useTime.ts
const useTime = () => {
  const timer = useRef<NodeJS.Timeout>();
  const [hour, setHour] = useState<number>(0);
  const [min, setMin] = useState<number>(0);

  useEffect(() => {
    timer.current = setInterval(() => {
      setHour(getKoreaTime().hour);
      setMin(getKoreaTime().min);
    }, 1000);
    return () => clearInterval(timer.current);
  });

  const amPm = useMemo(() => {
    if (!hour) return 'AM';
    if (hour >= 12) return 'PM';
    else return 'AM';
  }, [hour]);

  const formattedHour = useMemo(() => {
    if (!hour) return 0;
    return hour <= 12 ? hour : hour - 12;
  }, [hour]);

  return { hour, min, amPm, formattedHour };
};

useTIme.ts의 초기값을 0으로 고정시켜 배포한 후, 콘솔창에서 Hydration Error가 사라진 것을 확인하였습니다!🎉
new Date() 뿐만이 아닌 Math.random() 등 반환값이 일정하지 않은 API를 사용할 경우에도 같은 문제가 발생할 수 있다고 하니, 클라이언트/브라우저에 의존하는 API를 사용할 때는 이 점을 유의하여야겠습니다.

참고자료

profile
코딩하는 고양이🐱 / UI Developer, Front-end Developer

3개의 댓글

comment-user-thumbnail
6일 전

안녕하세요! 질문할 수 있는 게시판이 없어서 여기다 남겨보는데.. 구글에서 포트폴리오 사이트를 보고 들어왔는데요. gsap scrollsmoother를 사용하신거 같은데 gsap에서 기본적으로 제공하는 기능 말고 결제해서 사용가능한(scrollsmoother)같은 기능을 사용해서 배포하실 때 번들링 해서 배포하셨나요? 아니면 혹시 다른 방법이 있었나요? 궁금해서 여쭤봅니다!

1개의 답글