- Published on
여러 서비스에서 재사용할 수 있는 UI 아키텍처 설계하기
- Authors

- Name
- GW
개요
서비스가 하나일 때는 공통 컴포넌트를 관리하는 것이 크게 어렵지 않습니다.
Application
↓
components
↓
Button
Tabs
Toast
Modal
하지만 여러 애플리케이션을 하나의 저장소에서 관리하기 시작하면 이야기가 달라집니다.
각 서비스에서 비슷한 컴포넌트를 개별적으로 구현하다 보면 조금씩 다른 Button, Modal, Toast가 만들어지기 시작합니다.
Service A
↓
Button A
Service B
↓
Button B
Service C
↓
Button C
처음에는 서로 비슷하지만 시간이 지나면서 차이가 생깁니다.
Button A
→ loading 지원
Button B
→ loading 미지원
Button C
→ 새로운 디자인 적용
같은 역할을 하는 컴포넌트임에도 구현과 사용 방법이 달라지고, 수정 사항을 여러 서비스에 반복해서 적용해야 하는 문제가 발생합니다.
그래서 여러 서비스에서 반복적으로 사용되는 UI와 유틸리티를 별도의 공통 패키지로 분리하고, 각 서비스가 필요한 기능을 가져다 사용할 수 있는 구조를 만들 수 있습니다.
하지만 공통 컴포넌트를 패키지로 옮기는 것만으로 재사용 가능한 UI 아키텍처가 완성되는 것은 아닙니다.
패키지의 공개 범위를 어떻게 정의할지, 여러 모듈 환경에서 어떻게 사용할지, 컴포넌트의 Variant와 상태를 어떻게 제한할지, 접근성과 상호작용 로직은 어디까지 공통화할지 등을 함께 고민해야 합니다.
이번 글에서는 다음 내용을 중심으로 살펴보겠습니다.
- 공통 UI를 별도 패키지로 분리한 이유
- UI와 Utility 패키지의 책임 분리
- ESM과 CommonJS를 고려한 패키지 빌드
package.json의 Exports와 Subpath Exports- Radix UI를 활용한 Headless UI 설계
- CVA를 활용한 Variant와 타입 설계
- Tailwind CSS를 활용한 스타일 규칙 통합
- 재사용 가능한 컴포넌트의 API를 설계하며 고려한 것들
공통 컴포넌트가 있는데 왜 다시 구조를 바꿔야 할까?
React 프로젝트를 개발하다 보면 자연스럽게 공통 컴포넌트가 만들어집니다.
src
└── components
├── Button
├── Tabs
├── Toast
└── Modal
하나의 애플리케이션에서는 충분히 좋은 구조입니다.
문제는 여러 애플리케이션에서 동일한 UI가 필요해질 때입니다.
예를 들어 세 개의 서비스에서 동일한 디자인의 Button을 사용한다고 해보겠습니다.
각 서비스에서 Button을 따로 구현하면 다음과 같은 구조가 됩니다.
apps
├── service-a
│ └── components
│ └── Button
├── service-b
│ └── components
│ └── Button
└── service-c
└── components
└── Button
중복 코드 자체도 문제지만 더 큰 문제는 시간이 지나면서 구현이 서로 달라지는 것입니다.
예를 들어 디자인 시스템에서 Button의 높이가 변경되었다고 해보겠습니다.
Design 변경
↓
Service A Button 수정
↓
Service B Button 수정
↓
Service C Button 수정
새로운 상태가 추가되어도 마찬가지입니다.
Loading 상태 추가
↓
각 서비스의 Button 구현 확인
↓
각각 수정
↓
각각 테스트
서비스가 많아질수록 동일한 변경을 반복하게 됩니다.
그리고 결국 다음과 같은 상황이 발생할 수 있습니다.
같은 Button
↓
서비스마다 다른 Props,
서비스마다 다른 Style,
서비스마다 다른 Interaction
단순한 코드 중복을 넘어 UI의 일관성과 컴포넌트 API까지 서로 달라지는 문제가 생기는 것입니다.
(컴포넌트 API는 컴포넌트를 사용할 때 외부에서 전달할 수 있도록 제공하는 props와 사용 방법을 의미합니다.)
그래서 공통 UI를 애플리케이션 내부의 컴포넌트가 아니라 여러 애플리케이션이 의존하는 별도의 패키지로 관리하기로 했습니다.
공통 UI를 패키지로 분리하기
모노레포에서는 여러 애플리케이션과 패키지를 하나의 저장소에서 함께 관리할 수 있습니다.
구조를 단순화하면 다음과 같습니다.
Repository
├── apps
│ ├── service-a
│ ├── service-b
│ └── service-c
└── packages
├── ui
└── utils
공통 UI는 ui, 여러 서비스에서 사용하는 공통 함수는 utils에서 관리합니다.
packages/ui
↓
Button
Tabs
Toast
Modal
packages/utils
↓
formatDate
formatNumber
isEmpty
...
그리고 각 애플리케이션에서는 필요한 기능을 가져다 사용합니다.
import { Button } from '@packages/ui';
이제 Button의 구현은 한 곳에 존재합니다.
Service A ─┐
Service B ─┼→ UI Package → Button
Service C ─┘
Button의 스타일이나 동작을 수정하면 공통 패키지를 사용하는 서비스가 동일한 구현을 사용할 수 있습니다.
하지만 여기서 중요한 것은 모든 코드를 공통 패키지로 옮기는 것이 아닙니다.
무엇을 공통화해야 할까?
재사용되는 코드를 발견했다고 무조건 공통 패키지로 옮기면 오히려 유지보수가 어려워질 수 있습니다.
예를 들어 특정 서비스에서만 사용하는 컴포넌트가 있다고 해보겠습니다.
Service A
↓
ReservationCard
현재 다른 서비스에서 사용하지 않는데 단순히 나중에 사용할 수도 있다는 이유로 공통 패키지에 넣는다면 공통 패키지가 특정 서비스의 비즈니스 요구사항을 알기 시작합니다.
UI Package
↓
ReservationCard
↓
Service A의 Domain Model / Business Logic 의존
이렇게 되면 공통 패키지가 더 이상 범용적인 UI 계층이라고 보기 어려워집니다.
따라서 공통 패키지에는 가능한 한 서비스의 비즈니스 도메인과 독립적인 요소를 두는 방향으로 구성해야 합니다.
공통 UI
Button
Tabs
Toast
Modal
Input
...
공통 Utility
날짜 처리
문자열 처리
공통 Formatting
...
서비스 전용
비즈니스 컴포넌트
도메인 데이터
서비스 전용 로직
구조적으로 보면 다음과 같습니다.
Application (서비스 전체)
↓
Domain / Feature (서비스 기능)
↓
Shared UI (여러 기능에서 사용하는 공통 UI)
↓
UI Primitive (Button, Input 같은 기본 UI)
상위 계층은 하위의 공통 UI를 사용할 수 있지만, 공통 UI가 특정 서비스의 비즈니스 로직을 알지 않도록 만드는 것입니다.
이 원칙을 지키는 것이 중요했던 이유는 공통 패키지가 여러 애플리케이션에서 사용될수록 특정 서비스와의 결합이 전체 서비스에 영향을 줄 가능성도 커지기 때문입니다.
패키지를 만들었다고 끝이 아니다
여기까지 보면 단순해 보입니다.
중복 UI 발견
↓
packages/ui 생성
↓
공통 컴포넌트 이동
↓
완료
하지만 실제로 패키지로 분리하면 새로운 문제가 생깁니다.
예를 들어 다음과 같은 것들입니다.
- 패키지를 어떤 Module Format으로 제공할 것인가?
- 외부에 어떤 모듈을 공개할 것인가?
- 내부 구현까지 Import할 수 있게 둘 것인가?
- Button만 필요한 서비스가 어떻게 가져갈 것인가?
- TypeScript Type은 어떻게 제공할 것인가?
즉, 이제 단순한 React 컴포넌트 설계를 넘어 JavaScript Package를 어떻게 설계할 것인가라는 문제가 생깁니다.
그중 먼저 ESM과 CommonJS부터 살펴보겠습니다.
ESM과 CommonJS는 무엇이 다를까?
JavaScript 생태계에는 대표적으로 두 가지 Module System이 사용되어 왔습니다.
CommonJS(CJS) 와 ECMAScript Modules(ESM) 입니다.
ESM은 JavaScript 표준에 포함된 Module System으로 import와 export를 사용합니다.
export const Button = () => {
return <button>Button</button>;
};
import { Button } from './Button';
반면 CommonJS는 Node.js에서 전통적으로 사용해온 Module System으로 require와 module.exports 또는 exports를 사용합니다.
const button = require('./button');
module.exports = button;
단순화하면 다음과 같습니다.
ESM → import / export
CommonJS → require / module.exports
현대적인 프론트엔드 환경에서는 ESM이 중심이지만, 패키지를 여러 실행 환경과 도구에서 사용해야 한다면 소비 환경의 Module System을 고려해야 하는 경우가 있습니다.
따라서 패키지가 ESM과 CommonJS 양쪽의 진입점을 제공하도록 구성할 수도 있습니다.
예를 들어 개념적으로 다음과 같은 결과가 만들어질 수 있습니다.
패키지 소스 코드 (src/index.ts)
↓
Build
↓
패키지를 사용하는 환경에 맞는 파일 생성
↓
dist/
├─ index.js → ESM용 실행 코드 (import 환경에서 사용)
├─ index.cjs → CommonJS용 실행 코드 (require 환경에서 사용)
└─ index.d.ts → TypeScript용 타입 정보
여기서 중요한 것은 ESM과 CJS를 둘 다 빌드하면 무조건 더 좋은 패키지가 된다는 것은 아니라는 점입니다.
사용하는 애플리케이션과 빌드 도구가 모두 ESM을 안정적으로 지원한다면 ESM만 제공하는 것이 더 단순할 수 있습니다.
Dual Package는 지원 범위를 넓힐 수 있는 대신 패키지 설정과 테스트해야 할 조합도 늘어납니다.
따라서 ESM·CJS 이중 빌드는 목적이 아니라 패키지를 소비하는 환경과의 호환성을 위한 선택으로 보는 것이 정확합니다.
패키지의 진입점은 어떻게 정의할까?
패키지를 만들면 일반적으로 다음처럼 사용할 수 있습니다.
import { Button } from '@packages/ui';
그런데 UI 컴포넌트가 계속 늘어난다고 해보겠습니다.
packages/ui
├── Button
├── Tabs
├── Toast
├── Modal
├── Input
├── Checkbox
└── ...
모든 기능을 하나의 Root Entry Point에서 노출할 수도 있습니다.
export { Button } from './Button';
export { Tabs } from './Tabs';
export { Toast } from './Toast';
사용자는 다음처럼 가져옵니다.
import { Button, Tabs, Toast } from '@packages/ui';
간단하고 편리합니다.
하지만 패키지 규모가 커지면 어떤 경로를 공식적인 Public API로 제공할 것인가도 설계해야 합니다.
이때 사용할 수 있는 것이 package.json의 exports입니다.
package.json의 exports는 무엇일까?
Node.js의 package.json에는 패키지의 Entry Point를 정의할 수 있는 exports 필드가 있습니다.
Node.js 공식 문서는 새로운 패키지를 작성할 때 exports 필드를 사용하는 것을 권장하고 있으며, exports를 정의하면 패키지에서 공개하지 않은 Subpath에 대한 접근을 제한할 수 있습니다.
예를 들어 다음과 같습니다.
{
"exports": {
".": "./dist/index.js"
}
}
이제 패키지의 대표 Entry Point는 다음 파일입니다.
@packages/ui
↓
dist/index.js
하지만 exports의 중요한 역할은 단순히 Entry Point를 지정하는 것만이 아닙니다.
패키지의 Public API Boundary를 정의할 수 있습니다.
예를 들어 내부 구조가 다음과 같다고 해보겠습니다.
ui
├── Button
├── Tabs
└── internal
└── createStyle.ts
exports를 사용하지 않고 패키지 내부 경로가 그대로 노출된다면 소비하는 코드가 내부 구현에 직접 의존할 가능성이 생깁니다.
import { createStyle } from '@packages/ui/internal/createStyle';
문제는 internal/createStyle이 외부에 제공하기 위해 만든 API가 아니라는 것입니다.
나중에 내부 구조를 다음처럼 변경하면
internal/createStyle
↓
styles/createStyle
패키지 내부 리팩터링만 했는데도 이를 직접 Import하던 애플리케이션이 깨질 수 있습니다.
반면 exports를 통해 공개할 경로를 명시하면 다음과 같은 경계를 만들 수 있습니다.
Package
├── Public
│ ├── Button
│ └── Tabs
└── Private
└── internal/*
즉, exports는 단순한 빌드 설정이 아니라 패키지에서 무엇을 공식적인 API로 보장할 것인지 정의하는 역할도 합니다.
Subpath Exports로 기능별 진입점 만들기
exports는 하나의 Entry Point만 정의하는 것이 아니라 여러 Subpath를 정의할 수도 있습니다.
Node.js에서는 이를 Subpath Exports라고 부릅니다.
예를 들어 다음과 같이 구성할 수 있습니다.
{
"exports": {
".": "./dist/index.js",
"./button": "./dist/button.js",
"./tabs": "./dist/tabs.js",
"./toast": "./dist/toast.js"
}
}
이제 소비하는 애플리케이션에서는 필요한 기능을 명시적으로 가져올 수 있습니다.
import { Button } from '@packages/ui/button';
import { Tabs } from '@packages/ui/tabs';
구조적으로 보면 다음과 같습니다.
@packages/ui
├── button
├── tabs
└── toast
패키지의 실제 파일 구조와 소비자가 사용하는 Public API를 분리할 수 있는 것입니다.
예를 들어 실제 내부 구조가
src
└── components
└── button
├── Button.tsx
├── Button.styles.ts
└── Button.types.ts
처럼 되어 있더라도 소비자는 이를 알 필요가 없습니다.
내부 구조
↓
src/components/button/Button.tsx
Public API
↓
@packages/ui/button
내부 파일 구조를 변경해도 Public API를 유지한다면 사용하는 애플리케이션의 Import 경로는 바뀌지 않습니다.
이것은 패키지의 캡슐화와 변경 가능성 측면에서 중요한 차이를 만듭니다.
Subpath Exports를 사용하면 번들이 무조건 작아질까?
여기서 한 가지 주의할 부분이 있습니다.
다음과 같이 작성했다고 해서
import { Button } from '@packages/ui/button';
반드시
import { Button } from '@packages/ui';
보다 최종 JavaScript Bundle이 작아진다고 단정할 수는 없습니다.
현대적인 Bundler는 ESM의 정적 구조를 분석하여 사용하지 않는 코드를 제거하는 Tree Shaking을 수행할 수 있기 때문입니다.
예를 들어 패키지가 Side Effect 없이 적절하게 구성되어 있다면
import { Button } from '@packages/ui';
라고 작성하더라도 Bundler가 실제로 사용하지 않는 Tabs, Toast 등의 코드를 최종 Bundle에서 제거할 수 있습니다.
따라서
Subpath Exports를 사용하면 필요한 코드만 Import하므로 무조건 Bundle Size가 감소한다.
라고 설명하는 것은 정확하지 않습니다.
Subpath Exports의 더 본질적인 장점은 패키지의 기능별 Public Entry Point를 명확하게 정의할 수 있다는 것입니다.
Root Export (패키지의 대표 진입점)
@packages/ui
Subpath Export (기능별로 나누어진 진입점)
@packages/ui/button
@packages/ui/tabs
@packages/ui/toast
그리고 패키지 구조나 사용하는 Bundler에 따라 불필요한 모듈 평가나 Tree Shaking과 관련된 이점을 얻을 가능성은 있지만, 이는 실제 빌드 결과를 측정해서 판단해야 합니다.
이 부분을 구분하는 것이 중요합니다.
ESM과 CJS를 각각 다른 Entry Point로 제공하기
exports에서는 실행 환경에 따라 서로 다른 Entry Point를 제공하는 Conditional Exports도 사용할 수 있습니다.
예를 들어 개념적으로 다음과 같이 구성할 수 있습니다.
{
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
ESM의 import를 사용하는 환경에서는
import → dist/index.js
CommonJS의 require를 사용하는 환경에서는
require → dist/index.cjs
를 사용할 수 있습니다.
Subpath에도 같은 개념을 적용할 수 있습니다.
{
"exports": {
"./button": {
"import": "./dist/button.js",
"require": "./dist/button.cjs"
},
"./tabs": {
"import": "./dist/tabs.js",
"require": "./dist/tabs.cjs"
}
}
}
그러면 패키지는 다음과 같은 구조를 가질 수 있습니다.
패키지 소스 코드
↓
ESM / CJS 두 가지 방식으로 Build
↓
각 기능별 빌드 결과 생성
↓
button.js / button.cjs
tabs.js / tabs.cjs
↓
Conditional Exports에서 사용 환경 확인
↓
import → ESM 파일 제공
require → CJS 파일 제공
이렇게 보면 ESM·CJS 이중 빌드와 Subpath Exports는 서로 별개의 설정처럼 보이지만 실제 패키지 설계에서는 연결됩니다.
ESM / CJS → 어떤 형태로 코드를 제공할 것인가?
Exports → 어떤 경로를 외부에 공개할 것인가?
Subpath Exports → 기능별 Public API를 어떻게 나눌 것인가?
결국 공통 UI를 별도의 패키지로 만든다는 것은 단순히 컴포넌트를 다른 폴더로 이동하는 작업이 아닙니다.
패키지의 책임, 의존성, Module Format, Public API까지 함께 설계하는 작업입니다.
하지만 패키지 구조를 만들었다고 해서 좋은 공통 UI가 자동으로 만들어지는 것은 아닙니다.
다음으로 고민해야 할 것은 각 컴포넌트 자체를 어떻게 설계할 것인가입니다.
그래서 다음으로는 Radix UI, CVA, Tailwind CSS를 이용해 컴포넌트의 동작과 스타일, Variant를 어떻게 구조화할 수 있는지 살펴보겠습니다.
재사용 가능한 컴포넌트는 어떻게 설계해야 할까?
앞에서는 여러 서비스에서 공통 UI를 재사용하기 위해 패키지를 어떻게 구성하고, Public API를 어떻게 설계할 수 있는지 살펴봤습니다.
하지만 공통 패키지를 만들었다고 해서 UI의 일관성이 자동으로 만들어지는 것은 아닙니다.
예를 들어 Button 하나만 보더라도 여러 상태가 존재할 수 있습니다.
Variant
primary / secondary / danger
Size
small / medium / large
State
default / hover / focus / disabled / loading
각 서비스에서 필요한 스타일을 자유롭게 추가하기 시작하면 하나의 공통 Button을 사용하더라도 사용 방법이 점점 달라질 수 있습니다.
<Button className="bg-blue-500">저장</Button>
<Button className="h-12 bg-red-500">삭제</Button>
<Button className="rounded-none px-10">확인</Button>
컴포넌트 자체는 공통화했지만 디자인 규칙은 공통화되지 않은 상태입니다.
따라서 공통 UI를 설계할 때는 단순히 컴포넌트를 재사용하는 것뿐 아니라 어떤 상태와 스타일을 공식적으로 지원할 것인지 컴포넌트의 API로 정의하는 것도 중요합니다.
이를 위해 Radix UI, CVA, Tailwind CSS를 함께 활용했습니다.
Radix UI → 동작과 접근성
CVA → Variant와 스타일 규칙
Tailwind CSS → 실제 스타일 표현
TypeScript → 컴포넌트 API 제한
각 도구가 서로 다른 책임을 담당하도록 구성하는 것이 핵심입니다.
왜 Radix UI를 사용했을까?
Tabs나 Dialog 같은 컴포넌트를 직접 만드는 것은 생각보다 복잡합니다.
겉으로 보기에는 단순한 Tabs도 다음과 같은 동작을 고려해야 합니다.
Tabs 구현
↓
단순한 화면 전환뿐만 아니라
↓
├─ 어떤 Tab이 선택되었는지 상태 관리
├─ Tab과 Content 연결
├─ 방향키를 이용한 키보드 탐색
├─ Focus 이동 및 관리
└─ ARIA 속성을 통한 접근성 지원
Dialog나 Dropdown처럼 상호작용이 복잡한 컴포넌트에서는 고려해야 할 것이 더 많아집니다.
예를 들어 Dialog를 직접 구현한다면 단순히 화면 위에 Modal을 표시하는 것으로 끝나지 않습니다.
Dialog 구현
↓
열고 닫는 기능뿐만 아니라
↓
├─ Dialog 내부로 Focus 이동
├─ Focus가 Dialog 밖으로 빠져나가지 않도록 관리
├─ ESC 등 키보드 조작 지원
├─ Screen Reader를 위한 ARIA 처리
└─ 닫은 후 기존 요소로 Focus 복원
이런 기능을 서비스마다 직접 구현하면 동일한 상호작용 로직이 반복되고, 접근성과 키보드 동작이 서로 달라질 가능성도 있습니다.
Radix Primitives는 이러한 문제를 해결하기 위한 스타일이 적용되지 않은 UI 기본 요소를 제공합니다.

공식 문서에서는 Radix Primitives를 디자인 시스템과 웹 애플리케이션을 구축하기 위한 스타일이 적용되지 않은 접근성 높은 React UI 기반 요소로 설명합니다.
즉, Radix UI는 완성된 디자인을 제공하는 UI 라이브러리라기보다 컴포넌트의 동작과 접근성을 제공하고 스타일은 개발자가 직접 구성할 수 있도록 만들어진 기반 요소에 가깝습니다.
Radix Primitives가 담당하는 것
├─ 상호작용 (Interaction)
├─ 접근성 (Accessibility)
├─ 포커스 관리 (Focus Management)
└─ 키보드 탐색 (Keyboard Navigation)
우리의 UI Layer가 담당하는 것
├─ 색상 (Color)
├─ 간격 (Spacing)
├─ 타이포그래피 (Typography)
├─ 컴포넌트 변형 (Variant)
└─ 디자인 규칙 (Design Rule)
이 구조는 공통 UI 패키지를 만드는 목적과 잘 맞습니다.
동작과 접근성 같은 복잡한 부분은 검증된 Primitive를 활용하면서, 디자인과 컴포넌트 API는 서비스의 규칙에 맞게 설계할 수 있기 때문입니다.
Primitive 기반 접근은 무엇이 좋을까?
일반적인 UI Component Library는 디자인까지 포함해서 제공하는 경우가 많습니다.
일반적인 UI Component Library
→ 컴포넌트 동작 + 기본 스타일 제공
반면 Radix UI와 같은 Primitive 기반 접근에서는 역할이 분리됩니다.
Primitive
→ 컴포넌트 동작 + 접근성 + 상태 관리 제공
Design System
→ 스타일 + Variant + 컴포넌트 API 제공
예를 들어 Tabs를 생각해보겠습니다.
import * as TabsPrimitive from '@radix-ui/react-tabs';
const Tabs = () => {
return (
<TabsPrimitive.Root defaultValue="first">
<TabsPrimitive.List>
<TabsPrimitive.Trigger value="first">첫 번째</TabsPrimitive.Trigger>
<TabsPrimitive.Trigger value="second">두 번째</TabsPrimitive.Trigger>
</TabsPrimitive.List>
<TabsPrimitive.Content value="first">첫 번째 콘텐츠</TabsPrimitive.Content>
<TabsPrimitive.Content value="second">두 번째 콘텐츠</TabsPrimitive.Content>
</TabsPrimitive.Root>
);
};
Radix UI는 Tabs의 선택 상태, 키보드 탐색 등 상호작용에 필요한 기반을 제공하고, 실제 디자인은 직접 정의할 수 있습니다.
Radix 공식 문서에서도 Primitives가 WAI-ARIA Authoring Practices를 따르며 aria, role 속성부터 포커스 관리, 키보드 탐색과 같은 복잡한 접근성 구현을 처리한다고 설명합니다.
다만 Radix UI를 사용한다고 모든 접근성 문제가 자동으로 해결되는 것은 아닙니다.
예를 들어 적절한 Label이나 콘텐츠를 제공하는 것은 여전히 사용하는 쪽의 책임일 수 있습니다.
Radix 공식 문서 역시 Primitive가 접근성을 위한 많은 구현을 제공하지만, 적절한 Label 등을 제공하는 것은 개발자의 책임이라고 설명합니다.
즉,
Radix UI가 접근성을 모두 보장해주는 것이 아니라, 접근 가능한 컴포넌트를 만들기 위한 복잡한 기반 구현을 제공
정도로 이해하는 것이 더 정확합니다.
컴포넌트의 상태와 스타일을 어떻게 연결할까?
Radix UI의 또 다른 특징은 상태를 data-* Attribute로 노출한다는 점입니다.
예를 들어 상태를 가진 Primitive는 다음과 같은 DOM을 만들 수 있습니다.
<button data-state="active">첫 번째</button>
또는
<button data-state="inactive">두 번째</button>
컴포넌트마다 실제 data-state 값은 다르지만, 핵심은 JavaScript의 내부 상태를 스타일에서 사용할 수 있는 DOM Attribute로 노출한다는 것입니다.
Radix 공식 Styling 문서에서도 상태 기반 컴포넌트의 상태를 data-state Attribute로 노출한다고 설명합니다.
따라서 CSS에서는 다음과 같이 상태를 기준으로 스타일을 적용할 수 있습니다.
.tab[data-state='active'] {
font-weight: 700;
}
Tailwind CSS 역시 data-* Attribute를 기준으로 스타일을 적용하는 Variant를 지원합니다.
*Tailwind CSS - Hover, Focus, and Other States
따라서 다음과 같이 표현할 수도 있습니다.
<TabsPrimitive.Trigger className="text-gray-500 data-[state=active]:font-bold">
첫 번째
</TabsPrimitive.Trigger>
구조적으로 보면 다음과 같습니다.
사용자 상호작용
↓
Radix UI의 컴포넌트 상태 변경
↓
상태에 맞게 data-state 속성 변경
↓
해당 data-state에 정의된 Tailwind 스타일 적용
↓
화면 스타일 변경
이렇게 하면 컴포넌트 상태에 따른 스타일을 별도의 JavaScript 조건문으로 관리하지 않고, Radix UI가 DOM에 노출하는 상태와 Tailwind의 스타일 규칙을 직접 연결할 수 있습니다.
그런데 Variant가 많아지면 어떻게 될까?
단순한 컴포넌트라면 className만으로도 충분합니다.
하지만 공통 Button처럼 다양한 Variant를 지원하기 시작하면 클래스 조합이 복잡해질 수 있습니다.
예를 들어 다음과 같습니다.
const Button = ({ variant, size, disabled }: ButtonProps) => {
return (
<button
className={`inline-flex items-center justify-center ${variant === 'primary' ? 'bg-blue-500 text-white' : ''} ${variant === 'secondary' ? 'bg-gray-100 text-black' : ''} ${size === 'small' ? 'h-8 px-3 text-sm' : ''} ${size === 'medium' ? 'h-10 px-4 text-base' : ''} ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}
>
Button
</button>
);
};
처음에는 문제가 없어 보입니다.
하지만 Variant가 늘어나면 조건도 함께 증가합니다.
그리고 특정 Variant 조합에서만 적용해야 하는 스타일이 생기기 시작합니다.
primary + medium → 특정 Style
secondary + disabled → 다른 Style
컴포넌트 안에서 조건문으로 이를 계속 관리하면 컴포넌트의 렌더링 로직과 스타일 규칙이 섞이기 시작합니다.
이 문제를 정리하기 위해 CVA(Class Variance Authority)를 사용할 수 있습니다.
CVA는 어떤 문제를 해결할까?
CVA는 Variant에 따라 어떤 Class Name을 사용할지 선언적으로 정의할 수 있도록 도와주는 라이브러리입니다.
예를 들어 Button의 스타일을 다음처럼 정의할 수 있습니다.
import { cva } from 'class-variance-authority';
const buttonVariants = cva('inline-flex items-center justify-center rounded-md font-medium', {
variants: {
variant: {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
danger: 'bg-red-500 text-white hover:bg-red-600',
},
size: {
small: 'h-8 px-3 text-sm',
medium: 'h-10 px-4 text-base',
large: 'h-12 px-6 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'medium',
},
});
이제 컴포넌트에서는 다음처럼 사용할 수 있습니다.
buttonVariants({
variant: 'primary',
size: 'medium',
});
구조적으로 보면 다음과 같습니다.
Base Style + Variant Style + Size Style → 최종 className
기존에는 렌더링 코드 안에 흩어져 있던 조건부 스타일을 다음처럼 분리할 수 있습니다.
Component
→ 화면 렌더링 + 동작 처리
CVA
→ Variant별 스타일 정의 및 조합
즉, CVA의 장점은 단순히 className을 짧게 작성하는 데 있는 것이 아니라 컴포넌트가 지원하는 스타일의 경우의 수를 하나의 Variant Schema로 표현할 수 있다는 것입니다.
Variant를 TypeScript 타입과 연결하기
CVA를 선택하면서 중요하게 본 부분 중 하나는 Variant 정의를 TypeScript 타입과 연결할 수 있다는 점입니다.
CVA는 VariantProps를 이용해 Variant 정의로부터 타입을 추출할 수 있습니다.
앞에서 만든 buttonVariants가 있다고 해보겠습니다.
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva('inline-flex items-center justify-center rounded-md font-medium', {
variants: {
variant: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-100 text-gray-900',
danger: 'bg-red-500 text-white',
},
size: {
small: 'h-8 px-3 text-sm',
medium: 'h-10 px-4 text-base',
large: 'h-12 px-6 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'medium',
},
});
type ButtonVariantProps = VariantProps<typeof buttonVariants>;
그러면 ButtonVariantProps에는 CVA에서 정의한 Variant가 반영됩니다.
개념적으로 다음과 같습니다.
type ButtonVariantProps = {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
};
이를 실제 Button Props와 연결할 수 있습니다.
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
이제 사용하는 쪽에서는 정해진 값만 전달할 수 있습니다.
<Button variant="primary" size="medium">
저장
</Button>
다음 코드는 TypeScript에서 허용되지 않습니다.
<Button variant="purple" size="huge">
저장
</Button>
정의하지 않은 Variant이기 때문입니다.
이렇게 하면 스타일 정의와 TypeScript 타입을 따로 관리하지 않아도 됩니다.
예를 들어 Variant를 새로 추가하면
variant: {
primary: '...',
secondary: '...',
danger: '...',
ghost: '...',
}
VariantProps에도 해당 값이 반영됩니다.
따라서 Variant 스타일 정의와 variant prop의 TypeScript 타입 불일치가 발생할 가능성을 줄일 수 있습니다.
Variant와 자유로운 className 사이의 균형
공통 컴포넌트를 만들다 보면 두 가지 극단적인 설계가 가능합니다.
첫 번째는 모든 스타일을 자유롭게 열어두는 것입니다.
<Button className="h-20 rounded-none bg-purple-500 px-20">Button</Button>
자유도는 높지만 디자인 시스템의 일관성을 유지하기 어렵습니다.
반대는 모든 스타일을 Variant로 만드는 것입니다.
<Button height="large" radius="none" color="purple" padding="wide" fontWeight="bold">
Button
</Button>
이번에는 Props가 CSS 속성을 그대로 옮겨놓은 것처럼 변합니다.
이렇게 되면 디자인 시스템이 아니라 또 다른 CSS API를 만드는 것에 가까워질 수 있습니다.
따라서 Variant는 CSS 속성 자체가 아니라 디자인 시스템에서 의미를 갖는 선택지를 표현하도록 구성하는 것이 좋습니다.
예를 들어
<Button variant="primary" size="medium">
저장
</Button>
에서 primary는 단순히 background-color: blue를 의미하는 것이 아닙니다.
primary
↓
"주요 동작(Primary Action)을 나타내는 버튼"이라는 의미
↓
이에 맞는 스타일 규칙을 하나로 정의
↓
├─ 배경색
├─ 글자색
├─ Hover 상태
├─ Focus 상태
└─ Disabled 상태
처럼 하나의 디자인 규칙을 나타냅니다.
그래서 variant를 설계할 때도 단순한 색상보다 UI의 의미와 역할을 표현하는 것이 좋습니다.
❌ variant="blue"
→ 어떤 색인지 표현
✅ variant="primary"
→ 주요 동작이라는 역할을 표현
따라서 컴포넌트 API는 "어떤 CSS를 적용할까?" 보다 "이 UI가 어떤 의미와 역할을 가지는가?" 를 표현하도록 설계하는 것이 더 적절합니다.
이렇게 하면 실제 색상이나 스타일이 변경되더라도 primary라는 의미는 그대로 유지할 수 있고, 컴포넌트를 사용하는 쪽에서도 해당 UI의 역할을 더 명확하게 이해할 수 있습니다.
Primitive를 그대로 노출할까, 한 번 감쌀까?
Radix UI를 사용할 때 또 하나의 설계 선택지가 있습니다.
Primitive를 애플리케이션에서 직접 사용하는 방법입니다.
import * as Tabs from '@radix-ui/react-tabs';
이 방식은 Radix API를 그대로 사용할 수 있기 때문에 유연합니다.
하지만 여러 서비스가 직접 Radix UI에 의존하면 다음과 같은 구조가 됩니다.
각 서비스에서 Radix UI를 직접 사용
Service A → Radix UI
Service B → Radix UI
Service C → Radix UI
이 경우 서비스마다 스타일이나 사용 규칙이 달라질 수 있습니다.
반면 공통 UI 패키지에서 한 번 추상화하면
각 서비스에서 공통 UI Package를 사용
Service A ─┐
Service B ─┼→ UI Package → Radix UI
Service C ─┘
↓
공통 스타일과 사용 규칙 적용
와 같은 의존 관계를 만들 수 있습니다.
애플리케이션에서는 공통 UI의 API만 사용합니다.
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@packages/ui/tabs';
이렇게 하면 공통 패키지가 다음 역할을 담당할 수 있습니다.
Radix Primitive
↓ 기반으로 사용
공통 UI Package
├─ 공통 상호작용 규칙
├─ 공통 스타일
└─ 공통 Component API
↓
각 Application에서 사용
그리고 나중에 내부 구현을 변경하더라도 Public API를 유지할 수 있다면 사용하는 서비스의 변경 범위를 줄일 수 있습니다.
Package Exports
→ 내부 파일 구조를 숨기고 Public API만 공개
Shared Component
→ 내부 UI 구현을 숨기고 Component API만 공개
결국 두 가지 모두 사용하는 쪽이 내부 구현에 직접 의존하지 않도록 경계를 만드는 것이라고 볼 수 있습니다.
하지만 추상화가 항상 많을수록 좋은 것은 아니다
그렇다고 모든 Radix Primitive를 다시 한 번 감싸는 것이 항상 좋은 것은 아닙니다.
예를 들어 Wrapper가 아무런 추가 역할도 하지 않는다면
const Tabs = (props) => {
return <TabsPrimitive.Root {...props} />;
};
단순히 추상화 계층만 하나 추가한 셈입니다.
왜냐하면 TabsPrimitive.Root를 Tabs로 이름만 바꿔서 사용하는 것과 거의 비슷하기 때문입니다.
<TabsPrimitive.Root defaultValue="tab1" />
<Tabs defaultValue="tab1" />
이런 Wrapper가 많아지면 오히려 Radix의 기능을 사용하기 어려워지거나 새로운 Props가 추가될 때 Wrapper도 계속 수정해야 할 수 있습니다.
따라서 Wrapper를 만드는 기준 역시 명확해야 합니다.
예를 들어 다음과 같은 역할이 있을 때 공통 컴포넌트로 추상화할 가치가 커집니다.
- 공통 Style 적용 → 모든 서비스의 Tabs 디자인을 동일하게 만들고 싶을 때
- 공통 Variant 정의 → primary, secondary 같은 우리만의 규칙을 제공하고 싶을 때
- 공통 Interaction Rule 적용 → 클릭이나 동작 방식을 통일하고 싶을 때
- 접근성 규칙 보완 → 우리 서비스에서 추가로 지켜야 하는 접근성 규칙이 있을 때
- 반복되는 API 단순화 → 매번 복잡하게 사용하는 코드를 간단하게 만들고 싶을 때
- 내부 구현 캡슐화 → 애플리케이션이 Radix라는 구현체를 직접 알지 않게 하고 싶을 때
즉,
공통 컴포넌트의 목적은 Wrapper를 만드는 것이 아니라 반복되는 UI 규칙과 사용 방법을 하나의 API로 만드는 것
이라고 볼 수 있습니다.
공통 컴포넌트의 API는 많을수록 좋을까?
공통 컴포넌트를 설계하다 보면 재사용성을 높이기 위해 많은 Props를 추가하고 싶어집니다.
<Button
variant="primary"
size="medium"
loading={false}
rounded
fullWidth
iconPosition="left"
shadow="small"
fontWeight="bold"
/>
하지만 지원하는 옵션이 늘어날수록 가능한 조합 역시 빠르게 증가합니다.
예를 들어
Variant 3개 × Size 3개 × Loading 2개 × FullWidth 2개
↓
총 36가지 조합 가능
여기에 다른 Props까지 추가되면 테스트하고 일관성을 보장해야 하는 경우의 수도 계속 늘어납니다.
따라서 재사용 가능한 컴포넌트는 무엇이든 할 수 있는 컴포넌트가 아니라 반복되는 디자인 규칙을 제한된 API로 표현하는 컴포넌트에 더 가깝습니다.
이 관점에서 TypeScript의 역할도 중요합니다.
단순히 타입 오류를 방지하는 것을 넘어 디자인 시스템에서 허용한 사용법을 코드 수준에서 표현하는 수단으로 활용할 수 있기 때문입니다.
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
};
이 타입은 단순한 TypeScript 정의가 아니라 다음과 같은 의미를 가집니다.
디자인 시스템에서 허용한 규칙
↓
Button에서 사용할 수 있는 Variant로 정의
↓
TypeScript 타입으로 제한
↓
개발자는 허용된 값만 사용 가능
디자인 규칙과 개발자가 사용할 수 있는 API가 연결되는 것입니다.
공통화에도 비용이 있다
다만 공통 UI 패키지를 구축한다고 항상 유지보수 비용이 줄어드는 것은 아닙니다.
공통 패키지는 여러 애플리케이션이 의존하는 만큼 변경의 영향 범위도 커집니다.
Shared UI 변경
↓
Service A 영향
Service B 영향
Service C 영향
따라서 공통화 이후에는 새로운 문제도 고려해야 합니다.
Breaking Change
→ 어떤 서비스에 영향을 주는가?
새로운 Variant
→ 정말 공통으로 필요한가?
기존 API 변경
→ 하위 호환성을 유지해야 하는가?
Dependency Update
→ 사용하는 서비스의 라이브러리 버전과 충돌하지 않는가?
또 지나치게 많은 것을 공통화하면 공통 패키지가 각 서비스의 요구사항을 모두 받아들이는 거대한 패키지로 변할 수 있습니다.
Service A 요구사항 ─┐
Service B 요구사항 ─┼→ Shared UI
Service C 요구사항 ─┘
↓
복잡한 범용 Component
따라서 공통화의 기준을 단순히 "두 번 이상 사용되는가?" 로 정하기보다,
여러 서비스에서 동일한 의미와 규칙으로 유지되어야 하는가?
를 함께 고려하는 것이 중요합니다.
특정 서비스의 비즈니스 요구사항이라면 서비스 내부에 두고, 여러 서비스에서 동일한 디자인과 상호작용 규칙을 가져야 하는 요소만 공통 UI의 책임으로 두는 것입니다.
Shared UI
↓
서비스와 독립적인 UI 규칙
Application
↓
서비스별 Business Logic
이 경계를 유지해야 공통 패키지가 커져도 특정 서비스에 강하게 결합되지 않는 구조를 유지할 수 있습니다.
마치며
처음에는 여러 서비스에서 반복해서 사용하던 Button이나 Tabs 같은 컴포넌트를 공통화하는 문제에서 시작했습니다.
하지만 실제로 재사용 가능한 UI 아키텍처를 만들기 위해서는 단순히 컴포넌트를 한 폴더로 옮기는 것보다 더 많은 것을 고려해야 했습니다.
여러 서비스에서 중복되는 UI
↓
공통 UI Package로 분리
↓
재사용 가능한 구조를 위한 역할과 경계 설계
↓
├─ UI / Utils → 패키지별 책임 분리
├─ ESM / CJS → 다양한 Module System 지원
├─ Exports → 외부에 공개할 API 정의
├─ Radix UI → 동작과 접근성 처리
├─ CVA → Variant와 타입 관리
└─ Tailwind CSS → 스타일 규칙 적용
특히 중요했던 것은 공통화 자체보다 경계를 만드는 것이었습니다.
패키지에서는 어떤 기능을 외부에 공개할지 경계를 만들고, 컴포넌트에서는 어떤 Variant와 상태를 허용할지 경계를 만들며, 공통 UI와 각 서비스의 비즈니스 로직 사이에도 경계를 둡니다.
결국 재사용 가능한 UI 아키텍처는 단순히 같은 코드를 여러 곳에서 사용하는 구조가 아니라,
여러 서비스가 동일한 UI 규칙과 컴포넌트 API를 공유하면서도 각 서비스의 비즈니스 로직과는 독립적으로 변화할 수 있도록 만드는 구조
라고 생각합니다.
공통화할 것을 최대한 많이 만드는 것보다 어디까지를 공통의 책임으로 볼 것인지 결정하는 것이 더 중요한 이유입니다.