마지막 수정: 2026년 2월 24일
SaaS 관리 플랫폼에서 다양한 필터 타입(텍스트, 숫자, 단일/다중 선택, 날짜, 기간, 서버 검색 등)을 지원하면서도, 사용자가 정의한 Custom Field까지 동적으로 대응해야 하는 필터 시스템을 설계했다. TypeScript의 Discriminated Union과 React의 Composition 패턴을 활용해 OOP 원칙을 지키면서 확장 가능한 구조를 만든 과정을 정리한다.
플랫폼의 리스트 화면마다 필터가 필요했다. 처음에는 각 화면에서 필터 UI를 직접 구현했지만, 비슷한 패턴이 반복되었다. 텍스트 입력, 드롭다운 선택, 날짜 범위 등 기본 필터 타입은 거의 동일한데 매번 새로 만들고 있었다.
여기에 Custom Field 요구사항이 추가되었다. 관리자가 조직 설정에서 커스텀 필드(SHORT_TEXT, LONG_TEXT, NUMBER, SINGLE_SELECT, MULTI_SELECT, USER_SELECT 등)를 정의하면, 해당 필드가 필터에도 동적으로 나타나야 했다. 정적으로 하드코딩된 필터 구조로는 대응이 불가능했다.
모든 필터가 공유하는 BaseFilter 인터페이스를 정의하고, 각 필터 타입이 이를 확장하도록 했다.
interface BaseFilter {
type: FilterType;
parameterKey: string | { from: string; to: string };
label: string;
fixed?: boolean;
}
// 각 타입별 특화 인터페이스
interface TextFilterType extends BaseFilter {
type: 'SHORT_TEXT' | 'LONG_TEXT';
parameterKey: string;
placeholder?: string;
}
interface SingleSelectFilterType extends BaseFilter {
type: 'SINGLE_SELECT';
parameterKey: string;
options: { label: string; value: string }[];
}
interface PeriodFilterType extends BaseFilter {
type: 'PERIOD';
parameterKey: { from: string; to: string }; // 범위 필터는 키가 2개
}핵심은 type 필드를 discriminant로 사용하는 Discriminated Union이다.
type AvailableFilterType =
| TextFilterType
| NumberFilterType
| SingleSelectFilterType
| MultiSelectFilterType
| PeriodFilterType
| UserFilterType
| ...;이 구조 덕분에 Custom Field의 타입(SHORT_TEXT, NUMBER, SINGLE_SELECT 등)이 필터 시스템의 FilterType에 자연스럽게 매핑된다. 서버에서 커스텀 필드 목록을 받으면 AvailableFilterType[] 배열로 변환하기만 하면 된다.
FilterBar에서 필터 배열을 순회하며, type에 따라 적절한 컴포넌트로 디스패치한다.
<UnionCaseRenderer
value={filter}
extractKey="type"
cases={{
KEYWORD: (v) => <KeywordFilter {...v} />,
SHORT_TEXT: (v) => <TextFilter {...v} />,
LONG_TEXT: (v) => <LongTextFilter {...v} />,
NUMBER: (v) => <NumberFilter {...v} />,
SINGLE_SELECT: (v) => <EnumFilter {...v} />,
MULTI_SELECT: (v) => <EnumMultiFilter {...v} />,
PERIOD: (v) => <PeriodFilter {...v} />,
USER_SELECT: (v) => <UserFilter {...v} />,
}}
/>OOP의 다형성(Polymorphism)을 클래스 상속 없이 구현한 셈이다. 새 필터 타입을 추가할 때는 (1) 타입 정의, (2) 컴포넌트 구현, (3) cases에 등록 — 세 곳만 수정하면 된다.
필터 컴포넌트들이 공통으로 사용하는 동작을 기반 컴포넌트로 추출했다.
FilterContainer — 팝오버 UI, 앵커 위치, 클리어 버튼 등 공통 크롬
FilterLocalSearch<T> — 로컴 데이터 자동완성 (제네릭)
FilterServerSearch<T> — 서버 데이터 자동완성 (제네릭)
useIdFilter — ID 기반 필터의 URL 파라미터 관리FilterLocalSearch와 FilterServerSearch는 제네릭 타입 파라미터 <T>를 사용해 어떤 데이터 타입이든 대응한다.
const FilterServerSearch = <T,>({
options: T[];
getOptionId: (option: T) => string;
getOptionLabel?: (option: T) => string;
renderOption: (option: T, meta: { isSelected: boolean }) => ReactNode;
})이 구조 덕분에 User, License, Application 등 서로 다른 도메인의 서버 검색 필터를 같은 컴포넌트로 구현할 수 있었다. OOP의 개방-폐쇄 원칙(OCP)을 따른 결과다 — 기존 코드를 수정하지 않고 새로운 도메인 필터를 추가할 수 있다.
필터 상태를 Redux나 별도 store 없이 URL 쿼리 파라미터로 관리했다. useIdFilter 훅이 이를 추상화한다.
const useIdFilter = ({ parameterKey }) => {
const queryParams = new URLSearchParams(location.search);
const selectedIds = queryParams.getAll(parameterKey);
const onFilter = (value: string[]) => {
queryParams.delete(parameterKey);
value.forEach(id => queryParams.append(parameterKey, id));
history.replace(`${location.pathname}?${queryParams.toString()}`);
};
return { selectedIds, onFilter };
};이를 통해 필터 상태가 URL에 반영되어 북마크, 공유, 뒤로 가기가 자연스럽게 동작한다.
FilterServerSearch<T>처럼 타입 파라미터를 열어두면, 새 도메인이 추가되어도 기반 컴포넌트는 수정할 필요가 없다.