hongsoohyuk
홈이력서방명록프로젝트인스타그램블로그

© 2026 hongsoohyuk. All rights reserved.

GitHubLinkedIn
블로그/Custom Field 대응을 위한 필터 시스템 추상화 설계

Custom Field 대응을 위한 필터 시스템 추상화 설계

Software ArchitectureFrontend

마지막 수정: 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 등)를 정의하면, 해당 필드가 필터에도 동적으로 나타나야 했다. 정적으로 하드코딩된 필터 구조로는 대응이 불가능했다.

문제

  1. 필터 타입별로 UI와 동작이 다르다: 텍스트는 입력, 선택은 드롭다운, 기간은 날짜 범위 피커 등 — 각 타입마다 렌더링과 파라미터 처리 방식이 다르다
  2. Custom Field는 런타임에 결정된다: 어떤 타입의 필터가 몇 개 나올지 빌드 타임에 알 수 없다
  3. 새 필터 타입 추가 시 변경 범위를 최소화해야 한다

해결: 타입 기반 다형성 설계

인터페이스 계층 구조

모든 필터가 공유하는 BaseFilter 인터페이스를 정의하고, 각 필터 타입이 이를 확장하도록 했다.

typescript
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이다.

typescript
type AvailableFilterType =
  | TextFilterType
  | NumberFilterType
  | SingleSelectFilterType
  | MultiSelectFilterType
  | PeriodFilterType
  | UserFilterType
  | ...;

이 구조 덕분에 Custom Field의 타입(SHORT_TEXT, NUMBER, SINGLE_SELECT 등)이 필터 시스템의 FilterType에 자연스럽게 매핑된다. 서버에서 커스텀 필드 목록을 받으면 AvailableFilterType[] 배열로 변환하기만 하면 된다.

다형적 렌더링 (UnionCaseRenderer)

FilterBar에서 필터 배열을 순회하며, type에 따라 적절한 컴포넌트로 디스패치한다.

typescript
<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에 등록 — 세 곳만 수정하면 된다.

재사용 가능한 기반 컴포넌트 (Composition)

필터 컴포넌트들이 공통으로 사용하는 동작을 기반 컴포넌트로 추출했다.

javascript
FilterContainer          — 팝오버 UI, 앵커 위치, 클리어 버튼 등 공통 크롬
FilterLocalSearch<T>     — 로컴 데이터 자동완성 (제네릭)
FilterServerSearch<T>    — 서버 데이터 자동완성 (제네릭)
useIdFilter              — ID 기반 필터의 URL 파라미터 관리

FilterLocalSearch와 FilterServerSearch는 제네릭 타입 파라미터 <T>를 사용해 어떤 데이터 타입이든 대응한다.

typescript
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)을 따른 결과다 — 기존 코드를 수정하지 않고 새로운 도메인 필터를 추가할 수 있다.

URL 기반 상태 관리

필터 상태를 Redux나 별도 store 없이 URL 쿼리 파라미터로 관리했다. useIdFilter 훅이 이를 추상화한다.

typescript
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에 반영되어 북마크, 공유, 뒤로 가기가 자연스럽게 동작한다.

배운 점

  • Discriminated Union은 클래스 상속의 대안이 된다. TypeScript에서 다형성이 필요할 때 클래스 계층 대신 유니온 타입 + 패턴 매칭으로 더 간결하게 구현할 수 있다.
  • 제네릭 컴포넌트는 도메인 독립성을 보장한다. FilterServerSearch<T>처럼 타입 파라미터를 열어두면, 새 도메인이 추가되어도 기반 컴포넌트는 수정할 필요가 없다.
  • Custom Field 대응의 핵심은 "타입 → 컴포넌트" 매핑이다. 서버가 내려주는 필드 타입을 필터 타입으로 변환하는 매핑 레이어만 있으면, 런타임에 동적으로 필터를 생성할 수 있다.
  • URL 기반 상태 관리는 필터에 특히 적합하다. 별도 store 없이도 상태 공유, 딥링크, 히스토리 연동이 자연스럽다.

참고 자료

  • TypeScript Discriminated Unions
  • Composition vs Inheritance - React 공식 문서