이번 시리즈는 개인프로젝트 "정산하자"를 구현하는 과정의 일부를 작성한 것으로, 실제 코드와는 다를 수 있습니다. 완성된 소스코드는 GitHub에서, 서비스는 정산하자에서 확인하실 수 있습니다.
정산하자는 애플리케이션의 규모가 매우 작음에도 불구하고, 비슷한 UI가 여기저기 많이 사용된다. 그래서 어떻게 하면 코드를 조금이라도 적게 쓰고 여기저기서 유연하게 사용할 수 있을까를 고민하는 데 가장 많은 시간을 투자했다.
그 중 다중 선택창(MultiSelect)을 합성 컴포넌트로 구현한 사례를 포스팅하고자 한다.
컴포넌트를 구현하는 가장 쉬운 방법은 props로 필요한 값을 제공하고, 컴포넌트 내부에서 props의 값을 조건부로 판별하여 렌더링을 다르게 하는 것이다. 그래서 처음에는 props 방식으로 구현해보았다.
먼저, 가장 간단한 형태인 기본형부터 구현해보자.
기본형은 제목과 선택 옵션이 출력된다.
// 구현부
interface Props {
title: string;
options: string[];
values: string[];
onChange: (value: string) => void;
}
export default function MultiSelect({
title,
options,
values,
onChange,
}: Props) {
return (
<div>
<Title>{title}</Title>
<div>
<input
key={inputKey}
type="text"
css={visibilityHidden}
defaultValue={value}
/>
{options.map((option) => (
<Option
key={option}
label={option}
isSelected={values.includes(option)}
onClick={(e) => onChange(e.target.value)}
/>
))}
</div>
</div>
);
}
// 호출부
<MultiSelect
title='송금처'
options={paymentMethods}
values={selectedPaymentMethods}
onChange={setSelectedPaymentMethods}
/>
이번에는 선택 옵션을 추가할 수 있는 입력창이 더해진 형태를 만들어보자.
예시 사진은 정산하자 애플리케이션에서 모임 장소명을 입력 후 다음 단계에서 볼 수 있는 화면으로, 해당 장소에 참가한 사람을 작성하는 단계이다. 이 단계에서는 이전 단계에서 작성한 장소명 데이터를 받고, 장소에 따라 참가자를 추가하고 제거할 수 있다.
// 구현부
interface Props {
title: string;
options: string[];
values: string[];
onChange: (value: string) => void;
addOptionProps?: {
label: string;
addOption: (option: string) => void;
};
}
export default function MultiSelect({
title,
options,
values,
onChange,
addOptionProps,
}: Props) {
return (
<div>
<Title>{title}</Title>
<div>
<input
key={inputKey}
type="text"
css={visibilityHidden}
defaultValue={value}
/>
{options.map((option) => (
<Option
key={option}
label={option}
isSelected={values.includes(option)}
onClick={(e) => onChange(e.target.value)}
/>
))}
{addOptionProps
? <InputToAddOption label={addOptionProps.label} addOption={addOptionProps.addOption} />
: null}
</div>
</div>
);
}
// 호출부
<MultiSelect
title={placeName}
options={people}
values={participants}
onChange={setParticipants}
addOptionProps={{
label: '이름 입력',
addOption,
}}
/>
옵션 추가 입력창을 띄우려면 addOptionProps를 전달하고, 그렇지 않으면 옵션 추가 입력창을 띄우지 않는 것으로 구현했다. addOptionProps.label은 입력창의 label과 placeholder로 출력된다.
확장 2는 옵션이 선택된 개수가 추가된 형태이다.
// 구현부
interface Props {
title: string;
options: string[];
values: string[];
onChange: (value: string) => void;
addOptionProps?: {
label: string;
addOption: (option: string) => void;
};
showTotalCount?: boolean;
}
export default function MultiSelect({
title,
options,
values,
onChange,
addOptionProps,
showTotalCount,
}: Props) {
return (
<div>
<Title>{title}</Title>
<div>
<input
key={inputKey}
type="text"
css={visibilityHidden}
defaultValue={value}
/>
{options.map((option) => (
<Option
key={option}
label={option}
isSelected={values.includes(option)}
onClick={(e) => onChange(e.target.value)}
/>
))}
{addOptionProps
? <InputToAddOption label={addOptionProps.label} addOption={addOptionProps.addOption} />
: null}
</div>
{showTotalCount
? <TotalCount>{values.length}</TotalCount>
: null}
</div>
);
}
// 호출부
<MultiSelect
title={placeName}
options={people}
values={participants}
onChange={setParticipants}
addOptionProps={{
label: '이름 입력',
addOption,
}}
showTotalCount
/>
확장 2는 useTotalCount props를 추가하여 옵션이 선택된 총 개수를 출력한다.
개인적으로 여기까지는 props가 꽤 많이 추가되었다는 생각이 들 뿐 큰 문제를 느끼진 못하겠으니 다음 단계로 넘어가보자.
확장 3은 구조 변경 없이 props만 추가하던 기존 확장형과는 형태가 다르다. 하나의 큰 MultiSelect 항목 안에서 두 개의 field로 나눠진다.
정산하자에서는 장소명을 입력하고 해당 장소에 대한 세부 분류를 추가했을 경우 위 사진과 같은 형태가 출력된다. 예를 들어, 이전 단계에서 퍼블릭이라는 장소에서 술을 마신 테이블과 술을 안 마신 테이블을 나눠 금액을 입력했다면, 위 사진처럼 퍼블릭이라는 큰 항목 안에서 두 개의 필드로 나눠 출력된다.
// 구현부
interface Props {
title: string;
fields: {
subTitle: string;
options: string[];
values: string[];
onChange: (value: string) => void;
addOptionProps?: {
label: string;
addOption: (option: string) => void;
};
showSubTotalCount?: boolean;
}[]
showTotalCount?: boolean;
}
export default function MultiSelect({
title,
fields,
showTotalCount,
}: Props) {
return (
<div>
<Title>{title}</Title>
{fields.map(({ subTitle, options, values, onChange, addOptionProps, useSubTotalCount }) => (
<div key={subTitle}>
<SubTitle>{subTitle}</SubTitle>
<div>
<input
key={inputKey}
type="text"
css={visibilityHidden}
defaultValue={value}
/>
{options.map((option) => (
<Option
key={option}
label={option}
isSelected={values.includes(option)}
onClick={(e) => onChange(e.target.value)}
/>
))}
{addOptionProps
? <InputToAddOption label={addOptionProps.label} addOption={addOptionProps.addOption} />
: null}
</div>
{showSubTotalCount
? <TotalCount>{values.length}</TotalCount>
: null}
</div>
))}
{showTotalCount
? <TotalCount>{values.length}</TotalCount>
: null}
</div>
);
}
// 호출부
<MultiSelect
title={placeName}
fields={[
{
title: firstSubTitle,
options: people,
values: firstParticipants,
onChange: setFirstParticipants,
addOptionProps: {
label: '이름 입력',
addOption,
},
showSubTotalCount: true,
},
{
title: secondSubTitle,
options: people,
values: secondParticipants,
onChange: setSecondParticipants,
addOptionProps: {
label: '이름 입력',
addOption,
},
showSubTotalCount: true,
},
]}
showTotalCount
/>
필드별로 값을 다르게 출력해야 하기 때문에 fields props를 배열로 생성하고 필드별로 필요한 속성을 fields의 요소로 전달하도록 했다.
확장 3은 확실히 사용에 불편함이 보인다. props가 방대해지니 MultiSelect를 사용하는 쪽에서 MultiSelect가 어떤 구조로 출력될 지 예측하기가 어렵다. 구조를 판단하려면 MultiSelect의 선언부를 확인해야 하기 때문에 번거롭다.
기능 추가를 props로 확장하는 것은 어느정도 선까지는 문제가 없어보인다. 지정된 데이터만 추가하면 되니 사용하는 것도 생각보다 간단하다.
하지만, 확장되는 범위가 커지고 전달받아야 하는 데이터의 양이 많아지면 사용하는 면에서 컴포넌트의 출력 구조를 예측할 수 없는 문제가 생긴다. 뿐만 아니라 구조를 파악하기 위해 선언부를 살펴보면 여러 조건부가 섞여있기 때문에 컴포넌트를 해석하는 데도 불편함이 있다.
그렇다면 사용하는 측에서 컴포넌트의 구조를 쉽게 파악할 수 있게 하려면 어떻게 해야 할까?
내가 생각한 방법은 합성 컴포넌트 패턴(Compound Component Pattern)을 적용하는 것이다. 합성 컴포넌트 패턴이란 뭘까?
컴파운드 컴포넌트 패턴은 여러 컴포넌트들이 모여 하나의 동작을 할 수 있게 해 준다.
Compound 패턴 - patterns
합성 컴포넌트 패턴은 하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미합니다.
합성 컴포넌트로 재사용성 극대화하기 - 카카오엔터 기술블로그
합성컴포넌트와 관련된 여러 아티클을 찾아보면 헤드리스 패턴을 구현하는 방법으로도 소개되고, 상태를 추상화하는 용도로도 소개가 된다. 명확히 해야 할 점은 이런 아티클들은 합성 컴포넌트를 어떻게 활용할 수 있냐를 소개하는 범위인 것이지, 합성 컴포넌트 패턴의 정의를 설명하는 것이 아니라는 것이다. (나는 합성 컴포넌트 패턴의 활용을 정의로 착각해서 합성 컴포넌트를 이렇게 사용해도 되는건가 고민하는데 시간을 꽤 많이 허비했다...)
합성 컴포넌트 패턴은 말그대로 여러 컴포넌트를 조합하여 하나의 새로운 컴포넌트를 만드는 디자인 패턴이다. 컴포넌트를 선언적으로 사용할 수 있도록 작은 기능 단위를 가진 여러 컴포넌트로 분리하고, 사용자는 필요한 컴포넌트들을 조합하여 원하는 기능을 구현할 수 있다.
쉽게 말하면, 페이지를 구현할 때 Header, Main, Footer 컴포넌트를 각각 만들어 조합하여 하나의 페이지를 만드는 방식이 컴포넌트 단위에 적용된다고 생각하면 된다.
흔히들 <select>와 <option>의 조합을 합성 컴포넌트 패턴의 예시로 보여준다.
<select>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
다시 한번, 합성 컴포넌트는 여러 컴포넌트를 조합해서 하나의 컴포넌트를 만드는 방식이라는 것을 새기고 정산하자의 MultiSelct에 합성 컴포넌트를 적용해보자.
먼저 여러 컴포넌트들의 container 역할을 하는, 메인 컴포넌트(MultiSelectMain)를 구현한다.
interface Props {
title: string;
children: ReactNode;
}
function MultiSelectMain({
title,
children,
}: Props) {
return (
<div>
<Title>{title}</Title>
{children}
</div>
);
}
서브 컴포넌트 중 가장 중요한 선택 옵션들이 나열될 컴포넌트(Content)를 구현한다.
interface Props {
value: string[];
options: string[];
children?: ReactNode;
onChange: (value: string) => void;
}
function Content({
options,
value,
children,
onChange,
}: Props) {
const subTitle = getChildComponent(children, SubTitle);
const addOptionInput = getChildComponent(children, AddOptionInput);
const totalCount = getChildComponent(children, TotalCount);
const _onChange = (e: MouseEvent<HTMLButtonElement>) => {
onChange((e.target as HTMLButtonElement).value);
setInputKey(Date.now());
};
return (
<div>
{subTitle ?? nulll}
<div>
<input
key={inputKey}
type="text"
css={visibilityHidden}
defaultValue={value}
/>
{options.map((option) => (
<Option
key={option}
value={option}
isActive={value?.includes(option)}
onClick={_onChange}
>
{option}
</Option>
))}
{addOptionInput ?? null}
</div>
{totalCount ?? null}
</div>
);
}
Content 컴포넌트는 field가 분리될 때 필요한 SubTitle과 옵션 추가 입력창(AddOptionInput), 총 선택 개수(TotalCount)를 children으로 전달 받을 수 있다. 이때, 각 컴포넌트이 출력되는 위치가 고정돼야 하므로 getChildComponent 함수로 필요한 컴포넌트를 추출하여 원하는 위치에 작성한다.
getChildComponent 함수는 아래와 같다.
interface getChildComponent {
(children: ReactNode, target: ElementType): ReactElement | undefined;
}
export const getChildComponent: getChildComponent = (children, target) =>
Children.toArray(children).find((child): child is ReactElement => child.type === target);
* SubTitle, AddOptionInput, TotalCount 컴포넌트 코드는 생략했습니다.
// 구현부
export const MultiSelect = Object.assign(MultiSelectMain, {
Content: Content,
SubTitle: SubTitle,
AddOptionInput: AddOptionInput,
TotalCount: TotalCount,
});
마지막으로, 메인 컴포넌트와 서브 컴포넌트들을 MultiSelect 객체로 묶어 내보낸다.
여기까지 완료하면 위 컴포넌트들을 아래와 같이 작성할 수 있다.
// 호출부
// 기본형
<MultiSelect title='송금처'>
<MultiSelect.Content
options={paymentMethods}
values={selectedPaymentMethods}
onChange={setSelectedPaymentMethods}
>
</MultiSelect.Content>
</MultiSelect>
// 확장 1
<MultiSelect title={placeName}>
<MultiSelect.Content
options={people}
values={participants}
onChange={setParticipants}
>
<MultiSelect.AddOptionInput label='이름 입력' addOption={addOption} />
</MultiSelect.Content>
</MultiSelect>
// 확장 2
<MultiSelect title={placeName}>
<MultiSelect.Content
options={people}
values={participants}
onChange={setParticipants}
>
<MultiSelect.AddOptionInput label='이름 입력' addOption={addOption} />
</MultiSelect.Content>
<MultiSelect.TotalCount />
</MultiSelect>
// 확장 3
<MultiSelect title={placeName}>
<MultiSelect.Content
options={people}
values={firstParticipants}
onChange={setFirstParticipants}
>
<MultiSelect.SubTitle>{firstSubTitle}</SubTitle>
<MultiSelect.AddOptionInput label='이름 입력' addOption={addOption} />
<MultiSelect.TotalCount />
</MultiSelect.Content>
<MultiSelect.Content
options={people}
values={secondParticipants}
onChange={setSecondParticipants}
>
<MultiSelect.SubTitle>{secondSubTitle}</SubTitle>
<MultiSelect.AddOptionInput label='이름 입력' addOption={addOption} />
<MultiSelect.TotalCount />
</MultiSelect.Content>
<MultiSelect.TotalCount />
</MultiSelect>
내보낼 때 메인과 서브를 묶어서 보내는 과정을 생략해도 된다. 생략하면 사용자는 아래와 같이 작성하게 된다.
<MultiSelect title={placeName}>
<Content
options={people}
values={firstParticipants}
onChange={setFirstParticipants}
>
<SubTitle>{firstSubTitle}</SubTitle>
<AddOptionInput label='이름 입력' addOption={addOption} />
<TotalCount />
</Content>
<Content
options={people}
values={secondParticipants}
onChange={setSecondParticipants}
>
<SubTitle>{secondSubTitle}</SubTitle>
<AddOptionInput label='이름 입력' addOption={addOption} />
<TotalCount />
</Content>
<TotalCount />
</MultiSelect>
하지만, MultiSelect에서 사용되는 서브 컴포넌트라는 것을 명시하는 목적과 응집도를 높이기 위해 메인 컴포넌트와 서브 컴포넌트를 묶어서 내보내는 것을 추천한다.
타입스크립트를 사용한다면 타입스크립트 에디터 보조기의 혜택도 누릴 수 있다.
합성 컴포넌트 패턴은 props 방식과 달리 선언적이다. 때문에 사용자가 작성하는 것만으로도 컴포넌트의 구조를 쉽게 파악할 수 있다. 더불어, 조합해서 사용할 수 있기 때문에 유연하다. 필요한 부분은 추가하고 필요없는 부분은 삭제하는 것이 자유롭다. 기능이 더 추가되더라도 서브 컴포넌트를 따로 구현하고 컴포넌트가 export 되는 부분만 수정하면 되니 유지보수에도 간단하다.
props 방식에 비해 사용하는 측에서 작성해야 하는 코드가 많다는 것은 단점일 수 있다. 하지만, 이처럼 컴포넌트의 구조가 복잡하고 유연해야 한다면 합성 컴포넌트를 사용하는 것이 큰 장점으로 다가올 수 있다.
input, select, textarea 등 여러 입력창들은 required라는 속성으로 해당 입력창이 필수로 입력받아야 하는지를 표시하고, 이를 제어할 수 있어야 한다. 정산하자의 MultiSelect 또한 필수 입력창으로 사용된다.
// 구현부
interface Props {
title: string;
required?: boolean;
children: ReactNode;
}
function MultiSelectMain({
title,
children,
required,
}: Props) {
return (
<div>
<Title>{title}</Title>
{children}
</div>
);
}
// 호출부
<MultiSelect required>
// ...
</MultiSelect>
MultiSelect에 required 속성을 추가하는 가장 이상적인 사용법은 위 코드 형태일 것이다.
그러나, 정산하자는 required 여부를 input 요소를 이용하여 체크하고, MultiSelect의 required 속성을 체크할 수 있는 input은 MultiSelectMain 컴포넌트가 아닌 MultiSelect.Content 컴포넌트에 있다.
<MultiSelect>
<MultiSelect.Content required>
// ...
</MultiSelect.Content>
</MultiSelect>
그러면 위 코드처럼 MultiSelect가 아닌 MultiSelect.Content의 props로 required를 전달해야 할까? 분류 필드가 여러개라면 모든 Content에 required를 따로 전달해야 하는 걸까?
<MultiSelect>
<MultiSelect.Content required>
// ...
</MultiSelect.Content>
<MultiSelect.Content required>
// ...
</MultiSelect.Content>
</MultiSelect>
물론 위 코드처럼 작성할 수 있겠지만, 이 방법은 모든 Content 컴포넌트에 적용해야 하기 때문에 번거롭고, 유지보수의 어려움 및 휴먼에러가 발생할 수 있다는 위험이 있다.
그렇다면 MultiSelect 컴포넌트 내에서 메인에 전달한 required 값을 공유해야 하는 방법을 찾아야 한다. 지금부터 합성 컴포넌트 내부에서 동일한 값을 공유하는 방법을 알아보자.
첫번째 공유 방법은 메인 컴포넌트에서 서브 컴포넌트를 호출할 때 props로 전달하는 방법이 있다.
interface Props {
title: string;
required?: boolean;
children: ReactNode;
}
function MultiSelectMain({
title,
children,
required,
}: Props) {
return (
<div>
<Title>{title}</Title>
{Children.map((children, child) =>
child.type === Content // child가 Content 컴포넌트면
? cloneElement(child, { required, ...child.props }) // Content의 props에 required props가 추가된 새로운 Content 컴포넌트를 출력해라
: child)}
</div>
);
}
props로 전달하는 방식은 children을 순회하면서 Content 컴포넌트일 경우, Content 컴포넌트를 복제한 새로운 Content 컴포넌트에 required props를 추가해서 호출할 수 있다.
두 번째 방법은 Context API를 사용하는 것이다. Context를 이용하면 props로 전달하지 않고 자식~자손까지 원하는 값을 공유할 수 있다.
// Context
interface ContextValue {
required: boolean;
}
export const MultiSelectContext = createContext<ContextValue>({
required: false,
});
// 메인 컴포넌트
interface Props {
title: string;
required?: boolean;
children: ReactNode;
}
function MultiSelectMain({
title,
children,
required,
}: Props) {
return (
<MultiSelectContext.Provider value={{ required }}> // Provider로 required 제공
<div>
<Title>{title}</Title>
{children}
</div>
</MultiSelectContext.Provider>
);
}
// 서브 컴포넌트 - Content
function Content({
options,
value,
children,
onChange,
}: Props) {
const { required } = useContext(MultiSelectContext); // Context에서 required 값을 받아옴
return (
<div>
{subTitle ?? nulll}
<div>
<input
key={inputKey}
type="text"
css={visibilityHidden}
defaultValue={value}
required={required} // required 속성 추가
/>
{options.map((option) => (
<Option
key={option}
value={option}
isActive={value?.includes(option)}
onClick={_onChange}
>
{option}
</Option>
))}
{addOptionInput ?? null}
</div>
{totalCount ?? null}
</div>
);
}
props 방식은 작성하는 순간부터 이미 복잡하다. 값을 공유받고자 하는 child를 탐색해야 하고, cloneElement 함수로 기존의 컴포넌트를 복제하고 필요한 값을 props로 전달해야 한다. 게다가 공유받아야 하는 값이 많아지거나 각 컴포넌트마다 다른 값을 공유받기를 원하면 분기점이 많아져 코드가 더 복잡해진다.
반면, Context API를 사용하면 편리하다. Context에 공유할 값을 모두 담아두고 값을 필요로 하는 자식 컴포넌트에 가서 context의 값을 꺼내 쓰면 된다.
물론, Context API는 사용할 때 주의점이 있다. Context API는 Context가 공유하는 값이 변경되면 이를 제공받는 모든 컴포넌트들이 리렌더링된다. 때문에 본인의 상태를 유지해야 하는 자식 컴포넌트가 있다면 사용할 수 없다. 이때는 Context 대신 다른 방법을 고려해야 한다. (추후 합성 컴포넌트와 관련해서 Context API를 사용하지 못하는 경우에 대해 포스팅을 할 예정입니다.)
나는 MultiSelect에서 props 방식 대신 Context API를 사용했다. children을 순회하고 props를 전달하는 로직을 이해하지 않아도 되니 간단하고, MultiSelect에서는 Context로 인한 오류가 발생할 가능성이 없고, 초기 렌더링 때 required 값이 결정되고 이후 변경되지 않아 리렌더링을 일으키지 않기 때문이다.
결론적으로 이 글을 포스팅하는 이유는, 나와 같은 프린이가 합성 컴포넌트의 활용, 즉 기술에만 매몰되어 본래의 목적을 잃어버리지 않았으면 하는 마음에, 또는 훗날의 내가 또 다시 헤매지 않았으면 하는 마음에 포스팅하게 되었다.
합성 컴포넌트의 정의를 설명하면서 잠깐 언급했지만, 나는 합성 컴포넌트 패턴을 적용하면서 이 개념을 이해하는 데 많은 시간을 투자했다. 합성 컴포넌트에 대한 아티클을 읽을수록 본래의 합성 컴포넌트 정의에서 벗어났고, 합성 컴포넌트가 헤드리스 패턴을 구현하는 데 활용되고 상태를 추상화하는 데 사용된다는 것에 꽂혀, "내가 구현하려는 컴포넌트는 상태를 관리하고 있지 않는데? 그럼 사용할 수 없는 건가? 내가 구현하려는 컴포넌트에서는 어떤 상태를 관리해야 하지?"라는 고민에 빠졌다. 하지만 다시 한번 합성 컴포넌트의 정의를 되돌아보니, 핵심은 단순히 컴포넌트를 조합하여 사용할 수 있도록 하는 패턴이라는 것을 깨달았다. 그래서 선언적으로 사용할 수 있고, 조합으로 확장 가능하다는 것에 중점을 두고 구현을 시작했다.
헤매느라 흘려보낸 시간이 길어서 한동안 회의감에 빠졌었지만, 고민하고 깨닫는 경험은 즐겁다. 이번 포스팅으로 부딪힌 시간이 있으니까 깨달을 수 있었다고 생각하고, 내가 공부하는 동안은 헛발을 딛는 시간을 아까워하지 않겠다는 다짐과 함께 이 글을 마무리한다.