CSS in JS 라이브러리에서 Typesafe하게 Theme 관리하기

김민수

데브시스터즈에서는 React 기반의 GatsbyJS와 Typescript를 이용해 웹 사이트를 관리하고 있으며 프로젝트의 스타일링 방법론으로는 CSS in JS 방식을 사용하고 있습니다. Typescript 기반의 React 어플리케이션에서 CSS in JS를 사용하는 방식과 React의 Context API를 사용해 효과적으로의 Theme를 관리하는 방법을 소개합니다.

CSS in JS란?

CSS in JS는 Javascript 코드에서 CSS를 작성하는 방법을 말합니다. Facebook의 개발자인 vjeux는 아래와 같은 CSS 관리의 문제점을 CSS in JS를 도입함으로써 해결한 사례를 설명했습니다. 자세한 내용은 링크에서 확인할 수 있습니다.

  1. Global namespace
  2. Dependencies
  3. Dead Code Elimination
  4. Minification
  5. Sharing Constants
  6. Non-deterministic Resolution
  7. Isolation

대표적인 CSS in JS 라이브러리로는 JSS, styled-components, emotion이 존재합니다. 현재 프로젝트에서는 emotion을 사용해 개발을 진행하고 있으며 emotion의 대표적인 사용 방법은 아래와 같습니다.

import React from 'react';
import ReactDOM from 'react-dom';
import styled from '@emotion/styled';

// object 스타일
const Container = styled.div({
  width: 180,
  height: 180,
  backgroundColor: '#fd7622',
});

// template literal 스타일
const Title = styled.h1`
  color: #ffffff;
`;

const Example = () => (
  <Container>
    <Title>Devsisters</Title>
  </Container>
);

const rootElement = document.getElementById('root');
ReactDOM.render(<Example />, rootElement);

emotionobjecttemplate literal로 자바스크립트 코드를 작성해 스타일을 표현할 수 있습니다. 또한 자바스크립트 코드를 통해 실제 CSS가 생성되기 때문에 가상선택자와 같은 CSS의 기능을 대부분 이용할 수 있습니다.

CSS in JS 라이브러리에서의 Theme

React에서 사용할 수 있는 대부분의 CSS in JS 라이브러리는 테마(Theme)와 관련된 기능을 제공합니다.

데브시스터즈에서는 이런식으로 테마 기능을 사용합니다.

emotion은 React의 Context API를 기반으로 만들어진 ThemeProvider를 제공하며 emotion 공식문서가 소개하는 ThemeProvider를 이용하는 방법 3가지 중 데브시스터즈에서는 아래의 styled 기법을 사용합니다.

ThemeProvider로 감싸진 자식 컴포넌트들은 theme에 정의된 스타일을 props으로 전달받아 사용할 수 있습니다. 위의 예제에서는 Title, SubTitle, Linktheme에 존재하는 같은 fontColor를 사용합니다. ThemeProvider를 이용해 공통으로 사용하는 스타일을 관리할 수 있어 각각의 컴포넌트에 color 속성값을 따로 지정할 때 보다 스타일을 관리하기 간편해졌습니다.

Typescript와 CSS in JS 라이브러리에서의 Theme

실제 코드를 작성할 때에는 타입스크립트를 사용하고 있음으로 ThemeProvider에 전달되는 theme 속성값들 또한 타입 검사를 진행해야 합니다. 아래와 같이 기존의 ThemeProvider를 가져와 theme 속성에 들어갈 타입을 의미하는 Theme 타입을 정의해 확장하는 방식으로 사용할 수 있습니다.

또한 styled 방식으로 생성되는 컴포넌트의 propstheme에도 ThemeProvidertheme와 동일한 타입을 전달하기 25번 라인의 코드를 추가했습니다. styled.div와 같이 생성되는 컴포넌트의 propstheme에도 지정해준 타입이 모두 적용되었음을 확인할 수 있습니다. 작성한 ThemeProviderstyled는 기존의 Javascript로 작성된 코드와 동일하게 사용할 수 있습니다.

Typescript가 ThemeProvider의 필요한 값의 타입을 알고있다.
Typescript가 ThemeProvider의 필요한 값의 타입을 알고있다.

기존의 자바스크립트와 다른 점은 theme의 타입을 Typescript가 알고 있기 때문에 타입에 지정되지 않은 속성을 추가하거나 지정된 속성의 값의 타입이 맞지 않는 경우 타입 오류가 발생하는 것을 볼 수 있습니다. 이처럼 theme의 타입을 지정함으로써 불필요한 속성값이 ThemeProvider에 들어가는 것을 방지할 수 있게 되었습니다.

