최근 필자와 프론트엔드 팀에서는 코드 파편화, 중복코드 증가 문제로 인하여 디자인 시스템도입에 대한 논의가 여러 차례 있었고, 이에 따라 디자인시스템 라이브러리를 배포, 적용하는 데 성공하였다. 이에 대한 히스토리 및 개선점을 정리하기 위해 정리해보고자 한다.
당신이 어떤 페이지 1개를 만든다고 해보자(Page). 일반적으로 작업은 다음 순서를 따라 진행되게 된다.
- 프로젝트의 기능 등을 취합하여 스토리보드, 기획서 등을 제작한다
- 1에서 만든 자료들을 바탕으로 하여 개발을 진행한다.
- 개발이 완료되면 QA 등의 단계를 거쳐 서비스에 적용된다.
- 개발된 UI에 대한 문서를 작성한다.
1개의 페이지에는 여러 종류의 서로 다른 UI가 포함되어 있고, 이를 그림으로 나타내면 다음과 같을 것이다.

그리고 또 다른 페이지(Page2)에서 동일한 UI를 그대로 사용한다고 하면 다음과 같은 구조가 될 것이다.

그림만 봐도 알 수 있듯이, 1~4번까지의 과정이 불필요하게 반복된 것을 확인할 수 있다. 이러한 과정이 Page3, 4, 5... 와 같이 계속 반복되는 만큼 팀에 있어 시간적 손해가 될 것이고 이는 곧 서비스의 경쟁력과 직결된다.
이러한 문제를 해결하기 위해 Page3에서는 이전과는 다르게 이미 1~4까지의 과정을 거친 완성된 형태의 UI(UI-A)를 재사용하는 형태로 변경하였다. 그렇게 되면 중복된 요소가 있는 만큼 그에 대한 기획, 디자인, 개발 등에 대한 고민을 하지 않아도 될 것이다.

