Search

확장성 있는 컴포넌트 설계

subtitle
디자인 시스템에서의 재사용 가능한 컴포넌트 설계
Tags
디자인패턴
front-end
Created
2026/03/07
2 more properties

왜 컴포넌트는 점점 복잡해질까

Design System을 운영하다 보니 처음에는 단순했던 컴포넌트가 시간이 지나면서 점점 복잡해졌다. 특히 B2B, B2G 프로젝트나 SaaS 프로덕트를 동시에 운영하는 환경에서는 하나의 Design System을 여러 프로젝트에서 공통으로 사용하게 됐는데, 이때 각 프로젝트마다 조금씩 다른 요구사항이 생겼다. 처음에는 단순한 Button 컴포넌트로 시작했다.
<Button>저장</Button>
TypeScript
복사
하지만 시간이 지나면서 다양한 요구사항이 추가됐다.
아이콘을 함께 보여주고 싶다
링크처럼 동작해야 한다
Tooltip을 붙이고 싶다
Loading 상태가 필요하다
특정 이벤트를 로깅해야 한다
등등…
이런 요구사항을 반영하다 보면 Button 컴포넌트는 점점 많은 기능을 가지게 된다.
<Button icon={<SaveIcon />} href="/save" loading tooltip="저장" analyticsId="save-button" > 저장 </Button>
TypeScript
복사
처음에는 단순했던 컴포넌트가 점점 다양한 역할을 맡게 되는데, 아무런 설계 없이 기능을 계속 추가하다 보면 컴포넌트는 금방 복잡해지고 유지보수가 어려워진다.
Design System의 컴포넌트는 여러 프로젝트에서 재사용되기 때문에 확장성이 매우 중요하다.
그렇다면 Design System 컴포넌트를 확장 가능하게 설계하려면 어떻게 해야 할까?

문제: props로만 확장하려는 경우

컴포넌트를 확장할 때 가장 naive한 방법은 props를 추가하는 것이다.
interface ButtonProps extends PropsWithChildren { variant?: "primary" | "secondary" }
TypeScript
복사
하지만 새로운 요구사항이 생길 때마다 props를 계속 추가하게 된다.
interface ButtonProps extends PropsWithChildren { variant?: "primary" | "secondary" icon?: React.ReactNode loading?: boolean tooltip?: string href?: string }
TypeScript
복사
처음에는 간단해 보이지만 시간이 지나면 다음과 같은 문제가 발생한다.

1. props explosion

컴포넌트의 props가 점점 많아진다.
<Button icon={<SaveIcon />} loading tooltip="저장" variant="primary" > 저장 </Button>
TypeScript
복사
props가 많아질수록 컴포넌트의 사용법도 복잡해진다. Button을 사용하기 위해서는 매번 Button 코드를 보면서 어떤 props들이 있는지 확인해야 한다. 좋은 코드는 추상화된 내부를 굳이 찾아볼 필요가 없게 만드는데, props를 계속 추가하다 보면 코드에 대해 숙지해야 하는 번거로움이 생긴다.

2. 내부 로직이 복잡해진다

props에 따라 렌더링 방식이 계속 달라지기 시작한다.
if (href) { return <a {...props}>{children}</a> } if (tooltip) { return ( <Tooltip content={tooltip}> <button {...props}>{children}</button> </Tooltip> ) }
TypeScript
복사
이렇게 되면 컴포넌트 내부 로직이 점점 커지고 예외 케이스도 늘어난다.

3. 조합이 제한된다

props 기반 설계의 가장 큰 문제는 조합이 제한된다는 것이다.
예를 들어 다음과 같은 상황을 생각해보자.
Button 안에 아이콘을 두 개 넣고 싶다
Button 내부 구조를 조금 바꾸고 싶다
Button을 다른 컴포넌트와 조합하고 싶다
하지만 props 중심 설계에서는 이런 확장이 쉽지 않다.
결국 컴포넌트는 점점 거대한 조건문 덩어리가 된다.
개발자라면 한 번쯤 이런 코드를 본 적이 있을 것이다.
if (loading && icon) { ... } else if (loading) { ... } else if (icon) { ... }
TypeScript
복사
이렇게 되면 Button 컴포넌트가 아니라 작은 상태 머신이 되어버린다.

확장성 있는 컴포넌트를 설계하려면 어떻게 해야할까?

그렇다면 확장 가능한 컴포넌트는 어떻게 설계해야 할까?
Design System을 만들면서 느낀 핵심 원칙은 다음 네 가지다.

1. props explosion을 피한다

기능을 추가할 때마다 props를 늘리는 방식은 오래 지속되기 어렵다.
props가 많아질수록 컴포넌트의 API는 복잡해지고 학습 비용도 증가한다.
가능하다면 props 대신 composition을 활용하자.