React Context API를 사용해 Theme 확장 유틸 만들기

현재 진행 중인 React 프로젝트는 Fractal 패턴을 사용하여 폴더 구조를 관리하고 있습니다. 폴더의 깊이가 깊어지면서 하위 컴포넌트에서 부모 컴포넌트에서 사용하지 않던 테마 속성을 추가적으로 사용해야 할 경우 어플리케이션의 최상단에 존재하는 ThemeProvider에는 많은 속성값이 생겨날 것입니다. 또한 아래의 그림과 같이 부모에서 사용하는 속성값과 자식에서 사용하는 테마 속성값을 다르게 주고 싶은 경우가 생길 수 있습니다.

중첩된 테마에 관한 그림 예시
중첩된 테마에 관한 그림 예시

위의 이미지에서 A 컴포넌트의 부모에는 아래 타입을 갖는 테마 객체를 요구하는 ThemeProvider가 존재할 것입니다.

type Theme = {
  color: {
    bgColor: string;
    textColor: string;
  };
};

해당 ThemeProvider의 테마 속성이 갖는 bgColortextColor는 A, B, C, D, E 컴포넌트에서 모두 사용할 수 있습니다. 그러나 A 컴포넌트의 하위에 있는 B 컴포넌트와 D 컴포넌트는 A 컴포넌트와 theme에 존재하는 bgColor 속성값이 다릅니다. 또한 최상위에 있는 ThemeProvider에 존재하지 않는 border라는 속성값이 존재합니다. B 컴포넌트와 D 컴포넌트가 사용하고 있는 테마의 속성값의 타입은 아래와 같아야 정상적으로 동작할 것입니다.

type Theme = {
  color: {
    bgColor: string;
    textColor: string;
    border: string;
  };
};

이런 경우 React의 Context API를 사용해 ThemeProvider를 확장할 수 있습니다. 무한하게 중첩이 가능한 Context API의 성질을 이용해 기존에 작성한 ThemeProvider를 확장할 것입니다. 중첩되는 ThemeProvider에 주입되는 테마의 타입은 아래와 같을 것입니다.

src/utils/makeTheme.tsx

type MergedTheme<BaseTheme extends object, Theme extends object> = 
  & BaseTheme 
  & Theme;

여기서 BaseTheme는 부모 ThemeProvider의 테마 타입이며 Theme는 부모에 합칠 새로운 ThemeProvider의 속성값 타입입니다. Intersection Type을 표현하는 & 연산자를 이용해 두 테마의 타입을 합쳐줍니다. 그 후 기존의 새로운 theme을 받아 부모의 ThemeProvider와 합쳐주는 makeTheme 함수를 작성합니다.

src/utils/makeTheme.tsx

export function makeTheme<BaseTheme extends object, Theme extends object>() {
  // BaseTheme와 Theme 타입을 병합한 타입인 NewTheme를 정의
  type NewTheme = MergedTheme<BaseTheme, Theme>;
  // NewTheme 타입을 갖는 styled 함수를 생성
  const styled = _styled as CreateStyled<NewTheme>;
  // NewTheme 타입의 객체를 받는 ThemeProvider Context 생성
  const ThemeProvider: React.FC<{ theme: NewTheme }> = ({ 
      theme, 
      children,
    }) => {
    return (
      <BaseThemeProvider theme={theme}>
        {children}
      </BaseThemeProvider>
    );
  };
  return { styled, ThemeProvider };
}

기존에 작성했던 ThemeProvidermakeTheme 함수에서 반환하는 형태로 코드를 재사용해 사용할 수 있습니다. styled 방식으로 생성되는 컴포넌트에도 새로운 테마의 타입이 적용되도록 CreateStyled의 제네릭 타입에 NewTheme 타입을 전달해 줍니다. ThemeProvider는 기존의 테마를 확장해 사용할 수 있도록 매개변수로 전달받는 새로운 theme의 타입을 NewTheme로 설정합니다. 위의 코드를 보다 보면 의문이 드는 부분이 생길 수 있습니다.

<BaseThemeProvider theme={theme}>{children}</BaseThemeProvider>

BaseTheme 타입을 갖는 부모 레벨에 있는 ThemeProvider가 갖는 실제 속성값을 넘겨주는 부분이 따로 작성되어 있지 않습니다. 예를 들어 아래의 mergeTheme과 같은 theme 값들을 병합해주는 함수를 생각해볼 수 있습니다.

