Next.js 14 버전으로 개인 포트폴리오 사이트를 작업하던 중, 콘솔창에 4~5개의 에러가 뜨는 것을 확인했습니다. 🤔
에러 메세지에서 전체 메세지를 확인하기 위해서는 링크를 방문하라고 친절하게 안내해 주는데, 숫자는 다르지만 전체적으로 React Hydration Error
와 관련된 내용임을 확인할 수 있었습니다.
빈 HTML을 내려주고 자바스크립트를 이용해서 클라이언트 측에서 렌더링을 하는 React와 다르게, Next.js는 기본적으로 서버에서 페이지를 미리 렌더링(Pre-render)한 뒤 클라이언트 측에서 이 결과물을 다시 파싱하고 이벤트 리스너를 실제 DOM에 연결해 주는 작업을 거칩니다. (원래 있던 DOM 트리를 찾아 정해진 자바스크립트 속성들을 적용!🐳)
이 작업을 Hydration
이라고 하는데, 이때 서버에서 렌더링한 React 트리와 클라이언트 측에서 렌더링한 React 트리가 다르면 DOM과의 동기화에 실패할 수 있으며 의도하지 않은 컨텐츠가 화면에 표시될 수 있습니다. 이를 Hydration Error
를 통해 경고해 주는 것입니다.
Next.js 공식 문서에 따르면, 일반적인 원인을 다음과 같이 설명하고 있었습니다.
p
태그가 다른 p
태그에 중첩됨p
태그 안에 div
요소 사용ul
또는 ol
태그에 li
가 아닌 태그를 사용a
태그에 a
태그가 중첩되거나, button
태그 안에 button
태그를 쓰는 등 대화형 컨텐츠가 중첩typeof window !== 'undefined'
와 같은 로직을 사용(window 객체는 브라우저에서만 사용할 수 있다!)localStorage
와 같은 브라우저 전용 API 사용new Date()
와 같은 시간 종속 API 사용또한 다음 솔루션들을 제시하였습니다.
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>
}
import dynamic from 'next/dynamic'
const NoSSR = dynamic(() => import('../components/no-ssr'), { ssr: false })
export default function Page() {
return (
<div>
<NoSSR />
</div>
)
}
<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를 사용할 때는 이 점을 유의하여야겠습니다.
안녕하세요! 질문할 수 있는 게시판이 없어서 여기다 남겨보는데.. 구글에서 포트폴리오 사이트를 보고 들어왔는데요. gsap scrollsmoother를 사용하신거 같은데 gsap에서 기본적으로 제공하는 기능 말고 결제해서 사용가능한(scrollsmoother)같은 기능을 사용해서 배포하실 때 번들링 해서 배포하셨나요? 아니면 혹시 다른 방법이 있었나요? 궁금해서 여쭤봅니다!