짧은 지식으로 인해 글의 신뢰성이 높지 않습니다. 정확한 내용은 공식문서를 참고해 주세요.
목차)
1. 서론
2. Nextjs hydration 에러와 해결
3. 결론
1. 서론
다크모드를 구현하며, 실시간 시계를 추가하며 두번의 hydration에러를 만났고, 해결한 과정에 대한 글입니다.
hydration은 무엇일까?
수화라는 뜻으로, HTML에, 이벤트를 부착하고 리액트, js코드를 실행해서유저와 interaction 가능한 코드로 만드는 과정이다.
조금 더 자세히 말하면,
클라이언트에서 서버에서 전달된 HTML과 가상 DOM을 비교(recolciliation)하고 DOM을 업데이트하는 과정.
이고 이때 서버에서 전송된 HTML과 클라이언트에서 생성된 가상DOM구조가 일치하지 않을 경우 하이드레이션 에러가 발생한다.
이번 글은 next.js의 하이드레이션 에러를 해결한 과정 대해 다루었다.
2. Nextjs hydration 에러
하이드레이션에러가 발생하는 원인은 여러가지 있지만 나의 경우, 실시간 시간을 다룰 때와 localStorage에서 가져온 값에 의존해 조건부 렌더링을 할 때 발생했다.
hydration 에러가 발생하는 대표적인 경우가 궁금한 경우: 아래 레퍼런스에 첨부한 화해 블로그를
hydration에 대해 자세히 다룬 글이 궁금한 경우: 아래 레퍼런스에 첨부한 medium, joshwcomeau의 블로그글을
읽어보시기를 추천드린다.
나는 나의 경우에 대해만 간단히 다룰텐데,
서버에서는 알 수 없는 값(다크모드/라이트모드)에 따라 조건부 렌더링을 해서 하이드레이션 에러가 발생했다.
아래 코드는 처음에는 문제가 없다고 생각했다. 하지만 서버의 입장을 충분히 이해하지 못한거였다.
현재 다크모드인 상황을 가정해보자.
서버에서 useState가 실행되면 isDarkMode는 초기값인 false로 설정된다.
클라이언트에서 useState가 실행되면, 로컬 스토리지 값을 읽고 다크모드 상태니까 isDarkMode가 true로 설정된다.
따라서 서버에서는 다크모드버튼이, 클라이언트에서는 라이트모드 버튼이 렌더링 되고
이 HTML의 불일치가 하이드레이션 에러를 발생시킨다.
'use client'
export default function Nav() {
// 로컬 스토리지에 저장된 값이 없을 때 기본값을 false로 설정
const [isDarkMode, setIsDarkMode] = useState(() => {
if (typeof window !== 'undefined') {
const savedMode = localStorage.getItem('darkMode')
return savedMode === 'true' ? true : false // 기본값은 라이트 모드 (false)
}
return false
})
// 다크 모드 상태를 로컬 스토리지와 동기화하는 함수
const toggleDarkMode = useCallback(() => {
setIsDarkMode((prev) => {
const newMode = !prev
localStorage.setItem('darkMode', newMode ? 'true' : 'false')
return newMode
})
}, [])
// 클라이언트에서만 다크 모드 상태를 가져오기
useEffect(() => {
const savedMode = localStorage.getItem('darkMode') === 'true'
setIsDarkMode(savedMode)
}, [])
return (
<NavbarContent justify="end" className="gap-0.5">
<ClientOnly>
<NavbarItem>
<Button
onClick={toggleDarkMode}
className="bg-transparent"
isIconOnly
>
{isDarkMode ? (
<Lightmode width="20" height="20" />
) : (
<Darkmode width="20" height="20" />
)}
</Button>
</NavbarItem>
</ClientOnly>
</NavbarContent>
)
}
어떻게 해결 할 수 있을까?
서버에서 알 수 없는 값에 따라 조건부 렌더링을 해서 문제가 발생했으니,
서버에서는 렌더링 하지 말고, 클라이언트에서만 렌더링 하도록 해주면 된다.
가장 간단하고 확실한 방법으로는 하이드레이션 이후에, 즉 컴포넌트 마운트 후에 render해주는 방법이 있다.
useEffect를 통해 마운트 여부를 체크하는 방법이다.
'use client'
import { useState, useEffect, useCallback } from 'react'
export default function Nav() {
const [isDarkMode, setIsDarkMode] = useState(false) // 초기값은 false
const [isMounted, setIsMounted] = useState(false) // 마운트 여부 체크
// 클라이언트에서만 다크 모드 상태를 가져오기
useEffect(() => {
setIsMounted(true) // 컴포넌트가 마운트되었음을 표시
const savedMode = localStorage.getItem('darkMode') === 'true'
setIsDarkMode(savedMode)
}, [])
// 다크 모드 상태를 로컬 스토리지와 동기화하는 함수
const toggleDarkMode = useCallback(() => {
setIsDarkMode((prev) => {
const newMode = !prev
localStorage.setItem('darkMode', newMode ? 'true' : 'false')
return newMode
})
}, [])
if (!isMounted) {
// 컴포넌트가 마운트되기 전에는 아무것도 렌더링하지 않음
return null
}
return (
<NavbarContent justify="end" className="gap-0.5">
<NavbarItem>
<Button
onClick={toggleDarkMode}
className="bg-transparent"
isIconOnly
>
{isDarkMode ? (
<Lightmode width="20" height="20" />
) : (
<Darkmode width="20" height="20" />
)}
</Button>
</NavbarItem>
</NavbarContent>
)
}
문제는 해결되었다.
하지만 조금 아쉽다. 나는 서버에서 모르는 값만 클라이언트에서 렌더링 해주면 되는데,
만약 NavContent안에 여러 개의 NavbarItem이 있다면, 모든 Nav가 클라이언트에서 렌더링 되기 전까지 빈 화면으로 보일 것이다.
여러 컴포넌트에서 isMounted를 체크하는 코드가 중복되고,
화면에는 불필요하게 많은 요소들이 클라이언트에서 렌더링 되기를 기다려야 한다.
더 나은 방법이 있을까?
custom훅을 만들어서 children으로 클라이언트에서만 렌더링 할 요소를 전달하는 방법이 있다.
(아래 레퍼런스의 미디엄 글에서 소개된 방법)
'use client';
import React, { useState, useEffect } from 'react';
/**
* ClientOnly 컴포넌트는 서버 측에서 렌더링되지 않고,
* 오직 클라이언트 측에서만 렌더링.
* 컴포넌트가 마운트된 이후에만 자식 요소를 렌더링하므로,
* 서버와 클라이언트의 렌더링 불일치를 방지할 수 있다.
*
* @param {React.ReactNode} children - 클라이언트에서만 렌더링할 자식 요소들
*
* @returns {JSX.Element | null} - 마운트 이후 자식 요소를 감싸는 div를 반환하고,
* 마운트되기 전에는 null을 반환하여 아무것도 렌더링하지 않음
*/
export default function ClientOnly({ children }: { children: React.ReactNode }) {
// 컴포넌트가 마운트되었는지를 확인하는 상태
const [hasMounted, setHasMounted] = useState(false);
// 컴포넌트가 처음 마운트될 때 hasMounted를 true로 설정
useEffect(() => {
setHasMounted(true);
}, [])
// 컴포넌트가 마운트되기 전에는 null을 반환하여 아무것도 렌더링하지 않음
if (!hasMounted) return null;
// 마운트 후 자식 요소를 렌더링
return (
<div>
{children}
</div>
);
};
이제 간단히 ClientOnly컴포넌트로 아이콘을 감싸주면 끝이다!
<ClientOnly>
<NavbarItem>
<Button
onClick={toggleDarkMode}
className="bg-transparent"
isIconOnly
>
{isDarkMode ? (
<Lightmode width="20" height="20" />
) : (
<Darkmode width="20" height="20" />
)}
</Button>
</NavbarItem>
</ClientOnly>
4. 결론
클라이언트의 몫은 클라이언트에게.
레퍼런스로 첨부한 글 중 마지막 글인 joshwcomeau블로그에서 아주 인상 깊던 부분이 있었다.
알록달록 예쁜 시리얼박스랑 그 위의 유통기한이 적힌 작은 하얀 태그 부분이 있는 사진이었다.
시리얼박스를 만드는 공장에서는 이미지를 찍어 낸다. 그 시리얼박스의 이미 정해진 이미지가 있기 때문이다.
반면에 유통기한은 박스를 찍는 시점에서는 알 수 없다.
시리얼이 담기는 시기와 밀접한 정보이기 때문이다.
이처럼 서버에서는 알 수 없는 정보들은 클라이언트로 넘겨주는 방식으로 하이드레이션 에러를 해결할 수 있었다.
하이드레이션에러를 겪으며, 무심코 넘기던 서버사이드 렌더링에 대해 다시 한번 생각해보게 되었다.
레퍼런스:
하이드레이션 미스매치의 다양한 예제와 자세한 설명:https://blog.hwahae.co.kr/all/tech/13604
React의 hydration mismatch 알아보기 – 화해 블로그 | 기술 블로그
React의 hydration mismatch 알아보기 화해는 에러 트래킹 서비스인 Sentry를 사용하고 있는데요. 지난해 Next.js의 static export로 배포한 이벤트 페이지에서 hydration mismatch 에러가 쌓이기 시작했습니다.
blog-wp.hwahae.co.kr
하이드레이션에러에 대해 역시 깊이 있게 다루고 있는 블로그 글들:
Understanding Hydration Errors In NextJS 13 With A Web3 Wallet Connection
How To Fix Hydration Errors In NextJS 13 With A Frontend WAGMI Wallet Connection
codingwithmanny.medium.com
https://www.joshwcomeau.com/react/the-perils-of-rehydration/
The Perils of Hydration: Understanding how server-side rendering really works in React • Josh W. Comeau
A surprisingly-common misconception can lead to big rendering issues that are difficult to debug. This deep-dive tutorial examines how React and Gatsby can be used to pre-render content, and how we can work around the constraints to build dynamic, personal
www.joshwcomeau.com
'next.js' 카테고리의 다른 글
| Zod로 런타임에서 데이터 무결성을 확보한 경험 (0) | 2026.02.03 |
|---|---|
| Nextjs 미들웨어 토큰 유효성 검증, 미들웨어 런타임 (0) | 2024.09.22 |
| API Route에서 jwt 토큰 유효성검증, next/headers cookies 함수 (0) | 2024.09.10 |
| NextAuth.js, Lucia auth, 토큰과 세션의 특징, 라이브러리 선택 시 유의할 점 (0) | 2024.09.05 |
| vercel 배포 느림 해결 (vercel region 지역 설정) (0) | 2024.08.27 |