function mergeTheme<BaseTheme extends object, Theme extends object>(
  baseTheme: BaseTheme,
  theme: Theme
): MergedTheme<BaseTheme, Theme> {
  return { ...baseTheme, ...theme };
}

<ThemeProvider theme={mergeTheme(baseTheme, theme)}>{children}</ThemeProvider>;

기본적으로 emotion-theming 패키지의 ThemeProvider에서 Object.assign 수준으로 theme 객체들을 복사해주므로 테마를 합쳐주는 기능을 하는 mergeTheme 함수를 따로 작성할 필요는 없습니다. 자세한 내용은 emotion-theming 공식문서에서 확인할 수 있습니다. 최종적으로 ThemeProvider를 확장하기 위해서는 makeTheme 함수와 MergedTheme 타입만 존재하면 됩니다.

작성한 Theme 확장 유틸 사용하기

작성한 makeTheme 함수를 사용하기 위해 간단한 예제를 작성했습니다. 최종적인 결과는 아래의 이미지로 위에서 그려보았던 그림과 동일한 형태로 구현되어 있습니다. A, C, E 컴포넌트는 가장 최상단의 ThemeProvider를 사용하며 B 컴포넌트와 D 컴포넌트는 최상단의 ThemeProvider를 확장한 새로운 ThemeProvider를 사용합니다.

Theme를 확장하는 유틸 함수 사용 결과
Theme를 확장하는 유틸 함수 사용 결과

최상단에 존재하는 ThemeProvider는 아래와 같은 형태로 작성됩니다. string 타입의 값을 갖는 bgColortextColor 속성을 갖는 color 객체를 갖는 theme 속성이 작성되어 있으며 makeTheme<{}, Theme> 형태로 사용합니다. 여기에서 MergedTheme 타입은 {} & Theme이므로 Theme 타입과 동일합니다.

src/components/themeContext.ts

import { makeTheme } from '../utils/theme';

export type Theme = {
  color: {
    bgColor: string;
    textColor: string;
  };
};

export const { styled, ThemeProvider } = makeTheme<{}, Theme>();

themeContext.ts 파일에 작성된 ThemeProviderApp.tsx에서 아래와 같이 사용됩니다. App 컴포넌트는 물론 B와 C 컴포넌트 모두 App 컴포넌트의 최상단에 위치하는 ThemeProvider에 존재하는 theme 속성값을 사용합니다.

src/components/App.tsx

import React from "react";
import B from "./B";
import C from "./C";
import { styled, ThemeProvider } from "./themeContext";

const Container = styled.div((props) => ({
  backgroundColor: props.theme.color.bgColor
}));

const Title = styled.h1((props) => ({
  color: props.theme.color.textColor
}));

export default function App() {
  return (
    <ThemeProvider
      theme={{
        color: {
          textColor: '#FD7622',
          bgColor: '#FFF2CC',
        },
      }}>
      <Container className="App">
        <Title>This is A component</Title>
        <B />
        <C />
      </Container>
    </ThemeProvider>
  );
}

하지만 B와 D 컴포넌트는 App 컴포넌트 내부에서 사용된 ThemeProvider의 테마 속성값을 사용하지 않고 border 속성을 추가로 갖는 새로운 ThemeProvider의 테마 속성값을 사용해야 합니다. 따라서 B라는 폴더를 생성한 후 폴더 내부에 새로운 themeContext.ts 파일을 생성합니다. 이 파일에서 생성된 ThemeProvider의 타입은 BaseTheme & Theme가 됩니다.

src/components/B/themeContext.ts

import { Theme as BaseTheme } from '../themeContext';
import { makeTheme } from '../../utils/theme';

type Theme = {
  color: {
    border: string;
  };
};

export const { styled, ThemeProvider } = makeTheme<BaseTheme, Theme>();

결론적으로 App 컴포넌트 내부에 있는 ThemeProvider의 테마 속성값인 bgColor, textColor 외에도 추가로 border라는 속성을 갖게 됩니다. 따라서 B, D 컴포넌트는 이 ThemeProviderstyled를 가져와 사용할 수 있습니다.

src/components/B/index.tsx

import React from "react";
import D from "./D";
import { styled, ThemeProvider } from "./themeContext";

const Container = styled.div(props => ({
  backgroundColor: props.theme.color.bgColor,
  border: props.theme.color.border,
}));

const Text = styled.p(props => ({
  color: props.theme.color.textColor,
}));

