디자인 시스템의 일부인 컬러 시스템은 한 번 구축을 해두면, 추후에는 파일 한 곳에서 색상 하나만 변경하면 브랜드 컬러가 바뀌고, 수백 개의 페이지에서 사용하고 있는 색상이 일괄적으로 바뀌는 등의 장점을 가지고 있습니다.
만약, 컬러시스템이 없다면, 수백 개나 되는 파일에 일일이 접근해서 색상을 수정하고 있어야할 것입니다. 그렇기에 컬러 시스템은 현대 앱에서는 매우 필요한 시스템입니다. 최근에는 사용자의 경험의 수준이 높아짐에 따라 다크 테마 기능도 늘고 있는 추세인만큼 한 단계 더 높은 시스템을 가질 필요성이 필요해졌습니다.
물론 모든 시스템이 다크 테마를 지원하고 있는 것은 아니지만, 앱의 규모가 커지면 커질수록 초기에 Theme System 구조를 가지고 있느냐 없느냐의 차이는 더 큰 비용이 소요되게 됩니다. 그렇기에 요즘은 하나의 테마를 사용하더라도 Theme System은 미리 구조화 시켜두고 컬러 시스템을 구현하는 편입니다.
기존에 보통 작성하는 한 가지 테마를 사용하는 시스템은 다음 구조를 갖추고 있습니다.
// 색상표 const palette = { primary_050: '#000000', primary_100: '#000000', ... primary_800: '#000000', primary_900: '#000000', secondary_050: '#000000', secondary_100: '#000000', ... secondary_800: '#000000', secondary_900: '#000000', } // 컴포넌트 const Component = styled.div` background-color: ${palette.primary070}; `;
1. 색상표
정의
2. 컴포넌트 스타일에서 색상표에 있는 색상을 임포트해서 사용
매우 간단하게 구현할 수 있으며, 간단한만큼 개발에 대한 초기비용이 낮은 편입니다. 하지만, 여기에 다크 테마가 생긴다면? 다크 테마 뿐만 아니라, 사용자가 원하는 동적으로 주입된 커스텀 테마가 존재한다면? 지금과 같은 One-Theme System
으로는 불가능한 일입니다. 그렇기에 다음과 같이 Multiple-Themes System
을 통해서 구현을 진행할 수 있습니다.
다시 언급하지만, 이 구조는 Dark
, Light
테마 뿐만 아니라, 여러 개의 테마를 적용할 수 있습니다. 먼저 고려해야할 부분은 기존 palette
라는 상수를 어떻게 여러개의 테마로 보여줄 수 있을까? 입니다. 이 것에 대한 답은 Provider 입니다. Styled-Components
에서 제공하는 ThemeProvider
을 통해서 우리는 위에 대한 질문에 대한 답을 찾을 수 있습니다.
// 색상표 라이트 모드 const lightPalette = { primary_050: '#000000', primary_100: '#000000', ... primary_800: '#000000', primary_900: '#000000', secondary_050: '#000000', secondary_100: '#000000', ... secondary_800: '#000000', secondary_900: '#000000', } // 색상표 다크 모드 const darkPalette = { primary_050: '#000000', primary_100: '#000000', ... primary_800: '#000000', primary_900: '#000000', secondary_050: '#000000', secondary_100: '#000000', ... secondary_800: '#000000', secondary_900: '#000000', } // 앱 진입 파일 const App = () => { const [theme, setTheme] = useState('Light'); const onChangeLight = () => setTheme('Light'); const onChangeDark = () => setTheme('Dark'); return ( <ThemeProvider theme={theme === 'Light' ? lightPalette : darkPalette}> <App /> </ThemeProvider> ); } // 컴포넌트 const Component = styled.div` background-color: ${({ theme }) => theme.primary040}; `;
1. 각 테마별 색상표
정의 (테마 타입은 동일해야 합니다.)
2. 앱 진입점에서 현재 선택된 테마 Provider
로 주입
3. 주입된 theme
을 통해서 컴포넌트에서 사용
- 적용한 테마 모드의 저장
- SSR 에서의 다크 테마가 적용되기 전까지의 깜빡임 현상 발생
유저가 선택한 테마를 다음 접속 때에도 동일하게 선택된 테마로 보여주기 위해서는 선택된 테마를 저장할 필요성이 존재합니다. 테마 정보를 API를 사용하여 DB에 저장해도 되겠지만, Cookie나 LocalStorage에 저장하는 방법도 존재합니다. 그리고, 유저가 다시 접속하면 저장된 곳에서 값을 꺼내와서 그 값에 일치하는 테마를 주입시키면 됩니다.
만약 테마가 다크 테마(어두운 화면)으로 적용되어 있을 때, Client Side에서 적용된 테마 정보를 저장하게 된다면, Server Side에서는 현재 사용자가 적용된 테마가 무엇인지 모르기 때문에, 새로고침 시에 화면 깜빡임 현상은 필연적으로 발생하게 됩니다. 그렇기에 필자는 Cookie를 사용하여, Client Side, Server Side 두 곳 다 적용된 테마를 알 수 있도록 제작하려고 합니다.
추가된 로직의 순서는 다음과 같습니다.
1. 서버에서 Cookie를 읽어서 테마 적용한 템플릿 반환
(이로 인해서 깜빡이 현상이 없어집니다.)
2. 클라이언트에서 Cookie를 읽어서 Store에 테마 저장
(이 후, 사용자가 변경한 테마는 Store와 Cookie에 저장합니다.)
3. 앱 진입점에서 Store 에서 테마 값을 읽어서 Provider 에 주입
위 기능은 Next.js, Styled-Components, Recoil을 사용하여 구현되었습니다.
theme.ts
위 파일에서는 테마에 대한 정보를 저장하는 역할을 합니다.
mode
: 현재 적용 테마system
: 시스템 테마import { atom } from 'recoil'; export const themeState = atom({ key: 'themeState', default: { mode: 'Default', // 'Default' | 'Light' | 'Dark' system: 'Pending', // 'Pending' | 'Light' | 'Dark' }, });
ThemeProvider.tsx
이 파일이 제일 중요한 파일입니다. 아래 파일의 3줄부터 10줄까지를 유심히 볼 필요가 있습니다.
1순위
:Cookie
에 저장된 테마 값2순위
:Cookie
에 저장된 테마 값이 없다면, 시스템의 테마 값
- 시스템의 테마는
Light
,Dark
,Auto
모드가 존재합니다.3순위
: 시스템의 테마 값이Auto
일 때,Light
테마로 적용const ThemeProvider = ({ themeType, children }) => { const currentTheme = useRecoilValue(themeState); const theme = currentTheme.system === 'Pending' ? themeType === 'dark' ? themePalette.dark : themePalette.light : currentTheme.mode === 'Dark' ? themePalette.dark : themePalette.light; const setMode = useSetRecoilState(setTheme); const setSystem = useSetRecoilState(setSystemTheme); /** 시스템 테마 감지 */ useEffect(() => { if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { setSystem('Dark'); } if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { setSystem('Light'); } }, [setSystem]); /** Store에 테마 저장 */ useEffect(() => { if (themeType === 'light') { return setMode('Light'); } if (themeType === 'dark') { return setMode('Dark'); } if (currentTheme.system === 'Light') { return setMode('Light'); } if (currentTheme.system === 'Dark') { return setMode('Dark'); } setMode('Default'); }, [themeType, currentTheme.system, setMode]); /** 쿠키에 현재 테마 저장 */ useEffect(() => { if (currentTheme.mode === 'Light') { cookieUtils.setCookie('theme', 'light'); } if (currentTheme.mode === 'Dark') { cookieUtils.setCookie('theme', 'dark'); } }, [currentTheme.mode]); return <SC_ThemeProvider theme={theme}>{children}</SC_ThemeProvider>; }; export default ThemeProvider;
_app.tsx
위 파일에
getInitialProps
를 통해서 서버측에서 쿠키에 있는theme
값을 가져옵니다.App.getInitialProps = async ({ ctx, Component }) => { let pageProps: any = {}; if (Component.getInitialProps) { pageProps = await Component.getInitialProps(ctx); } const cookie = ctx.req.cookies; if (cookie) { Object.assign(pageProps, { theme: cookieUtils.getCookieFromServer('theme', ctx), }); } return { pageProps }; };
그리고,
ThemeProvider
으로 감싸줍니다.return ( <ThemeProvider theme={pageProps.theme}> <GlobalStyles /> <>{getLayout(<Component {...pageProps} />)}</> </ThemeProvider> );
이 때,
createGlobalStyle
을 사용하여 만든GlobalStyles
컴포넌트는 반드시ThemeProvider
안에 넣어서, 테마의 변수를 사용할 수 있도록 제작합니다. 사용 방식은 아래와 같습니다.const Component = styled.div` background-color: ${({ theme }) => theme.surface}; `;
더 궁금한 것이 있다면, 댓글로 남겨주세요! 🌻