2. Composition을 우선한다

예를 들어 다음 두 방식이 있다고 가정해보자.
props 전달 방식
<Button icon={<SaveIcon />} label="저장" />
TypeScript
복사
Composition 방식
<Button> <SaveIcon /> 저장 </Button>
TypeScript
복사
Composition을 사용하면 UI 조합이 훨씬 자유로워진다.

3. DOM 구조를 유연하게 유지한다

컴포넌트가 항상 특정 DOM 구조를 강제하면 확장이 어려워진다.
예를 들어 Button이 항상 button 태그를 렌더링해야 한다면 다음과 같은 문제가 생긴다.
Link처럼 사용하기 어렵다
다른 컴포넌트와 조합하기 어렵다
따라서 컴포넌트는 DOM 구조를 유연하게 변경할 수 있어야 한다.
DOM 구조를 유연하게 유지하는 방법은 뒤에서 설명한다!

4. 컴포넌트의 책임을 분리한다

확장 가능한 컴포넌트는 하나의 컴포넌트가 모든 일을 하지 않는다.
대신 여러 컴포넌트가 역할을 나누고 조합되는 구조를 만든다.
이런 원칙을 기반으로 React 생태계에서는 여러 패턴이 사용되고 있다.
다음 섹션에서는 확장성 있는 컴포넌트를 만들기 위해 자주 사용되는 패턴들을 하나씩 살펴보려고 한다.

Composition 패턴

앞서 살펴본 것처럼 props를 계속 추가하는 방식은 컴포넌트를 빠르게 복잡하게 만든다. React에서 이를 해결하는 가장 기본적인 방법이 바로 Composition 패턴이다.
Composition은 간단하게 말해 컴포넌트를 조합하여 UI를 구성하는 방식이다.
예를 들어 Button에 아이콘을 추가해야 하는 상황을 생각해보자.
props 전달 방식은 보통 이런 형태다.
<Button icon={<SaveIcon />} label="저장" />
TypeScript
복사
이 방식은 편해 보이지만 컴포넌트 내부에서 icon, label 같은 props를 모두 처리해야 한다.
요구사항이 늘어날수록 props도 계속 늘어나게 된다.
Composition 방식에서는 children을 이용해 UI를 직접 조합한다.
<Button> <SaveIcon /> 저장 </Button>
TypeScript
복사
이 방식의 장점은 명확하다.
첫째, 컴포넌트 API가 단순해진다.
둘째, UI 조합이 훨씬 자유로워진다.
셋째, 컴포넌트 내부 로직이 줄어든다.
예를 들어 아이콘이 두 개 필요하다면 별다른 수정 없이 다음과 같이 사용할 수 있다.
<Button> <SaveIcon /> <CheckIcon /> 저장 </Button>
TypeScript
복사
props 기반 설계였다면 새로운 props를 추가해야 했겠지만, Composition 방식에서는 별도의 수정이 필요 없다.
예전 React 공식 문서에서도 컴포넌트 재사용을 위해 inheritance보다 composition을 사용할 것을 권장한다.

Compound 패턴

Compound 패턴은 여러 컴포넌트가 하나의 상태를 공유하면서 함께 동작하는 구조다.
대표적인 예시는 Dropdown이나 Tabs 같은 UI 컴포넌트다.
예를 들어 Dropdown을 구현한다고 가정해보자.
Compound 패턴을 사용하면 다음과 같은 API를 만들 수 있다.
<Dropdown> <Dropdown.Trigger>메뉴</Dropdown.Trigger> <Dropdown.Content> <Dropdown.Item>삭제</Dropdown.Item> <Dropdown.Item>수정</Dropdown.Item> </Dropdown.Content> </Dropdown>
TypeScript
복사
이 구조의 특징은 다음과 같다.
첫째, 컴포넌트 구조가 매우 직관적이다.
둘째, 내부 상태를 여러 컴포넌트가 공유할 수 있다.
셋째, UI 구조를 자연스럽게 표현할 수 있다.
간단한 예시로 Dropdown의 상태를 Context로 공유할 수 있다.
const DropdownContext = React.createContext(null) export function Dropdown({ children }) { const [open, setOpen] = React.useState(false) return ( <DropdownContext.Provider value={{ open, setOpen }}> {children} </DropdownContext.Provider> ) }
TypeScript
복사
Trigger 컴포넌트에서는 이 상태를 사용할 수 있다.
export function Trigger({ children }) { const { setOpen } = React.useContext(DropdownContext) return ( <button onClick={() => setOpen(prev => !prev)}> {children} </button> ) }
TypeScript
복사
Compound 패턴의 핵심은 컴포넌트를 분리하면서도 하나의 동작을 공유하게 만드는 것이다.

