안녕하세요. 올리브영에서 UI를 개발하고 있는 시공입니다.
현재 올리브영 서비스에 사용되는 컴포넌트들의 디자인 시스템 가이드를 제작하고 있어, 작업을 시작하게 된 이유와 진행 과정에 대해 공유하고자 합니다.
목차
디자인 시스템이란
디자인 시스템이란 웹이나 각종 서비스 UI 디자인에서 재사용할 수 있는 컴포넌트와 패턴을 정의한 가이드라인이나 규칙을 말합니다.
올리브영 서비스 운영 시 공통 원칙을 준수한 디자인 패턴을 컴포넌트화하여 디자인 및 개발 작업에서 재사용한다면, 작업자 간의 지식 수준을 일치시켜 보다 통일성과 효율성을 높인 작업이 가능해집니다. 이는 우리에게 꼭 필요한 요소라 할 수 있습니다.
따라서 서비스에 필요한 컴포넌트들을 별도의 저장소로 분리하여 체계적으로 관리하고, 컴포넌트 옵션 변경 시 모양과 동작을 확인할 수 있는 가이드라인 문서를 구축하는 것이 이번 작업의 목표였습니다.
이를 위해 Vite + React + TypeScript 환경에서 storybook과 emotion을 사용해 디자인 시스템을 구축했습니다.
storybook
은 컴포넌트를 모아서 문서화로 보여주는 오픈소스 툴로, 독립적인 환경에서 컴포넌트의 구조와 형태를 쉽게 파악할 수 있습니다.
emotion
은 JS로 CSS를 작성하도록 설계된 라이브러리입니다. 스타일 적용을 위해서 styled-component를 사용해도 무방하지만, 리액트와 함께 사용할 수 있는 @emotion/react
패키지를 선택했습니다.
(세팅 방법은 공식 문서에 친절하게 나와 있기 때문에 퀵하게 어떻게 컴포넌트를 구현했는지 설명하겠습니다.)
전역 스타일의 적용
storybook을 설치하고 처음 화면을 띄우면 가장 기본적인 화면을 마주하게 됩니다. 저희가 작업하는 컴포넌트들은 모바일 환경에서 사용되기 때문에 pc의 storybook 환경에서 잘 보이도록 컨테이너와 image에 스타일을 적용하는 작업이 필요했습니다. 이때, preview.tsx 파일에서 Global 컴포넌트를 사용하여 스타일을 쉽게 적용할 수 있습니다. CSS 리셋 등의 storybook 내에서 전반적으로 적용되어야 하는 전역 스타일을 Global을 사용해 관리하면 사이트의 통일성을 유지할 수 있습니다.
.storybook > preview.tsx
<Global
styles={css`
* {
margin: 0;
padding: 0;
}
.innerZoomElementWrapper {
min-height: 70px;
}
.sb-story > div > div {
max-width: 100%;
}
img {
position: absolute;
height: 100%;
width: 100%;
left: 0;
top: 0;
right: 0;
bottom: 0;
color: transparent;
}
`}
/>
컴포넌트 만들기
Text 같은 기본적인 컴포넌트가 만들어져 있다는 가정하에 조금 더 복잡한 Radio 컴포넌트를 스토리로 만들어 보겠습니다.
화면에 띄워질 stories 파일들의 경로를 main.ts 파일 아래에 설정해 두고 해당 폴더(여기서는 src) 아래 작성할 컴포넌트 파일을 생성해 줍니다.
//.storybook > main.ts
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
세 가지 파일이 컴포넌트 하나를 구성하는 기본 세트입니다.
[index.ts]
Radio 컴포넌트를 모듈로 내보내기 위한 파일입니다.
import Radio from "./Radio";
export default Radio;
[Radio.tsx]
Radio 컴포넌트에 대한 파일입니다.
emotion을 사용하기 위해 @emotion/react
를 import 해줍니다.
import { css } from '@emotion/react';
이어서 Radio 컴포넌트 내용물을 작성합니다.
interface Props {
id: string;
name: string;
className?: string;
label?: string;
children?: React.ReactNode;
onChange?: (e: React.ChangeEvent) => void;
}
const Radio = ({
id,
name,
className,
label,
children,
onChange,
...attr
}: Props) => {
return (
<label className={className} htmlFor={id} data-label={label}>
<div>
<input
{...attr}
id={id}
name={name}
type="radio"
onChange={onChange}
/>
</div>
{label && <Text as="span">{label}</Text>}
{children}
</label>
);
};
export default Radio;
emotion을 사용하면 아래와 같이 css prop을 직접 컴포넌트에 전달할 수 있습니다.
<label
className={className}
htmlFor={id}
data-label={label}
css={css`
display: flex;
position: relative;
cursor: pointer;
`}
>
emotion의 장점 중 하나는 컴포넌트의 상태나 props에 따라 동적 스타일링을 적용할 수 있다는 점입니다. 조건부 스타일링이 가능하여, props 값을 기반으로 스타일을 입맛대로 설정할 수 있습니다. 예를 들어 Radio의 disabled
prop이 true
일 경우에만 스타일을 적용하고 싶다면 아래처럼 추가하면 됩니다.
<label
htmlFor={id}
data-label={label}
css={css`
display: flex;
position: relative;
cursor: pointer;
${disabled &&
css`
cursor: auto;
`}
`}
>
checked
와 disabled
상태에 대한 제어도 개발에서 가능하도록 Props에 추가해 주겠습니다.
아래는 Radio.tsx 의 최종 형태입니다. Props 정의 아래에 주석으로 텍스트를 입력하면 스토리에 컴포넌트에 대한 설명을 추가할 수 있습니다.
import React from "react";
import { css } from "@emotion/react";
import { colors } from "../Color";
import { Text } from "../";
interface Props {
id: string;
name: string;
className?: string;
label?: string;
checked?: boolean;
disabled?: boolean;
children?: React.ReactNode;
onChange?: (e: React.ChangeEvent) => void;
}
/**
* 리스트에서 항목을 선택을 하거나, 설정을 켜거나 끌 수 있도록 해주는 용도의 컨트롤입니다.<br/>
* - 옵션 중 한가지를 선택해야 하는 경우 사용 합니다.<br/>
* - 한 가지 옵션을 선택할 경우, 다른 옵션들은 자동적으로 해제됩니다.<br/>
* - **20px * 20px** 크기가 디폴트입니다.
*/
const Radio = ({
id,
name,
className,
label,
checked,
disabled = false,
children,
onChange,
...attr
}: Props) => {
return (
<label
htmlFor={id}
data-label={label}
css={css`
display: flex;
position: relative;
cursor: pointer;
${disabled &&
css`
cursor: auto;
`}
`}
>
<div
css={css`
position: relative;
width: 20px;
height: 20px;
flex-shrink: 0;
`}
>
<input
{...attr}
id={id}
name={name}
type="radio"
checked={checked}
disabled={disabled}
onChange={onChange}
css={css`
width: 100%;
height: 100%;
border: 1px solid;
border-radius: 50%;
appearance: none;
border-color: ${colors.gray30};
:checked {
border-color: ${colors.gray100};
:after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: calc(100% - 8px);
height: calc(100% - 8px);
border-radius: 50%;
background-color: ${colors.gray100};
transform: translate(-50%, -50%);
}
}
:disabled {
border-color: ${colors.gray40};
background-color: ${colors.gray20};
}
`}
/>
</div>
{label && (
<Text
as="span"
css={css`
margin-left: 10px;
`}
>
{label}
</Text>
)}
{children}
</label>
);
};
export default Radio;
스타일 적용까지 모두 끝이 났다면, 이제는 스토리를 띄워 보겠습니다.
[Radio.stories.tsx]
화면에 띄울 Radio 컴포넌트를 import하고, 페이지의 경로와 이름, props에 대한 설명을 작성합니다.
import Radio from ".";
export default {
title: "Components/Radio", // storybook 경로
component: Radio,
argTypes: {
id: {
description: "id",
},
name: {
description: "name",
},
className: {
description: "클래스 이름",
},
label: {
description: "내용",
},
checked: {
description: "체크",
},
disabled: {
description: "비활성",
},
onChange: {
description: "onChange 함수",
},
children: {
control: { type: "" },
description: "하위 컴포넌트",
},
},
};
가장 기본적인 형태의 Radio를 보여주기 위해 Default 상태를 정의하고, 체크 상태를 제어하는 props checked를 true로 정의하여 checked 상태의 스토리를 추가합니다.
export const Default = {
args: {
id: "id",
name: "name",
label: "text",
},
};
export const Checked = {
args: {
id: "id",
name: "name",
label: "text",
checked: true,
}
};
이제 Control 영역에서 props를 제어하면 각 상태일 때 컴포넌트가 변경되는 모습과 소스를 바로 확인 할 수 있습니다. 여기에 더하여 스토리안에서도 useState 등으로 상태 값에 따라 컴포넌트의 변화되는 모습을 보여줄 수 있으며 이를 활용해서 더 복잡한 컴포넌트들도 구현이 가능합니다.
Emotion의 추가적인 기능
위 컴포넌트 작업에는 사용되지 않았지만, emotion의 추가적인 기능들이 있습니다.
Keyframes
emotion은 keyframes를 통해 복잡한 애니메이션을 모듈형으로 만들고, 이를 컴포넌트에 적용되도록 제공합니다.
import { css, keyframes } from "@emotion/react";
const tooltipEnter = keyframes`
0% { transform: scale(0); }
100% { transform: scale(100%); }
`;
const Tooltip = () => {
return (
<div
css={css`
animation: ${tooltipEnter} 0.25s;
`}
>
tooltip
</div>
);
};
Media Queries
emotion에서도 css에서처럼 미디어쿼리를 사용할 수 있으나 단순 적용이 아닌 중단점을 배열에 넣어 재사용하는 방법도 제공합니다.
const breakpoints = [280, 320, 768]
const mq = breakpoints.map(bp => `@media (min-width: ${bp}px)`)
render(
<div
css={{
color: 'black',
[mq[0]]: {
color: 'red'
},
[mq[1]]: {
color: 'blue'
},
[mq[2]]: {
color: 'green'
}
}}
>
text
</div>
)
올리브영 서비스에 디자인 시스템 적용하기
지금까지 만들어진 디자인 시스템은 머지않아 github packages로 배포되고, 배포 후에는 실제 마티니 프로젝트에 적용될 예정입니다. Text 컴포넌트를 사용해야 하는 경우를 예로 들어 사용법을 말씀드리자면 다음과 같습니다.
패키지 설치
npm
npm install @oy-alldev/ui
yarn
yarn add @oy-alldev/ui
mtn적용
import { Text } from "@oy-alldev/ui";
...(생략)
<Text as="p" size={15}>텍스트</Text>
이렇게 emotion을 활용하여 storybook에서 컴포넌트를 구현하고, 패키징 화하여 사용하는 방법까지 둘러보았습니다. 아주 작은 요소에서부터, 다수의 Icon이나 swith, chip 등 앞으로도 필요한 컴포넌트들이 계속해서 업데이트될 것입니다. 디자인 시스템을 지켜봐 주시고 많이 사용되길 바라며 이만 인사드리겠습니다. 긴 글 읽어주셔서 감사합니다.