function B() {
  return (
    <ThemeProvider
      theme={{
        color: {
          textColor: '#FD7622',
          bgColor: '#FFFFFF',
          border: '4px solid red',
        },
      }}>
      <Container>
        <Text>A component's child B component</Text>
        <D />
      </Container>
    </ThemeProvider>
  );
}

export default B;

이처럼 프로젝트의 테마 확장이 필요한 경우 하위 폴더에 새로운 themeContext.ts 파일을 생성한 후 부모 경로에 있는 themeContext.ts에서 Theme 타입만 가져와 합치게 되면 기존의 ThemeProvider를 확장할 수 있게 됩니다.

Theme 확장 유틸을 사용하는 예시

이로써 간단하게 React의 Context API의 중첩 가능한 특성을 이용해 CSS in JS 라이브러리인 emotion의 테마 기능을 Typesafe하게 확장할 수 있게 되었습니다. Fractal 패턴을 이용하지 않은 프로젝트에서도 사용 가능하며 복잡하게 중첩된 ThemeProvider를 사용하면서 테마 속성값에 잘못된 값을 넣게 되는 상황을 피하여 테마를 확장할 수 있습니다.

Emotion 11버전 마이그레이션

emotion의 버전이 메이저 버전이 11로 올라가게 되면서 emotion의 많은 타입 정의들이 변경되었습니다. 타입 정의가 크게 변경되었지만, emotion의 타입을 확장하는 방식으로 타입을 관리하고 있었기 때문에 약간의 타입 정의만 추가해 동일하게 사용할 수 있습니다. 우선 예시 코드에서 사용했던 타입 중 변경된 타입을 알아보겠습니다.

  • 11버전 이전의 CreateStyled 인터페이스
export interface CreateStyled<Theme extends object = any>
  extends BaseCreateStyled<Theme>,
    StyledTags<Theme> {}
  • 11버전의 CreateStyled 인터페이스
export interface CreateStyled extends BaseCreateStyled, StyledTags {}

CreateStyled 인터페이스가 더는 Theme 제네릭을 사용하지 않습니다. 따라서 기존과 같이 CreateStyled를 사용하기 위해서는 새로운 CreateStyled가 확장하고 있는 BaseCreateStyledStyledTags를 확장해 사용해야 합니다. BaseCreateStyled 인터페이스의 정의는 아래와 같습니다.

  • emotion/styledCreateStyled 인터페이스 정의
export interface CreateStyled {
  <
    C extends React.ComponentClass<React.ComponentProps<C>>,
    ForwardedProps extends keyof React.ComponentProps<
      C
    > = keyof React.ComponentProps<C>
  >(
    component: C,
    options: FilteringStyledOptions<React.ComponentProps<C>, ForwardedProps>
  ): CreateStyledComponent<
    Pick<PropsOf<C>, ForwardedProps> & {
      theme?: Theme
      as?: React.ElementType
    },
    {},
    {
      ref?: React.Ref<InstanceType<C>>
    }
  >

  <C extends React.ComponentClass<React.ComponentProps<C>>>(
    component: C,
    options?: StyledOptions<React.ComponentProps<C>>
  ): CreateStyledComponent<
    PropsOf<C> & {
      theme?: Theme
      as?: React.ElementType
    },
    {},
    {
      ref?: React.Ref<InstanceType<C>>
    }
  >

  <
    C extends React.ComponentType<React.ComponentProps<C>>,
    ForwardedProps extends keyof React.ComponentProps<
      C
    > = keyof React.ComponentProps<C>
  >(
    component: C,
    options: FilteringStyledOptions<React.ComponentProps<C>, ForwardedProps>
  ): CreateStyledComponent<
    Pick<PropsOf<C>, ForwardedProps> & {
      theme?: Theme
      as?: React.ElementType
    }
  >

  <C extends React.ComponentType<React.ComponentProps<C>>>(
    component: C,
    options?: StyledOptions<React.ComponentProps<C>>
  ): CreateStyledComponent<
    PropsOf<C> & {
      theme?: Theme
      as?: React.ElementType
    }
  >

  <
    Tag extends keyof JSX.IntrinsicElements,
    ForwardedProps extends keyof JSX.IntrinsicElements[Tag] = keyof JSX.IntrinsicElements[Tag]
  >(
    tag: Tag,
    options: FilteringStyledOptions<JSX.IntrinsicElements[Tag], ForwardedProps>
  ): CreateStyledComponent<
    { theme?: Theme; as?: React.ElementType },
    Pick<JSX.IntrinsicElements[Tag], ForwardedProps>
  >

