[MUI] MUI를 통한 디자인 시스템 구조 뜯어보기

Gyuwon Lee·2022년 12월 27일
2

디자인 시스템

목록 보기
3/6
post-thumbnail

현재 회사에서 MUI 기반의 디자인 시스템을 개발하며, wrapper 라이브러리를 작업 중이다.

간단히 말하면 MUI 커스텀 테마를 라이브러리화 하여, import 하는 것만으로 간단히 디자인 시스템을 사용할 수 있도록 하는 작업이다.

디자인 시스템을 구축하는 데 있어서 주요 작업이란, 디자이너에게는 ‘디자인’일 것이다.

그럼 개발자의 역할은 뭘까? 자문해 보았다.

작업을 하면서, 프론트엔드의 역할이란 단순히 디자인을 코드로 옮기는 CSS 작업이 아니라, 디자인 혹은 컴포넌트 스펙이 변경되어도 유연하게 대응할 수 있는 구조를 구축하는 것이라고 느꼈다.

그래서 틈틈이 MUI의 소스 코드를 바탕으로 이들이 디자인 시스템을 구축한 방법을 공부하고 고민해 보았다.

0. 컴포넌트

우선, 디자인 시스템의 핵심 단위는 컴포넌트다. 완성된(ready-to-use) 컴포넌트들을 사용하여 디자인 및 개발 효율을 올리려는 것이 디자인 시스템의 목적이니 당연하다. 하지만 여기서 ‘어떻게 예쁜 컴포넌트를 만들 것인가’ 를 고민할 것은 당연히 아니다. 고민은 ‘어떤 컴포넌트를 개발해둘 것인가’ 에서부터 시작해야 한다.

현재 MUI에서 제공하고 있는 컴포넌트는 와 같다. 우선 어떻게 컴포넌트를 분류하고 있는지를 보았다. 아래는 각 분류에 대해 임의로 정의해본 내용이다:

  • Input
    • 유저가 직접 값을 입력 / 수정하고 제출하여 UI상의 변경을 일으키는 컴포넌트
  • Data Display
    • 서비스 혹은 다른 UI에 대한 정보를 담고 있는 컴포넌트
  • Feedback
    • 유저의 액션에 반응하는 컴포넌트 (ex. 유저가 버튼을 클릭 → Dialog 혹은 Snackbar 오픈)
  • Surfaces
    • UI의 한 구획이나 구역을 명시적으로 나타내는 데 쓰이는 컴포넌트
  • Navigation
    • 다른 페이지 혹은 UI로 유저를 이동시킬 수 있는 컴포넌트
  • Layout
    • UI의 배치 레이아웃을 결정하는 컴포넌트
    • Surfaces 와 달리, 유저에게 UI 요소로서 명시적으로 보이지 않는다.

이렇게 나누어 보니 우선 컴포넌트들을 기능상의 역할로 분류해두었음을 알 수 있었다. 다음은 각 분류 안에서 어떤 컴포넌트들을 구현해두었는지 들여다볼 차례다.

📌 Atomic Design Pattern

그 전에, 재사용 가능한 컴포넌트를 만드는 데 있어 종종 언급되는 Atomic Design Pattern 을 먼저 간단히 언급하는 것이 좋을 것 같다.

Atomic Design Pattern에서는 ‘디자인 부품을 만들어 조립한다’는 개념을 화학에 빗대 설명한다. “원자가 모여 분자가 되고, 분자가 모여 유기체가 된다”는 원리를 컴포넌트에 적용한 것이다. 원자는 절대로 쪼개지지 않는 최소 단위이며, 원자끼리 결합했을 때 분자가 되어 특정 행동을 할 수 있게 되고, 이 분자들이 결합한 형태의 유기체가 모여서 의미 있는 하나의 단위가 되는 것처럼, 컴포넌트 역시 이러한 방식으로 쪼갤 수 있다는 개념이다.

다만 Atomic Design Pattern은 프로세스가 아니라 멘탈 모델이다. 보통 방법론으로 소개되지만, 5가지 단계로 이루어지는 디자인이나 개발하는 프로세스라기보다는 일종의 멘탈 모델에 해당한다.

컴포넌트 나누기

다시 MUI로 돌아와, 방금 위에서 본 Atomic Design Pattern 의 관점에서 컴포넌트 목록을 보면 atom과 molecule 들이 섞여 있는 것으로 보인다. 예를 들어 Input의 Button은 atom이지만, Data Display 의 List 는 molecule에 가깝다. 일반적으로 리스트의 각 요소는 눌렀을 때 유저를 이동시키거나 새 UI를 보여주는 버튼에 해당하기 때문이다. 이 관점에서 List는 여러 버튼(ListItem)들 및 divider 등으로 이루어진 molecule이다.

