Compound component pattern은 리액트에서 사용할 수 있는 디자인 패턴 중 하나로, 여러개의 작은 컴포넌트들을 조합하여 하나의 큰 컴포넌트를 만든다.
예를 들어, 여러 페이지에서 사용하는 모달을 만든다고 해보자. 디자인은 비슷한데, 어떤 것은 제목이 있고, 어떤것은 제목이 없다. 어떤 모달은 버튼을 포함할 수도 있다. 모든 모달들이 서로 다른 디자인을 가지고 있다면 하나씩 만드는 것이 낫겠지만, 비슷한 디자인을 공유한다면 제목, 텍스트, 버튼 등의 요소들을 작은 컴포넌트로 만들어 원하는 배치대로 조합하여 사용하는 것이 편할 수 있다. 이 때 Compound component pattern을 사용할 수 있다.
내가 컴파운드 컴포넌트를 만들고자 한 코드는 웹사이트에서 전반적으로 사용되는 인풋 컴포넌트이다. 일관된 스타일을 가지고 있지만, 그 안에 들어가는 요소는 조금씩 다르다.



이렇게 제목과 인풋이 들어가는 위치, 스타일은 동일하지만, 사용자 입력값을 받는 부분은 텍스트 인풋, 드롭다운 리스트, 태그 등 다양하다. 일부 선택사항들은 제목 옆에 ‘(선택)’이라는 표시도 해주어야 한다.
처음에는 하나의 공통 컴포넌트로 만들어 props를 통해 내용물을 정해주었으나, 내용물의 종류가 너무 다양해서 하나의 컴포넌트 안에 모든것을 담아내기에는 무리였다. 여러가지 방법을 고민했지만, 내용물을 다양하게 넣어줄 수 있고, 상황에 따라 배치도 자유롭게 해줄 수 있는 컴파운드 컴포넌트가 적합하다고 판단했다.
우선, 모든 인풋 컴포넌트가 공통으로 가지고 있는 부분을 뽑아주었다. 공통 부분은 따로 넣어주는것보다 이렇게 모아주면 재사용을 할 수 있어 코드의 중복을 줄일 수 있다고 생각했기 때문이다.
function CompoundInput(props: CompoundInputProps) {
const { title, children, option } = props
return (
<CompoundInputContainerBox>
<CompoundInputTitleContainer>
<CompoundInputTitle>{title}</CompoundInputTitle>
{option !== '' && <p>{option}</p>}
</CompoundInputTitleContainer>
{children}
</CompoundInputContainerBox>
)
}
Styled component를 사용해 기본 스타일을 정의해주고, 제목과 선택사항은 props로 받도록 했다. 그리고 실제 사용할 때 원하는 내용을 원하는 순서대로 배치할 수 있도록 인풋이 들어가야하는 부분에 children을 넣어주었다.
children 자리에는 어떤 요소든 들어갈 수 있지만, 여러번 반복해서 쓰이는 요소들은 컴포넌트로 만들었다. 컴포넌트로 만들어준 요소는 텍스트 인풋, 드롭다운 리스트, 태그, 텍스트 에어리어 4가지이다.
예시) 태그 컴포넌트
function CompoundInputTag(props: CompoundInputTagProps) {
const { placeholder, setValue, defaultTags, whiteList, maxTagCount, isHashTag } = props
const setting = isHashTag ? {
maxTags: maxTagCount,
placeholder,
editTags: null,
}
: {
dropdown: {
enabled: 0,
},
maxTags: maxTagCount,
placeholder,
editTags: null,
enforceWhitelist: true,
}
function handleSetValue(e: CustomEvent<Tagify.ChangeEventData<Tagify.BaseTagData>>) {
const selectedList = e.detail.value.length
? JSON.parse(e.detail.value).map((it: { value: string }) => it.value)
: []
setValue(selectedList)
}
return (
<TagifyDropdown>
<Tags
value={defaultTags}
settings={setting}
whitelist={whiteList}
onChange={(e) => handleSetValue(e)}
/>
</TagifyDropdown>
)
}
export default CompoundInputTag
드롭다운 태그인지 해시태그인지, 태그의 최대 갯수는 몇개인지, 태그가 없을 때 어떤 텍스트를 보여줄지 등 태그의 상태를 props로 받도록 했다. 다른 컴포넌트 안에 속한 것이 아니라 children으로 배치해주기 때문에 이러한 props들을 상위 컴포넌트를 통하지 않고 해당 props를 사용하는 컴포넌트로 바로 넣어줄 수 있다는 장점이 있다. 하나의 컴포넌트에 모든걸 표현하려면 이렇게 각각의 요소가 사용하는 모든 props들을 상위 컴포넌트가 받아서 넘겨주어야 해서 무수히 많은 props들을 받게 된다. 그리고 상위 컴포넌트는 단순히 props를 하위 컴포넌트로 전달해주는 역할만 하게 되어 props drilling이 일어난다.
function CompoundInput(props: CompoundInputProps) {
const { title, children, option } = props
return (
<CompoundInputContainerBox>
<CompoundInputTitleContainer>
<CompoundInputTitle>{title}</CompoundInputTitle>
{option !== '' && <p>{option}</p>}
</CompoundInputTitleContainer>
{children}
</CompoundInputContainerBox>
)
}
CompoundInput.TextInput = CompoundInputTextInput
CompoundInput.Dropdown = CompoundInputDropdown
CompoundInput.Tag = CompoundInputTag
CompoundInput.Textarea = CompoundInputTextarea
export default CompoundInput
구현해준 하위 컴포넌트들은 CompoundInput 컴포넌트의 static property로 만들어주었다. 이렇게 해주면 사용할 때 CompoundInput만 import하면 다른 요소들도 사용할 수 있다.
만들어진 컴파운드 컴포넌트는 이렇게 기본적인 형태로 사용하거나,
<CompoundInput title="제목">
<CompoundInput.TextInput
placeholder="입력해주세요."
register={register('title')}
/>
</CompoundInput>
특정 요소를 넣고자 하면 이렇게 children 자리에 원하는대로 넣어주기만 하면 된다.
<CompoundInput title="희망 가격">
<PriceContainer>
<PriceInput
value={displayPrice}
placeholder="가격을 입력해주세요."
onChange={(e) => handleChangePrice(e)}
/>
<PriceCurrencyOption
currency={currency}
setCurrency={setCurrency}
/>
</PriceContainer>
</CompoundInput>
직접 사용해보니 컴파운드 컴포넌트는 (하나의 컴포넌트에서 타입을 나누어 보여주는것에 비하면) 내용을 풀어 쓰는거나 다름없어서, 사용하는 쪽의 코드가 길어진다. 하지만 내용물이 미묘하게 다르거나 아주 다양할 경우에는 모든 상황을 한 파일 안에서 고려하기가 매우 어려워진다. 이럴 때 컴파운드 컴포넌트를 사용한다면 다양한 상황에 유연하게 대처할 수 있고, 앞서 말한 props drilling의 문제도 해결할 수 있게 된다. 다음에도 비슷하게 여러가지 형태를 가진 컴포넌트를 만나게 된다면 적절하게 사용해볼 수 있을 것 같다.