blog.yoouyeon
about/tags
Back to blog
May 15, 2026

모또 디자인 시스템 구축기 1편 — 토큰 구조 설계부터 시작

들어가며

모또 프론트엔드 프로젝트에는 디자이너 분들과 함께 만들었던 꽤 오래된 스타일 시스템이 있었다. (벌써 일년이나 되었다…)

프로젝트의 깔끔한 마무리를 짓기로 하고 기존 산출물을 정리하다 보니 디자이너분의 눈에도, 나의 눈에도 우리 디자인 시스템이 참 아쉽다는 생각이 들었던 것 같다. (배포는 하고 홍보도 조금은 하겠지만 아무래도 이제 사용자 유입은 어렵겠다는 공통적인 의견이 있었다 🥹)

그래서 우선 디자이너분이 디자인 시스템 토큰과 컴포넌트 정리 작업을 진행해주셨다. 그리고 완료되어 전달받은 토큰의 모양을 보니 단순히 색상 뿐 아니라 계층이라던가 사용 방식 같은 것들이 많이 달라져 있었다.

코드적인 측면에서도 기존의 디자인 시스템이 아쉬운 부분이 있었기 때문에 단순히 토큰의 변경사항을 매핑하는 것에서 그치지 않고, 기존의 설계를 살펴보고, 개선할 수 있는 구조가 있을지를 고민하기로 했다.

그래서 시작한 작업이 이번 글에서 다루게 되는 디자인 시스템 구축 작업이다. 그리고 이 글은 그 첫번째 이야기! 토큰 구조를 어떻게 설계했는지, 왜 그렇게 결정했는지를 정리하려고 한다.

기존 구조의 문제

기존 구조에서 문제라고 생각한 부분은 두 가지였다.

첫 번째는 styled-component에 강하게 묶인 구조라는 것이다. 기존에는 ThemeProvider를 통해서 theme 객체를 주입하는 방식을 사용했었다. theme 객체는 이렇게 생겼다.

const primitiveColor: PrimitiveColorType = {
base: { white: '#FFFFFF', black: '#000000' },
orange: {
100: '#FDF0E7',
200: '#FFD8BE',
300: '#FFB17F',
400: '#FF9958',
500: '#E8742A',
600: '#B55B21',
},
transparent: {
white0: '#FFFFFF00',
white10: '#FFFFFF1A',
// 생략...
},
// 생략...
} as const;
const color: ColorType = {
primitive: PrimitiveColor,
semantic: SemanticColor,
} as const;
const theme: Theme = {
color,
unit,
radius,
typography,
} as const;

이 방식은 styled-components 생태계에선 정말 자연스러운 방식이다. 하지만 styled-components를 쓰지 않는 프로젝트에서 이 디자인 시스템을 재사용하려고 하다면? 불가능한 구조였다. 팀원들과 만약에… 다른 사이드프로젝트를 하게 된다면 이 디자인 시스템을 쓰자는 얘기가 나왔을 때 더더욱 기존 방식에 문제가 있다는 것이 확실히 보였다.