Polymorphic Component 패턴

Polymorphic Component 패턴은 컴포넌트가 렌더링할 DOM을 유연하게 변경할 수 있도록 만드는 방식이다.
Design System을 만들다 보면 다음과 같은 요구사항이 자주 생긴다.
Button을 링크처럼 사용하고 싶다
Button을 Next.js Link와 함께 사용하고 싶다
Button을 div로 렌더링하고 싶다
하지만 Button이 항상 button 태그를 렌더링한다면 이런 확장이 어렵다.
이 문제를 해결하기 위해 사용하는 패턴이 Polymorphic Component 패턴이다.
가장 흔한 방식은 as prop을 사용하는 것이다.
<Button as="a" href="/home"> 홈으로 이동 </Button>
TypeScript
복사
Button 컴포넌트는 전달된 as 값을 기반으로 어떤 DOM을 렌더링할지 결정한다.
간단한 구현 예시는 다음과 같다.
type ButtonProps<T extends React.ElementType> = { as?: T children: React.ReactNode } & React.ComponentPropsWithoutRef<T> export function Button<T extends React.ElementType = "button">({ as, children, ...props }: ButtonProps<T>) { const Component = as || "button" return <Component {...props}>{children}</Component> }
TypeScript
복사
이 방식의 장점은 다음과 같다.
첫째, 하나의 컴포넌트를 다양한 DOM으로 사용할 수 있다.
둘째, Design System 컴포넌트의 재사용성이 높아진다.
셋째, 다양한 프레임워크와 자연스럽게 통합할 수 있다.
Polymorphic 패턴은 컴포넌트의 DOM 구조를 유연하게 바꿀 수 있게 해주는 중요한 설계 전략이다.
하지만 이 방식에도 한 가지 단점이 있다.
컴포넌트가 직접 DOM을 선택해야 하기 때문에 composition의 유연성이 제한될 수 있다.
예를 들어, Next.js Link와 함께 쓰고 싶은 경우,
<Link href="/home"> <Button></Button> </Link>
TypeScript
복사
또는
<Button as={Link} href="/home"></Button>
TypeScript
복사
이 경우 문제가 생길 수 있다.
href 타입 문제
ref 문제
framework specific props
children 구조 문제
Button(부모 컴포넌트)이 어떤 DOM을 렌더링할지 결정해야 하기 때문에
외부 컴포넌트와 조합할 때 제한이 생긴다.
이 문제를 해결하기 위해 Radix UI에서는 또 다른 접근 방식인 Slot 패턴을 사용한다.

Radix Slot 패턴으로 유연한 확장 만들기

앞에서 Polymorphic Component 패턴을 살펴보았다. 이 패턴은 as prop을 통해 컴포넌트가 렌더링할 DOM을 변경할 수 있게 해준다.
하지만 이 방식은 여전히 컴포넌트가 DOM을 직접 선택하는 구조다. 즉 부모 컴포넌트가 어떤 DOM이 렌더링될지 결정한다.
Radix UI는 이 문제를 다른 방식으로 접근한다. 바로 Slot 패턴이다.
Slot 패턴의 핵심 아이디어는 다음과 같다.
부모 컴포넌트가 DOM을 생성하는 대신 자식 컴포넌트의 DOM을 그대로 사용하면서 필요한 props만 주입하는 것이다.
예를 들어 Button 컴포넌트를 생각해보자.
기존 방식에서는 Button이 항상 button 태그를 렌더링한다.
<Button>저장</Button>
TypeScript
복사
하지만 Slot 패턴을 사용하면 다음과 같이 사용할 수 있다.
<Button asChild> <a href="/home">홈으로 이동</a> </Button>
TypeScript
복사
이 경우 Button은 button 태그를 렌더링하지 않는다. 대신 자식으로 전달된 a 태그를 그대로 사용한다.
즉 다음과 같은 DOM이 만들어진다.
<a class="button" href="/home">홈으로 이동</a>
HTML
복사
이 방식의 장점은 다음과 같다.
첫째, 불필요한 wrapper DOM이 생기지 않는다.
둘째, 기존 컴포넌트를 그대로 사용할 수 있다.
셋째, 여러 컴포넌트를 자유롭게 조합할 수 있다.
특히 Design System을 만들 때 Trigger 컴포넌트Button 컴포넌트처럼 다양한 환경에서 재사용되는 컴포넌트에 매우 유용하다.

asChild 패턴의 동작 원리

