Last edited: February 24, 2026
SaaS 관리 플랫폼에서 다양한 필터 타입(텍스트, 숫자, 단일/다중 선택, 날짜, 기간, 서버 검색 등)을 지원하면서도, 사용자가 정의한 Custom Field까지 동적으로 대응해야 하는 필터 시스템을 설계했다. TypeScript의 Discriminated Union과 React의 Composition 패턴을 활용해 OCP 원칙을 지키면서 확장 가능한 구조를 만든 과정을 정리한다.
플랫폼의 리스트 화면마다 필터가 필요했다. 처음에는 각 화면에서 필터 UI를 직접 구현했지만, 비슷한 패턴이 반복되었다.
여기에 Custom Field 요구사항이 추가되었다. 관리자가 조직 설정에서 커스텀 필드를 정의하면, 해당 필드가 필터에도 동적으로 나타나야 했다. 정적으로 하드코딩된 필터 구조로는 대응이 불가능했다.
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 };
}
핵심은 type 필드를 discriminant로 사용하는 Discriminated Union이다.
type AvailableFilterType =
| TextFilterType
| NumberFilterType
| SingleSelectFilterType
| MultiSelectFilterType
| PeriodFilterType
| UserFilterType
| ...;
<UnionCaseRenderer
value={filter}
extractKey="type"
cases={{
KEYWORD: (v) => <KeywordFilter {...v} />,
SHORT_TEXT: (v) => <TextFilter {...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 파라미터 관리
const FilterServerSearch = <T,>({
options: T[];
getOptionId: (option: T) => string;
getOptionLabel?: (option: T) => string;
renderOption: (option: T, meta: { isSelected: boolean }) => ReactNode;
})
이 구조 덕분에 User, License, Application 등 서로 다른 도메인의 서버 검색 필터를 같은 컴포넌트로 구현할 수 있었다.
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>처럼 타입 파라미터를 열어두면, 새 도메인이 추가되어도 기반 컴포넌트는 수정할 필요가 없다.