시스템 설계에 있어서 가장 기반이 되는 Foundation
을 지정하게 되는데, 그중 디자인 토큰
은 디자인 시스템 구축에 있어서 기반이 되고 공수가 많이 드는 작업 중 하나다. 다음으로는 설계한 토큰
을 실제 컴포넌트에 어떻게 적용하고 피그마에도 활용할 수 있는지 확인하고 마지막으로 컴포넌트의 다형성(Polymorphism)
을 위해 Type-Safe
한 인터페이스를 만들어 더 확장성 있는 디자인 시스템을 구축하는 단계를 살펴보자.
Foundation
디자인 토큰 설계 시 아래 4가지를 고민하여 설계한다.
- 브랜드 컨셉을 따르는 컬러와 폰트에 대한 대략적인
네이밍 구조
설계 - Object Styles, Color 및 Typography에 대한 토큰
네이밍의 의미
를 정의 - 토큰값을 서로
참조
할 수 있는 구조 설계 테마
적용에도 유연하게 반영할 수 있는 구조 설계
먼저 브랜드 디자인 컨셉의 기본이 되는 Foundation을 선정한다. 이는 디자인 시스템의 발판이 되는 구조인데 이 단계에서는 일반적으로 Color, Typography, Size, Border, Spacing, Layout...
등 시스템화할 수 있도록 견고하게 구조화하고 디자인 정책을 확립하는 단계이다.
디자인 토큰
먼저 컬러에 대한 의미를 명확하게 하기 위해 디자인 토큰에 대한 네이밍을 지어준다. 이때 명칭의 기준을 라인 LDSG를 많이 참고했다.
우리는 다소 간소하게 사용하기 위해 Visual Element, Category, Value
만을 사용해서 PirmitiveColor
값을 선별했다.
const PrimitiveColor = {
'--color-transparent': '#ffffff00',
'--color-black-100': '#000000',
'--color-credit-100': '#fffbc5',
'--color-primary-100': '#e5e4fd',
...
}
이처럼 의미에 맞는 위치에 네이밍함으로써 컬러의 hex
값을 일일이 찾아볼 필요가 없다. 그리고 테마 컬러 작업 시에도 PirmitiveColor
에 있는 값을 가지고 변경하도록 세팅해 두면 테마 변경에도 문제가 없다.
const LightColorTokenX = {
'--effect-shadow-e0': PrimitiveColor['--color-transparent'],
'--outline-black': PrimitiveColor['--color-black-100'],
'--outline-warning-low-em': PrimitiveColor['--color-credit-100'],
'--outline-primary-low-em': PrimitiveColor['--color-primary-100'],
...
};
const DarkColorTokenX = {
'--effect-shadow-e0': PrimitiveColor['--color-transparent'],
'--outline-black': PrimitiveColor['--color-black-100'],
'--outline-warning-low-em': PrimitiveColor['--color-credit-900'],
'--outline-primary-low-em': PrimitiveColor['--color-primary-800'],
...
};
실제 사용하는 코드에선 위에 정의한 컬러 토큰값을 각 테마에 집어넣어서 사용하게 되면 type intelligence를 통해 토큰값을 불러올 수 있게 되고, 개발할 때 의미에 맞는 컬러 값을 직관적인 네이밍으로 인해 개발할 수 있게 된다.
const Wrapper = styled.div`
background-color: ${({ theme }) => theme.coloTokenX['--effect-shadow-e0']};
`;
const CheckBox = styled.button`
&:focus {
outline: ${(props) => props.theme.coloTokenX['--outline-primary-low-em']} solid 2px;
}
`
그리고 사전에, 피그마에서 컬러 Foundation을 지정하여, 각 디자인 토큰을 기반으로 주로 사용되는 컬러에 대한 정보를 더욱 직관적으로 네이밍 해서 관리했다.
이제 피그마에선 Foundation에 정의한 컬러 정보에 그대로 대응할 수 있게 되어 실제 디자인 시스템에서 작성된 컴포넌트나 서비스별 디자인 시안에서 사용되는 컬러값은 토큰을 기준으로 작성되기 때문에 디자인과 개발 간의 괴리감이 없어지게 된다.
디자인 시스템의 모든 부분은 컬러를 지정한 방식과 동일하다. 모두 작성하기에는 너무 많으니, 정책을 잘 수립한 회사의 문서를 참고해 보길 바란다.
컴포넌트
이제 토큰을 정했으니 실제 UI 컴포넌트에 적용할 단계이다. 가장 많이 사용되는 컴포넌트인 Button
을 가지고 확인해 보자.
default 색상은 surface/primary
, hover 색상은 surface/primiary_accent_3
, disabled 색상은 surface/primiary_accent_2_transparent
와 같이 시스템화시킬 수 있다.
모달에 적용되는 버튼을 확인해 보면, size
, type
, status
, fill
정보를 선택만 하게 되면 디자인 시안을 작성할 때에도, 개발을 할 때에도 더 이상 화면마다 다른 코드와 디자인을 작성할 필요가 없게 된다.
확장성
그러면 이제 확장해서 사용해 볼 수 있다. 아래는 안내창의 디자인에서 상용되는 버튼이다. 디자인 요구 사항에 따라 좌우에 아이콘을 넣는 니즈가 있어 leftIcon
, rightIcon
을 확장시켜서 넣을 수 있다. 또 버튼에 로딩을 넣고 싶은 니즈가 있어 isLoading
을 확장시킬 수 있다. 만약 아이콘 하나만 필요한 버튼이라면? leftIcon
, rightIcon
중 하나만 작성하고 내부 text에 대한 정보를, 옵션을 받으면 된다.
디자인 시스템은 적재적소에 맞는 요구사항에 대응하기 위해 기존 컴포넌트의 확장을 위해 옵션을 줄 수 있도록 설계하고 구성해야 한다. 이제 코드로 구성을 해보자.
사용하기
Button에 대한 구조는 props를 그대로 내려받아 style 파일로 던져지는 단순한 구조로 구성된다.
// Button.tsx
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({
label,
color = 'primary',
size = 'md',
leftIcon,
rightIcon,
disabled = false,
isLoading,
...props
}, ref
) => {
...
return (
<ButtonContainer
ref={ref}
disabled={isLoading || disabled}
$size={size}
$color={color}
{...props}
...
>
{isLoading ?
(<Loading size="xs" />) :
(<>
{leftIcon}
{label}
{rightIcon}
</>)}
</ButtonContainer>
)}
);
style 파일의 primary
, secondary
색상만 확인해 보자. color
, background-color
에 대한 정보, hover
와 disabled
시 정보를 지정하면 된다.
// Button.style.ts
const ButtonColorStyles = {
primary: css`
color: ${(props) => props.theme.coloTokenX['--text-white']};
background-color: ${(props) => props.theme.coloTokenX['--surface-primary']};
@media (hover: hover) and (pointer: fine) {
&:hover {
background-color: ${(props) =>
props.theme.coloTokenX['--surface-primary-accent-3-transparent']};
}
}
&:disabled {
color: ${(props) => props.theme.coloTokenX['--text-disabled-transparent']};
background-color: ${(props) =>
props.theme.coloTokenX['--surface-primary-accent-2-transparent']};
}
`,
secondary: css`
color: ${(props) => props.theme.coloTokenX['--text-high-em']};
background-color: ${(props) => props.theme.coloTokenX['--surface-surface-2']};
@media (hover: hover) and (pointer: fine) {
&:hover {
background-color: ${(props) => props.theme.coloTokenX['--surface-surface-3']};
}
}
&:disabled {
color: ${(props) => props.theme.coloTokenX['--text-disabled']};
background-color: ${(props) => props.theme.coloTokenX['--surface-surface-2']};
}
`,
...
}
이제 사용하는 곳에서는 HTMLButtonElement
타입을 그대로 받을 수 있게 하여 button 태그의 속성, 이벤트 핸들러, aria 등 모두 적용이 되도록 하고, props로 내려받는 color, size 등에 따라 내부에서 지정한 컬러와 크기, 아이콘 등을 규격화해서 사용이 가능해진다.
<Button label="Button" color="primary" />
<Button label="Button" size="xs" isLoading />
<Button label="Button" size="xs" leftIcon={/* 아이콘 */} rightIcon={/* 아이콘 */ />
Type-Safe하게 다형성(Polymorphism) 지원하기
마지막으로 컴포넌트의 다형성
을 지원하는 구조에 대해 알아보자. 필자는 실제 적용 시 많이 참고했던
Type-Safe하게 다형성 지원하기, Polymorphic한 React 컴포넌트 만들기를 기준으로 정리했다.
Polymorphism 컴포넌트는 말 그대로 여러 형태를 가지는 컴포넌트
이다.
- 하나의 컴포넌트에 다양한
시멘틱
을 표현할 수 있는 UI 컴포넌트 - 하나의 컴포넌트에 다양한
속성
을 가질 수 있는 UI 컴포넌트 - 하나의 컴포넌트에 다양한
스타일
을 있는 UI 컴포넌트
즉 동일한 디자인 UI 컴포넌트
를 상황에 맞게 시멘틱한 속성을 다르게 사용할 수 있고, button
태그이지만 a
태그처럼 특수한 상황에 따라 다양한 방식으로 사용할 수 있는 특수한 용도로 사용할 수 있게 추상화
한 구조를 말할 수 있다. 쉽게 말해서 다형성(Polymorphism)
이란 하나
의 일만 하는 컴포넌트를, 추상화를 통해 Polymorphic한 형태의 컴포넌트를 말한다.
대부분의 다형성을 지원하는 디자인 시스템을 뜯어보면 위와 비슷한 구조를 가지게 된다. 사용처에서는 상황에 맞는 Specific Element
를 내려받을 수 있게 하고, UI 컴포넌트에선 그대로 Style
컴포넌트로 내려받아 내부적으로 변환하여 작성할 수 있다.
문제 인식
이런 요구사항이 언제 있을까?
시스템을 구축하는 과정에서 마크업 개발자와 논의하면서 동일한 디자인의 버튼 컴포넌트에서 a
태그의 역할을 하는 디자인이 생기게 되었다. 단순하게는 버튼의 이벤트 핸들러에 라우팅 하는 핸들러를 집어넣으면 해결되는 문제였다.
하지만 이런 요구가 점차 많아지게 되고, 그렇다고 링크용 버튼을 새로 만들자니 동일한 디자인과 비슷한 인터페이스를 가지는 UI 컴포넌트를 관리하기가 번거롭다고 생각되었다. 이때 styled-components의 as
속성을 알게 되었는데 이 속성의 다형성을 지원하기 위해 컴포넌트에 적용되는 스타일을 유지하고 최종적으로 핸들이 되는 내용으로(다른 HTML 태그 또는 다른 사용자가 정의한 컴포넌트)만 바꾸기 위해 런타임
시 변경할 수 있도록 제공해 주는 기능을 알게 되었다.
쉽게 말해 아래처럼 div
컴포넌트를 as 구문으로 사용자 정의를 통해 button
태그로 렌더링시켜 스타일을 동일하게, 하지만 더 시멘틱한 구조로 버튼 구조를 변경해서 사용할 수 있게 된다.
const Component = styled.div`
color: red;
`;
render(
<Component
as="button"
onClick={() => alert('It works!')}
>
Hello World!
</Component>
)
Polymorphic하게 확장하기
다형성 인터페이스를 구축하기 위해 단계를 잡아보자.
as
props을 받을 수 있도록 인터페이스를 구성하고,ElementType
을 통해 as 구문의 Element 타입을 추론한다.type AsProp<T extends React.ElementType> = { as?: T; };
as
props를 받는 인터페이스AsProp
와 실제 Button의 Props로 받는 인터페이스를(&)교차 타입
을 통해 필요한 기능만을 가진단일 타입
으로 결합해Mixin 패턴
을 가지게 하여 key 정보만 받는KeyWithAs
타입을 구성한다.type KeyWithAs<E extends React.ElementType, Props> = keyof (AsProp<E> & Props);
- 리액트로 구성된 대부분의 Primitive한 컴포넌트는
Ref
를 가질 수 있다. 따라서 Ref를 가질 때와 가지지 않을 때의 인터페이스 타입이 달라지는데, 다형성 추상화 구현 시 이를 구분하기 위해 먼저 내려받는 Element Type을 기준으로 Ref 타입을 추론할 수 있도록 구분한 타입을 구성한다.type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>['ref'];
- 마지막으로 사용처에서 Ref 포함 여부에 따라 다형성 컴포넌트를 제어할 수 있도록 두 가지 타입을 구성한다.
// Ref 미사용 시 type PolymorphicComponentProps<E extends React.ElementType, Props = {}> = (Props & AsProp<E>) & Omit<React.ComponentPropsWithoutRef<E>, KeyWithAs<E, Props>>; // Ref 사용 시 type PolymorphicComponentPropsWithRef<E extends React.ElementType, Props = {}> = Props & { ref?: PolymorphicRef; };
전체적으로 정리해 보면 아래와 같은 구조를 가지게 된다.
import { type ComponentPropsWithoutRef, type ComponentPropsWithRef, type ElementType } from 'react';
// Element Type을 기준으로 as 타입을 추가 추론
// as 타입에 따라 추가로 따라오는 html 속성을 타입 추론이 가능하게 하도록 유도한다.
// 예) as='a'시 href, target 속성 추가 가능
type AsProp<T extends React.ElementType> = {
as?: T;
};
// AsProps + Props의 keyof 정보만 별도 분리
type KeyWithAs<E extends React.ElementType, Props> = keyof (AsProp<E> & Props);
// 다형성 컴포넌트 타입 정의
// 컴포넌트의 Ref 함수의 분리를 위해 Element Type을 기준으로 ref가 포함된 타입에서 'ref' 타입 별도 분리
type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>['ref'];
// 컴포넌트의 ComponentPropsWithoutRef 타입에서 기존 ElementType, Props 타입을 제거한 다형성 타입 정의(사용처에서 해당 타입을 재정의하기 위함)
type PolymorphicComponentProps<E extends React.ElementType, Props = {}> = (Props & AsProp<E>) &
Omit<React.ComponentPropsWithoutRef<E>, KeyWithAs<E, Props>>;
// 컴포넌트 타입 Props와 ref 타입을 추가한 다형성 타입 정의
type PolymorphicComponentPropsWithRef<E extends React.ElementType, Props = {}> = Props & {
ref?: PolymorphicRef<E>;
};
마지막으로 Button
컴포넌트에서는 Element Type과 다형성을 구성할 ButtonProps을 확장해 PolymorphicComponentProps
타입을 조합하고, 마지막으로 Ref 구조를 가지는 컴포넌트이므로 PolymorphicComponentPropsWithRef
타입을 확장한 ButtonType
을 구성하여 컴포넌트의 타입 선언(type annotation)을 통해 더욱 강력한 타입을 구성하여 Type-Safe한 구조를 구성할 수 있게 되었다.
// Button.tsx
type PolymorphicButtonProps<E extends React.ElementType> = PolymorphicComponentProps<
E,
ButtonProps
>;
type ButtonType = <E extends React.ElementType = 'button'>(
props: PolymorphicComponentPropsWithRef<E, PolymorphicButtonProps<E>>
) => React.ReactNode | null;
const Button: ButtonType = memo(
forwardRef(
<E extends React.ElementType>(
{
as,
... // 동일한 코드
}: PolymorphicButtonProps<E>,
ref?: PolymorphicRef<E>
) => {
...
return (
<ButtonContainer
ref={ref}
// 동일한 코드
...
>
// 동일한 코드
{...}
</ButtonContainer>
)}
);
이제 실제 사용처에서는 ref의 Element 타입을 통해 어떤 타입이 내려갈지 추론할 수 있게 되었고, 만약 as
를 통해 a
태그와 관련된 구조라면 type intelligence를 통해 href
와 target
속성이 통해 추론이 가능해진다.
const ref2 = useRef<HTMLAnchorElement>(null);
<Button
ref={ref2}
label="anchor:tag > Go Google!"
color="secondary"
as="a"
href="https://www.google.com"
target="_blank"
/>
또한 기존에 Link
태그와 Button
태그 모두 사용해 레이어를 늘렸다면 아래와 같이 Link
컴포넌트를 직접 주입하여, 해당 속성을 Button
태그에 그대로 사용하게 되어 개선할 수 있게 된다.
// 기존
<Link href={EXTERNAL_BASE_URL.STUDIO + 'project/new'}>
<Button
label={t('ws_Create an Avatar Video')}
size="sm"
style={{ margin: '12px auto 0' }}
/>
</Link>
// 개선 후
<Button
label={t('ws_Create an Avatar Video')}
size="sm"
style={{ margin: '12px auto 0' }}
as={Link}
href={EXTERNAL_BASE_URL.STUDIO + 'project/new'}
/>
마무리
이번 글에서는 디자인 시스템 구축을 위해 참고한 자료를 통해 현업에서는 어떻게 구성했는지, 실제 코드 구성을 어떻게 짰지, 시스템 확장을 위해 어떤 고민과 니즈를 해결했는지에 대해 전달했다.
현업에서는 시스템을 구축하기 위해 많은 리소스가 필요하다. 그 때문에 대기업이나 디자이너, 개발자에게 따로 리소스를 할당해 주는 회사는 많지 않다. 따라서 모든 것을 완벽하게 설계하고 구축하는 것은 어려운 환경이 대부분이다.
따라서 회사 상황에 맞게 최소한의 시스템을 만들면서 점차 시스템을 확장해 나가는 수밖에 없다. 그리고 서로의 니즈가 충족될 수 있도록 지속적으로 팔로업하면서 리드를 해야 한다. 나는 이 일이 결국 나와 팀, 회사를 위한 투자이며, 성숙한 서비스를 개발할 수 있는 단계 중 하나라고 생각하며 시작했다. 서비스를 성숙함은 단순히 기능 개발에만 그치지 않는다. 그 과정을 어떤 식으로 해결해 나가고, 어떻게 하면 불필요한 리소스를 덜어내면서 좋은 서비스를 개발할지에 대해 고민하고 직접 적용하는 것도 중요하다.
'회사 > 도입' 카테고리의 다른 글
창립이래 최초의 디자인시스템 구축하기(feat. 30년만에!) (0) | 2025.01.19 |
---|---|
🤝 Storybook으로 퍼블리셔, 디자이너와 협업 그리고 디자인 시스템 구축까지 (2) | 2023.12.20 |
yarn-berry 마이그레이션 적용기 (0) | 2023.07.31 |