이전 회사에서 개발한 테마 오버라이딩 기능에 대해 정리해보려 한다.
| 구분 | 내용 |
|---|---|
| 문제 | 디자인 시스템의 테마를 고객사별로 커스터마이징해야 하는데 기존 구조에서는 테마 오버라이딩이 불가능 |
| 해결 | makeTheme 유틸 함수를 만들어 테마를 확장하면서도 타입 안전성을 유지하도록 개선 |
| 결과 | 각 컴포넌트에서 Theme 상수 객체를 직접 import하지 않고 props.theme으로 타입 안전하게 접근 가능해짐 |
고객사마다 다른 테마가 필요했다.
이전 회사의 디자인 시스템은 Material UI + Emotion 기반이었다. Emotion의 ThemeProvider로 테마를 관리했고 declare module을 통해 인터페이스를 확장하는 방식이었다.
const MyCompanyProvider = ({ children }: PropsWithChildren) => {
return (
<ThemeProvider theme={COMPANY_THEME}>
{children}
</ThemeProvider>
);
};
declare module '@emotion/react' {
export interface Theme extends MyCompanyTheme {}
}
문제는 고객사마다 요구하는 색상이 달랐다는 점이다.
여러 고객사가 자사 브랜드 컬러를 적용해달라고 요청했다.
하지만 기존 디자인 시스템은 테마 오버라이딩을 지원하지 않았다.
그래서 당시 다른 팀들은 테마 값을 상수로 직접 import해서 사용하고 있었다.
/** constants/colors.ts */
const BLACK = { 0: '#000', /* ... */ };
export const COLORS = {
...WHITE,
...BLACK,
...GREEN,
...RED
};
// components/Example.tsx
import { COLORS } from '@/constants/colors';
const Container = styled.div({
backgroundColor: COLORS.white100,
border: `1px solid ${COLORS.black100}`,
});
const Text = styled.p({
color: COLORS.green300,
});
프로젝트 규모가 커질수록 많은 컴포넌트에서 Theme 관련 객체를 import해야 함
디자인 시스템을 사용하는 서비스 코드에서 테마를 바꿀 수 없음
props.theme 대신 상수를 직접 참조하므로 Emotion의 테마 시스템을 활용하지 못함 + 생산성 비효율
결국 타 팀에서 디자인 시스템 팀에 지속적으로 요청이 들어왔다.
컴포넌트마다 Theme 관련 객체를 import하는 게 너무 번거로워요.
styled에서 props.theme으로 접근할 수 있게 테마 오버라이딩을 지원해주세요.
테마 오버라이딩과 props.theme에서의 타입 자동완성을 지원해야 했다.
데브시스터즈의 기술 블로그에서 비슷한 접근법을 발견했고 이를 참고해서 구현했다.
핵심 아이디어는 테마 타입을 제네릭으로 받아서 해당 타입에 맞는 styled와 ThemeProvider를 반환하는 함수를 만드는 것이다.