여기서 하려는 말은, “MUI가 컴포넌트를 이렇게 분류해 두었으니 이렇게 만들어야 해” 가 아니라, atom과 molecule 모두 디자인 블럭으로 만들어두어야 한다는 것이다. atom 만 만들어 두고 알아서 molecule 로 조합하게 두어서도 안 되고, 반대로 molecule 만 만들어 두고 각 atom은 재사용하기 어렵게 만들어서도 안 된다.

사실 MUI에는 List와 관련해 이만큼의 API가 있다. 이 중에는 atom 도 있고, atom 에 의존하는 molecule 도 있다. 목록을 보면 ListItemSecondaryAction 의 경우, 단순 Button 이지만 ‘리스트 요소 안에서 사용된다’ 는 특정한 맥락이 있기 때문에 별도 컴포넌트로 개발되었다. ListItemText 나 ListItemAvatar 등 다른 요소들과 레이아웃을 맞춰야 하기 때문인 것으로 보인다.

이처럼, 디자인 시스템의 목표인 ‘ready-to-use’ 컴포넌트를 만든다는 것은 당장 프로덕트에 넣어도 이질감이 없는 ‘예쁜’ 컴포넌트를 만드는 것이 아니라, 프로덕트의 세부적인 페이지들과 UI들뿐 아니라 잠재적으로 도입될 서비스에 대한 UI, 유저 인터랙션에 의한 UI 변경점 등을 전부 고려해서 ‘최대한 많은 사용 케이스에 대응 가능한’ 컴포넌트 집합을 만드는 것이 핵심이라고 결론 내렸다.

한편, 최대한 많은 사용 케이스에 대응 가능하다는 것은 곧 최대한 모든 페이지가 일관된 디자인을 유지하게 하는 것과도 같다. 그런데 이때 일관된 디자인을 위해 필요한 것은 꼼꼼한 컴포넌트 구성만이 아니다. 앞서 말한 ‘예쁜’ 컴포넌트를 만드는 CSS style 역시 일관된 톤으로 모든 컴포넌트에 걸쳐 적용되어야 한다.

이를 위해서는 CSS 사용되는 값(ex. palette의 color code들) 역시 별도로 관리가 필요하다. 이를 일반적으로 Theme 객체라고 칭한다. 디자인 시스템에서는 모든 컴포넌트가 사전에 정의된 Theme 의 값들에 기반해 작동하도록 설계한다. 여기서 Theme 이 컴포넌트에 주입될 수 있도록 로직을 구성하는 것이 개발의 핵심이다.

1. Theme이란?

MUI를 커스텀하는 일반적인 방법은 컴포넌트를 불러와서 styled() 로 감싸거나 sx prop을 사용하는 것이다.

import { Box, styled } from '@mui/material'

// styled()
const styledBox = styled(Box)((
	{ theme, ownerState }) => ({
		width: '1rem',
	})
)

// sx props
return <Box sx={{width: '1rem'}} />

이 경우 흔히 우리가 사용하는 CSS 문법으로 필요한 부분만 스타일링할 수 있다. 위 방법이 컴포넌트에 특정 스타일을 ‘주입’ 하는 방식이라면, 라이브러리 형태로 커스텀하는 것은 어플리케이션의 최상단에서 아예 커스텀 테마로 ‘감싸는’ 방식이라고 생각할 수 있을 것 같다.

Material UI provides theme tools for managing style consistency between all components across your user interface. 참고

MUI에서는 Theme 객체를 사용해서 스타일을 관리하고, 모든 컴포넌트에 일관된 디자인 매너가 적용될 수 있도록 만들었다. 즉, MUI 디자인 시스템의 기반에는 이 Theme 객체가 있다. 모든 컴포넌트에서 접근 가능한 공통의 객체 Theme 을 정의해 두고, 이 객체가 style 에 있어 단일 진실의 원천(Single Source of Truth)으로 기능하는 구조가 핵심이다.

소스 코드

MUI는 스타일을 최소화하여 기능 위주로 구현한 @mui/base 의 컴포넌트를 기반으로, styled 함수를 사용해 스타일을 입힌다. 이 때 theme 객체에 정의되어 있는 token 값들을 기반으로 스타일을 계산하기 때문에 모든 컴포넌트에 간편하게 일관된 디자인 매너를 적용할 수 있다. 일부 token의 값이 바뀌어도, 해당 token을 사용하는 컴포넌트를 일일히 고칠 필요 없이 theme에 들어가는 값만 변경하면 되기 때문이다.

📌 Theme 사용의 장점

