트러블이슈/이약저약

컴파운드 컴포넌트 패턴 알아보기

Ahyeon, Jung 2024. 4. 20. 18:37

아토믹 패턴으로 프로젝트 구조를 설정했을 때, atom과 mocule에서 가져오면서 계속해서 이 organism이나 template이 굳이 src/components 아래에서 관리해야하는지에 대해 끊임없는 의문이 들었다. 막상 페이지 단위에는 <main><Template1/><Template2/><Template3/></main>이 끝이라서 다시 src/components에서 해당 파일을 찾아야했고 페이지 단위에서 그래서 이 페이지가 어떻게 구성되어있는건데? 라는 생각이 들었다. 그래서 이번에는 src/pages 아래에 각각의 재사용없는 componets를 두고 그 안에서 불러왔다. UI, styles, utils도 굳이 공통 관리 할 필요 없겠다 싶어 점차 추가되었고 페이지별로 구분하기 쉬웠다. 하지만 여전히, 페이지를 본다고 해서 페이지의 구성이 직관적으로 와닿지 않았다. 리액트는 선언형이라면서 선언된 UI가 쉽게 그려지지않았다. 그러다가 컴파운드 컴포넌트 패턴을 사용해서 공통 컴포넌트를 만들어야겠다는 생각을 했다.


Compound Component Pattern

 

import styles from "./index.module.scss";
import { useState } from "react";

export interface SortType {
  name: string,
  value: string,
}

export default function SelectSort() {
	const [selectedOption, setSelectedOption] = useState("bestReview");

	const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
		setSelectedOption(e.target.value);
	};

	return (
		<div className={styles.container}>
			<select value={selectedOption} onChange={handleChange}>
				<option value="bestReview">베스트 리뷰순</option>
				<option value="highestRating">별점 높은 순</option>
				<option value="lowestRating">별점 낮은 순</option>
			</select>
		</div>
	);
}

 

컴파운드 컴포넌트 패턴은 select 태그 아래 option 태그를 놓는 것처럼 말그대로 컴포넌트를 합성하는 디자인 패턴이다. 일반적으로 부모 컴포넌트에서 상태와 기능을 정의하여 context를 통해서 하위 컴포넌트들이 서로 상태와 기능을 공유할 수 있다. 이를 통해서 컴포넌트를 더욱 유연하고 재사용 가능하게 만들며, 확장성도 높여준다.

Headless Component

헤드리스 컴포넌트는 UI를 렌더링하지 않고 상태 관리와 로직 처리만 담당하는 컴포넌트를 의미한다. 상태 관리와 로직 처리 코드가 UI와 분리함으로써 코드의 가독성과 유지보수성이 향상되며, UI를 쉽게 커스텀할 수 있다.

 

컴파운드 컴포넌트 패턴과 헤드리스 컴포넌트 패턴을 적절히 활용하면 계층적 구조를 가지며, 각 하위 컴포넌트는 상태와 로직만 독립적으로 가지는 컴포넌트를 활용할 수 있다. 사실 이번 프로젝트에서는 아직 완전하게 UI를 분리해야하는가에 대한 의문이 있어서 스타일을 포함해 놨지만, 상태 관리와 로직 처리에 집중하자는 헤드리스 컴포넌트의 의의를 생각하면서 최대한 반영해보았다.


컴포넌트 분리

처음에는 좀 뭐부터 해야하는지 잘 감이 안오지만, 일단 어떻게 구성되어있는지부터 생각해보면 편하다. Select 태그는 이미 친근하기 때문에 Select를 만들어보면서 감을 잡고, 다른 컴포넌트를 만들어보면 좀 이해가 간다. 아무생각없이 제일 복잡한거 집어들었다가 관리해야하는 값들이 엉망이어서 context만 한 세번을 바꿨다. 물론 끊임없이 바꿔야하는 부분은 생각난다. 지금 다시 봐도 자식 컴포넌트들의 Sort 굳이 빼도 될거같다는 생각이 든다.

<SelectSort>
    <SelectSort.SortCurrentOption />
    <SelectSort.SortOptionList>
        <SelectSort.SortOption />
        <SelectSort.SortOption />
        <SelectSort.SortOption />
    </SelectSort.SortOptionList>
</SelectSort>

관리해야하는 상태 정의

컴포넌트를 분리하고 나면 어떤 컴포넌트들한테 어떤 역할을 부여할지 쉽게 보이기 때문에, 부모에서 관리해야할 값들과 외부에서 받아와야할 값들을 정의할 수 있다. 일단 당연히 재사용할 값들이기 때문에 option 목록은 외부에서 받아와야한다. 처음에는 List의 prop으로 전달했는데, Option이 너무 숨어있는 것 같아서 그냥 템플릿에서 map하고 전달하지 않고 각각의 Option이 자신의 상태를 들고 있게 했다. 그리고 외부에서 굳이 사용할때마다 isOpen 상태를 만들지 않을 수 있게 내부에서 생성했다. 그리고 이제 currentOption을 관리해서, Option이 클릭되면 currentOption이 업데이트 되고 이를 외부에서 전달받은 콜백 함수의 인자로 넣었다.

 

부모가 관리해야하는 상태

  • 현재 선택된 Option
  • OptionList 오픈 상태
  • OptionList

외부에서 받아올 상태

  • Option이 변경되면 실행될 함수
  • 옵션 목록

Context 생성

import { createContext, useContext } from "react";

export interface CurrentSortType {label: string, value: string};

interface SelectSortContextType {
	handleCurrentSort: (sortOption: CurrentSortType) => void;
	currentSort: CurrentSortType;
	isOpenOptionList: boolean;
	toggleIsOpenOptionList: () => void;
}