먼저 두 테마를 합치는 타입을 정의한다.
type MergedTheme<BaseTheme extends object, Theme extends object> =
BaseTheme & Theme;
import _styled from "@emotion/styled";
import { ThemeProvider as BaseThemeProvider } from "@emotion/react";
import type { CreateStyled, StyledTags } from "./emotion";
import type { PropsWithChildren } from "react";
export function makeTheme<BaseTheme extends object, Theme extends object>() {
type NewTheme = MergedTheme<BaseTheme, Theme>;
// 핵심: styled에 새로운 테마 타입을 주입
const styled = _styled as CreateStyled<NewTheme> & StyledTags<NewTheme>;
type ThemeProps = PropsWithChildren<{ theme: NewTheme }>;
const ThemeProvider = ({ theme, children }: ThemeProps) => {
return <BaseThemeProvider theme={theme}>{children}</BaseThemeProvider>;
};
return { styled, ThemeProvider };
}
Emotion의 styled는 내부적으로 Theme 타입을 declare module로 확장하도록 설계되어 있다. 하지만 이 방식은 전역으로 하나의 테마 타입만 지정할 수 있다는 한계가 있다.
데브시스터즈의 블로그에서는 이를 우회하기 위해 styled에 제네릭 테마 타입을 주입하는 방식을 사용했고 나도 이 방식을 따랐다.
런타임에서 styled의 동작은 동일하지만 타입 레벨에서 Theme을 교체해야 했기 때문에 타입 단언을 사용했다.
const styled = _styled as CreateStyled<NewTheme> & StyledTags<NewTheme>;
이 구조 덕분에 하위 컴포넌트에서도 테마 확장이 가능해졌다. 처음 의도한 건 아니었지만 구현 과정에서 얻은 추가 이점이다.
@emotion/styled가 export하는 styled는 Theme 타입이 고정되어 있다. 제네릭으로 교체하려면 내부 타입 구조를 알아야 해서 Emotion 소스 코드를 분석했다.
// @emotion/styled/types/index.d.ts
import { ReactJSXIntrinsicElements } from './jsx-namespace'
import {
CreateStyledComponent,
CreateStyled as BaseCreateStyled
} from './types'
export type StyledTags = {
[Tag in keyof ReactJSXIntrinsicElements]: CreateStyledComponent
{
theme?: Theme // Theme이 고정됨
as?: React.ElementType
},
ReactJSXIntrinsicElements[Tag]
>
}
export interface CreateStyled extends BaseCreateStyled, StyledTags {}
const styled = baseStyled.bind(null) as CreateStyled
export default styled
styled의 타입은 두 타입의 교집합으로 이루어져 있다.
문제는 둘 다 Theme 타입이 고정되어 있다는 점이다.
BaseCreateStyled를 더 깊이 살펴봤다.
// @emotion/styled/types/base.d.ts
export interface CreateStyled {
<
C extends React.ComponentClass<React.ComponentProps<C>>,
ForwardedProps extends keyof React.ComponentProps<C> &
string = keyof React.ComponentProps<C> & string
>(
component: C,
options: FilteringStyledOptions<React.ComponentProps<C>, ForwardedProps>
): CreateStyledComponent<
Pick<PropsOf<C>, ForwardedProps> & {
theme?: Theme // Theme 고정
},
{},
{
ref?: React.Ref<InstanceType<C>>
}
>
<C extends React.ComponentClass<React.ComponentProps<C>>>(
component: C,
options?: StyledOptions<React.ComponentProps<C>>
): CreateStyledComponent<
PropsOf<C> & {
theme?: Theme // Theme 고정
},
{},
{
ref?: React.Ref<InstanceType<C>>
}
>
<
C extends React.ComponentType<React.ComponentProps<C>>,
ForwardedProps extends keyof React.ComponentProps<C> &
string = keyof React.ComponentProps<C> & string
>(
component: C,
options: FilteringStyledOptions<React.ComponentProps<C>, ForwardedProps>
): CreateStyledComponent<
Pick<PropsOf<C>, ForwardedProps> & {
theme?: Theme // Theme 고정
}
>
<C extends React.ComponentType<React.ComponentProps<C>>>(
component: C,
options?: StyledOptions<React.ComponentProps<C>>
): CreateStyledComponent<
PropsOf<C> & {
theme?: Theme // Theme 고정
}
>
<
Tag extends keyof ReactJSXIntrinsicElements,
ForwardedProps extends keyof ReactJSXIntrinsicElements[Tag] &
string = keyof ReactJSXIntrinsicElements[Tag] & string
>(
tag: Tag,
options: FilteringStyledOptions<
ReactJSXIntrinsicElements[Tag],
ForwardedProps
>
): CreateStyledComponent<
{ theme?: Theme; // Theme 고정
as?: React.ElementType },
Pick<ReactJSXIntrinsicElements[Tag], ForwardedProps>
>
<Tag extends keyof ReactJSXIntrinsicElements>(
tag: Tag,
options?: StyledOptions<ReactJSXIntrinsicElements[Tag]>
): CreateStyledComponent<
{ theme?: Theme; // Theme 고정
as?: React.ElementType },
ReactJSXIntrinsicElements[Tag]
>
}
CreateStyled는 6개의 오버로드 시그니처를 가지고 있었고, 모든 시그니처에서 Theme이 고정되어 있었다.
styled.div, styled.span 같은 태그 기반 호출을 담당하는 StyledTags도 살펴봤다.
// @emotion/styled/types/index.d.ts
export type StyledTags = {
[Tag in keyof ReactJSXIntrinsicElements]: CreateStyledComponent
{
theme?: Theme // 여기도 고정
as?: React.ElementType
},
ReactJSXIntrinsicElements[Tag]
>
}
StyledTags는 모든 HTML 태그(div, span, button 등)에 대해 CreateStyledComponent를 매핑하는 타입이다. 여기서도 Theme이 고정되어 있었다.
이제 할 일은 명확했다. CreateStyled와 StyledTags 모두 Theme을 제네릭 파라미터로 바꾸는 것:
// CreateStyled 변경
// 변경 전
export interface CreateStyled {
// ...
): CreateStyledComponent<PropsOf<C> & { theme?: Theme }>
}
// 변경 후
export interface CreateStyled<Theme> { // 👈 제네릭 추가
// ...
): CreateStyledComponent<PropsOf<C> & { theme?: Theme }>
}
// StyledTags 변경
// 변경 전
export type StyledTags = {
[Tag in keyof ReactJSXIntrinsicElements]: CreateStyledComponent
{ theme?: Theme; as?: React.ElementType },
ReactJSXIntrinsicElements[Tag]
>
}
// 변경 후
export type StyledTags<Theme> = { // 👈 제네릭 추가
[Tag in keyof React.JSX.IntrinsicElements]: CreateStyledComponent
{ theme?: Theme; as?: React.ElementType },
React.JSX.IntrinsicElements[Tag]
>
}
전체 타입 정의는 GitHub 레포에서 확인할 수 있다.
Emotion 버전이 달라서 ReactJSXIntrinsicElements를 React.JSX.IntrinsicElements로 변경하고 일부 타입 시그니처를 현재 버전에 맞게 수정했다.
디자인 시스템 라이브러리에서는 네 가지를 export했다.
// design-system/index.ts
const { styled, ThemeProvider } = makeTheme<{}, BaseTheme>();
// 1. 기본 테마가 적용된 Provider (바로 사용 가능)
export const MyCompanyProvider = ({ children }: PropsWithChildren) => (
<ThemeProvider theme={defaultTheme}>{children}</ThemeProvider>
);
// 2. 기본 테마가 적용된 styled
export { styled };
// 3. 추가 확장이 필요한 경우를 위한 유틸 함수
export { makeTheme };
// 4. 기본 테마 타입 (확장 시 필요)
export type { BaseTheme };
사용하는 쪽에서는 두 가지 선택지가 있다.
가장 단순한 케이스이다. MyCompanyProvider로 감싸고, styled를 import해서 쓰면 된다.
import { styled, MyCompanyProvider } from '@company/design-system';
const Container = styled.div((props) => ({
backgroundColor: props.theme.color.bgColor, // 타입 자동완성
}));
function App() {
return (
<MyCompanyProvider>
<Container />
</MyCompanyProvider>
);
}
기본 테마에 고객사 전용 색상을 추가해야 할 때는 makeTheme을 사용한다.
import { makeTheme, type BaseTheme } from '@company/design-system';
type ExtendedTheme = {
ACompany: {
color: {
primary: string;
};
}
};
const { styled, ThemeProvider } = makeTheme<BaseTheme, ExtendedTheme>();
// App.tsx
import { styled, ThemeProvider } from './theme-context';
const Header = styled.header((props) => ({
backgroundColor: props.theme.ACompany.color.primary, // 확장된 타입도 자동완성
}));
function App() {
return (
<ThemeProvider
theme={{
...defaultTheme,
ACompany: {
color: {
primary: '#FF5733',
},
},
}}
>
<Header>A Company 전용 헤더</Header>
</ThemeProvider>
);
}
처음 요구사항엔 없었지만 이 구조 덕분에 컴포넌트 트리 깊은 곳에서도 테마를 확장할 수 있게 됐다.

