재사용 가능한 필터 컴포넌트 설계하기

_sw_·2026년 1월 28일

들어가며

이번 글에서는 필터 컴포넌트 설계하면서 고민했던 과정을 공유하려고 합니다. 단순히 컴포넌트를 나누는 것을 넘어, 어떻게 하면 재사용 가능하면서도 유연한 구조를 만들 수 있을까에 초점을 맞췄습니다.

문제 정의

( *예시는 실제 프로젝트와 다소 차이가 있습니다. )

프로젝트에는 여러 개의 리스트 페이지가 있었습니다:

  • 영화 검색 페이지: 장르, 연도, 평점 필터 + 정렬
  • 감독별 영화 페이지: 연도, 평점 필터 + 정렬 (장르 필터 제외)
  • 배우별 영화 페이지: 역할, 연도 필터 + 정렬

각 페이지마다 필터 조합이 다르지만, UI 패턴은 유사했습니다:

  • 드롭다운 선택 (정렬, 연도)
  • 체크박스 다중 선택 (장르, 역할)
  • 칩 선택 (평점)
  • 필터 초기화 버튼

처음에는 각 페이지마다 필터를 구현했지만, 코드 중복이 심각했고 수정 시 여러 곳을 동시에 고쳐야 하는 문제가 있었습니다.

요구사항 정리

리팩토링을 시작하기 전에 요구사항을 정리했습니다:

  1. 재사용성: 같은 UI 패턴(드롭다운, 체크박스)을 여러 곳에서 사용
  2. 유연성: 페이지마다 다른 필터 조합을 쉽게 구성
  3. 일관성: 모든 필터가 동일한 방식으로 상태를 관리
  4. 조건부 표시: 필터가 활성화되었을 때만 초기화 버튼 표시
  5. 확장성: 새로운 필터 타입을 쉽게 추가 가능

3단계의 계층 구조

고민 끝에 선택한 구조는 3단계 계층 구조였습니다:

ListOptions/
├── core/           # 재사용 가능한 기본 UI 컴포넌트
│   ├── RadioSelector.tsx
│   ├── CheckboxSelector.tsx
│   └── ChipSelector.tsx
├── tools/          # 비즈니스 로직이 포함된 도구 컴포넌트
│   ├── SortDropdown.tsx
│   ├── GenreCheckbox.tsx
│   └── RatingChip.tsx
└── groups/         # 페이지별 필터 조합
    ├── SearchPageTools.tsx
    └── DirectorPageTools.tsx

Core Layer: 순수 UI 컴포넌트

가장 하위 계층은 비즈니스 로직이 전혀 없는 순수 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>
  );
}

핵심은 paramKeyvalueMap을 외부에서 주입받는다는 점입니다. 이렇게 하면:

  • "추천순" → "recommended" 같은 변환 로직을 외부에서 제어
  • 같은 UI를 다른 URL 파라미터에 재사용 가능

Tools Layer: 도메인 로직을 가진 컴포넌트

중간 계층은 특정 필터의 데이터와 로직을 담당합니다.

// 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}
    />
  );
}

여기서 중요한 점:

  • 데이터 변환 로직: URL의 "recommended"를 화면의 "추천순"으로 변환
  • 기본값 처리: sort가 없을 때 "recommended"를 기본값으로
  • Core 컴포넌트 활용: RadioSelector를 재사용

Groups Layer: 페이지별 조합

최상위 계층은 페이지에 필요한 필터를 조합합니다.

// 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는 드롭다운, 버튼 그룹 등 다양한 형태로 확장될 수 있다고 판단했습니다.
이 시점에서 이미 두 가지 관심사가 분리됩니다.

  • 정렬이라는 도메인 로직
  • 이를 표현하는 UI

여기에 더해, 정렬 기능을 사용하는 각 페이지마다 어떤 정렬 옵션을 제공할지, 혹은 다른 필터들과 어떻게 함께 사용될지가 모두 달랐습니다.
이 요구사항을 하나의 컴포넌트에서 props나 조건문으로 처리하기보다는, 구현하는 쪽에서 선택적으로 조합할 수 있는 구조가 더 적합하다고 판단했습니다.

그래서 단일 컴포넌트에 모든 책임을 몰아넣는 대신, 조합(Composition) 패턴을 활용해 역할을 분리했습니다.
그 결과, UI만 책임지는 컴포넌트, 도메인 로직을 담은 컴포넌트, 그리고 이를 페이지 단위로 조합하는 컴포넌트까지 총 3단계의 컴포넌트 구조로 정렬 기능을 구현할 수 있었습니다.

이러한 의사결정을 통해, 각 컴포넌트는 더 단순해졌고, 새로운 페이지나 요구사항이 추가되더라도 기존 코드를 크게 수정하지 않고 유연하게 대응할 수 있는 구조를 만들 수 있었습니다

의사결정 : 조건부 필터 초기화

"초기화 버튼을 언제 보여줄까?"가 의외로 복잡한 문제였습니다.

문제점:

  • 검색 페이지: 검색어(query)는 유지하고 필터만 초기화
  • 감독 페이지: 감독 ID(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)는 전혀 수정할 필요가 없습니다.

배운 점과 트레이드오프

잘된 점

  1. 명확한 책임 분리
    • Core: UI 렌더링만
    • Tools: 데이터 변환과 기본값
    • Groups: 조합과 페이지별 로직
  2. URL 기반 상태 관리
    • 공유, 새로고침, 히스토리 모두 자연스럽게 해결
    • 디버깅이 쉬움 (URL만 보면 현재 상태 파악)
  3. 높은 재사용성
    • RadioSelector는 정렬, 연도, 언어 필터 등에 재사용
    • CheckboxSelector는 장르, 역할, 태그 등에 재사용

아쉬운점

  1. 관리 파일의 개수 증가
    재사용성과 유연성을 제공하려고 하다보니 자연스럽게 파일의 개수가 늘어났다. 파일의 개수에 따라서 다른 개발자가 이 컴포넌트를 사용했을 때 각 컴포넌트들이 어떤 역할을 하고, 어떤 식으로 사용해야하는지에 대한 가이드를 곧바로 파악하기에는 어려움이 있을 것 같다고 느껴졌다.
  2. 상태 관리 방식에 따른 대응 불가
    현재 컴포넌트는 URL의 query 기반으로 상태를 관리하고 있다. 하지만 params를 사용하지 않는 경우에 대해서는 유연하게 대처하기가 어렵다.

마치며

AI로 빠른 개발이 가능해지면서 설계에 대한 역량이 더 필요해지는 것 같다. 이번에 설계를 해보면서 단번에 최적의 설계를 찾기에는 어려움이 있는 것 같아서 앞으로 블로그 글을 통해서 정리하는 방식을 주로 활용해려고 한다

0개의 댓글