이제 이러한 요소들을 여러 프로젝트간에서 사용할 수 있게 공용 라이브러리로 분리하여 일관적인 UI 제작과 효율적인 작업이 가능해지게 되었다. 이렇게 자주 사용되는 디자인 규격, 가이드라인, 패턴 등을 공통으로 정리&문서화시킨 라이브러리를 디자인시스템이라고 하며, 서비스의 운영에 매우 큰 역할을 한다.
디자인시스템이 무엇이고 어떤 일을 하는지는 알았는데 구체적으로 어떤 장점이 있다는 것일까?
프로젝트, 버전 간 호환성 고려
각 프로젝트마다 환경과 버전이 모두 다르므로 이를 고려한 설계가 필요하다. 예를 들어, v2.0에서 v3.0으로 업그레이드할 때 호환성을 고려하지 않으면 모든 변경을 수작업으로 처리해야 하며, 이는 작업량 증가와 안정성 하락을 초래한다. 마이너한 변경의 경우 동일한 컴포넌트 내에서 처리하되, 메이저 변경이 발생하면 컴포넌트를 old(deprecated)와 new로 분리해야 할 필요가 있다.
외부 의존성 남용 지양
프로젝트마다 의존성 트리가 전부 달라 의존성 충돌 등의 문제가 있을 수 있으므로 외부 의존성 추가는 최소화하는 것으로 한다.
변화에 유연하여야 함
웹 프로젝트마다 세부적인 여백, 스타일, 색상, 레이아웃 등이 조금씩 다를 수 있어 이를 고려하지 않으면 동일한 UI를 추가하더라도 다른 결과물이 나올 수 있다. 따라서 유동적인 변화를 고려해야 하며, 스타일 커스터마이징이 가능해야 한다.
개발 일관성, 효율성 고려
FE 개발자가 최소 2명이상 있는 환경에서는 개발자마다 사용 방식이 달라질 수 있는데, 이러면 다른 개발자의 코드 해석에 시간이 소요되고, 예상치 못한 버그를 발생시킬 수도 있어 코드리뷰, 예제 문서화 작업 등을 통해 최대한 일관적으로 사용하도록 유도해야 한다.
css 스타일 충돌 고려
디자인 시스템의 css 클래스와 프로젝트의 css 클래스의 충돌이 발생할 가능성이 있음을 고려해야 한다. 따라서 스코프가 확실히 보장되는 Styled Components, CSS Module과 같은 스택을 도입하는 것을 추천한다.
🚨 2025 초부터 Styled-Components의 개발이 중단되어 유지보수 모드에 들어갔다고 하여 사용을 추천하지 않는다. tailwindcss 의 prefix 기능을 추가하는 등의 다른 대책이 필요해보인다.
https://opencollective.com/styled-components/updates/thank-you
이제 무엇을 만들지, 어떻게 만들지가 다 정해졌으니 본격적인 제작에 들어가보자.
당신은 Awesome Design System (이하 ADS) 디자인시스템 라이브러리의 개발을 담당하게 되었고, Button, Input 컴포넌트를 추가하여 이를 모든 프로젝트에서 사용할 수 있게 배포해야 한다.
(여기서부터의 설명은 React, 모듈 번들러에 대한 기본적 개념을 인지하고 있다는 가정하에서 진행하여 기본 개념 설명은 생략할 예정이니 참고 바랍니다.)
우선 가장 먼저 정해야 할 것은 기술 스택이다. 필자의 기준으로 하였으며, 상황에 따라 적절한 의존성을 사용하자.
번들러 툴: Vite
현재 회사에서 공통 번들러로 Vite를 사용하기로 결정하였다. 이전 회사에서 라이브러리 빌드 시 Rollup을 사용하였지만, Vite는 Webpack보다 더 간결하고 빠르며, ESM을 지원하는 등 라이브러리 빌드에 적합하다고 판단하였고 내부적으로도 Rollup을 사용하고 있었기 때문에 Vite를 채택하였다.
CSS Module
위에서 설명한 css 충돌 관련 이슈를 고려하여 사용하였다. 번들러에서 기본으로 제공하므로 별도 의존성도 필요없고, 간단한 예시에 가장 적합하여 채택하였다.
CI/CD: Github Actions
문서화: Storybook
프론트엔드 문서 툴 중 가장 대중적인 storybook을 사용하였다.
디자인 시스템과 같은 모듈, 라이브러리 유형의 프로젝트의 경우, SPA 애플리케이션(ex. React Create App)과는 프로젝트 구조와 번들 설정이 약간 다르다는 점에 유의해야 한다.
1. 컴포넌트 및 exports 작성
lib/components/Button.tsx
import React from 'react';
import styles from './Button.module.css';
interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement{}
const Button = ({ customStyle, children, ...props }: IButtonProps) => {
return (
<button
type="button"
className={styles.button}
{...props}
>
{children}
</button>
);
};
export default Button;
/* Button.module.css */
.button {
width: 52px;
height: 10px;
}
lib/components/Input.tsx
import React from 'react';
import styles from './Input.module.css';
interface IInputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = ({ customStyle, ...props }: IInputProps) => {
return (
<input
type="text"
className={styles.input}
{...props}
/>
);
};
export default Input;
/* Input.module.css */
.input {
width: 100%;
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid #ccc;
}
lib/index.tsx
import Button from './components/Button';
import Input from './components/Input';
export {
Button,
Input
}
기본 컴포넌트를 정의한다. html button, input의 props를 그대로 사용할 수 있게 props를 통째로 넘겨준다. 여기서 customStyle의 은 스타일을 강제로 변경해야 하는 경우에만 사용하며, 될 수 있으면 사용하지 않도록 한다. index.tsx에서는 라이브러리에서 내보낼 모든 컴포넌트를 import 후 export한다.
2. entry & output 설정
entry, output 설정은 vite.config.js에서 할 수 있다.
entry의 경우 export문을 추가한 index파일로, output의 경우 루트경로의 dist로 한다.
outDir: path.resolve(__dirname, 'dist'), // output 지정
lib: {
entry: {
index: path.resolve(__dirname, 'lib/index.tsx'), // entry 지정
},
formats: ['es'],
},
3. build 실행
이제 빌드 커맨드를 실행하게 되면 dist 폴더가 생성되면서 결과물이 나올 것이다.
vite build
d.ts 설정
라이브러리를 타입스크립트 기반으로 만들었다고는 하지만 빌드해보면 결과물은 무조건 자바스크립트 파일로 나오게 된다. 따라서 타입스크립트를 지원할 경우 js 번들파일에 타입을 부여하는 d.ts를 추가하는 작업이 필요하다.
이러한 작업을 지원하는 플러그인은 다양하게 있으며, 여기서는 vite-plugin-dts 플러그인을 사용하였다.
plugins: [
react(),
dts({
include: ['lib'], // lib폴더 내의 파일에 대해 d.ts 생성
exclude: ['**/*.stories.tsx'], // 필요없는 파일들은 skip
}),
...
],
다음과 같이 설정후 빌드 실행 시, 다음과 같은 구조로 d.ts파일이 생성된다. [디렉토리경로]/파일명.d.ts 의 규칙으로 파일이 생성되는 것을 확인할 수 있다.
1. external dependencies
디자인 시스템과 같은 라이브러리 프로젝트 제작시 주의해야 할 점 중 하나는 라이브러리 내에서 추가한 의존성들에 대한 관리이다.
예를 들어 A 라이브러리에 X라는 의존성을 사용하고 있고, A를 X 의존성을 사용하고 있는 B 애플리케이션에서 추가한 뒤 B를 빌드한 경우, 다음과 같은 번들 구조가 나오게 된다.