이 theme의 강력한 점은, styledsx 모두에서 접근할 수 있다는 점이다. 뿐만 아니라 컴포넌트 객체의 defaultProps, styleOverrides, variants 속성에서도 모두 접근할 수 있다. 그래서 어느 수준에서 컴포넌트를 수정하든 정의된 팔레트 안에서 일관된 값을 적용할 수 있다.

이 theme 객체는 여러 수준에서 접근 가능하다.

// sx 레벨
<Paper sx={theme => ({ background: theme.palette.primary.main })} />

// styled 레벨
const StyledPaper = styled(Paper)(
	({theme, ownerState}) => ({
		background: theme.palette.primary.main,
	})
);

// 컴포넌트 레벨
const muiComponentPaper: MuiComponent<'MuiPaper'> = {
  styleOverrides: {
    root: ({ theme, ownerState }) => ({     
      background: theme.palette.primary.main,
    }),
	},
};

그래서 theme 을 잘 정의해 두고 효과적으로 사용한다면, 아래와 같은 장점을 얻을 수 있다:

  • 모든 컴포넌트에 일관된 palette, spacing 등을 적용하고, theme 파일에서 값을 변경하는 것만으로 수정 사항을 시스템 전체에 반영할 수 있다.
  • sx 또는 styled를 사용하여 일부 컴포넌트를 일회성으로 커스텀할 때에도, 시스템의 규격을 유지할 수 있다.
    • 예를 들어, 기존에 p 태그의 fontWeight가 normal 로 설정되어 있는 상황에서 딱 한 개의 p 태그만 fontWeight를 bold로 수정한다고 생각해 보자.
      <p sx={{fontWeight: 'bold'}} />
      위 형태로 수정한다면, 나중에 bold의 값이 변경되었을 때 이 p태그는 theme의 값을 사용하고 있지 않으므로 theme에 정의된 bold 값을 사용한 다른 컴포넌트들과 동떨어져 보일 것이다.
      <p sx={(theme)=>({fontWeight: theme.typography.fontWeightBold})} />
      하지만 위 형태로 수정한다면, 나중에 fontWeightBold 의 값이 수정되었을 때, 개별적으로 수정한 컴포넌트를 일일히 찾아다니지 않아도 일괄 적용시킬 수 있다.

2. Theme의 형태와 요소

이 다음은 Theme을 생성해 주는 createTheme() 함수에 들어가 Theme의 형태를 알아보았다.

// mui-material/src/styles/createTheme.d.ts

function createTheme(options?: ThemeOptions, ...args: object[]): Theme;

쉽게 말하자면, ThemeOptions 타입의 options 객체를 createTheme 함수의 파라미터로 넘겨 리턴받은 Theme 타입의 객체라고 볼 수 있다.

// mui-material/src/styles/createTheme.d.ts

export interface ThemeOptions extends Omit<SystemThemeOptions, 'zIndex'> {
  mixins?: MixinsOptions;
  components?: Components<Omit<Theme, 'components'>>;
  palette?: PaletteOptions;
  shadows?: Shadows;
  transitions?: TransitionsOptions;
  typography?: TypographyOptions | ((palette: Palette) => TypographyOptions);
  zIndex?: ZIndexOptions;
  unstable_strictMode?: boolean;
  unstable_sxConfig?: SxConfig;
}  

ThemeOptions 는 이렇게 생겼다. 즉 우리는 createTheme 에 위처럼 생긴 객체를 첫 번째 인자로 주어 원하는 커스텀 테마를 만들 수 있는 것이다. MUI의 기본 테마 역시 이 객체의 형태를 따라 만들어진 것으로, 공식문서에서 default theme viewer 를 제공하고 있다.

다만 단순히 ‘별도의 객체로 존재하던 token 들을 하나의 객체로 합치는 것’ 이 아니라, 추가적으로 꽤나 복잡한 로직이 필요하다. 위의 createTheme 과 같이, Theme을 생성하는 함수가 필요하다. Theme에 따라 모든 프로퍼티를 갖고 있지 않을 수 있기 때문에, defaultTheme 과 deepmerge 하는 과정 등 후처리 로직이 뒤따른다.

📌 Theme-tokens 구조의 장점

아무튼 디자인 시스템에서 테마란, 적어도 MUI에 따르면, palette, shadow, typography 등 각각의 token으로 관리되는 스타일들을 하나의 객체로 모아 적용시키는 wrapper다. 이러한 구조는 어떤 장점이 있을까?

