SSG를 이용한 블로그에서 테마 변경하기
개발 블로그 개발 여정
nextjsdesignreact
Sat Jun 15 2024
이 글은 개발 블로그 개발 여정의 게시글이예요
해당 포스트는 NextJS를 이용하여 개발 블로그를 만들며 작성한 포스트입니다.
기술 블로그의 전체 코드는 🪢 yonglog github 에서 확인 하실 수 있습니다.
- 1 . 개발블로그 제작을 시작하며
- 2 . CI/CD 파이프라인 구성하기
- 3 . tailwind 환경 설정 및 디자인 레퍼런스 찾기
- 4 . / 경로 레이아웃 , 페이지 디자인 생성하기
- 5 . [BUG] Build 시 발생하는 typeError 해결하기
- 6 . MDX를 사용하기 위한 라이브러리들 설치 및 환경 설정
- 7 . Post들이 저장된 FileSystem 파싱하기
- 8 . PageNavigation UI 만들기
- 9 . tag , series Identifier 만들기
- 10 . / 경로 UI 디자인 하기
- 11 . 서버 컴포넌트에 Dynamic Routing 추가하기
- 12 . Post/page.tsx 생성하기
- 13 . 마크다운 파일 코드블록 꾸미기
- 14 . [BUG] Vercel 에 환경 변수 추가하기
- 15 . Loading Suspense 구현하기
- 16 . generateStaticParams 이용해 SSR 에서 SSG로 넘어가자
- 17 . SSG를 이용한 블로그에서 테마 변경하기
- 18 . 인터렉티브한 사이드바 만들기
- 19 . 기술블로그에 giscus를 이용하여 댓글 기능을 추가하기
- 20 . 기술 블로그의 SEO를 최적화 하기 위한 방법 Part1
- 21 . 기술 블로그의 SEO를 최적화 하기 위한 방법 Part2
- 22 . 바닐라 자바스크립트로 깃허브 OAuth 를 구현해보자
- 23 . 라이브러리 없이 깃허브 API를 이용해 댓글창을 구현해보자
- 24 . 리팩토링 : Promise 패턴을 이용하여 비동기 처리중인 전역 객체에 접근하기
- 25 . NextJS로 만든 기술블로그에서 검색 기능 구현하기
기술 블로그들에 항상 존재하는 다크모드 테마 기능을 추가해보려고 한다.
사실 요즘 같은 시대에 다크모드가 존재하지 않는 페이지를 보기 힘들 정도로 UX
관점에서 다크모드는 중요한 역할을 한다.
그래서 ! 나도 다크모드를 생성해보려고 한다.
기본 아이디어
Context는 이용하고 싶지 않았다.
사실 다크모드를 구현하는 가장 쉬운 방법은 모든 컴포넌트를 클라이언트 컴포넌트로 생성해두고 가장 최상단에 theme
과 관련 state
를 생성해주는 것이다.
이후 해당 theme state
값에 따라서 body
에 클래스 이름을 다르게 하거나, 사용 할 컴포넌트들에 theme state
값을 이용한 스타일링 값들을 넣어주는 것일 것이다.
하지만 ThemeProvider
를 사용하면 하위에 존재하는 모든 컴포넌트가 Client Component
로 변경되어 Client Boundary
가 너무 커져버리게 된다.
나는 내부 페이지들을 초기 접근 속도를 빠르게 하기 위해 서버 컴포넌트의 SSG
형태를 이용하는데 Client Boundary
내부에 들어가게 되면 SSG
를 이용하지 못하게 된다.
그래서 다른 방법을 찾아야 했다.
어떻게 할까 ? : CSS 를 활용하자
다크모드와 라이트 모드일 때 변경이 되어야 하는 스타일 속성들을 global.css
에서 처리해주기로 하였다.
테마에 영향을 받는 스타일 속성들을 css variable
형태로 생성해두고 테마가 변경되면 css variable
값들을 변경해주기로 했다.
어차피 css variable
값이 변경되면 해당 css variable
을 참조하는 값들이 Actual DOM
의 업데이트 과정에서 repaint
될 것이기 때문에 Virtual DOM
의 client boundary
를 넓히지 않고도 화면을 변경하는 것이 가능 할 것이다.
1. CSS variable 생성
다음과 같이 :root , [data-theme='dark']
에 대한 css variable
들을 생성해줬다.
해당 css variable
은 최상단 태그인 html
의 어트리뷰트 값에 따라 변경된다.
dark theme
:[data-theme='dark'] css variable
light theme
::root css variable
이후 테마에 영향받을 태그들을 생성한 css variable
등을 이용해 스타일링 해준다.
영향 받을 컴포넌트들에 알아보기 쉽게 어트리뷰트들을 넣어줬다. 클래스명으로 넣어줘도 상관 없지만 난 클래스명엔 tailwind
를 이용하고 있기 때문에 어트리뷰트로 넣어줬다.
2. 클라이언트 컴포넌트 생성
최상단의 Global Nav 컴포넌트
최상단 우측을 보면 테마 버튼으로 사용하기 위해 만들어뒀던 컴포넌트가 있다.
해당 컴포넌트는 현재 단순한 li,svg
태그로 생성해뒀지만 클라이언트 컴포넌트로 생성하여 여러 로직들을 추가해주도록 하자
상태값에 따른 ThemeButton 의 반환 값
해당 컴포넌트는 theme state
값에 따라 해와 달 모양을 반환한다.
클라이언트 데이터와 동기화
초기 클라이언트 단에서 하이드레이션시에는 useLayoutEffect
를 이용하여 localStorage
에 존재하는 값에 맞춰 동기화 한다.
useLayoutEffect
를 사용한 이유useLayoutEffect
는useEffect
와 비슷한 역할을 하지만Actual DOM
의layout
,repaint
과정 전에 발생한다.이를 통해 최대한 불필요한
Actual DOM update
를 방지해줄 수 있다.
useEffect
를 사용했을 경우리렌더링 -> Actual DOM update -> useEffect -> Actual DOM update
useLayoutEffect
를 사용했을 경우리렌더링 -> useLayoutEffect -> Actual DOM update
하지만 이 과정에서 이런 의문이 들 수 있다.
그럼 애초에 useState
부분에서 lazy initalize
를 이용해 localStorage
에 접근하면 되는거 아닌가?
나도 처음엔 그렇게 생각했다.
그래서 해당 부분을 처음엔 다음처럼 설계했었다.
client component
는 서버 단에서도 한 번 렌더링 되기 때문에 서버 단에서 렌더링 되는 경우엔 light
로 클라이언트 단에서 렌더링 될 땐 localStorage.getItem('theme') or light
로 설계했었다.
하지만 이렇게 하게 되면 문제가 발생한다.
서버에서 렌더링 한 ThemeButton과 Hydration 된 ThemeButton이 일치하지 않는다.
서버 단에서 렌더링 된 ThemeButton
과 클라이언트 단에서 hydration
된 ThemeButton
이 일치하지 않으면 문제가 발생 할 수 있기 때문에 NextJS
에선 오류를 발생시킨다.
이로 인해 두 다른 환경에서 렌더링 된 컴포넌트를 일치 시켜주기 위해 light
로 초기에 렌더링 한 후 useLayoutEffect
를 이용해 동기화 시켜주는 방법을 선택했다.
테마 버튼을 클릭하면 변경되는 일들
해당 테마 버튼을 누르면 다음과 같은 일들이 발생한다.
setTheme(nextTheme)
해당 부분은ThemeButton
이 반환하는 값에만 영향을 미치며 해당 함수의 결과 값은 다른 컴포넌트들에 영향을 미치지 않는다.localStorage.setItem('theme' , nextTheme)
해당 부분은 재방문시의 동기화를 위해localStorage
의 값을 변경한다.setTheme(nextTheme)
으로ThemeButton
의 상태가 변경되면 위에서 설정했던useLayoutEffect
가 호출되어Actual DOM
의 어트리뷰트를 변경한다.
사실 Actual DOM
을 직접적으로 조작하는 행위는 리액트에서 모범적인 정답은 아니다.
모범적인 정답은 리액트 내부에 존재하는 state
값들을 이용해 Virtual DOM
을 조작하고 변경된 Virtual DOM
과 Actual DOM
을 동기화 하는 것일 것이다.
하지만 위 방법을 사용하면 위에서 말했듯 Client Boundary
가 넓어져 SSG
의 장점을 사용하지 못하기 때문에 Actual DOM
을 직접적으로 조작하는 방법을 택했다.
ThemeButton이 열일하는 모습
테마 버튼을 클릭하면 html
의 어트리뷰트가 변경되어 이전에 설정해둔 css variable
값들이 변경되고
해당 태그들이 repaint
되는 모습을 볼 수 있다.
3. 초기 테마가 깜박이는 현상 해결하기
ThemeButton
을 클릭하면 html
의 어트리뷰트가 변경되는 잘 해결되었다.
하지만 여전히 문제는 존재한다.
동기화 되기 전까지의 플리커링 현상
localStorage
의 값이 dark
인 상태로 재접속 했을 때
ThemeButton
이 hydration => useLayoutEffect
로 동기화 되기 전까지 html
의 어트리뷰트는 존재하지 않기 때문에 플리커링 현상이 발생했다.
이러한 이유는 localStorage
에 존재하는 값에 접근하는 행위가 ThemeButton
의 hydration
이후에 발생했기 때문이다.
dangerouslySetInnerHTML를 이용하자
그래서! 초기에 서버에서 렌더링 되어 보내지는 첫 페이지를 클라이언트 단에서 hydration
될 때부터
localStorage
에 접근 할 수 있도록 /layout.tsx
을 수정해주었다.
다음과 같이 layout.tsx
에서 html
태그 하단에 script
태그를 넣어주게 되면
클라이언트 단에서 index.html
파일을 파싱하던 과정에서 script
태그를 만나 parsing
과정이 block
되고 script
태그를 실행하게 된다.
다음처럼 작성하게 되면 클라이언트가 받는 html
파일은 다음과 같이 생겼다.
이를 통해 클라이언트가 처음으로 보게 되는 페이지에선 localStorage
에 접근하여 이전에 설정한 테마와 동기화 된 페이지를 보게 되기 때문에 플리커링 현상 없이 동기화가 가능하다.
플리커링이 발생하지 않는 모습
그렇다면 useLayoutEffect로 동기화 하는 과정을 리팩토링 하자
초기 동기화를 위해 useLayoutEffect
로 theme
가 변경될 때 마다 html
의 어트리뷰트를 바꿔주던 부분을 handleTheme
로 옮겨주었다.
handleTheme
이 호출될 때 마다 변경되는 Effect
는 Effect
를 잘못 쓰는 대표적인 안티패턴이기 때문이다.
굿~~~