디자인 시스템 개발기 (1)
기존 서비스에서 디자인 시스템을 분리하여 라이브러리로 만드는 사이드 프로젝트를 진행하고 있습니다. 편리하다고 생각했던 배럴 파일이 오히려 모든 컴포넌트를 번들에 포함시키는 원인이 되었고, 이를 해결하는 과정에서 얻은 경험들을 공유합니다.
디자인 시스템 개발기 (1)

출처: 생성형 AI 이미지, GPT
기존 서비스에서 디자인 시스템을 분리하여 라이브러리로 만드는 사이드 프로젝트를 진행하고 있습니다. 편리하다고 생각했던 배럴 파일이 오히려 모든 컴포넌트를 번들에 포함시키는 원인이 되었고, 이를 해결하는 과정에서 얻은 경험들을 공유합니다.
스택: React.js, Vite.js, Vanilla Extract.
1. Barrel File과 Tree Shaking
DND Design System은 Barrel File 방식으로 컴포넌트를 export하고 있습니다. 짧은 경로로 여러 컴포넌트를 한 번에 import할 수 있다는 장점이 있습니다.
import { Button, Icon } from "@dnd-lab/desktop";
하지만 이 방식은 트리 쉐이킹(Tree Shaking) 이 올바르게 동작하지 않는다는 문제가 있습니다.
트리 쉐이킹이란?
사용하지 않는 코드를 번들에서 제거하는 최적화 기법입니다. 예를 들어 10개의 컴포넌트 중 2개만 사용하더라도, 배럴 파일로 묶여 있으면 나머지 8개도 함께 번들에 포함됩니다.
[@**dnd-lab/desktop](https://www.npmjs.com/package/@dnd-lab/desktop?activeTab=readme)**을 보면 **index.ts**파일은 다음 처럼 구상이 되어있습니다.
// primitives/index.tsx
export * from './button'
export * from './txt'
export * from './icon'
export * from './textfield'
export * from './textarea'
export * from './chip'
export * from './popover'
이 파일은 하위 경로의 컴포넌트를 최상단으로 re-export하는 역할을 합니다.
// primitives/txt/index.tsx
export { Txt } from './Txt'
export type { TxtProps } from './Txt'
// /chip/index.tsx
export { Chip } from './Chip'
export type { ChipProps } from './Chip'
export type { ChipIconProps } from './compound'
문제는 번들러가 배럴 파일의 트리 구조를 정적으로 분석하지 못해, 실제로 사용하지 않는 컴포넌트까지 번들에 포함된다는 점입니다.
export * from './button'
export * from './icon'
export * from './textfield' // ❌ 사용하지 않지만 포함됨
export * from './textarea' // ❌ 사용하지 않지만 포함됨
export * from './chip' // ❌ 사용하지 않지만 포함됨
export * from './popover' // ❌ 사용하지 않지만 포함됨
실제로 [size-limit](https://github.com/ai/size-limit) 라이브러리로 측정한 초기 번들 크기는 다음과 같습니다.
# 초기 상태
barrel: { Button } → 23.3 kB (minified + brotli)
barrel: all → 23.66 kB (minified + brotli)
Button 하나만 import해도 전체 컴포넌트와 거의 같은 크기가 나오는 것을 확인할 수 있습니다.
해결 방법
React.js에서는 방법 1을, Next.js를 사용할 경우 방법 2까지 사용할 수 있습니다.
방법 1. 경로 직접 import
배럴 파일 대신 각 컴포넌트 폴더를 직접 import하는 방식입니다.
import { Button } from "@dnd-lab/desktop/primitives/button";
import { Icon } from "@dnd-lab/desktop/primitives/icon";
import { Txt } from "@dnd-lab/desktop/primitives/txt";
이러한 서브경로 import를 지원하려면 두 가지를 설정해야 합니다.
- 빌드 결과물이 소스의 폴더 구조를 유지해야 하고
package.json의exports가 해당 경로를 외부에 노출해야 합니다.
1. 빌드 설정- 폴더 구조 보존
vite.config.ts의 빌드 설정을 살펴보겠습니다.
build: {
lib: {
entry: globbySync('src/**/index.tsx', { cwd: __dirname, absolute: true }),
},
rollupOptions: {
output: {
preserveModules: true,
preserveModulesRoot: 'src'
}
}
}
각 옵션의 역할은 다음과 같습니다.
**entry: 진입점 자동 수집**
entry: globbySync('src/**/index.tsx', { cwd: __dirname, absolute: true })
globbySync는 glob 패턴으로 파일을 검색하는 라이브러리입니다. src/ 하위의 모든 index.tsx를 자동으로 찾아 entry로 등록하므로, 컴포넌트를 추가할 때마다 빌드 설정을 수정할 필요가 없습니다.
src/primitives/index.tsx → entry[0]
src/primitives/button/index.tsx → entry[1]
src/primitives/txt/index.tsx → entry[2]
...컴포넌트 추가 시 자동 포함
**preserveModules: 파일 구조 유지**
기본적으로 번들러는 여러 모듈을 하나의 파일로 합칩니다. 이 옵션을 켜면 소스 코드의 파일 구조를 그대로 유지한 채 빌드합니다.
# false (기본값) — 하나로 합쳐짐
dist/
└── index.mjs
# true — 구조 유지
dist/
├── primitives/
│ ├── index.mjs
│ ├── button/index.mjs
│ ├── icon/index.mjs
│ └── txt/index.mjs
preserveModulesRoot: 출력 경로에서 접두사 제거
소스 경로가 src/... 형태이기 때문에, preserveModules만 켜면 빌드 결과에 src/ 접두사가 그대로 남습니다. 이 옵션으로 출력 경로에서 src/를 제거합니다.
# 미설정 → dist/src/primitives/button/index.mjs
# 'src' → dist/primitives/button/index.mjs
2. exports 설정 — 와일드카드로 경로 노출
빌드 결과물의 구조가 유지되므로, package.json의 exports에 컴포넌트를 하나씩 나열할 필요 없이 와일드카드 패턴 하나로 처리할 수 있습니다.
// ❌ 컴포넌트마다 계속 추가해야 함
{
"./primitives/button": {
"types": "./dist/primitives/button/index.d.ts",
"import": "./dist/primitives/button/index.mjs",
"require": "./dist/primitives/button/index.js"
},
"./primitives/txt": {
"types": "./dist/primitives/txt/index.d.ts",
"import": "./dist/primitives/txt/index.mjs",
"require": "./dist/primitives/txt/index.js"
}
// ...
}
// ✅ 와일드카드로 한 번에 처리
{
"./primitives/*": {
"types": "./dist/primitives/*/index.d.ts",
"import": "./dist/primitives/*/index.mjs",
"require": "./dist/primitives/*/index.js"
}
}
globbySync가 entry를 자동 수집하고, preserveModules가 폴더 구조를 보존하고, 와일드카드 exports가 경로를 자동 매핑하기 때문에 컴포넌트를 추가해도 빌드 설정과 package.json을 수정할 필요가 없습니다.
경로 직접 import만 적용했을 때의 번들 크기 변화입니다.
# 방법 1 적용 후
direct: { Button } → 2.74 kB (minified + brotli)
초기 23.3 kB에서 2.74 kB로 줄어든 것을 확인할 수 있습니다.
방법 2. optimizePackageImports (Next.js 한정)
Next.js를 사용하는 경우, 아래 설정만으로 배럴 파일을 유지하면서도 트리 쉐이킹 효과를 얻을 수 있습니다.
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["@dnd-lab/desktop"],
},
};
Next.js가 내부적으로 배럴 import를 경로 직접 import로 변환해줍니다.
// 작성한 코드
import { Button, Icon } from "@dnd-lab/desktop"
// Next.js가 내부적으로 변환
import { Button } from "@dnd-lab/desktop/primitives/button"
import { Icon } from "@dnd-lab/desktop/primitives/icon"
Q&A
Q1. package.json에서 "." exports 설정을 제거해도 optimizePackageImports를 사용할 수 있나요?
"exports": {
".": { // 제거 가능할까요?
"types": "./dist/primitives/index.d.ts",
"import": "./dist/primitives/index.mjs",
"require": "./dist/primitives/index.js"
},
"./primitives/*": { ... },
"./desktop.css": "./dist/desktop.css"
}
아니요, 사용할 수 없습니다. optimizePackageImports는 배럴 파일을 대체하는 게 아니라, 배럴 파일을 전제로 동작하는 방식입니다. "." 진입점이 없으면 번들러가 @dnd-lab/desktop 패키지 자체를 resolve하지 못해 변환 전 단계에서 에러가 발생합니다.
Q2. 배럴 파일(root index.ts)을 아예 제거하는 게 팀에 좋을까요?
// primitives/index.tsx
export * from './button'
export * from './txt'
export * from './icon'
export * from './textfield'
export * from './textarea'
export * from './chip'
export * from './popover'
즉시 제거보다는 점진적 마이그레이션을 권장합니다.
배럴 파일을 제거하면 트리 쉐이킹 문제를 근본적으로 해결할 수 있지만, 기존 import 구문을 전부 수정해야 하는 Breaking Change가 발생합니다. 경로를 직접 알아야 하므로 DX가 저하되고, 신규 팀원의 학습 비용도 늘어납니다.
기존 배럴 파일은 Deprecated 처리만 해두고, 신규 코드부터 경로 직접 import 컨벤션을 적용하여 점진적으로 전환하는 것이 팀 혼란을 최소화하면서 최적화 목표도 달성할 수 있는 현실적인 방법입니다.
2. sideEffects 설정
sideEffects는 package.json에 명시하여 번들러(Webpack, Vite, Rollup 등)의 트리 쉐이킹을 돕는 설정입니다.
{
"name": "@dnd-lab/desktop",
"sideEffects": [
"**/*.css"
]
}
sideEffects: false로 설정하면 "이 패키지의 모든 모듈은 import하지 않으면 안전하게 제거해도 된다"는 의미가 됩니다.
하지만 CSS 파일은 import 자체가 부수 효과(side effect)입니다. import './style.css'는 아무것도 export하지 않지만, import만으로 스타일이 적용되기 때문입니다. 이 설정이 없으면 번들러가 CSS import를 "사용하지 않는 코드"로 판단해 제거할 수 있습니다.
**/*.css를 sideEffects 배열에 추가하면 번들러에게 "CSS 파일은 트리 쉐이킹하지 말라"고 명시적으로 알릴 수 있습니다. 이 프로젝트에서는 Vanilla Extract가 빌드 시 생성하는 CSS 파일이 여기에 해당됩니다.
/* dist/style.css — 빌드 시 생성되는 CSS 파일 */
._14grssf6 {
display: flex;
align-items: center;
color: var(--_14grssf4);
background-color: var(--_14grssf0);
border-radius: 4px;
transition: background-color 0.3s ease;
}
._14grssf6:hover {
background-color: var(--_14grssf1);
}
sideEffects 설정만 적용했을 때의 번들 크기 변화입니다.
# 방법 1 + sideEffects 적용 후
barrel: { Button } → 2.48 kB (minified + brotli)
barrel: all → 23.66 kB (minified + brotli)
경로 직접 import(2.74 kB)에서 2.48 kB로 추가로 줄어든 것을 확인할 수 있습니다. 두 방법을 함께 적용하는 것이 중요합니다.
| 적용 방법 | Button 하나 import |
|--------------------------|-------------------|
| 초기 (배럴 파일) | 23.3 kB |
| 경로 직접 import | 2.74 kB |
| + sideEffects 설정 | 2.48 kB |
다음은 컴포넌트를 더 유연하게 설계하는 방법인 Compound 패턴에 대해 정리해봅니다.
메타데이터
- post_id
- e93bb14798b7
- slug
- 디자인-시스템-개발기-1-e93bb14798b7
- url
- https://medium.com/@Zero-1016/%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-e93bb14798b7
- canonical_url
- https://medium.com/@Zero-1016/%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-e93bb14798b7
- author_url
- https://medium.com/@Zero-1016
- status
- ok
- fetched_at
- 2026-06-21 21:05:38