  <Tag extends keyof JSX.IntrinsicElements>(
    tag: Tag,
    options?: StyledOptions<JSX.IntrinsicElements[Tag]>
  ): CreateStyledComponent<
    { theme?: Theme; as?: React.ElementType },
    JSX.IntrinsicElements[Tag]
  >
}

BaseCreateStyled 인터페이스 중간중간 Theme 타입을 갖는 theme를 사용하는 것을 볼 수 있습니다. 이 부분을 이전과 같이 제네릭으로 만들어 아래와 같이 확장하여 사용할 수 있습니다.

  • 확장한 CreateStyled 인터페이스 정의
import type { CreateStyledComponent, StyledOptions } from '@emotion/styled';
import type { FilteringStyledOptions } from '@emotion/styled/types/base';
import type { PropsOf } from '@emotion/react';

export interface CreateStyled<Theme> {
  <
    C extends React.ComponentType<React.ComponentProps<C>>,
    ForwardedProps extends keyof React.ComponentProps<
      C
    > = keyof React.ComponentProps<C>
    >(
    component: C,
    options: FilteringStyledOptions<React.ComponentProps<C>, ForwardedProps>
  ): CreateStyledComponent<
    Pick<PropsOf<C>, ForwardedProps> & {
      theme?: Theme
      as?: React.ElementType
    }
  >

  <C extends React.ComponentType<React.ComponentProps<C>>>(
    component: C,
    options?: StyledOptions<React.ComponentProps<C>>
  ): CreateStyledComponent<
    PropsOf<C> & {
      theme?: Theme
      as?: React.ElementType
    }
  >

  <
    Tag extends keyof JSX.IntrinsicElements,
    ForwardedProps extends keyof JSX.IntrinsicElements[Tag] = keyof JSX.IntrinsicElements[Tag]
    >(
    tag: Tag,
    options: FilteringStyledOptions<JSX.IntrinsicElements[Tag], ForwardedProps>
  ): CreateStyledComponent<
    { theme?: Theme; as?: React.ElementType },
    Pick<JSX.IntrinsicElements[Tag], ForwardedProps>
  >

  <Tag extends keyof JSX.IntrinsicElements>(
    tag: Tag,
    options?: StyledOptions<JSX.IntrinsicElements[Tag]>
  ): CreateStyledComponent<
    { theme?: Theme; as?: React.ElementType },
    JSX.IntrinsicElements[Tag]
  >
}

마찬가지로 StyledTags 타입 또한 emotion 내부에 정의된 타입들을 가져와 직접 관리해야 합니다. 11버전의 StyledTags 타입은 아래와 같습니다.

  • emotion/styledStyledTags 타입 정의
export type StyledTags = {
  [Tag in keyof JSX.IntrinsicElements]: CreateStyledComponent<
    {
      theme?: Theme
      as?: React.ElementType
    },
    JSX.IntrinsicElements[Tag]
  >
}

StyledTags 역시 emotion 내부에 존재하는 Theme 타입을 사용하고 있습니다. 이 부분 역시 제네릭을 이용해 타입을 확장할 수 있습니다.

  • 확장한 StyledTags 타입 정의
export type StyledTags<Theme> = {
  [Tag in keyof JSX.IntrinsicElements]: CreateStyledComponent<
    {
      theme?: Theme
      as?: React.ElementType
    },
    JSX.IntrinsicElements[Tag]
  >
}

최종적으로 기존에 작성했던 makeTheme 함수는 아래와 같이 변경됩니다.

export function makeTheme<BaseTheme extends object, Theme extends object>() {
  type NewTheme = MergeTheme<BaseTheme, Theme>;

  // 변경된 부분
  const styled = _styled as CreateStyled<NewTheme> & StyledTags<NewTheme>;
  const ThemeProvider: React.FC<{ theme: NewTheme }> = ({
    theme,
    children,
  }) => {
    return (
      <BaseThemeProvider theme={theme}>
        {children}
      </BaseThemeProvider>
    );
  };

  return { styled, ThemeProvider };
}

emotion이 11로 메이저 버전이 올라가며 치명적인 타입 이슈가 생길 수 있었지만, 중간에서 다리 역할을 해주는 makeTheme과 같은 함수가 존재하였기 때문에 어렵지 않게 패키지 내부의 타입들을 확장해 마이그레이션을 할 수 있습니다.

데브시스터즈는 최고의 인재를 찾고 있습니다.

자세한 내용은 채용 사이트를 확인해주세요!

© 2024 Devsisters Corp. All Rights Reserved.