현재 진행중인 프로젝트에서 메인 컨텐츠 페이지를 담당하고 있는데, 카테고리 필터 기능이 필요했다.
기본적인 카테고리 필터 기능이 아니라, 계층형(트리형) 구조 형식으로 기능을 구현하는 게 요구 사항에 있었다.
예를 들어, 부모를 체크하면 자식 카테고리는 전체 체크, 자식 카테고리를 전체 체크하면 부모 카테고리가 체크되는 형식이다.
처음에는 라이브러리를 찾아보다가 딱 알맞은 라이브러리가 존재하지 않아서 직접 구현해보기로 했다.
React + Typescript
Mui + Emotion
실제 프로덕트는 Next.js로 되어 있지만 Codesandbox에선 react + typescript로 진행했다.
css스타일링은 원래부터 CSS in JS를 선호하고, Mui의 checkbox컴포넌트가 간편해서 사용했다.
export type Category = {
id: number;
name: string;
children: Category[] | [];
collapsed?: boolean; // 숨김/보임 상태
};
export const categories = [
{
id: 1,
name: "데이터",
children: [
{ id: 2, name: "게임데이터", children: [] },
{ id: 3, name: "로드데이터", children: [] },
{ id: 4, name: "외부데이터", children: [] }
],
collapsed: true
},
{
id: 5,
name: "개선사항",
children: [
{ id: 6, name: "복지", children: [] },
{
id: 7,
name: "연봉",
children: [
{ id: 8, name: "월급", children: [] },
{
id: 9,
name: "주급",
children: [{ id: 10, name: "일당", children: [] }]
}
]
},
{ id: 11, name: "식대", children: [] },
{ id: 12, name: "도서비", children: [] },
{ id: 13, name: "와우", children: [] }
],
collapsed: true
},
...,
];
카테고리 데이터는 부모 자식 관계이고, children에 배열로 담아 자식을 구성하고 있다.
카테고리 필터 아이템 컴포넌트를 구현한다.
import styled from "@emotion/styled";
import { Box, Checkbox, FormControlLabel } from "@mui/material";
import { Category } from "../data/categories";
const LabelName = styled("span")`
font-size: 12px;
line-height: 17px;
color: #5d5d62;
margin-left: 8px;
user-select: none;
`;
type Props = {
category: Category;
depth: number;
handleClick: (category: Category, parentCategoryList: Category[]) => void;
selectedCategoryIds: number[];
parentCategoryList: Category[];
};
const SearchFilterItem = ({
category,
depth,
handleClick,
selectedCategoryIds,
parentCategoryList
}: Props) => {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
marginLeft: `${30 + depth * 12}px`, // depth가 깊어질 때마다 12px 오른쪽으로
gap: "10px"
}}
>
<FormControlLabel
label={<LabelName>{category.name}</LabelName>}
control={
<Checkbox
sx={{
width: 13,
height: 13,
"& .MuiSvgIcon-root": { fontSize: 18 }
}}
onChange={() => handleClick(category, parentCategoryList)}
checked={selectedCategoryIds.includes(category.id)}
/>
}
/>
</Box>
);
};
export default SearchFilterItem;
// App.tsx
const getAllChildIds: (category: Category) => number[] = (
category: Category
) => {
let childIds: number[] = [];
for (const child of category.children) {
childIds.push(child.id);
childIds = childIds.concat(getAllChildIds(child)); // 재귀
}
return childIds;
};
// App.tsx
const [selectedCategoryIds, setSelectedCategoryIds] = useState<number[]>([]);
// ...
// getAllChildIds
const toggleCategory = (
category: Category,
parentCategoryList: Category[]
) => {
const childIds = getAllChildIds(category); // 자식들의 id를 가져온다.
if (selectedCategoryIds.includes(category.id)) {
let tempIds = selectedCategoryIds.filter((id) => id !== category.id);
for (const parentCategory of parentCategoryList) {
// 부모가 체크되어 있고, 자식 체크박스를 풀었을 때 부모 체크 해제, 조부모 체크 해제
if (parentCategory && selectedCategoryIds.includes(parentCategory.id)) {
tempIds = tempIds.filter((id) => id !== parentCategory.id);
}
}
setSelectedCategoryIds(tempIds.filter((id) => !childIds.includes(id)));
} else {
const tempIds = [...selectedCategoryIds, ...childIds, category.id];
parentCategoryList = parentCategoryList.reverse();
for (const parentCategory of parentCategoryList) {
// 자식을 모두 체크했을 때 부모가 체크되게 변경
const parentChildrenIds = parentCategory?.children.map(
(item) => item.id
);
if (
parentCategory &&
parentChildrenIds?.every((item) => tempIds.includes(item))
) {
tempIds.push(parentCategory.id);
}
}
setSelectedCategoryIds(tempIds);
}
};
기본적인 check 기능과 함께 추가적인 기능을 탑재했다.
parentCategoryList는 부모 배열입니다. renderCategories함수가 재귀로 호출되서 결과적으로 [조부모, 부모, ...] 이러한 값을 받아옵니다.
이 배열을 reverse 메서드를 사용해 돌린 뒤 for of 문을 사용해 가까운 부모들부터 하나씩 불러옵니다.
map 메서드를 사용해 자식들의 id 배열 형태로 변경한 뒤 부모의 자식들 id가 현재 state에 있는지 확인합니다.
모두 있으면 부모의 id를 현재 state에 추가하는 형태로 위 과정을 반복합니다.
// App.tsx
function App() {
// ...
// getAllChildIds
// toggleCategory
const renderCategories: any = (
categoryList: Category[],
depth = 0,
parentCategory: Category[] = []
) => {
return categoryList.map((category) => (
<React.Fragment key={category.id}>
<SearchFilterItem
depth={depth}
category={category}
handleClick={() => toggleCategory(category, parentCategory)}
parentCategoryList={parentCategory}
selectedCategoryIds={selectedCategoryIds}
/>
{category.children.length > 0 &&
renderCategories(category.children, depth + 1, [
...parentCategory,
category
])}
</React.Fragment>
));
};
return (
<Container>
<CategoryWrapper>
{renderCategories(categoriesState, 0, [])}
</CategoryWrapper>
</Container>
);
}
renderCategories를 재호출할 때 현재 카테고리 객체를 배열에다 추가해서 보냅니다.
전체 코드는 코드 샌드박스를 클릭해서 볼 수 있다.
요구 사항에서 봤을 때는 쉬워보였는데 막상 구현해보니까 어려웠다.
아마 재귀형식으로 구현해서 그런 것 같다.
더 쉬운 방식이나 좋은 코드가 있을 것 같지만 현재는 이러한 코드로 진행했다.
그리고 나중에도 비슷한 필터 기능이 있을 때 사용할 것 같다.
추가적으로 toggleCategory 함수가 약간 복잡한 것 같아서 함수 리팩토링을 진행하면 좋을 것 같다.