두 번째는 과하게 복잡한 타입 시스템이었다. 중첩된 theme 객체에 타입 안전하면서도 편하게 접근하기 위해서 유틸리티 타입을 만들어서 썼었다. Theme가 변경되었을 때에도 타입이 깨지지 않게 하겠다는 나름대로의 원대한 계획으로 만든 타입이었다. 만들고 나서 너무 뿌듯해서 글도 썼었다. (참고: https://blog.yoouyeon.dev/convert-object-keys-to-type) 그 결과를 정리해서 글로 남길 만큼 공들인 타입이었는데, 그럼에도 불구하고 사용하는 측에서 어떤 타입을 써야 하는지 몰라서 그냥 string 타입을 사용하는 곳도 있었고, 그러다보니 존재하지 않는 값이 들어가는 경우도 있어서 알고보니 토큰이 적용되지 않는다거나 하는 문제도 있었다. 유틸리티 타입을 써야만 다룰 수 있다는 것 자체가 이미 복잡도가 높다는 뜻이었던 것 같기도 하다.

그래서 개선 구조의 방향성은 styled-components에 묶이지 않으면서, 타입 사용도 직관적인 구조로 바꾸는 것으로 잡았다.

토큰 구조 설계

토큰 구조는 기본적으로 디자이너분이 Figma Variable로 정리해주신 것을 기반으로 했다. 여기서 좀 고민했던 것은 그 구조를 코드에서 어떤 방식으로 강제할 것인가였다.

토큰은 두 계층으로 나뉜다.

Atomic token은 원시값이다. 여기에는 orange-50이 #FF9958이라는 사실만 담겨 있다.

Semantic token은 의도를 담은 토큰이다. fg.primary.normal처럼 "전경색 중에서 주요 텍스트의 기본 상태에 쓰인다." 라는 의미가 이 토큰 안에 담겨 있다. Semantic token은 내부적으로 Atomic token을 참조하고 있다. fg.primary.normal 역시 내부적으로는 orange-50을 참조하고 있다.

컴포넌트와 페이지에서 실제로 쓰는 토큰은 semantic token이다. 이런 규칙은 전체 디자인에서 일관된 색상 체계와 시각적 계층 구조를 유지하기 위한 전략이다. 이걸 그냥 관습으로만 두면 지켜지지 않을 수도 있어서, 실제 토큰에서 값을 가져오는 API 함수인 getToken과 getTypographyToken에서는 Semantic token에만 접근할 수 있도록 강제했다. Atomic token에 직접 접근하는 API는 의도적으로 만들지 않은 것이다.

사실 규칙이 너무 빡빡한 것이 아닌가 고민을 했었는데, 만약 Figma에서 primitive 값이 semantic alias 없이 그대로 쓰이고 있었다면 그건 단순히 코드 상에서 돌려서 해결해야 할 문제가 아니라 실제로 디자인 쪽에서도 뭔가 의도와 맞지 않은 색이 쓰이고 있다는 의미일수도 있다. 따라서 개발자가 혼자서 그 문제를 넘겨 버리기 보다는 디자이너에게 리포트해야 할 문제라고 생각했다. 따라서 우회로를 만들기 보다는 일부러 좀 빡빡한 규칙을 적용해두었다.

CSS variable로 빌드하기

이렇게 토큰 구조를 정한 뒤에는 이걸 어떻게 앱에서 쓸 수 있게 만들 것인가를 결정해야 했다.

우선 기존처럼 JavaScript 객체로 runtime에 주입하는 방식은 쓰지 않기로 했다. styled-component 없이도 쓸 수 있어야 했기 때문이다. 대신 빌드 단계에서 코드를 기반으로 token.css를 생성하고, 이 파일 하나를 import하는 구조를 선택했다.

/* token.css (일부) */
:root {
--spacing-2: 2px;
--color-fg-strong: #101113;
--spacing-gap-1: var(--spacing-2);
--radius-xs: 4px;
--font-sans: Pretendard;
--text-heading-medium: 24px;
}

CSS Variable 방식을 쓰면 styled-component 밖에서도 라이브러리에 관계 없이 그냥 쓸 수 있다는 장점이 있다. 그리고 DevTools에서도 변수 이름이 바로 보여서 디버깅도 훨씬 편하다는 장점이 있다.

이렇게 선언한 CSS Variable을 컴포넌트에서는 getToken을 통해 접근한다. 이렇게 API를 만들어 둔 것은 CSS Variable을 직접 문자열로 쓰면 번거롭기도 하고 오타가 난 경우에 그대로 넘어갈 수 있기 때문이다. 각 token의 이름을 type으로 정의해뒀기 때문에 getToken('fg.primary.normal') 형태로 쓰면 TypeScript가 잘못된 key를 잡아준다.

export const Button = styled.button`
padding: ${getToken('padding.4')} ${getToken('padding.6')};
color: ${getToken('fg.accent-red.normal')};
${applyTypography('typography.body.medium')};
`;

타이포그래피는 별개의 API인 applyTypography를 이용하도록 했다. 타이포그래피에서는 variant에 따라서 font-size, line-height, letter-spacing, font-weight가 항상 묶음으로 적용되어야 하는데, 값을 하나씩 꺼내면 조합이 어긋날 수 있어서 타이포그래피 토큰 이름 하나로 관련 CSS 속성 전체를 뱉도록 했다.

CSS 빌드는 따로 스크립트를 만들어서, package.json에 해당 스크립트를 돌릴 수 있도록 설정했다. Github Action으로 설정해서 빌드 과정을 자동화하는 방법도 생각했는데, 일단! 아직은 변경 예정이 없기도 하고 남은 할일이 너무 많아서 (ㅠㅠㅠ) 우선은 수동으로 돌리는 상황이다. 만약에 필요하다면 디자인 시스템 디렉토리 경로에서 변경이 발생하면 새로 빌드하는 식으로 설정하면 될 것 같다.

CSS Variable 네이밍 고민

CSS Variable 이름을 어떻게 지을지도 고민을 좀 했었다. 지금 프로젝트는 styled-component를 쓰고 있지만, 같은 팀원들과 만약에 다른 사이드 프로젝트를 하게 되면 이 디자인 시스템을 그대로 사용하기로 했고, 왠지.. 그때는 Tailwind를 쓰게 될 것 같았다.

Tailwind에서 CSS Variable을 커스텀 토큰으로 연결하려면 --color-*, --spacing-*, --radius-* 같은 namespace 형식에 맞아야 한다. (참고 : https://tailwindcss.com/docs/theme#default-theme-variable-reference) 지금 당장은 Tailwind를 쓰지 않지만, 나중에 Tailwind 환경에서도 그대로 가져다 쓸 수 있도록 처음부터 이 형식에 맞춰 이름을 지어 주었다.

디자인 시스템을 어디에 둘 것인가

이 프로젝트의 디렉토리 구조는 FSD(Feature-Sliced Design)아키텍쳐를 기반으로 하고 있다. 이 구조 안에서 디자인 시스템 관련 코드를 어디에 둬야 할지도... 정말 오래 고민한 것 중 하나이다.

FSD 원칙을 엄격하게 따른다면 shared 레이어 하위에 디자인 시스템 관련 코드들이 기능별로 분산되었어야 했다. 하지만 디자인 시스템은 token 정의와 build 스크립트, CSS 산출물, 관련 유틸 함수, 그리고 UI 컴포넌트가 하나의 관심사로 묶여 있는 코드이다. 이걸 FSD 원칙에 맞춰서 분산하면 유지보수 때 여러 곳을 동시에 건드려야 하는 상황이 생길 것이다. 그리고 나중에... 언젠가는 모노레포로 분리할 수도 있지 않을까 하는 생각이 있기도 했다.

그래서 FSD 원칙과는 완전히 일치하지 않더라도, shared/design-system이라는 디렉토리에 관련 코드를 모두 모으는 방향을 선택했다. 응집도가 유지보수성에 더 직접적으로 영향을 준다고 생각했기 때문이다.

따라서 최종 디렉토리 구조는 이렇게 되었다.

.
├── index.ts
├── lib
│   ├── applyTypography.ts
│   ├── buildThemeCss.ts
│   ├── getToken.ts
│   └── getTypographyToken.ts
├── tokens
│   ├── atomic
│   │   ├── color.ts
│   │   └── typography.ts
│   ├── build
│   │   └── token.css
│   └── semantic
│   ├── color.ts
│   └── typography.ts
└── ui
├── Accordion
│   ├── Accordion.stories.tsx
│   ├── Accordion.styles.ts
│   ├── Accordion.tsx
│   └── index.ts
└── ...

다음 글에 이어서…

사실 그냥 토큰을 치환하고, 공통 컴포넌트를 구현해서 갈아끼우는 정도만 하면 될 것이라 생각했었는데 생각보다 고민할 것들이 정말 많았다. 이 글에서도 고민이라는 단어를 6번이나 썼다. 지금 생각해보니 설계하는 데만 작업 과정의 거의 절반을 썼다. 그런데 또 생각해보면 실제로 컴포넌트를 구현하고 치환하는 과정은 코드 변경 범위에 비해서 생각보다 훨씬 빠르게 끝났다. 기준을 잡는 데 충분히 시간을 쓰는 것의 중요성을 체감할 수 있었던 경험이었다.

이어지는 2편! 다음 글에서는 이 기반 위에서 공통 컴포넌트를 어떻게 분류하고 구현했는지, 기존 디자인 시스템을 어떤 기준으로 정리했는지를 정리해볼 예정이다!

end