Multi Themes 적용 - Next.js

NB·2022년 6월 27일
7
post-thumbnail

디자인 시스템의 일부인 컬러 시스템은 한 번 구축을 해두면, 추후에는 파일 한 곳에서 색상 하나만 변경하면 브랜드 컬러가 바뀌고, 수백 개의 페이지에서 사용하고 있는 색상이 일괄적으로 바뀌는 등의 장점을 가지고 있습니다.
만약, 컬러시스템이 없다면, 수백 개나 되는 파일에 일일이 접근해서 색상을 수정하고 있어야할 것입니다. 그렇기에 컬러 시스템은 현대 앱에서는 매우 필요한 시스템입니다. 최근에는 사용자의 경험의 수준이 높아짐에 따라 다크 테마 기능도 늘고 있는 추세인만큼 한 단계 더 높은 시스템을 가질 필요성이 필요해졌습니다.

물론 모든 시스템이 다크 테마를 지원하고 있는 것은 아니지만, 앱의 규모가 커지면 커질수록 초기에 Theme System 구조를 가지고 있느냐 없느냐의 차이는 더 큰 비용이 소요되게 됩니다. 그렇기에 요즘은 하나의 테마를 사용하더라도 Theme System은 미리 구조화 시켜두고 컬러 시스템을 구현하는 편입니다.



One-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을 통해서 구현을 진행할 수 있습니다.



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에 저장하는 방법도 존재합니다. 그리고, 유저가 다시 접속하면 저장된 곳에서 값을 꺼내와서 그 값에 일치하는 테마를 주입시키면 됩니다.


SSR 깜빡임 현상

만약 테마가 다크 테마(어두운 화면)으로 적용되어 있을 때, 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};
`;

더 궁금한 것이 있다면, 댓글로 남겨주세요! 🌻

profile
𝙄 𝙖𝙢 𝙖 𝙛𝙧𝙤𝙣𝙩𝙚𝙣𝙙 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙚𝙧 𝙬𝙝𝙤 𝙚𝙣𝙟𝙤𝙮𝙨 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙢𝙚𝙣𝙩. 👋 💻

0개의 댓글