Compound Pattern을 사용해 재사용 극대화하기
합성 컴포넌트를 사용해 유연한 UI, 재사용성 향상, 가독성 높은 코드를 만들어보자. 👀

Compound Pattern이란
Compound Pattern은 여러개의 작은 컴포넌트들을 합성해 만든 디자인 패턴입니다. 여러 개의 작은 컴포넌트란 어떤 말일까요? HTML의 select
태그를 봐봅시다.
<select>
<option value="js">JavaScript</option>
<option value="ts">TypeScript</option>
</select>
select 태그와 option 태그를 사용해 하나의 기능이 완성되는 것 처럼 요소들을 세분화하고 합성해서 사용하는 것을 말합니다. Compound Pattern은 UI 라이브러리(Radix UI, MUI)에서 많이 볼 수 있습니다. UI 라이브러리에서 이 패턴을 사용한다는 건 많은 이점이 있기 때문에 사용한다고 볼 수 있습니다.
Compound Pattern을 사용하면 어떤 이점이 있는지 알아보겠습니다
Compound Pattern의 장점
- 유연한 UI 구조
- 가독성 및 일관성 있는 코드
- props 관리
- prop drilling
- 관심사 분리
Compound Pattern을 사용하면 장점은 크게 4가지로 구분됩니다. 우선 코드 예시를 봐보겠습니다. 다음은 제가 실제로 작업하면서 만들었던 코드입니다.
<FormControl type="form" id="email" hasError={hasError}>
<FormControl.Label>이메일</FormControl.Label>
<FormControl.Input type="text" placeholder="이메일을 입력해주세요" {...register("email")} {...props} />
<FormControl.ErrorMessage />
</FormControl>
저는 각 Form에 해당하는 Field들을 Compound Pattern을 적용 했습니다. 먼저 왜 유연한 UI 구조인지에 대해 알아볼게요.
왜 유연한 UI 구조인가?
위 코드를 보시면 Atomic Pattern처럼 여러 개의 작은 컴포넌트들을 조합해 사용하고 있습니다. 즉 아래와 같이 하나의 컴포넌트가 여러 가지의 일들을 내장하고 있는 게 아니라 조합해서 사용하기 때문에 유연하다는 특징이 있습니다.
조금 더 설명하자면 아래 예시는 컴포넌트 이름이 잘못되었을 수 있지만, 어떠한 이름을 지어도 저 컴포넌트 안에 어떠한 요소들이 있는지 파악하기 힘들거나 이름부터 하는일이 많아진다고 느껴 거부감이 들게됩니다. 만약 새로운 요구사항이 들어와 Label
이 없는 FormField를 만든다면 어떻게 해야할까요 ??? 아마 Compound Pattern을 사용하지 않는다면 새로운 컴포넌트를 만들거나 또는 props로 전달해 내부 로직에 추가가 될거에요.
<FormControl /> // 안에 구성요소를 보기위해 팀원들은 무조건 내부로직을 봐야합니다.
<FormFieldWithLabel /> // 괜찮지만 이게 정말 명확한 이름일까요 ? 에러메세지가 없다면요 !?
<FormFieldWithLabelWithMessage /> // 음.. 해석하기 싫어지는 이름입니다...
props 관리
요구사항에 맞춰 작업하기 위해 props를 추가적으로 전달하게 된다면 props는 끝날 줄 모르게 늘어나게 됩니다. 즉 컴포넌트의 props가 3개를 초과하게 됩니다. 다음은 제가 협업하면서 팀원이 작성했던 코드를 가져와 볼게요.
<InputField
label="이메일"
id="email"
type="email"
value={email.value}
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
onBlur={validateEmail}
placeholder="이메일을 입력해 주세요"
error={email.error}
ref={focusRef}
/>
위에 Compound Pattern을 사용할 때를 비교해보시면 어떤 느낌이 드시나요? "이건 좀 아니지 않나" 또는 "나쁘지 않은데?" 라는 생각이 들 수 있지만, 이 컴포넌트를 사용하는 팀원 입장에서도 생각해보면 좋을 것 같습니다. 본인이 혼자 만들고 사용하는 건 전혀 문제가 되지 않습니다. 처음부터 만들었으니까요. 하지만 사용하는 팀원들은 이해하는데 시간을 꽤 투자할 거에요.
실제로 협업할 때 저희 팀원은 Label이 없는 상황일 때 새로운 컴포넌트를 만들었습니다. 결국 재사용성 부분도 문제가 있습니다. 컴포넌트도 마찬가지로 전달해야하는 props를 웬만하면 3개를 초과하지 않는게 사용하는 개발자 입장에서 더 좋을 것 같습니다. 사소하지만 이러한 DX들이 중요하다는 생각이 듭니다.
prop drilling
Compound Pattern은 Context API를 사용하기 때문에 부모 컴포넌트에서 자식 컴포넌트로 전달할 때 직접 전달할 수 있다는 장점이 있습니다. 따라서 Props Drilling에 대한 걱정이 없고 작은 단위로 Context API를 사용하기 때문에 리 렌더링 문제도 크게 걱정하지 않아도 됩니다.
가독성 및 일관성 있는 코드
가독성이 높을 수밖에 없는 이유는 여러 가지의 요소들로 구성되어있기 때문입니다. 추가적으로 이름의 통일성이 있어 가독성이 높아진 것도 한몫합니다. 추가 요구사항에도 대응할 수 있기 때문에 재사용성도 높아지니 일관성 있는 코드라고도 부를 수 있을 것 같네요.
관심사 분리
핵심적인 로직은 부모 컴포넌트가 담당하고 있습니다. 따라서 자식 컴포넌트들은 본연의 역할만 수행하게 됩니다. Atomic Pattern처럼 컴포넌트들을 세분화했기 때문에 수정해야 할 요소를 쉽게 파악할 수 있고 컴포넌트 내부만 보아도 간단하여서 파악하거나 수정할 때 유리합니다. 즉 재사용성을 높이는 방법이라고 볼 수 있습니다.
Compound Pattern의 단점
단점으로는 JSX 코드가 많이 길어지는 부분과 레이아웃 순서대로 배치되기 때문에 간혹 이상하게 나오는 경우도 있습니다. 너무 좋은 장점 때문에 일어난 단점이기에 팀원들과 상의하면 충분히 해결할 수 있는 문제라고 봅니다.
Compound Pattern 구현해보기
정말 간단한 형태인 카운트를 예제로 Context API를 사용해 만들어보겠습니다. 항상 무엇인가를 만들 때 필요한 요소가 무엇인지를 알아야 하고, 상태를 어떻게 관리할 건지에 대해서도 고민해보면 좋을 것 같습니다.
- 카운트된 숫자를 보여줄 컴포넌트
- +1 증가하는 버튼 컴포넌트
- -1 감소하는 버튼 컴포넌트
구현부
import { createContext, ButtonHTMLAttributes, ReactNode, useContext } from "react"
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode
}
type CountContextType = {
count: number
onIncrease: () => void
onDecrease: () => void
}
interface CountProps extends CountContextType {
children: ReactNode
}
const CountContext = createContext<CountContextType>({
count: 0,
onIncrease: () => {},
onDecrease: () => {},
})
// CountProvider에서 사용안하는 경우를 대비한 코드입니다.
function useCountContext() {
const context = useContext(CountContext)
if (!context) throw new Error("CountContext에서 사용해주세요.")
return context
}
export default function Count({ children, ...countStates }: CountProps) {
return <CountContext.Provider value={{ ...countStates }}>{children}</CountContext.Provider>
}
function Score() {
const { count } = useCountContext()
return <span>{count}</span>
}
function IncreaseButton(props: ButtonProps) {
const { onIncrease } = useCountContext()
return <button onClick={onIncrease}>{props.children}</button>
}
function DescreaseButton(props: ButtonProps) {
const { onDecrease } = useCountContext()
return <button onClick={onDecrease}>{props.children}</button>
}
Count.Score = Score
Count.IncreaseButton = IncreaseButton
Count.DescreaseButton = DescreaseButton
사용부
// 사용부
export default function Usage() {
const [count, setCount] = useState(0)
const onIncrease = () => setCount((count) => count + 1)
const onDecrease = () => setCount((count) => count - 1)
return (
{/* customHook을 사용하면 {...countState}로 더 깔끔하게도 가능합니다! */}
<Count count={count} onIncrease={onIncrease} onDecrease={onDecrease}>
<Count.Score />
<Count.IncreaseButton>+1</Count.IncreaseButton>
<Count.DescreaseButton>-1</Count.DescreaseButton>
</Count>
)
}
마치며
상태가 부모 컴포넌트에서만 관리된다면 로직을 부모 컴포넌트(Count)에서 관리해도 상관없습니다. 만약 부모 컴포넌트에서 로직을 관리한다면 props 전달은 필요 없겠네요. 이러한 부분에서 Popover
와 같은 컴포넌트에서 매우 유용할 것 같습니다.
저는 부모 컴포넌트에서 로직을 지정하지 않고 사용하는 측 컴포넌트에서 작성해 전달해주도록 했습니다. 컴포넌트 만들 때 재사용성을 고려한다면 이처럼 Compound Pattern을 적극 사용해보는 걸 추천합니다.