더 빠른 다크모드 전환을 위해
다크 모드는 어둠에 다크
블로그에 다크 모드와 라이트 모드를 전환하는 기능을 구현했습니다. 처음에 블로그를 만들면서는 생각하지 못한 기능이었는데, 다 구현하고 나서 아차 싶었던 기능이었습니다. 그렇게 어영부영 시간이 흘렀고, 한참 후에야 기능 추가를 하게 되었습니다.
리액트 리렌더링 최소화: Vanilla Extract w. React
모드 전환 자체는 쉬운 일입니다. CSS 팔레트 셋을 전부 바꾸면 되는 간단한 작업입니다. 문제는 이걸 하는 방법인데요, React의 상태를 이용해 클라이언트 사이드에서 동적으로 바꿀 수도 있지만 Vanilla-Extract의 이점을 이용하기 위해 createGlobalThemeContract
API 를 사용하기로 했습니다.
createGlobalThemeContract — vanilla-extract
https://vanilla-extract.style/documentation/global-api/create-global-theme-contract/
createGlobalThemeContract
는 css variable를 생성할 수 있는 기능을 제공합니다. 저는 블로그에 Notion API를 사용하고 있어서 거기에서 제공하고 있는 색상들에도 묶여 있는데요, 제가 블로그에서 따로 사용하고 있는 색상들과 Notion API에서 제공하는 색상들을 createGlobalThemeContract
를 이용해 아래와 같이 vars
라는 객체로 묶을 수 있습니다.
export const vars = createGlobalThemeContract({
color: {
b1: 'color-light-b1',
b2: 'color-light-b2',
b3: 'color-light-b3',
...
},
notion: {
blue: 'notion-blue',
blue_background: 'notion-blue-background',
brown: 'notion-brown',
brown_background: 'notion-brown-background',
...
},
});
또 createGlobalTheme
API를 통해 :root
같은 글로벌 테마로 아까 만든 contract 변수를 등록할 수 있는데요, 이렇게 등록한 contract는 --color-b1
꼴의 pure css로 등록되게 됩니다. 물론 다른 곳에서 사용할 때에는 vars.color.b1
처럼 일반 객체로 사용하면 됩니다.
createGlobalTheme('[data-theme="light"]', vars, {
color: {
b1: '#FFFFFF', //--color-b1
b2: '#F7F8FB',
b3: '#E6E6E7',
...
},
notion: {
blue: '#1b64da', //--notion-blue
blue_background: '#90c2ff',
...
},
});
createGlobalTheme('[data-theme="dark"]', vars, {
color: {
b1: '#202226',
b2: '#303236',
b3: '#404246',
...
},
notion: {
blue: '#4A90E2',
blue_background: '#A6D4FF',
...
},
});
저는 custom data attribute를 사용해 두 가지 팔레트를 등록하는 방식을 택했습니다. 이렇게 하면 최상단부에 Provider가 존재하더라도, 해당 Provider를 사용하는 Context가 Navigation 외에 존재하지 않기 때문에 React의 상태를 조작하는 컴포넌트 리렌더가 최소한으로만 일어납니다. 즉 Navigation 외의 다른 컴포넌트는 리렌더가 일어나지 않으면서 색상값이 변합니다. 이것이 Vanilla-Extract의 이점입니다.
이슈 해결: SSR(SSG) 대응
초기 모드 설정은 브라우저 및 시스템 설정을 따르고, 그 다음부터는 저장한 설정값을 따르는 기능을 구현했습니다. 따로 값이 없다면 라이트 모드를 사용하는데, 배포 환경에서만 hydration error가 발생했습니다. 원인으로는 아래와 같다고 판단했습니다.
서버에서 html 구조를 생성함
브라우저 설정에 따라 클라이언트에서
useLayoutEffect
훅 내부의 로직이 실행됨초기 렌더링이 완료된 후 1과 2를 비교함
hydration error 발생 (
Minified React Error #418
)
useEffect
로 변경하니 통상적인 화면 깜빡임만 발생하고 에러가 사라지기에, useIsomorphicLayoutEffect 구현을 통해 서버에서는 useEffect
, 클라이언트에서는 useLayoutEffect
를 사용하도록 변경해 해결했습니다. 즉 초기 렌더링 전에 변경된 DOM이 서버 렌더링 결과와 달라 발생한 hydration error인데, 실질적으로 두 훅 모두 서버에서 실행되는 것은 아니나 SSR되는 페이지에서 useEffect
가 사용되었을 때 에러를 출력하는 것으로 보입니다.
react-hooks/src/use-isomorphic-layout-effect at main · frfla/react-hooks · GitHub
https://github.com/frfla/react-hooks/tree/main/src/use-isomorphic-layout-effect
마무리


아이콘은 우측 상단에 있는 해와 달 이모지로 결정했습니다. 예전부터 좋아하던 이모지라 언젠가 한 번은 써먹고 말겠다는 일념으로… 아무튼 코드를 쓰는 것보다도 팔레트를 결정하는 게 더 심적으로 힘들었습니다. 코드블록 팔레트가 복병이었는데, GPT의 위대함을 깨달은 날이었던 것 같아요. 다른 테마를 하나 받을까 하다가 코드 중복 문제 때문에 GPT에게 맡겨 봤는데, 테마 배경에 잘 맞게 톤을 잘 맞춰 줘서 아주 만족스럽게 해결됐습니다.
간단해 보이는 기능 하나도 제대로 해 보려니까 품이 많이 든다는 걸 또 깨달았습니다. 코드 작업 자체는 하루만에 하긴 했지만 좀 진이 빠지는 느낌이었습니다(디자인 작업을 하니 체력이 많이 빠지더라고요). 오래 걸릴 줄 알았는데 생각보다 간단히 끝나서 허무하기도 하고 숙원 사업 하나를 끝내서 후련하기도 하네요. 블로그는 계속해서 Notion API를 유지해볼 생각입니다. 읽어주셔서 감사합니다.