[2024-02-09] 팀프로젝트 개인 회고 - SSG에서 코드블록 무시
1. 서버에서 렌더링한 것과 클라이언트에서 렌더링한 결과가 다르다고?
다크모드를 위해 다크모드 버튼을 만들면서 생긴 상황이다.
기존에 리액트에서 구현해서 그대로 사용하면 되지않을까? 했다.
"use client";
import { ComponentProps, useEffect, useState } from "react";
import Icon from "../Icon";
import { cn } from "@/utils/cn";
interface ThemeButton extends ComponentProps<"button"> {}
const ThemeButton = ({ className }: ThemeButton) => {
const isLocalStorageAvailable = typeof localStorage !== "undefined";
const isBrowser = typeof window !== "undefined";
const savedDarkMode =
isLocalStorageAvailable && localStorage.getItem("darkMode");
const windowDarkMode =
isBrowser && window.matchMedia("(prefers-color-scheme: dark)").matches;
const initialDarkMode =
savedDarkMode === null
? windowDarkMode
: savedDarkMode && JSON.parse(savedDarkMode) === "dark"
? true
: false;
const [dark, setDark] = useState(initialDarkMode);
const darkSetButton = () => {
setDark(!dark);
};
useEffect(() => {
if (dark) {
document.documentElement.classList.add("dark");
localStorage.setItem("darkMode", JSON.stringify("dark"));
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("darkMode", JSON.stringify("light"));
}
}, [dark]);
return (
<button
className={cn(
"z-10 rounded-full shadow-md dark:bg-black dark:fill-primary dark:shadow-gray-800",
className
)}
onClick={darkSetButton}
>
{dark ? <Icon id="dark" /> : <Icon id="light" />}
</button>
);
};
export default ThemeButton;
정말 간단한 코드다
간단하게 설명하면
localStorage에서 darkMode라는 키에 값을 가져와서 이전에 dark였는지 light였는지 확인하고,
값이 없다면 현재 윈도우의 테마가 다크테마인지 라이트테마인지 확인해서
dark라는 state값을 초기화 시켜주는 것이다.
그래서 다크모드버튼을 누를 때마다 setState로 바꿔주고,
해당되는 값을 다시 로컬스토리지에 저장하고,
<html> 요소에 "dark"라는 클래스를 추가하거나 제거하는 것이다.
dark라는 클래스를 통해 테일윈드로 다크모드일 때의 스타일링을 지정해줄 수 있다.
그런데 문제가 있다!
위 처럼 코드를 작성하고 실행하면 아래와 같은 오류가 뜬다
NextJS를 사용하면서 자주 보게될 것 같은 에러였다.
이 에러를 간단하게 설명하면
현재 서버에서 렌더링하는 Icon컴포넌트의 svg 경로와
클라이언트에서 렌더링하는 Icon 컴포넌트의 svg 경로가 다르다는 것이다.
처음에는 이해가 안갔는데 내가 작성한 코드에서 오류가 있었다.
아무리 상단에 "use client"를 작성했더라 하더라도 서버에서 먼저 한번 요소를 그리고,
그다음에 다시 클라이언트에서 그리는 것이다.
아래 코드를 자세히 보자
const isLocalStorageAvailable = typeof localStorage !== "undefined";
const isBrowser = typeof window !== "undefined";
const savedDarkMode =
isLocalStorageAvailable && localStorage.getItem("darkMode");
const initialDarkMode =
(savedDarkMode && JSON.parse(savedDarkMode) === "dark") ||
(isBrowser && window.matchMedia("(prefers-color-scheme: dark)").matches)
? true
: false;
const [dark, setDark] = useState(initialDarkMode);
컴포넌트가 호출되었을 때 수행하는 부분이다.
이 문제는 다시 정확한 원리를 알고 수정할 예정
일단 현재 윈도우 환경이 다크모드라고 가정한다.(window.matchMedia의 값이 true임)
이 코드의 흐름을 보면
처음에 서버사이드에서는 dark라는 state가 false다.
왜냐하면 isLocalStorageAvailable과 isBrowser로 인해 서버사이드에서 렌더링 될때 false가 되므로
dark는 initialDarkMode로 false값을 갖는다.
이후 클라이언트사이드에서는 dark라는 state가 true가된다.
isLocalStorageAvailable과 isBrowser가 클라이언트 사이드에서는 true값을 갖게된다.
그래서 dark의 initialDarkMode는 true 값을 갖게된다.
오류를 다시 보자
오류를 보면 서버와 클라이언트의 href가 같지않다는 것이다.
그래서 내가 추측한 문제로 인해 생긴 오류라고 판단되었다.
이 문제를 해결하기 위해서는 dark의 initialDarkMode를 서버 사이드와 클라이언트 사이드에서 렌더링 할 때 동일하게 해줘야 한다.
아래 코드로 해결했다.
"use client";
import { ComponentProps, useEffect, useState } from "react";
import Icon from "../Icon";
import { cn } from "@/utils/cn";
interface ThemeButton extends ComponentProps<"button"> {}
const ThemeButton = ({ className }: ThemeButton) => {
const [dark, setDark] = useState(false);
useEffect(() => {
const savedDarkMode = localStorage.getItem("darkMode");
const prefersDarkMode = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const initialDarkMode =
savedDarkMode === null
? prefersDarkMode
: JSON.parse(savedDarkMode) === "dark";
setDark(initialDarkMode);
}, []);
const toggleDarkMode = () => {
setDark((state) => {
const update = !state;
localStorage.setItem(
"darkMode",
JSON.stringify(update ? "dark" : "light")
);
return update;
});
};
useEffect(() => {
if (dark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}, [dark]);
return (
<button
className={cn(
"z-10 rounded-full shadow-md dark:bg-black dark:fill-primary dark:shadow-gray-800",
className
)}
onClick={darkSetButton}
>
{dark ? <Icon id="dark" /> : <Icon id="light" />}
</button>
);
};
export default ThemeButton;
처음에 dark의 상태를 동적으로 지정하는 게 아닌
initialState를 false로 고정하고,
이후 클라이언트 사이드 렌더링으로 갔을 때 useEffect로 값을 바꿔주는 것이다.
이때 문제는 처음에 화면에 뷰가 떴을 때 false로 고정되어있기 때문에
false 상태의 Icon 컴포넌트가 노출된다.
이 부분은 뒤에서 추가로 설명해보겠다.
2. 새로고침했을 때 클라이언트사이드에서 다크모드를 적용하게되니까 깜빡거려..
아까 위에서 말했듯이 클라이언트 사이드에서 다크모드 값을 적용하다보니 위와 같이 깜빡임이 생겼다.
false값이 서버사이드와 클라이언트 사이드에서 고정되었기 때문에 light 모드에서 -> dark모드로 가는 현상이다.
이 문제를 해결하기 위해서 즉시 실행 함수를 사용해서,
페이지가 로드 될때 즉시 실행함수를 실행시켜주어서 이 깜빡임 현상을 방지시켜줄 것이다.
이를 RootLayout에서 적용한다.
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import ThemeButton from "./_component/common/ThemeButton";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Hands Up",
description: "중고물품을 실제 경매장처럼 사고 팔 수 있다?!",
};
export const viewport: Viewport = {
themeColor: "yellow",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const themeInitializerScript = `(function() {
${setInitialColorMode.toString()}
setInitialColorMode();
})()
`;
function setInitialColorMode() {
function getInitialColorMode() {
const savedDarkMode = localStorage.getItem("darkMode");
const prefersDarkMode = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const initialDarkMode =
savedDarkMode === null
? prefersDarkMode
: JSON.parse(savedDarkMode) === "dark";
return initialDarkMode;
}
const currentColorMode = getInitialColorMode();
if (currentColorMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
return (
<html lang="en">
<body className={inter.className}>
<script
dangerouslySetInnerHTML={{
__html: themeInitializerScript,
}}
/>
<main className="relative mx-auto h-[100dvh] max-w-[360px] overscroll-y-none px">
{children}
<ThemeButton />
</main>
</body>
</html>
);
}
RootLayout에서 현재 운영체제의 테마 모드가 무엇인지와 로컬스토리지의 값이 있다면 무슨 값인지 검사하고,
html에 class를 추가하거나 제거한다.
이때 즉시 실행함수를 만들고 이를 dangerouslySetInnerHTML로 실행 시킨다.
왜 스크립트에 함수를 넣는건가요?
이 이유를 위해서는 렌더링 과정을 알 필요가 있다.
렌더링 과정에서 HTML 파일을 읽으면서 script태그를 파싱이 블로킹된다.
이를 통해서 한가지 알 수 있는 점이 하나 있다.
DOM트리가 생성되기 직전에 어떤 테마로 화면을 그려낼지 결정을 하면 되는 것이다.
*dangerouslySetInnerHTML란?
https://ko.legacy.reactjs.org/docs/dom-elements.html#:~:text=dangerouslySetInnerHTML,%EC%88%98%20%EC%9E%88%EA%B8%B0%20%EB%95%8C%EB%AC%B8%EC%97%90%20%EC%9C%84%ED%97%98%ED%95%A9%EB%8B%88%EB%8B%A4
const themeInitializerScript = `(function() {
${setInitialColorMode.toString()}
setInitialColorMode();
})()
`;
...
<script
dangerouslySetInnerHTML={{
__html: themeInitializerScript,
}}
/>
위처럼 함수를 만들어서 스크립트로 실행하면 깜빡임 거의 보이지않게된다.
-> *깜빡임이 조금 생기기는 함?? -> 이 문제를 다시 뒤에서 해결하겠다.
그런데 아래와 같은 오류가 생긴다.
이 오류는 현재 서버사이드의 html의 요소의 class값과 클라이언트 사이드에서의 html 요소의 class값이 서로 일치하지 않는다는 것을 의미한다.
왜 그런지 아래와 같이 실험을 해보았다.
아래 html요소에 className값에 what이라는 값을 넣어서 실행했다.
그랬더니 위처럼 Server에서는 what과 dark 값이 class에 들어가 있고,
Client에는 what 값만 들어가있다고 오류가 뜬다.
추측컨대
1. script 태그에 직접 즉시실행 함수를 넣어준다.
2. 서버는 HTML 파일을 파싱할 때 script태그를 만나서 블로킹된다.
2. 그래서 Server는 pre-redering에서 html요소를 읽을 때 스크립트 태그를 읽게 된다.
3. 그래서 Server는 html의 className에 dark가 생긴것이다.
근데 RootLayout.tsx는 서버 컴포넌트이고, 위에서 작성한 다크모드 설정 코드를 서버에서 실행 후에 적용하고,
이후 클라이언트 사이드에서 렌더링을 할 때 처음에는 html의 class가 비어있으므로 위 오류가 뜨게되는 것이다.
이때 아까 위에서말한 깜빡임이 조금 생기기는 함?? 부분을 해결할 수 있다.
위 과정에서 말했듯이 클라이언트 사이드에서 렌더링할 때 초기에 잠깐 흰색 배경이 생긴다.
위 과정을 알게 되면 이 깜빡임을 해결할 수 있게 된다.
일단 클라이언트 사이드부분을 다시보면 useState가 이상하다는 것을 알게된다.
const [dark, setDark] = useState(false);
useEffect(() => {
const savedDarkMode = localStorage.getItem("darkMode");
const prefersDarkMode = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const initialDarkMode =
savedDarkMode === null
? prefersDarkMode
: JSON.parse(savedDarkMode) === "dark";
setDark(initialDarkMode);
}, []);
이 부분을 보면 dark의 초기 값이 false가 되어있다.
왜 false를 했느냐는 아까 위에서 말해서 생략하겠다.
근데 하지만 이 부분때문에 렌더링 사이에 흰색배경을 보이게 하는 것이다.
그러므로 이 부분을 해결해야됐다.
나는 dark의 초기값을 useEffect에서 수행하는 함수로 넣기로 했다.
const [dark, setDark] = useState<boolean | undefined>(() => {
if (typeof window !== "undefined") {
const savedDarkMode = window.localStorage.getItem("darkMode");
const prefersDarkMode = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const initialDarkMode =
savedDarkMode === null
? prefersDarkMode
: JSON.parse(savedDarkMode) === "dark";
return initialDarkMode;
}
});
그럼 아까 위에서 말했듯이 아래 오류가 또 뜰 것이다.
이게 아까 위에서 말한 false 상태의 Icon 컴포넌트가 노출 부분이다.
근데 이제는 왜 이런 오류가 뜨는지 정확히 알게된다.
그러면 아래와 같이 해결이 가능하다.
const ThemeButton = ({ className }: ThemeButtonProps) => {
const { dark, toggleDarkMode } = useDarkMode();
const [isMount, setMount] = useState(false);
useEffect(() => {
setMount(true);
}, []);
if (!isMount) {
return null;
}
...
위 useDarkMode안에 dark의 초기값이 지정 될 때까지 ThemeButton을 출력하지않고 null을 반환하게 한다.
그럼 화면에서는 배경이 깜빡이는 대신에 버튼이 잠깐 깜빡일 것이다.
그리고 dark의 초기값이 정해지면 정상적으로 버튼이 출력되는 것이다.
간단히 말하면 배경이 깜빡이는 걸 버튼으로 옮긴 셈이다.
나중에 버튼에 스피너를 달면 더욱 UX에 좋을 것이라 생각된다.
3. 이제 깜빡이는 걸 해결 했으니 오류를 해결해보자
깜빡이는 것을 해결했지만 아직 아래오류는 해결하지 못했다.
우리는 html에 class를 두고있기 때문에 위에서 사용한 return null이나 useEffect를 사용하지 못한다.
그럼 어떻게해야할까?
구글링한 결과 많은 예상 문제가 있었다.
1. 확장프로그램( 특히 보고 있는 웹 페이지를 읽고 수정할 수 있는 확장 프로그램 )문제
2. 개발할 때만 생기는 문제니까 상관없다.
3. 시크릿모드로 개발하면 된다.
4. 해당 요소에 suppressHydrationWarning를 true로 설정
하지만 나는 문제를 추측할 수 있는 상황이었다.
class가 비어있는 문제를 해결해야했다.
일단 나는 4번째 방법을 선택했고, 바로 해결이 되었다.
하지만 4번째 방법은 일시적으로 오류를 안보이게하는 것이므로 본질적인 문제를 해결해야한다.
<html lang="en" suppressHydrationWarning={true}>
5. 개발 중에 새로고침을 하다가 계속 생기는 오류..
새로고침을 자주한다던가 하면 위처럼 오류가 갑자기 뜨면서 서버가 다운되고, 500에러가 뜬다.
이게 원래는 자주 안그러다가 이번에 즉시실행 함수를 script로 넣으면서 자주 생기게 되었다.
해결방법은
1. .next 폴더 삭제 후 다시 yarn dev
2. node_modules 삭제후 다시 yarn install
3. visual studio 껏다 키기
4. yarn cache clean
진짜 온갖 방법을 써가면서 했는데 갑자기 해결된다던지
이유없이 됐다가 다시 이유없이 오류가 떴다.
아직 왜 이런 오류가 뜨는지 모르고, 해결방법도 자세히 모른다.
위 해결 방법 다해도 안되는 경우가 있고, 되는 경우가 있다.
잠시 다른 거 하다가 다시 yarn dev로 키면 되는 경우도 있었다..
추가 해결+
내가 RootLayout에 있는 meta에 description을 적으면서 특수문자를 넣었었다!
그래서 그걸 지워줬더니 오류가 더이상 안나타나게 되었다!😂
그걸 괜히 넣어가지고..
다음부터는 특수문자나 한글은 되도록이면 확인하고 쓰려고한다..ㅠ
6. NextJS에서 지원하는 <Script>??
https://nextjs.org/docs/app/building-your-application/optimizing/scripts#layout-scripts
찾아보다가 Next에서 script 태그를 사용할 때 최적화를 도와주는 모듈이있어 사용해봤다.
이 스크립트는 애플리케이션의 경로에 액세스할 때 로드되고 실행됩니다 .
Next.js는 사용자가 여러 페이지 사이를 이동하더라도 스크립트가 한 번만 로드 되도록 보장합니다.
라고 한다.
strategy라는 속성도 지원한다.
- beforeInteractive: Next.js 코드 이전과 페이지 하이드레이션이 발생하기 전에 스크립트를 로드합니다.
- afterInteractive: ( 기본값 ) 스크립트를 일찍 로드하지만 페이지에 약간의 수화가 발생한 후에 로드합니다.
- lazyOnload: 나중에 브라우저 유휴 시간 동안 스크립트를 로드합니다.
- worker: (실험적) 웹 워커에 스크립트를 로드합니다.
<Script
id="set-darkMode"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: themeInitializerScript,
}}
/>
이렇게 바꿔봤다.
하지만 이걸 적용하니까 깜빡임 현상이 다시 생겼다.
완전 처음처럼 엄청 느린 깜빡임이 아닌 조금 빠른 깜빡임이긴하지만
이게 나은 것일지는 잘 모르겠다.
afterInteractive라는 속성도 지원해서 이걸 쓰면 아래 명령어를 안써도 되긴 했다.
suppressHydrationWarning={true}
나중에 이 다크모드 때문에 최적화가 필요하게 된다면 깜빡임을 감수하고 해당 모듈을 사용해야겠다.
📜참고자료
return null 방법과 Extra attributes from the server: 관련 예제
깜빡임 현상 해결 예제(script태그로 즉시실행함수 실행)
suppressHydrationWarning를 true로 설정
외국 커뮤니티 -suppressHydrationWarning를 true로 설정
https://www.reddit.com/r/nextjs/comments/138smpm/how_to_fix_extra_attributes_from_the_server_error/
외국 사이트 - 다양한 Warning: Extra attributes from the server [solved]
https://www.slingacademy.com/article/next-js-warning-extra-attributes-from-the-server/
서버 컴포넌트란?
https://html-jc.tistory.com/657