보시다시피, 동일한 의존성인 X가 중복으로 2번 들어가있어 번들용량이 불필요하게 커진 것을 알 수 있다.
다음과 같은 문제를 해결하기 위해 rollupConfig.external 옵션이다. 이 옵션을 사용시, 번들에 포함시키지 않고 외부에서 import하게 되어 이러한 문제를 해결할 수 있다.
peerDependencies(검증필요)
라이브러리에서 외부 의존성 사용시 또 문제가 될 수 있는 부분은 버전 호환성 문제이다. A의 X의 버전이 1.x.x 기반인 반면 B의 X버전은 주요 feature가 다른 2.x.x를 사용한다면 호환이 맞지 않을 수 있다.
어떤 요소가 필요할지는 상황에 따라 다르며
"peerDependencies": {
"react": "^18.2.0"
}
Tree Shaking 지원 확인
tree-shaking이 제대로 되는지 확인해보기 위해 토이프로젝트에서 설치 후 bundle analyzer 실행하였으나 esmodule을 적용하였는데도 잘 되지 않았다.

자세히 확인해보니 import 모듈에 따라 번들이 바뀌는것으로 보아 ts는 적용되고 있으나 단일파일에 전부 제공되는 것이 문제라 판단, 공식 문서를 서치하여 관련 속성을 찾아냈다.
preserveModules: true,
옵션 설명으로는 각각의 모듈마다 1개씩의 chunk 파일을 생성하는 옵션이다. 우리는 필요한 모듈만을 부분적으로 사용해야 하기때문에 활성화 해주자.
추가 후 다시 확인해보니 제대로 Tree Shaking이 되어있는 것을 확인할 수 있다.

Commonjs, Esmodule 동시 지원
이번에는 ADS를 설치한 프로젝트에서 Input, Button 컴포넌트에 대한 단위테스트 작성 후 실행시 다음과 같은 문제가 발생하였다.
에러 발생 원인을 찾아본 결과 한가지 놓친 점이 있었는데, Jest의 실행 환경 때문이었다.
라이브러리는 esmodule만을 지원하였는데 commonjs환경을 사용하는 Jest에서 import구문을 인식하지 못한것이 문제였다. jest는 Node.js 환경에서 구동되는데 Node.js는 기본적으로 commonjs 기반으로 돌아가다보니esmoule의 import 구문을 인식하지 못한 탓이었다.
또한 이러한 에러는 Next.js 등 SSR환경에도 문제가 발생할수 있기 때문에(역시 Node.js 기반으로 작동한다) 근본적인 문제 해결을 위해 commonjs도 동시에 지원하는 것으로 설정을 변경하였다.
관련 설정에 대한 참고는 개인적으로 가장 정리가 깔끔하게 되어있었다고 생각한 토스의 기술블로그를 참고하여 제작하였다.
CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기: exports field
lib: {
formats: ['es', 'cjs'], // es, cjs 2개 포맷으로 빌드하도록 설정
fileName: (format, entryName) => {
// format의 종류에 따라 확장명을 다르게 해주고 있다.
if (format === 'es') {
return 'index.mjs';
} else if (format === 'cjs') {
return 'index.js';
}
return 'index.js';
}
}
https://webpack.kr/guides/package-exports/
"exports": {
".": {
"require": "./dist/index.js", // require import시에는 index.js 참조
"import": "./dist/index.mjs" // module import시에는 index.mjs 참조
},
},
여기서 주목할 점은 commonjs의 경우 .js를 mjs의 경우 .mjs를 사용하였다는 점이다. React 등 SPA 프로젝트의 경우 기본적 모듈시스템은 commonjs로 되어있다. 배포결과인 index.html에서의 script 태그를 사용해 로드되어야 하기 때문이다. (이렇게 할 시 cjs모듈을 로드한다.)
따라서 기본 환경인 commonjs에서는 js 확장명을 사용하고, esmodule 번들의 경우, esm인것을 확실히 인지할 수 있도록 .ejs로 확장명을 지정하였다.
지금까지 디자인 시스템이 무엇인지, 그리고 어떻게 설계하고 빌드해야 하는지에 대해 대략적으로 살펴보았다. 글이 예상보다 길어졌지만, 그 과정에서 모듈 시스템과 d.ts 같은 개념을 더 깊이 이해하게 되었고, 나아가 React와 번들러 간의 상호작용과 모듈의 형태 등을 깊이 있게 배울 수 있었다. 무엇보다도 디자인 시스템 도입 이후 생산성이 크게 향상되어 불필요한 초과근무가 많이 사라진 것도 큰 이점이었다.
다음 글에서는 ADS를 Github Packages에 배포하는 방법과 의존성 설치에 대해 설명하겠다.