← /blog # State 2025.12.04 7 min

도메인마다 같은 필터 로직을 다시 쓰지 않기 — Zustand 팩토리

Admin에서 반복되는 필터 store를 제네릭 팩토리로 추상화. 도메인은 타입 인자로만, 액션과 selector는 자동으로.

복사-붙여넣기는 처음엔 빠르고, 나중엔 부채가 된다. 세 번째 복사를 하는 순간, 그건 추상화 신호다.

Admin은 필터로 이루어져 있다

어웨어랩의 Admin 대시보드를 만들고 있었다. 회원 관리, 콘텐츠 관리, 결제 내역, 구독 관리, 쿠폰 관리 — 5개 이상의 도메인이 있고, 각각에 상태 필터, 검색어, 날짜 범위, 페이지네이션이 달려 있다. Admin은 결국 필터의 집합이다.

처음에는 도메인마다 Zustand 스토어를 따로 만들었다. 멤버 필터 스토어, 콘텐츠 필터 스토어, 결제 필터 스토어... 구조는 거의 같은데 타입만 다른 코드가 5벌 복사되었다.

복사-붙여넣기의 비용

처음 두 개는 괜찮았다. 세 번째 스토어를 복사하면서 문제가 보이기 시작했다.

  • 날짜 필터 로직을 수정하면 5개 파일을 모두 고쳐야 한다
  • 하나를 빠뜨리면 특정 도메인에서만 버그가 발생한다
  • 새 도메인이 추가될 때마다 파일을 복사하고, 타입과 기본값을 바꾸고, import를 연결해야 한다
  • 리뷰어가 "이 코드 다른 스토어에서 본 것 같은데" 라고 말하기 시작한다

반복되는 구조에서 달라지는 것은 딱 두 가지였다 — 상태(status) 타입도메인별 추가 필터. 나머지(검색어, 날짜 범위, 페이지네이션, reset, setter)는 동일했다.

팩토리 아이디어

달라지는 부분을 제네릭 타입 인자로 빼고, 동일한 부분은 팩토리 함수가 자동으로 생성하면 된다. createFilterStore<TStatus, TAdditional>()을 호출하면 완전한 필터 스토어가 반환되는 구조다.

typescript createFilterStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface BaseFilter {
  search: string;
  dateRange: { start: Date | null; end: Date | null };
  page: number;
  pageSize: number;
}

type FilterState<TStatus extends string, TAdditional extends Record<string, unknown>> =
  BaseFilter & { status: TStatus | 'all' } & TAdditional;

type FilterActions<TStatus extends string, TAdditional extends Record<string, unknown>> = {
  setStatus: (status: TStatus | 'all') => void;
  setSearch: (search: string) => void;
  setDateRange: (range: { start: Date | null; end: Date | null }) => void;
  setPage: (page: number) => void;
  reset: () => void;
} & {
  [K in keyof TAdditional as `set${Capitalize<string & K>}`]:
    (value: TAdditional[K]) => void;
};

export function createFilterStore<
  TStatus extends string,
  TAdditional extends Record<string, unknown> = Record<string, never>,
>(defaults: { status: TStatus | 'all' } & TAdditional) {
  const initialState: FilterState<TStatus, TAdditional> = {
    search: '',
    dateRange: { start: null, end: null },
    page: 1,
    pageSize: 20,
    ...defaults,
  };

  // Generate dynamic setters for TAdditional keys
  const additionalSetters = Object.keys(defaults).reduce((acc, key) => {
    if (key === 'status') return acc;
    const setterName = `set${key.charAt(0).toUpperCase()}${key.slice(1)}`;
    acc[setterName] = (value: unknown) =>
      (state: FilterState<TStatus, TAdditional>) => {
        (state as Record<string, unknown>)[key] = value;
      };
    return acc;
  }, {} as Record<string, Function>);

  return create<FilterState<TStatus, TAdditional> & FilterActions<TStatus, TAdditional>>()(
    immer((set) => ({
      ...initialState,
      setStatus: (status) => set((s) => { s.status = status; }),
      setSearch: (search) => set((s) => { s.search = search; s.page = 1; }),
      setDateRange: (range) => set((s) => { s.dateRange = range; s.page = 1; }),
      setPage: (page) => set((s) => { s.page = page; }),
      reset: () => set(() => ({ ...initialState }) as any),
      ...Object.fromEntries(
        Object.entries(additionalSetters).map(([name, fn]) => [
          name,
          (value: unknown) => set(fn(value) as any),
        ])
      ),
    }) as any)
  );
}

사용 예시

새 도메인 필터를 추가하는 것은 이제 파일 하나, 몇 줄이면 된다.

typescript stores/members-filter.ts
import { createFilterStore } from './createFilterStore';

type MemberStatus = 'active' | 'expired' | 'cancelled';

export const useMemberFilterStore = createFilterStore<
  MemberStatus,
  { plan: string; country: string }
>({
  status: 'all',
  plan: 'all',
  country: 'all',
});

타입 인자 두 개와 기본값만 넘기면, 검색, 날짜, 페이지네이션, reset, 그리고 setPlan, setCountry 같은 setter까지 자동으로 생성된다.

Date 직렬화 함정

이 팩토리를 URL 쿼리 파라미터와 동기화하려고 했을 때 함정에 빠졌다. Date 객체는 JSON 직렬화 시 문자열로 바뀌는데, 역직렬화 시 자동으로 Date로 복원되지 않는다. Zustand의 persist 미들웨어를 쓰면 이 문제가 발생한다.

typescript utils/customDateStorage.ts
// Date를 ISO 문자열로 저장하고, 복원 시 다시 Date로 변환
const customDateStorage = {
  getItem: (name: string) => {
    const raw = sessionStorage.getItem(name);
    if (!raw) return null;
    return JSON.parse(raw, (key, value) => {
      if (key === 'start' || key === 'end') {
        return value ? new Date(value) : null;
      }
      return value;
    });
  },
  setItem: (name: string, value: unknown) => {
    sessionStorage.setItem(name, JSON.stringify(value));
  },
  removeItem: (name: string) => sessionStorage.removeItem(name),
};

JSON의 reviver 함수를 사용해서 startend 키를 자동으로 Date 객체로 복원하는 커스텀 스토리지를 만들었다.

결과

  • 필터 관련 보일러플레이트 약 70% 감소
  • 새 도메인 추가 시 파일 1개 + 5줄로 완성
  • 필터 로직 수정 시 팩토리 1곳만 변경하면 전체 도메인에 반영
  • 리뷰 시간 단축 — 반복 코드가 없으므로 비즈니스 로직에만 집중

팩토리 패턴을 언제 쓸 것인가

팩토리는 만능이 아니다. 구조가 정말로 같고 타입만 다를 때 효과적이다. 각 도메인의 필터가 구조적으로 다르다면 (예를 들어 한 도메인은 트리 필터, 다른 도메인은 지도 필터), 무리하게 팩토리에 우겨넣는 것이 오히려 복잡도를 높인다.

  • 세 번 이상 복사했다면 팩토리를 고려하라
  • 달라지는 부분이 타입 인자로 표현 가능한지 먼저 확인하라
  • 팩토리가 복잡해지기 시작하면, 추상화 레벨이 잘못된 것이다
  • Date 같은 비-JSON 타입은 직렬화/역직렬화 전략을 미리 세워라