export const SelectSortContext = createContext<SelectSortContextType>({
	handleCurrentSort: () => {},
	currentSort: {label: '', value: ''},
	isOpenOptionList: false,
	toggleIsOpenOptionList: () => {},
});

export const useSelectSort = () => {
	const context = useContext(SelectSortContext);

	if (!context) {
		throw new Error("SelectSort의 context를 벗어남");
	}

	return context;
};

부모 컴포넌트 생성

import { SelectSortContext } from "../hooks/useSelectSort";
import styles from "../styles/SortSelectRoot.module.scss";
import { useState } from "react";

export interface CurrentSortType {label: string, value: string};

interface SortSelectRootProps {
	children: React.ReactNode;
}

export default function SortSelectRoot({
	children,
}: SortSelectRootProps) {
	const [isOpenOptionList, setIsOpenOptionList] = useState(false);

	const toggleIsOpenOptionList = () => {
		setIsOpenOptionList((prev) => !prev);
	};

	const value = {
		currentSort,
		isOpenOptionList,
		handleCurrentSort,
		toggleIsOpenOptionList,
	};

	return (
		<SelectSortContext.Provider value={value}>
			{children}
		</SelectSortContext.Provider>
	);
}

하위 컴포넌트 생성

import { IoIosArrowDown } from "react-icons/io";
import styles from "../styles/SortCurrentOption.module.scss";
import { useSelectSort } from "../hooks/useSelectSort";

export default function SortCurrentOption() {
	const { currentSort, toggleIsOpenOptionList } = useSelectSort();
  
	return (
		<div onClick={toggleIsOpenOptionList} className={styles.container}>
			{currentSort.label}
			<IoIosArrowDown />
		</div>
	);
}
import styles from "../styles/SortOptionList.module.scss";
import { useSelectSort } from "../hooks/useSelectSort";

interface SortOptionListProps {
	children: React.ReactNode;
}

export default function SortOptionList({ children }: SortOptionListProps) {
	const { isOpenOptionList } = useSelectSort();
	return isOpenOptionList ? (
		<div className={styles.container}>{children}</div>
	) : null;
}
import { CurrentSortType } from "./SortSelectRoot";
import { useSelectSort } from "../hooks/useSelectSort";

export default function SortOption({ label, value }: CurrentSortType ) {
	const { handleCurrentSort, toggleIsOpenOptionList } = useSelectSort();
  
	return (
		<div
			onClick={() => {
				handleCurrentSort({label, value});
				toggleIsOpenOptionList();
			}}
		>
			{label}
		</div>
	);
}

엮기

import SortCurrentOption from "./UI/SortCurrentOption";
import SortOption from "./UI/SortOption";
import SortOptionList from "./UI/SortOptionList";
import SortSelectRoot from "./UI/SortSelectRoot";

const SelectSort = Object.assign(SortSelectRoot, {
	SortOption,
	SortCurrentOption,
	SortOptionList,
});

export default SelectSort;

 


컴파운드 컴포넌트를 사용하면서 느낀 장점은, 이 컴포넌트가 어떻게 구성되어있는지 템플릿에서 쉽게 볼 수 있다는 점이었다. 그리고 모달을 만들게 되면서, chakra UI나 Radix UI의 컴포넌트들이 왜 그렇게 구성되어있는지 이해가 갔다. 어느샌가 모달에 필요한 하위 컴포넌트들이 눈에 익숙해졌는데, 이미 해당 라이브러리에서 사용해본 것들이었다. 한번 사용해본 chakra UI를 다시 보면서, 비즈니스 로직을 왜 분리해야하는가를 이해할 수 있었다. 사실 처음에 검색창 컴포넌트를 만들면서 단순한 컴포넌트의 분리로 받아들였기 때문에 api 요청마저 분리했었다. 심지어 api 요청 케이스가 이름만 검색하는 api와 성능, 태그 등을 포함해서 검색하는 api로 나눠졌기 때문에 안에서 조건문으로 분리해야했다. 그러다가 비즈니스 로직이 왜 포함되어있는지 모르겠다는 의견을 받았고, 처음에는 비즈니스 로직이 뭔지도 정확히 몰라서 내가 뭘 포함시킨건지 그게 왜 잘못된건지 몰랐었다. 그러다가 Select와 Modal을 컴파운드 컴포넌트 패턴으로 만들어보면서 단순한 분리가 아니라 재사용과 확장성을 위한 분리임을 이해할 수 있었다. 어떻게 더 잘 만들지는 좀 더 만들어보면서 배워야겠다..

export default function ReviewPostModal() {
	return (
		<Modal>
			<Modal.Trigger
				openElement={<button className={styles.button}>후기 작성하기</button>}
			/>
			<Modal.Content>
				<h3>제목</h3>
                <div>모달 컴포넌트</div>
                <h3>내용</h3>
                <p>내용입니다</p>
			</Modal.Content>
		</Modal>
	);
}
export default function MedicineSearch() {

    return (
    <>
    {isTagsModalOpen && <TagsModal toggleIsTagsModalOpen={toggleIsTagsModalOpen}/>}
		<section>
			<SearchBar>
				<SearchBar.KeywordInput
					placeholder="검색어를 입력해주세요"
					onClick={handleKeywordCompletedClick}
					onChange={handleGetAutoCompleteResults}
          />
				<SearchBar.SearchResultList keywordSearchResult={keywordSearchResult} />
				<SearchBar.SelectedKeywordTagsList  />
			</SearchBar>
			<MedicineCardList toggleIsTagsModalOpen={toggleIsTagsModalOpen}/>
		</section>
  </>
	);
}