이번 글에서는 필터 컴포넌트 설계하면서 고민했던 과정을 공유하려고 합니다. 단순히 컴포넌트를 나누는 것을 넘어, 어떻게 하면 재사용 가능하면서도 유연한 구조를 만들 수 있을까에 초점을 맞췄습니다.
( *예시는 실제 프로젝트와 다소 차이가 있습니다. )
프로젝트에는 여러 개의 리스트 페이지가 있었습니다:
각 페이지마다 필터 조합이 다르지만, UI 패턴은 유사했습니다:
처음에는 각 페이지마다 필터를 구현했지만, 코드 중복이 심각했고 수정 시 여러 곳을 동시에 고쳐야 하는 문제가 있었습니다.
리팩토링을 시작하기 전에 요구사항을 정리했습니다:
고민 끝에 선택한 구조는 3단계 계층 구조였습니다:
ListOptions/
├── core/ # 재사용 가능한 기본 UI 컴포넌트
│ ├── RadioSelector.tsx
│ ├── CheckboxSelector.tsx
│ └── ChipSelector.tsx
├── tools/ # 비즈니스 로직이 포함된 도구 컴포넌트
│ ├── SortDropdown.tsx
│ ├── GenreCheckbox.tsx
│ └── RatingChip.tsx
└── groups/ # 페이지별 필터 조합
├── SearchPageTools.tsx
└── DirectorPageTools.tsx
가장 하위 계층은 비즈니스 로직이 전혀 없는 순수 UI 컴포넌트입니다.
// core/RadioSelector.tsx
export default function RadioSelector({
options,
selected,
paramKey,
valueMap,
}: {
options: string[];
selected?: string;
paramKey: string;
valueMap?: Record<string, string>;
}) {
const { updateSearchParams } = useRouterWithParams();
const handleSelect = (option: string) => {
const value = valueMap ? valueMap[option] : option;
updateSearchParams({ [paramKey]: value });
};
return (
<Modal>
{options.map((option) => (
<button onClick={() => handleSelect(option)}>
{option}
</button>
))}
</Modal>
);
}
핵심은 paramKey와 valueMap을 외부에서 주입받는다는 점입니다. 이렇게 하면:
중간 계층은 특정 필터의 데이터와 로직을 담당합니다.
// tools/SortDropdown.tsx
const SORT_OPTIONS = ["추천순", "최신순", "평점순"];
const SORT_MAP = {
추천순: "recommended",
최신순: "latest",
평점순: "rating",
};
const REVERSE_SORT_MAP = {
recommended: "추천순",
latest: "최신순",
rating: "평점순",
};
export default function SortDropdown() {
const router = useRouter();
const currentSort = (router.query.sort as string) || "recommended";
return (
<RadioSelector
options={SORT_OPTIONS}
selected={REVERSE_SORT_MAP[currentSort]}
paramKey="sort"
valueMap={SORT_MAP}
/>
);
}
여기서 중요한 점:
최상위 계층은 페이지에 필요한 필터를 조합합니다.
// groups/SearchPageTools.tsx
export default function SearchPageTools() {
const { hasActiveFilters, resetFilterQuery } = useRouterWithParams();
const excludeKeys = ["query"]; // 검색어는 초기화 대상에서 제외
const showReset = hasActiveFilters(excludeKeys);
return (
<div className="flex gap-4">
<SortDropdown />
{showReset && <FilterResetButton onClick={() => resetFilterQuery(excludeKeys)} />}
<GenreCheckbox />
<RatingChip />
</div>
);
}`
`// groups/DirectorPageTools.tsx
export default function DirectorPageTools() {
const { hasActiveFilters, resetFilterQuery } = useRouterWithParams();
const excludeKeys = ["directorId"]; // 감독 ID는 초기화 대상에서 제외
const showReset = hasActiveFilters(excludeKeys);
return (
<div className="flex gap-4">
<SortDropdown />
{showReset && <FilterResetButton onClick={() => resetFilterQuery(excludeKeys)} />}
<RatingChip />
{/* GenreCheckbox는 제외 */}
</div>
);
}
이렇게 하면:
가 가능해집니다.
유연함과 재사용성을 모두 만족하는 컴포넌트를 만들기 위해, 먼저 각 UI가 어떤 성격을 가지고 있는지를 이해하는 것부터 시작했습니다.
그리고 그 성격 안에서 무엇이 변할 수 있는 지점인지, 또 그 변화를 구현부에서 얼마나 편리하게 다룰 수 있도록 만들 수 있을지라는 흐름으로 고민을 이어갔습니다.
정렬 기능을 예로 들어보면, 정렬은 그 자체로 하나의 비즈니스 요구사항입니다. 동시에 이 요구사항을 해결하는 UI는 드롭다운, 버튼 그룹 등 다양한 형태로 확장될 수 있다고 판단했습니다.
이 시점에서 이미 두 가지 관심사가 분리됩니다.
여기에 더해, 정렬 기능을 사용하는 각 페이지마다 어떤 정렬 옵션을 제공할지, 혹은 다른 필터들과 어떻게 함께 사용될지가 모두 달랐습니다.
이 요구사항을 하나의 컴포넌트에서 props나 조건문으로 처리하기보다는, 구현하는 쪽에서 선택적으로 조합할 수 있는 구조가 더 적합하다고 판단했습니다.
그래서 단일 컴포넌트에 모든 책임을 몰아넣는 대신, 조합(Composition) 패턴을 활용해 역할을 분리했습니다.
그 결과, UI만 책임지는 컴포넌트, 도메인 로직을 담은 컴포넌트, 그리고 이를 페이지 단위로 조합하는 컴포넌트까지 총 3단계의 컴포넌트 구조로 정렬 기능을 구현할 수 있었습니다.
이러한 의사결정을 통해, 각 컴포넌트는 더 단순해졌고, 새로운 페이지나 요구사항이 추가되더라도 기존 코드를 크게 수정하지 않고 유연하게 대응할 수 있는 구조를 만들 수 있었습니다
"초기화 버튼을 언제 보여줄까?"가 의외로 복잡한 문제였습니다.
문제점:
query)는 유지하고 필터만 초기화directorId)는 라우트 파라미터이므로 필터가 아님처음에는 초기화 버튼이 필터 종류를 직접 알고 있었지만, 이는 페이지마다 필터 구성이 바뀔 때마다 수정이 필요했습니다.
해결책: excludeKeys 패턴
const FILTER_KEYS = ["sort", "genre", "rating"] as const;
function hasActiveFilters(excludeKeys: string[] = []) {
return Object.keys(router.query).some(
(key) =>
FILTER_KEYS.includes(key as typeof FILTER_KEYS[number]) &&
!excludeKeys.includes(key)
);
}
function resetFilterQuery(excludeKeys: string[] = []) {
const query = { ...router.query };
Object.keys(query).forEach((key) => {
if (!excludeKeys.includes(key)) {
delete query[key];
}
});
router.push({ pathname: router.pathname, query }, undefined, {
shallow: true,
});
}`
사용 예시:
`// 검색 페이지: query는 제외
const excludeKeys = ["query"];
const showReset = hasActiveFilters(excludeKeys);
// 감독 페이지: directorId는 제외
const excludeKeys = ["directorId"];
const showReset = hasActiveFilters(excludeKeys);
이 패턴의 장점:
이 아키텍처의 가장 큰 장점은 레고 블록처럼 조립할 수 있다는 점입니다.
새로운 페이지를 추가한다면?
// groups/ActorPageTools.tsx
export default function ActorPageTools() {
const { hasActiveFilters, resetFilterQuery } = useRouterWithParams();
const excludeKeys = ["actorId"];
const showReset = hasActiveFilters(excludeKeys);
return (
<div className="flex gap-4">
<SortDropdown />
{showReset && <FilterResetButton onClick={() => resetFilterQuery(excludeKeys)} />}
<RoleCheckbox /> {/* 새로운 필터 */}
</div>
);
}
새로운 필터 타입을 추가한다면?
// tools/RoleCheckbox.tsx
const ROLE_OPTIONS = [
{ value: "lead", label: "주연" },
{ value: "supporting", label: "조연" },
];
export default function RoleCheckbox() {
const router = useRouter();
const selected = (router.query.role as string)?.split(",") || [];
return (
<CheckboxSelector
options={ROLE_OPTIONS}
selected={selected}
paramKey="role"
label="역할"
/>
);
}
Core 컴포넌트(CheckboxSelector)는 전혀 수정할 필요가 없습니다.
AI로 빠른 개발이 가능해지면서 설계에 대한 역량이 더 필요해지는 것 같다. 이번에 설계를 해보면서 단번에 최적의 설계를 찾기에는 어려움이 있는 것 같아서 앞으로 블로그 글을 통해서 정리하는 방식을 주로 활용해려고 한다