Radix UI에서 Slot 패턴을 사용할 때 보통 asChild prop을 함께 사용한다.
asChild가 true인 경우 컴포넌트는 자신이 DOM을 렌더링하는 대신 Slot 컴포넌트를 사용하도록 전환한다.
Radix의 Slot은 내부적으로 React.cloneElement를 사용한다.
동작을 단순화하면 다음과 같은 형태다.
function Slot({ children, ...props }) { return React.cloneElement(children, { ...props, ...children.props }) }
TypeScript
복사
이 코드가 하는 일은 단순하다.
1.
자식 요소를 그대로 가져온다.
2.
부모 컴포넌트의 props를 주입한다.
3.
자식의 기존 props와 병합한다.
예를 들어 다음 코드를 생각해보자.
<Button asChild> <a href="/home"></a> </Button>
TypeScript
복사
Button이 className을 가지고 있다면 Slot은 이를 a 태그에 주입한다.
결과적으로 다음과 같은 DOM이 생성된다.
<a href="/home" class="button"></a>
HTML
복사
이 방식은 컴포넌트의 DOM을 교체하지 않고 기존 DOM에 기능을 추가하는 구조다.
그래서 다른 컴포넌트들과의 Composition이 훨씬 유연해진다.

실제 구현 예시: 확장 가능한 Button 컴포넌트

Radix Slot을 사용하면 확장 가능한 Button 컴포넌트를 간단하게 구현할 수 있다.
먼저 Slot을 import한다.
import { Slot } from "@radix-ui/react-slot"
TypeScript
복사
Button 컴포넌트를 구현해보자.
type ButtonProps = { asChild?: boolean children: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement> export function Button({ asChild, children, ...props }: ButtonProps) { const Comp = asChild ? Slot : "button" return ( <Comp className="px-4 py-2 rounded bg-blue-500 text-white" {...props} > {children} </Comp> ) }
TypeScript
복사
이제 Button은 두 가지 방식으로 사용할 수 있다.
기본 사용
<Button>저장</Button>
TypeScript
복사
이 경우 일반적인 button 태그가 렌더링된다.
<button class="button">저장</button>
HTML
복사
다른 DOM으로 확장하려면 asChild=true props를 전달한다.
<Button asChild> <a href="/home"></a> </Button>
TypeScript
복사
이 경우 button 대신 a 태그가 렌더링된다.
<a href="/home" class="button"></a>
HTML
복사
이 구조 덕분에 Button은 다양한 환경에서 재사용할 수 있다.
예를 들어 Next.js Link와도 자연스럽게 결합할 수 있다.
<Button asChild> <Link href="/home"></Link> </Button>
TypeScript
복사

언제 Slot 패턴을 쓰고 언제 쓰지 말아야 할까?

Slot 패턴은 매우 유용하지만 모든 컴포넌트에 적용할 필요는 없다.
다음과 같은 경우 Slot 패턴이 특히 유용하다.
1.
Trigger 컴포넌트
2.
Button
3.
Link
4.
MenuItem
이런 컴포넌트들은 다양한 환경에서 재사용되기 때문에 DOM 구조를 유연하게 변경할 필요가 있다.
반대로 다음과 같은 경우에는 Slot 패턴이 크게 필요하지 않다.
1.
Layout 컴포넌트
2.
Container 컴포넌트
3.
DOM 구조가 고정된 컴포넌트
예를 들어 Grid나 Card 같은 컴포넌트는 구조 자체가 중요한 경우가 많기 때문에 DOM을 자유롭게 변경할 필요가 없다.
따라서 Slot 패턴은 재사용성이 높은 UI 컴포넌트에 선택적으로 적용하는 것이 좋다.

정리: 확장성 있는 컴포넌트 설계를 위한 체크리스트

지금까지 확장성 있는 컴포넌트를 설계하기 위한 여러 패턴을 살펴보았다.
정리하면 다음과 같은 질문을 스스로 해보는 것이 좋다.
props가 계속 늘어나고 있는가
children composition으로 해결할 수 있는가
컴포넌트의 DOM 구조를 유연하게 변경해야 하는가
wrapper DOM이 불필요하게 생기고 있는가
컴포넌트를 다른 UI와 쉽게 조합할 수 있는가
이 질문들에 대한 답을 바탕으로 다음과 같은 패턴을 적절히 선택할 수 있다.
Composition 패턴
Compound Component 패턴
Polymorphic Component 패턴
Slot 패턴
Design System을 설계할 때 가장 중요한 것은 모든 요구사항을 미리 예측하는 것이 아니라, 변화에 유연하게 대응할 수 있는 구조를 만드는 것이라고 생각한다. 상황에 따라 필요한 패턴을 자유롭게 사용하면 좋을 것 같다.