다크 모드는 2020년의 트렌드였고, 2022년인 지금, 너무나도 당연해져서 없으면 사용자가 많이 불편해 하죠. 다크 모드는 벨로그에도 정말 필요했는데요, 미루고 미루다가 드디어 이번 설날 연휴를 맞이하여 이를 적용해보려고 합니다.
디자이너가 따로 없고, 다크 모드를 고려하지 않고 기획했던 서비스에서 다크 모드가 도입되는 과정을 여러분들께 이야기해드릴게요. 이 포스트는 적용 후의 회고가 아니라 적용하는 과정에서 생각을 정리하면서 작성하고 있답니다. 그럼, 출발해볼까요!
물론 기획도 중요하고 다크 테마를 위한 팔레트도 중요하지만, 프로젝트에 다크 테마 도입을 위한 기술적인 준비부터 시작을 하는게 좋겠다고 생각했습니다. 기술적으로 준비가 되어야 개발 공수에 대한 파악도 되고 작업 속도를 가속화할 수 있을 것이라 생각을 했거든요.
벨로그는 리액트를 사용중이고 스타일 시스템은 Styled Components를 사용중입니다. 이 프로젝트에서 다크 모드를 도입하려면 2가지 방법이 있습니다.
ThemeProvider
사용하기첫 번째 방법은 Styled Components 에서 제공하는 ThemeProvider
를 사용하는 것입니다. (참고 링크)
이를 사용하는 경우엔 다음과 같이 theme
객체를 선언하고
export const lightTheme = {
body: 'white',
text: 'black'
};
export const darkTheme = {
body: 'black',
text: 'white'
};
theme
상태를 리액트 상태로 관리하며, ThemeProvider
의 theme
Prop을 설정해줍니다.
import { ThemeProvider } from 'styled-components'
import { useState } from 'react'
import { lightTheme, darkTheme } from './themes'
function App() {
const [theme, setTheme] = useState('light')
const isDarkMode = theme === 'dark'
const toggle = setTheme(isDarkMode ? 'light' : 'dark')
return (
<ThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
<div>Current Mode: {theme}</div>
<button onClick={toggle}>Toggle</button>
</ThemeProvider>
)
}
그 다음에 이런식으로 테마의 색상을 사용하면 되겠죠.
import { createGlobalStyle } from 'styled-components';
export const GlobalStyles = createGlobalStyle`
body {
background: ${({theme}) => theme.body};
color: ${({theme}) => theme.text};
}
`
첫 번째로 드는 생각은 SSR과 hydration을 어떻게 할 까? 입니다. 다크 모드의 상태가 리액트 상태로 관리가 되고 있고 이 상태에 따라 스타일이 결정되기 때문에 처음 방문한 사용자의 경우엔 초반에 다크 모드로 보여줄지 라이트 모드로 보여줄지 서버 단에서 알 방법이 없습니다. 그래서 아마 SSR 지원을 하려면, 초반에는 라이트 모드로 렌더링을 하고, 사용자 브라우저에서 JS로 상태를 확인하여 쿠키에 넣어줘야겠군요. 그리고 추후 서버에서 렌더링을 해줄 때 쿠키가 있다면 그 상태를 다크 모드의 초깃값으로 사용하면 되겠습니다.
이렇게 구현한다면 시스템 다크 모드가 설정되어있는 사용자가 구글 검색을 하다가 벨로그에 처음 들어오면 한번 라이트 모드로 보여졌다가 JS 로딩 후 다크 모드로 보여주기 때문에 화면이 한번 깜빡이는 느낌이 들 것 같아요.
그런 깜빡임 현상을 방지하려면 다크 모드의 활성화를 오직 사용자 인터랙션을 통해서만 활성화 할수 있도록 설계해야 되겠군요.
벨로그는 SSR을 사용하고 있고, 벨로그의 많은 사용자들이 구글을 통해서 유입되기 때문에, 이 방법은 이상적이진 않다는 생각이 들어서 이제 다른 방법을 고려해보려고 합니다.
만약에 CSR위주로 작동하는 웹 서비스고, 구글 유입이 별로 없는 곳이라면 이 방식도 나쁘진 않겠다는 생각이 듭니다.
2번째 방법은 CSS Variable을 사용하는 것입니다. 웹 프런트엔드 개발을 전문으로 몇년동한 하며 먹고 살고 있긴 하지만,, 사실 이 기술의 존재는 알고 있었지만 실무에서 쓸 일이 없었어요. 과연.. 이번에 드디어 사용해보게 될지도? 모르겠습니다.
우선, CSS Variable은 모던 웹 브라우저에서 지원되는 기술로, CSS 단에서 변수를 사용할 수 있게 해준답니다.
Can I Use에서 브라우저 지원 현황이 어떤지 확인해 볼까요?
벨로그는 멋진 개발자들이 사용하는 곳이니까, IE 사용자는 고려하지 않습니다. 사실 2018년에 연세대 공학원에 있던 프린터실에서 IE11로 벨로그가 잘 보여지나 한번 사용해 본 이후로 잘 보여지는지 테스트 하고 있지도 않아요. 요새 어떻게 보여지는지도 잘 모르겠네요. macOS를 위주로 쓰다보니 확인해보기도 귀찮습니다.
CSS Variable을 어떻게 쓰는지 한번 알아볼까요? 우선, 기본적인 사용법은 다음과 같아요.
:root {
--bg-color: black;
--size: 64px;
}
.box {
background: var(--bg-color);
width: var(--size);
height: var(--size);
}
<div class="box"></div>
이렇게 하면 .box
클래스를 가진 div
엘리먼트는 검정색 사각형 스타일을 갖게 됩니다.
그럼 이 기능을 사용하여 다크 모드를 구현한다면 어떻게 해야될까요? 이런식으로 body
의 data-theme
속성을 설정하여 테마를 정할 수 있습니다.
body[data-theme='light'] {
--color-text: black;
--color-background: white;
}
body[data-theme='dark'] {
--color-text: white;
--color-background: black;
}
body {
color: var(--color-text);
background: var(--color-background);
}
그리고, 테마를 변경할땐 다음과 같이 body
의 속성을 변경합니다.
document.body.dataset.theme = 'dark'
위 Codepen 예시에서는 초기 테마를 JS를 사용하여 light
로 지정을 하고 있는데요, 초기 테마를 시스템 설정으로 따라가게 하려면 어떻게 해야 할지 알아봅시다.
초기 테마를 시스템의 테마 설정을 따라가려면 prefers-color-scheme
미디어 쿼리를 사용하면 된다고 해요. (참고 링크)
이 미디어쿼리는 다음과 같이 사용합니다.
@media (prefers-color-scheme: light) {
body {
--color-text: black;
--color-background: white;
}
}
@media (prefers-color-scheme: dark) {
body {
--color-text: white;
--color-background: black;
}
}
그런데 prefers-color-scheme
의 값은 light
, dark
외에도 no-preference
값도 있다고 하네요. 이 값은 시스템에 선호하는 테마가 없음을 의미합니다.
따라서, 위 예시처럼 light
와 dark
를 위한 미디어 쿼리를 각각 사용하는 것 보다는, light
를 위한 CSS Variable은 기본으로 선언해두고 dark
를 위한 미디어 쿼리를 작성하는게 더욱 안전합니다.
이제 시스템 테마를 따라가는 코드를 작성해보았는데요, 이 방식은 테마를 토글하는것이 좀 번거롭습니다. 미디어 쿼리를 사용하는 경우엔 중복 코드를 좀 작성해야 토글을 할 수 있습니다.
body {
--color-text: black;
--color-background: white;
}
@media (prefers-color-scheme: dark) {
body {
--color-text: white;
--color-background: black;
}
}
body[data-theme='light'] {
--color-text: black;
--color-background: white;
}
body[data-theme='dark'] {
--color-text: white;
--color-background: black;
}
body {
color: var(--color-text);
background: var(--color-background);
}
이렇게 이전 예시에서 작성했던 data-theme
속성을 사용하는 셀렉터를 넣어두고, JavaScript 단에서 스타일을 덮어쓰도록 하는거죠. 단, JavaScript 단에서도 정상적으로 테마를 토글하려면 초기 테마가 무엇이였는지 알 필요가 있겠지요? 이는 다음 코드를 통해서 조회할 수 있습니다.
systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
let mode = systemPrefersDark ? 'dark ': 'light';
그런데 만약 중복되는 코드가 싫다면, 미디어 쿼리를 지우고, JavaScript를 사용하여 시스템 설정을 조회한뒤 바로 data-theme
을 설정해주는것도 한 솔루션이 되겠습니다.
이렇게 구현하는 경우, 페이지 로딩 후 시스템 테마가 바뀔 때 처리하는건 따로 구현해주어야 합니다.
window.matchMedia('(prefers-color-scheme: dark)').addListener(function (e) {
setTheme(e.matches ? 'dark' : 'light')
});
저는 미디어 쿼리를 사용하고 중복되는 코드를 사용할 계획입니다. 만약 JavaScript로 초기 테마를 설정하는 방식으로 한다면, 다른 .js 파일들이 로딩이 끝나기 전에 실행될 수 있도록 SSR 이후 HTML도 변경을 해줘야되고, 시스템 테마 이벤트도 등록을 해야하고, 관리 포인트가 늘어난다고 생각되기 때문입니다.
무엇보다, 저는 프로젝트에서 CSS in JS를 쓰기 때문에 실제로 코드를 중복해서 작성하진 않고 CSS Variable에 해당하는 코드를 재사용할 수 있어서 별로 큰 문제가 되지 않습니다.
결국 CSS Variable을 쓰기로 결정했습니다. 주요 이유는 처음 벨로그에 방문한 사용자에게 시스템 테마로 UI를 보여주기 위함입니다.
자, 그러면 Styled Components에서 어떻게 CSS Variable을 잘 사용할지 고민을 해보아야겠죠. 우선, CSS Variable은 GlobalStyles
에서 선언을 해주어야겠죠.
import { createGlobalStyle } from 'styled-components';
const GlobalStyles = createGlobalStyle`
/* 기존 코드 생략 */
body {
--text: black;
--background: white;
}
@media (prefers-color-scheme: dark) {
body {
--text: white;
--background: black;
}
}
body[data-theme='light'] {
--text: black;
--background: white;
}
body[data-theme='dark'] {
--text: white;
--background: black;
}
body {
color: var(--text);
background: var(--background);
}
`;
export default GlobalStyles;
이렇게 작성을 하면, 중복 코드가 있는게 좀 불만이 있을 수 있잖아요? 그래서 다음과 같이 리팩토링을 했습니다.
import { createGlobalStyle } from 'styled-components';
const lightTheme = `
--text: black;
--background: white;
`;
const darkTheme = `
--color-text: white;
--color-background: black;
`;
const GlobalStyles = createGlobalStyle`
/* 기존 코드 생략 */
body {
${lightTheme};
}
@media (prefers-color-scheme: dark) {
body {
${darkTheme}
}
}
body[data-theme='light'] {
${lightTheme};
}
body[data-theme='dark'] {
${darkTheme};
}
`;
export default GlobalStyles;
이제 원하는 곳에서 이런 식으로 CSS Variable을 쓰면 되겠군요!
background: var(--background);
그런데 이렇게 var(--varname)
방식으로 쓰는건 입력할 것도 많고, 자동 완성도 안되서 좀 불편할 것 같은데요, 저는 이런 식으로 각 CSS Variable을 매핑한 객체를 만들어서 내보내서 쓸 예정입니다.
const cssVar = (name: string) => `var(--${name})`;
export const themedPalette = {
text: cssVar('text'),
background: cssVar('background'),
};
이렇게 객체를 만들어서 사용을 한다면 다음과 같이 자동 완성이 되기 때문에 실수할 일도 없고 생산성이 더욱 올라가겠죠?
이건 현재 임시 코드이고, 나중에 조금 더 리팩토링을 할 예정입니다.
휴! 이제 다크 모드를 구현할 기술적인 준비는 끝났습니다. 물론 다크 모드를 토글하는 기능과 사용자의 설정을 기억해놓고 SSR을 할 때에도 잘 반영하는 것은 구현하지 않았지만, 이건 후반부에 살펴보도록 하죠!
기존 벨로그의 팔레트는 다음과 같이 관리하고 있었습니다.
이를 JavaScript 객체로 관리하고, 필요한 곳에서 그대로 가져다가 쓰고 있었는데요.
const palette = {
/* gray */
gray0: '#F8F9FA',
gray1: '#F1F3F5',
gray2: '#E9ECEF',
gray3: '#DEE2E6',
gray4: '#CED4DA',
gray5: '#ADB5BD',
gray6: '#868E96',
gray7: '#495057',
gray8: '#343A40',
gray9: '#212529',
/* red */
/* teal */
};
color: ${palette.gray8};
기존의 팔레트는 색상 이름으로 작명을 했었지만, 다크 테마 전용 팔레트는 용도에 따라 작명을 해볼 예정입니다. 당장 생각이 나는 것들은 다음과 같습니다.
gray9 → gray0 이런식으로 치환을 시킨다면 고민할게 별로 없어서 편하긴 하겠지만, 해당 팔레트가 다크 모드를 고려하고 만든 팔레트가 아니였기 때문에, 다크 모드 전용 색상은 꼭 기존의 팔레트에 의존하지 않고 눈에 가장 편해보이는 색상을 정해 볼 예정입니다.
다크 모드가 적용된 페이지의 배경색을 완전한 검정색 #000000
로 하는 것은 별로 권장하지 않는다고 하네요. 그 이유는 완전한 검정색에 밝은 색의 텍스트가 있으면 눈에 피로가 쌓일 수 있기 때문이라고 합니다.
트위터의 경우에는 다크 모드일때 완전한 검정색 배경을 사용하긴 하는데, 설정에서 파랑 계열의 어두운 회색으로 변경할 수도 있습니다.
기억을 더듬어보면 트위터가 완전한 검정색을 사용해서 처음엔 좀 이상하다고 생각했고 조금은 불편하게 느껴지기도 했는데 어느새부터인가 별로 신경이 안 쓰였던 것 같습니다. 보니까, Lights out 모드일때에는 텍스트 색도 조금 어두워지는 것 같네요. 완전한 검정색을 쓰는게 권장되지는 않는다고는 하지만, 훌륭한 UX 전문가가 있다면 별 문제가 되지 않는 것 같습니다.
저는 UX전문가가 아니므로 일반적인 권장사항을 따라볼까 합니다. 3가지 시도를 해볼까 하는데요. 기존에 gray0
로 사용하고 있던 #212529
를 사용해보고, 구글의 머터리얼 디자인에서 권장하는 #121212
도 사용해보고, #121212
에 브랜드 색상을 8% 섞은 #13211D
도 사용을 해서 비교를 해보겠습니다.
그냥 드는 느낌을 애기해보자면, 1번은 무난한것같습니다. 뭔가, Notion의 느낌이 드네요. 2번은 확실히 좀 더 어두워서 눈이 편한 느낌이 듭니다. 3번은, 초록빛이 돌고 있어서 뭔가 어색하군요. 벨로그는 텍스트 위주의 콘텐츠를 제공하는데 저렇게 초록 계열로 만들면 사용자들이 별로 안 좋아할것같습니다. 개인적으로 1번이 좀 더 멋져보이는데 2번이 눈이 더 편해서 2번으로 채택했습니다.
그렇다면, #121212
를 기반으로, 4가지 배경색을 만들어보겠습니다. 배경색을 만들때는 #121212
에 #ffffff
를 5%, 8%, 12% 를 섞어서 만들었습니다.
그리고 이에 맞춰 라이트 모드의 색상 4개도 준비를 해놓겠습니다. 라이트 모드의 색상은 기존에 사용하던 팔레트들을 재사용하겠습니다. 만약 서비스를 처음부터 기획하는 것이라면 방금 했던 것 처럼 색상을 직접 조합해가며 만들면 좋았겠지만, 벨로는 이미 기획이 완료된 서비스고 라이트모드일 때 사용자가 느낄 변화를 최소화하고 싶었습니다. 물론, 색상을 통합하는 과정에서 어느정도 변화는 있겠지만요.
그런데 막상 레벨별로 팔레트를 만들고 나니까 문득 벨로그 에서는 색상이 1:1 대응이 되지 않겠다는 생각이 들었습니다. 예를 들어서 라이트 모드에서 벨로그는 홈페이지에서는 회색 계열의 배경이여서, background1
로 하면 해결이 되겠지만, 포스트 페이지에 들어가면 배경이 흰색으로 되기 때문에 조금 복잡해집니다. 흰색 배경위에서 background1
을 사용하는것들이 꽤나 있거든요. 시리즈, 좌측 좋아요/공유 영역, 최하단 이전/다음 포스트 링크가 background1
을 사용하고 있습니다.
그리고, 홈페이지에서는 라이트 모드에서는 카드가 흰색인데, 다크 모드에서는 background2의 색으로 보여줘야 합니다.
아무래도 변수명을 새로 지어줘야겠습니다. 용도를 잘 고민해보고 다음과 같이 정리해보았습니다.
각 색상의 용도를 설명드려보자면, 다음과 같습니다.
bg_page1
: 라이트 모드에서의 회색 계열의 페이지 배경 (홈, 읽기 목록 페이지)bg_page2
: 라이트 모드에서의 완전히 흰색의 페이지 배경 (그 외의 모든 페이지)bg_element1
: 헤더, 헤더 메뉴, 검색창 처럼 라이트 모드에선 흰색이지만 다크 모드에서는 조금 다른 색으로 보여줘야 하는 경우bg_element2
: 태그, 좋아요/공유 영역, 다음 페이지bg_element3
: 답글, 토글 스위치 등bg_element4
: bg_element3
에 호버 효과가 필요한 경우이 정도면 벨로그 내에서의 대부분의 배경색을 커버할 수 있겠군요!
텍스트색을 정하기 위해서는 2가지에 대하여 고려해야 합니다. 첫 번째는 용도에 따라 텍스트 강조를 잘 할 수 있어야 합니다. 두 번째는 어두운 배경에서 대비율이 적당해서 눈이 편안해야 합니다.
텍스트 색상은 이렇게 4개로 분류했습니다. 사실 벨로그에서 gray4
부터 gray9
까지 다양하게 사용되고 있었는데요, 그 중에서 덜 사용되는 색상들을 없애고 4개로 줄였습니다.
다크 모드에서의 색상은 단순히 라이트 모드에서의 색상을 반전 시킨 것이 아니라, 흰색에서 92%, 84%, 65%, 30% 으로 투명도를 준 색상입니다.
색을 선정한 다음엔 대비율이 적당한지 봐야 하는데요, 대비율은 WebAIM 이라는 곳에서 편하게 확인할 수 있습니다. 이 사이트에서 제공하는 도구를 사용하면 다음과 같이 WCAG AA, WCAG AAA를 통과하는지 알 수 있습니다.
WCAG는 웹 콘텐츠 접근성 가이드라인을 의미하며, 장애인, 고령자 등이 웹 사이트에서 비장애인과 동등하게 콘텐츠에 접근할 수 있도록 보장하는 가이드라인입니다.
저는 다음 이미지에서 강조된 부분들의 색상이 가이드라인에 통과할 수 있도록 색을 조정해주었습니다. 나머지 색들은 사용되지 않는 조합이거나, 비활성화 됐을때 나타나는 것이므로 가이드라인에 통과하지 않아도 괜찮습니다.
이제 텍스트 색도 최종적으로 정리가 되었습니다.
기존에 벨로그에서 테두리가 gray0
부터 gray9
까지 다양하게 사용되고 있었는데요, 테두리는 또한 4개로 통합해보려고 합니다.
회색 계열 색 이외에도 링크, 버튼 등에서 사용되는 Primary 색상과 삭제 및 오류와 같이 부정적인 의미를 갖는 Destructive 색상을 정해보겠습니다.
그대로 사용해도 크게 이상하진 않은데요, Google Material의 다크 모드 Accent Color 관련 섹션을 읽어보니, Desaturated 색을 쓰고 검정색 텍스트를 사용하더군요. 그래서 어떤 모습일지 한번 반영을 해보았습니다.
이 색이 조금 더 맘에 드는 것 같군요. 그래서 우선 이렇게 다음과 같이 5개의 변수를 만들기로 계획했습니다.
그런데, 위 색상들이 텍스트로 사용 될 때에도 괜찮은지 확인해봐야겠군요.
괜찮게 보이네요. 그러면, 주요 색상들은 모두 정리된 것 같습니다!
물론, 방금 정리한 색상들에 해당되지 않는 상황들도 있을텐데요, 그런 예외적인 색상들은 나중에 하나 하나 조정해 주어야겠습니다.
이제 제가 준비한 팔레트를 코드로 작성해줄 차례입니다. 지금까지 총 19개의 색상이 준비되었는데요, 이 코드들을 CSS로 작성하지 않고, 저는 JavaScript 객체로 작성한 다음에 이를 CSS Variable을 선언하는 스타일 코드로 변환하는 유틸 함수를 만들어서 구현할 예정입니다. 제 기준에서는 이렇게하면 가독성도 높아지고 나중에 색상을 더 추가하거나 이름을 변경하게 될 때 실수하는 일을 방지될 수 있기 때문에 이렇게 하는 것 입니다. 꼭 이렇게 할 필요는 없습니다.
type ThemeVariables = {
bg_page1: string;
bg_page2: string;
bg_element1: string;
bg_element2: string;
bg_element3: string;
bg_element4: string;
text1: string;
text2: string;
text3: string;
text4: string;
border1: string;
border2: string;
border3: string;
border4: string;
};
type Theme = 'light' | 'dark';
type VariableKey = keyof ThemeVariables;
type ThemedPalette = Record<VariableKey, string>;
const themeVariableSets: Record<Theme, ThemeVariables> = {
light: {
bg_page1: '#F8F9FA',
bg_page2: '#FFFFFF',
bg_element1: '#E9ECEF',
bg_element2: '#F8F9FA',
bg_element3: '#FFFFFF',
bg_element4: '#DEE2E6',
text1: '#212529',
text2: '#495057',
text3: '#868E96',
text4: '#CED4DA',
border1: '#343A40',
border2: '#ADB5BD',
border3: '#DEE2E6',
border4: '#F1F3F5',
},
dark: {
bg_page1: '#121212',
bg_page2: '#121212',
bg_element1: '#1E1E1E',
bg_element2: '#1E1E1E',
bg_element3: '#252525',
bg_element4: '#2E2E2E',
text1: '#ECECEC',
text2: '#D9D9D9',
text3: '#ACACAC',
text4: '#595959',
border1: '#E0E0E0',
border2: '#A0A0A0',
border3: '#4D4D4D',
border4: '#2A2A2A',
},
};
const buildCssVariables = (variables: ThemeVariables) => {
const keys = Object.keys(variables) as (keyof ThemeVariables)[];
return keys.reduce(
(acc, key) =>
acc.concat(`--${key.replace(/_/g, '-')}: ${variables[key]};`, '\n'),
'',
);
};
export const themes = {
light: buildCssVariables(themeVariableSets.light),
dark: buildCssVariables(themeVariableSets.dark),
};
const cssVar = (name: string) => `var(--${name.replace(/_/g, '-')})`;
const variableKeys = Object.keys(themeVariableSets.light) as VariableKey[];
export const themedPalette: Record<VariableKey, string> = variableKeys.reduce(
(acc, current) => {
acc[current] = cssVar(current);
return acc;
},
{} as ThemedPalette,
);
CSS Variable의 네이밍 컨벤션은 kebab-case 인데 snake_case 로 작성해야 나중에 themedPalette.bg_page1
이런 식으로 사용할 수 있기 때문에 _ 와 -를 치환하는 작업을 해주었습니다.
그 다음에는 코드를 통해 생성한 CSS Variable 스타일 코드를 GlobalStyles
에 적용했습니다.
import { createGlobalStyle } from 'styled-components';
import { themes } from './lib/styles/themes';
const GlobalStyles = createGlobalStyle`
/* 기존 코드 생략 */
body {
${themes.light}
transition: 0.125s all ease-in;
}
@media (prefers-color-scheme: dark) {
body {
${themes.dark}
}
}
body[data-theme='light'] {
${themes.light};
}
body[data-theme='dark'] {
${themes.dark};
}
`;
export default GlobalStyles;
이제 부터는... 엄청난 반복 작업 시작입니다! 다행히도 저는 대부분의 색을 palette.gray4
이런 식으로 색상 코드를 직접 사용하지 않고 팔레트에 등록해서 사용헀었기 때문에 VS Code에서 텍스트를 찾아서 치환해주겠습니다.
일괄 교체를 하고, import error 가 나는 파일들을 열어서 Auto Import 기능을 통해 고쳐줘야겠습니다. 라고 생각했지만, themedPalette
를 수동으로 계속 import
하는 작업이 고역이더군요. 그냥 아예 var(--name)
으로 했으면 이런 문제는 없었을텐데..! 이걸 어떻게 해결 할 방법이 없을까 생각해봤을 때 든 생각은 다음과 같았습니다.
palette
를 사용하고 있는 파일을 찾고, themedPalette
의 상대경로를 삽입하는 스크립트를 돌리기import { themedPalette } from '@/lib/themes.ts'
이런 식으로 absolute path로 불러올 수 있게 프로젝트 설정을 변경한뒤 import palette
의 앞부분에서 불러오도록 치환ProvidePlugin
사용이거 때문에 프로젝트 설정을 변경하고 싶지는 않았고, 3번이 가장 공수가 적어서 이 방법을 선택하기로 했습니다.
정확히는 이렇게 처리하는 것 입니다.
다행히도 벨로그 프로젝트에서는 제가 컴포넌트 디렉터리가 2단계 이상으로 깊어지게 작성하지 않아서 금방 처리했었습니다.
그 이후로 엄청난 반복 작업이 있었고, 기존에 만든 팔레트로 구현할 수 없는 UI들이 생겨서 색상들을 더 추가해주었습니다. 색상 중에서는 rgba(255,255,255,0.1)
처럼 투명도가 있는 색상도 있고, 라이트 모드에서는 서로 같은 색상인데 다크 모드에서는 서로 달라야 하는 경우도 있었습니다. 라이트 모드에서 검정색 배경을 쓰고, 다크 모드에서 흰색 배경을 쓰는 경우도 있었죠.
색상을 채도 별로 준비해놓고 작업하면 모두 대비 될 것이라고 생각했었으나 예외적인 상황이 너무나 많았고, 작업을 하면서 유동적으로 팔레트에 색상을 계속 추가해주었습니다.
이 섹션이 지금 이 블로그에선 텍스트 길이가 가장 짧지만.. 사실은 가장 길은 복잡하고 오래 걸린 작업이였답니다. 그런데, 딱히 텍스트로 정리할 수가 없네요.
반복적인 작업이긴 했지만 자동화 할 수는 없었습니다. 어떤 색이 적당할지 인간의 판단이 필요했거든요. 물론 기술적으로는 불가능하진 않을 것 같습니다만, 자동화 하는 코드를 작성하는게 더 오래걸릴 것 같아서 포기했습니다.
우선 위와 같은 버튼을 먼저 구현해주었습니다. SVG 두개를 transform
을 통해 scale
, rotate
속성을 설정하여 전환 효과를 만들었습니다. react-spring을 사용하여 간단하게 구현했습니다. (코드보기)
다크 테마의 상태는 로컬스토리지와 쿠키로 저장을 해주었습니다. 로컬 스토리지는 CSR에서 테마를 저장하기 위함이며, 쿠키는 SSR에서 테마를 저장하기 위함입니다. 사실 벨로그 프로덕션은 모든 페이지에 SSR이 적용되어 있어서, 로컬 스토리지를 사용할 필요는 없으나, 개발 서버에서 필요로 하므로 두 코드를 적용해주었습니다.
다크 테마 상태를 담기 위해서 전역적인 상태가 필요했는데요, 이 상황에 꼭 리덕스를 쓸 필요는 없으나 프로젝트에 리덕스가 이미 적용되어 있기 때문에 새로운 Context를 만드는 대신에 다음과 같이 리덕스 모듈을 만들어서 상태를 관리하도록 구현했습니다.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export type DarkModeState = {
theme: 'dark' | 'light' | 'default';
systemTheme: 'dark' | 'light' | 'not-ready';
};
const initialState: DarkModeState = {
theme: 'default',
systemTheme: 'not-ready',
};
const darkMode = createSlice({
name: 'darkMode',
initialState: initialState,
reducers: {
enableDarkMode(state) {
state.theme = 'dark';
},
enableLightMode(state) {
state.theme = 'light';
},
setSystemTheme(state, action: PayloadAction<'dark' | 'light'>) {
state.systemTheme = action.payload;
},
},
});
export default darkMode;
여기서 theme
은 사용자가 설정한 테마고, systemTheme
은 시스템 테마입니다. 사용자 설정 테마는 초깃값이 default
이고, 이 값이 따로 정해져있지 않으면 systemTheme
을 사용합니다.
systemTheme
은 초깃값이 not-ready
인데요, 이렇게 준비되지 않았음을 의미하는 상태를 만든 이유는 SSR을 할 때에 다크 테마 토글 버튼을 숨기기 위함입니다. SSR을 할 때에는 현재 사용자의 테마를 모르기 때문에 토글 버튼에서 해 아이콘을 보여줄지, 달 아이콘을 보여줄지 모릅니다. 만약 무작정 해 아이콘을 보여주게 되면 hydrate하는 과정에서 오류가 날 수 있습니다. 따라서, 클라이언트에서 렌더링 될 때에 이 값을 업데이트하도록 구현하여 이 토글 버튼이 클라이언트에서만 보여주도록 만들어주겠습니다.
시스템 테마 읽기 및 테마 적용 코드는 useThemeEffect
라는 Hook을 만들어서 관리를 해주었습니다.
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../../../modules';
import darkMode from '../../../modules/darkMode';
export function useThemeEffect() {
const dispatch = useDispatch();
const theme = useSelector((state: RootState) => state.darkMode.theme);
useEffect(() => {
const systemPrefersDark = window.matchMedia(
'(prefers-color-scheme: dark)',
).matches;
dispatch(
darkMode.actions.setSystemTheme(systemPrefersDark ? 'dark' : 'light'),
);
}, [dispatch]);
useEffect(() => {
if (theme !== 'default') {
document.body.dataset.theme = theme;
}
}, [theme]);
}
서비스 내에서의 테마는 기본적으로 시스템 설정을 따라가지만, 사용자가 토글 버튼을 한번이라도 누른 이후에는 브라우저에 저장된 사용자의 설정을 따라갑니다. JavaScript 단에서 현재 테마 값을 조회해야할 일이 자주 있는데요, 반복되는 코드를 방지하기 위해서 다음과 같이 useTheme
이라는 Hook읆 만들었습니다.
import { useSelector } from 'react-redux';
import { RootState } from '../../modules';
export function useTheme() {
const darkModeState = useSelector((state: RootState) => state.darkMode);
const theme = (() => {
if (darkModeState.systemTheme === 'not-ready') return 'light';
if (darkModeState.theme !== 'default') return darkModeState.theme;
return darkModeState.systemTheme;
})();
return theme;
}
그리고 토글하는 기능은 다음과 같이 리덕스를 통해 상태를 변경하고 로컬 스토리지와 쿠키에 저장하도록 만들었죠.
import { useDispatch } from 'react-redux';
import { useTheme } from '../../../lib/hooks/useTheme';
import storage from '../../../lib/storage';
import darkMode from '../../../modules/darkMode';
export function useToggleTheme() {
const dispatch = useDispatch();
const theme = useTheme();
const save = (value: 'light' | 'dark') => {
storage.setItem('theme', value); // For CSR
document.cookie = `theme=${value}; path=/;`; // For SSR
};
const toggle = () => {
if (!theme) return;
if (theme === 'dark') {
dispatch(darkMode.actions.enableLightMode());
save('light');
} else {
dispatch(darkMode.actions.enableDarkMode());
save('dark');
}
};
return [theme, toggle] as const;
}
사용자가 페이지를 로딩 할 때 만약 다크 테마 설정이 브라우저에 저장되어있다면 이를 불러와서 초기에 적용하는 것을 위해서 2가지 작업을 했습니다.
우선, index.ts
에서는 렌더링 전에 스토어에 상태를 업데이트하고 테마를 적용하는 작업을 해주었습니다.
const loadTheme = () => {
const theme = storage.getItem('theme');
if (!theme) return;
if (theme === 'dark') {
store.dispatch(darkMode.actions.enableDarkMode());
} else {
store.dispatch(darkMode.actions.enableLightMode());
}
document.body.dataset.theme = theme;
};
loadTheme();
만약 SSR을 안한다면 위에 있는 코드만 작성하면 끝이지만, SSR을 한다면 만약 사용자의 시스템 설정과 사용자가 저장한 테마 설정이 일치하지 않는다면 JavaScript가 모드 로딩될 때 까지는 다른 테마가 적용됩니다. 이 부분을 고치기 위해서 SSR 코드에서 쿠키를 조회하고 body
에 data-theme
을 직접 주입해주는 작업을 했습니다.
function extractFromCookie(cookie: string | undefined, key: string) {
if (!cookie) return null;
const cookieArray = cookie.split(';');
const keyValue = cookieArray.find((item) => item.trim().startsWith(key));
if (!keyValue) return null;
const value = keyValue.split('=')[1];
return value;
}
const theme = extractFromCookie(cookie, 'theme');
const html = (
<Html
content={content}
apolloState={initialState}
reduxState={store.getState()}
styledElement={styledElement}
extractor={extractor}
helmet={helmetContext.helmet}
theme={theme}
/>
);
<body data-theme={theme}>
이번에 다크 테마를 서비스에 도입하면서 느낀 것은, 규모가 어느정도 되는 프로젝트라면 절대 쉽지 않은 작업이라는 것 입니다. 이 부분은 정말, 개발이 어려운게 아니라 색상의 고민을 하는데 시간이 생각보다 오래 들어갑니다. 흔히 사용된 색상들은 쉽게 쉽게 교체할 수 있었지만, 예외적으로 따로 설정해주어야 하는 색상들이 참 작업을 어렵게 만들었습니다. 제가 대부분의 UI를 검수하긴 했지만 미처 커버하지 못한 부분도 있을 것입니다. 그러한 부분은 앞으로 사용해가면서 발견하는대로 개선해볼게요 :)
2년동안 미룬 작업을 드디어 마쳐서 매우 뿌듯하군요. 이번에 배운 점은, 다크 테마를 적용할 때 처음부터 다크 테마를 고려하고 한 다자인이 아니라면 매우 빡세다는 것 입니다.
처음에는 정말 색상을 잘 정리해서 관리하려고 했지만 그 부분에 시간이 너무 많이 들어가는게 아까워서 후반부에는 색을 필요한대로 대충 대충 추가해서 사용했었답니다. 우선 다크 테마를 빨리 도입하고 앞으로 천천히 정리해나가는게 좋겠다고 판단했어요. 그리고 포스트도 마찬가지로.. 처음엔 야심차게 제가 하는 생각들을 모두 텍스트로 정리했지만 후반부로 가면서 지쳐서 대충 쓰기도 했습니다 🤣
앞으로 새로운 서비스를 만들거나 디자인 시스템을 만든다면 꼭, 다크 테마를 처음부터 고려하고 기획을 하게 될 것 같네요. 텍스트색, 배경색에 대한 색상 레퍼런스를 사용할 때, gray1
이런 식으로 지을게 아니라 text1
이런식으로 짓는게 중요할 것 같습니다.
오오오 다크모드 예쁘네요! 🙆♂️ 중간중간 민트 느낌 나는 게 인상적입니다