바로 위에 적은 대로, “palette, shadow, typography 등을 각각의 token으로 관리” 할 수 있게 된다. 이는 재사용성 측면에서 효율성을 크게 높인다. Theme 파일 안에서 직접 모든 스타일을 정의할 수도 있지만, 이 경우 스타일 재사용이 불가능하다. 하지만 각각을 별도 모듈로 관리할 경우, 필요한 모듈만 cherry-pick 해서 Theme으로 합치는 방식을 통해 재사용이 가능하다.

3. Theme의 요소: tokens

모든 디자인 시스템은 공통 token을 기반으로 디자인된다. 경우에 따라 이 token을 atomic design pattern 상의 atom 에 해당하는 것으로 보는 관점도 있다. 개인적으로는 token 은 디자인 시스템의 기반이고 atomic design pattern 은 컴포넌트 레벨에서 고려되어야 한다고 보기 때문에 동의하지는 않는다. 하지만 그만큼 컴포넌트를 이루는 근본 요소에 해당한다는 뜻으로 생각해볼 수 있겠다.

// MUI default theme (partial)

const palette = {
	mode: "light"
	common: {
		black: '#000',
		white: '#fff',
	}
	primary: {
		main: '#1976d2',
		light: '#42a5f5',
		dark: '#1565c0',
		contrastText: '#fff',
	},
	secondary: {
		main: '#9c27b0',
		light: '#ba68c8',
		dark: '#7b1fa2',
		contrastText: '#fff'
	},
	...
}

token 의 목적은 많은 컴포넌트들에서 공통으로 쓰이는 값들을 모듈로 분리함으로써 재사용성과 유연성을 높이고 디자인 일관성을 손쉽게 유지하는 데 있다. 즉, 디자인 시스템 안에서 일관되게 적용되어야 할 것들을 미리 전부 정의해 두는 것이다. 이렇게 사전 정의된 token 값은 theme에 의해 컴포넌트들에 덧씌워지거나, 여의치 않은 경우 컴포넌트 내부에서 직접 해당 token을 import 해 사용하게 될 수도 있다.

따라서 일관적으로 적용되어야 하거나, 또는 조건에 따라 자주 변경되는 값 등은 미리 token으로 정의해 두고 컴포넌트 내에서는 구체적인 값이 아닌 token 이름으로 불러오는 것이 좋다.

예시로 가져온 위 코드는 MUI default theme 의 일부다. common, primary, secondary 각각의 contrastText 는 해당 main 컬러 코드와 대비되는 텍스트 색상을 의미한다.

보통 버튼은 위와 같이 세 가지 종류가 있는데, 가운데 contained 타입의 경우 배경색에 따라 텍스트 색상이 바뀌어야 한다. 배경색이 어두운 경우 위처럼 흰색 텍스트가 잘 보이겠지만, 배경이 밝은 색인 경우에는 검은색 등 어두운 텍스트가 들어가야 한다. 여기서 각 색상에 따라 아래와 같이 정의하면 어떨까?

const setTextColor = (variant) => {
	switch (variant) {
		case "primary":
			return "#fff"
		case "secondary":
			return "#fff"
		case "whateverLight":
			return "#000"
		...
		default:
			return `${tokens.palette[variant]}`
	}
}
...
color: setTextColor(variant)

코드가 길어질 뿐더러, 색상이 변경될 경우 그에 따라 텍스트 색상을 위 코드에서 찾아 컴포넌트 안에서 손수 변경해줘야 한다. 하지만 contrastText 라는 이름으로 미리 palette에 정의해 둔 경우, variant 에 관계없이 아래의 한 줄로 해결할 수 있다:

color: token.palette[variant].contrastText

따라서 token 을 정의할 때는 컴포넌트의 사용 맥락이 변경될 수 있는 여러 케이스들과 유저 인터랙션 상황을 잘 고려하여 자세하게 명시해 두는 편이 좋다.

마치며

MUI 소스 코드나 커스텀 테마의 구조, 라이브러리화 할 경우의 모듈 구조 등 코드와 관련된 부분들은 제외하고 최대한 디자인 시스템의 구조에 집중해서 내용을 정리하려다 보니, 생각보다 내용이 많지 않아서 아쉽다. 프로젝트를 처음 접했을 때는 ‘이런 식으로 컴포넌트를 관리하다니’ 싶어 정말 신세계 같았다. 정해진 구조 아래서 값만 조금 바꾸면 프로덕트의 모습이 확확 달라지는 게 신기했는데, 글로 정리하려니 그 내용을 충분히 담지 못한 것 같아 아쉽다. 일단 구조를 다뤄 두었으니, MUI 소스 코드를 뜯어봤던 삽질기나 각 컴포넌트 별 커스텀 방법 등 공부했던 내용들도 각각 글로 정리해두려고 한다.

profile
하루가 모여 역사가 된다

0개의 댓글