// components/B/theme-context.tsx
import { type Theme as BaseTheme } from '../../theme-context';
import { makeTheme } from '../../util/theme';
type ExtendedTheme = {
color: {
border: string;
};
};
export const { styled, ThemeProvider } = makeTheme<BaseTheme, ExtendedTheme>();
// components/B/index.tsx
import { styled, ThemeProvider } from './theme-context';
const Container = styled.div((props) => ({
backgroundColor: props.theme.color.bgColor, // 기존 테마
border: props.theme.color.border, // 확장된 테마
}));
function B() {
return (
<ThemeProvider
theme={{
color: {
textColor: '#FD7622',
bgColor: '#FFFFFF',
border: '4px solid red',
},
}}
>
<Container>
<p>B 컴포넌트</p>
</Container>
</ThemeProvider>
);
}
TypeScript 제네릭의 활용
라이브러리의 고정된 타입을 제네릭으로 확장하는 패턴을 익혔다.
Emotion 내부 구조 이해
styled가 Theme 타입을 어떻게 참조하는지 알게 됐다.
타입 단언의 적절한 사용
런타임 동작은 같지만 타입만 변경해야 할 때 as를 사용하는 케이스를 알게 됐다. 이 패턴은 추후 컴포넌트 다형성을 구현할 때도 활용할 수 있었다.
자세한 예제 코드는 깃허브 레포에서 확인할 수 있다.