yoowonyoungabout

백오피스 필터 40개를 하나의 훅으로 — useFilter 추상화로 보일러플레이트 제거하기

URL 동기화·리셋·상태 분리를 하나의 훅으로

백오피스 필터는 어디서나 비슷하다. 날짜 범위, 검색어, 드롭다운, 페이지네이션 — 매번 같은 보일러플레이트를 반복하는 대신, useFilter 하나로 URL 동기화·상태 분리·리셋까지 해결한 과정을 공유한다.


문제 — 백오피스 필터의 반복 패턴

백오피스를 만들어본 개발자라면 공감할 겁니다. 거의 모든 목록 페이지에 필터가 있고, 필터의 구조는 놀라울 정도로 비슷합니다.

주문내역:  날짜범위 + 검색타입 + 키워드 + 주문상태 + 페이지
회원관리:  검색타입 + 키워드 + 등급 + 페이지
상품관리:  검색타입 + 키워드 + 카테고리 + 판매상태 + 페이지
정산내역:  날짜범위 + 가맹점 + 정산상태 + 페이지
문의관리:  날짜범위 + 검색타입 + 키워드 + 처리상태 + 페이지
...40개 이상의 필터

패턴은 같은데 매번 새로 구현합니다. 그리고 이 필터들에는 공통적으로 3가지 UX 문제가 있었습니다.

1. 새로고침 시 필터 초기화

useState로만 필터를 관리하면, 사용자가 복잡한 조건을 설정하고 새로고침하는 순간 모든 설정이 날아갑니다.

2. URL 공유 불가

동료에게 "이 필터 결과 보세요"라고 URL을 보낼 수 없습니다. 필터 상태가 URL에 반영되지 않으니까요.

3. 뒤로가기 시 필터 깨짐

필터를 변경하고 상세 페이지에 갔다가 뒤로가기를 눌러도, 이전 필터 상태로 복원되지 않습니다.

세 문제의 공통 원인은 명확합니다. 필터 상태가 URL과 동기화되지 않기 때문입니다.


왜 nuqs인가

필터 상태를 URL에 동기화하는 방법은 여러 가지가 있습니다.

선택지 장점 단점
useSearchParams 직접 사용 의존성 없음, React Router 네이티브 파싱/직렬화 직접 구현, 배열·정수 처리 번거로움, 타입 안전성 부족
nuqs 타입 안전 파서, 배열/정수 지원, history push/replace 선택, useState 유사 API 외부 의존성 추가
상태관리 라이브러리 (Zustand 등) 전역 상태로 관리 가능 URL 동기화 직접 구현 필요, 새로고침 시 초기화

nuqs는 React용 타입 안전 URL 상태 관리 라이브러리입니다. useState와 비슷한 API로 쿼리스트링을 관리하되, parseAsString, parseAsInteger, parseAsArrayOf 같은 타입 파서를 제공하여 URL 값을 자동으로 올바른 타입으로 변환합니다.

// nuqs 기본 사용법 — useState와 거의 동일
const [keyword, setKeyword] = useQueryState('keyword', parseAsString)
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))

// 여러 파라미터를 동시에 관리
const [state, setState] = useQueryStates({
  keyword: parseAsString,
  page: parseAsInteger.withDefault(1),
}, { history: 'push' })  // 뒤로가기 지원

nuqs를 선택하면 URL 동기화, 새로고침 복원, 뒤로가기 지원은 해결됩니다. 하지만 새로운 문제가 생깁니다. 40개 페이지에서 useQueryStates를 직접 사용하면, 매번 같은 보일러플레이트를 반복해야 합니다.


Before — nuqs를 직접 사용할 때의 보일러플레이트

useQueryStates를 각 페이지에서 직접 사용하면, 페이지마다 이런 코드를 작성해야 합니다:

// [X] Before: 매 페이지마다 반복
function useOrdersFilter() {
  // 1. 파서 정의 (매번 수동)
  const [committed, setCommitted] = useQueryStates({
    startDate: parseAsString, endDate: parseAsString,
    keyword: parseAsString, status: parseAsString,
    page: parseAsInteger.withDefault(1),
  }, { history: 'push' })

  // 2. 로컬 폼 상태 (매번 수동)
  const [input, setInput] = useState({ ...committed, startDate: committed.startDate ?? today })

  // 3. 뒤로가기 동기화 (매번 수동)
  useEffect(() => { setInput({ ... }) }, [committed])

  // 4. 검색/초기화 핸들러 (매번 수동)
  const onSearch = () => setCommitted({ ...input })
  const onReset = () => { setInput({ ... }); setCommitted(null) }

  // 5. API 파라미터 변환 (매번 수동)
  const params = useMemo(() => ({ ...committed, keyword: committed.keyword || undefined }), [committed])

  return { input, setInput, onSearch, onReset, params }
}

문제점:

  • 파서 정의, 로컬 상태, useEffect, 검색/초기화, 파라미터 변환 — 5단계의 보일러플레이트가 매 페이지마다 반복
  • 필드가 string인지 number인지에 따라 파서를 수동으로 골라야 함
  • null(URL 제거)과 undefined(API 미포함)을 직접 구분해야 함
  • 40개 페이지에 이 코드를 복사하면 유지보수 지옥

After — useFilter 하나로 해결

// After: defaults만 넘기면 끝
function useOrdersFilter() {
  const filter = useFilter({
    startDate: formatToday(),   // 'yyyy-MM-dd'
    endDate: formatToday(),
    searchType: 'ALL',
    keyword: '',
    status: '',
    page: 1,
  })

  const params = useMemo(() => ({
    ...filter.params,
    searchType: filter.state.searchType !== 'ALL'
      ? filter.state.searchType : undefined,
    size: 20,
  }), [filter.params, filter.state.searchType])

  return { state: filter.state, params, set: filter.set, reset: filter.reset }
}

달라진 점:

  • 파서 정의 → defaults 객체에서 자동 생성 (string이면 parseAsString, number면 parseAsInteger)
  • 로컬 상태, useEffect, 검색/초기화 → useFilter 내부에서 처리
  • ''undefined 변환 → 자동 (filter.params가 알아서 변환)
  • 남은 건 해당 페이지만의 특수 로직 (searchType ‘ALL’ 처리, size 설정)

useFilter 내부 구현

export function useFilter<T extends Record<string, string | number>>(defaults: T) {
  // 1. defaults에서 파서 자동 생성
  const parsers = Object.fromEntries(
    Object.entries(defaults).map(([key, value]) => [
      key,
      typeof value === 'number'
        ? parseAsInteger.withDefault(value)
        : parseAsString.withDefault(value),
    ]),
  )

  // 2. nuqs로 URL 상태 관리
  const [state, setState] = useQueryStates(parsers, { history: 'push' })
  const [isPending, startTransition] = useTransition()

  // 3. 부분 업데이트 (startTransition으로 렌더링 최적화)
  const set = (next: Partial<T>) => {
    startTransition(() => setState(next))
  }

  // 4. 초기화 (null → URL에서 모든 파라미터 제거)
  const reset = () => {
    startTransition(() => setState(null))
  }

  // 5. API용 파라미터 ('' → undefined 자동 변환)
  const params = useMemo(() => {
    const result = {} as Record<string, unknown>
    for (const [key, value] of Object.entries(state)) {
      result[key] = value === '' ? undefined : value
    }
    return result as StripEmpty<T>
  }, [state])

  return { state: state as T, params, set, reset, isPending }
}

핵심 설계 결정

결정 구현 이유
파서 자동 생성 defaults 값의 typeof로 string/number 판별 매 페이지마다 파서를 수동 정의하는 보일러플레이트 제거
state vs params 분리 state'' 포함 (폼 바인딩), params''->undefined (API 호출) 폼 input에는 ''이 자연스럽지만, API에는 필드 자체를 보내지 않아야 함
startTransition 적용 set()reset() 모두 startTransition으로 래핑 URL 변경 시 렌더링 우선순위를 낮춰 입력 응답성 유지
reset = setState(null) nuqs에서 null은 URL 파라미터 전체 제거 기본값으로 돌아가면 URL이 깨끗해짐 (/orders vs ?startDate=...&...)

검색 버튼의 데이터 흐름

flowchart TD subgraph UI["사용자 조작"] A["드롭다운, 키워드, 날짜"] end subgraph Local["로컬 form state"] B["useState로 UI 임시값 관리"] end subgraph Search["검색 버튼 클릭"] C["filter.set 호출"] end subgraph URL["URL 상태 (nuqs)"] D["쿼리스트링 업데이트"] end subgraph Query["TanStack Query"] E["queryKey 변경 -> refetch"] end A --> B B --> C C --> D D --> E D -- "뒤로가기 시 useEffect" --> B

핵심: 사용자가 드롭다운을 바꿀 때마다 API가 호출되는 게 아니라, 검색 버튼을 눌렀을 때만 filter.set()이 호출되어 URL이 업데이트되고 API가 호출됩니다. 로컬 form state는 필터 UI 컴포넌트 내부에서 useState로 관리합니다.


필터 UI 컴포넌트 조합

useFilter가 상태를 관리한다면, 필터 UI 컴포넌트가 렌더링을 담당합니다.

<FilterCard actions={<><Button onClick={filter.reset}>초기화</Button><Button onClick={handleSearch}>검색</Button></>}>
  <FilterGrid>
    <DateRangePresets value={dateRange} onChange={setDateRange} />
    <KeywordSearch searchType={form.searchType} keyword={form.keyword} onChange={...} />
    <Dropdown label="상태" value={form.status} options={STATUS_OPTIONS} onChange={...} />
  </FilterGrid>
</FilterCard>
컴포넌트 역할 사용 빈도
FilterCard 필터 컨테이너 + 검색/초기화 버튼 영역 모든 필터 페이지
FilterGrid 2열 그리드 레이아웃 모든 필터 페이지
KeywordSearch 검색타입 드롭다운 + 키워드 입력 대부분의 필터
DateRangePresets 날짜 범위 + 프리셋 버튼 (오늘/어제/7일/30일) 날짜 필터 포함 페이지
MultiSelectDropdown 다중선택 드롭다운 (전체/개별) 상태 필터 등


실제 적용: 단순 필터 vs 복합 필터

단순 필터 — 회원관리

// 10줄로 완성
function useMembersFilter() {
  const filter = useFilter({
    searchType: 'all',
    keyword: '',
    page: 1,
  })

  const params = useMemo(() => ({
    ...filter.params,
    searchType: SEARCH_TYPE_MAP[filter.state.searchType],
    size: 20,
  }), [filter.params, filter.state.searchType])

  return { state: filter.state, params, set: filter.set, reset: filter.reset }
}

복합 필터 — 주문내역

// 날짜 + 검색타입 + 키워드 + 주문상태 + 결제방식 + 페이지
function useOrdersFilter() {
  const filter = useFilter({
    startDate: formatToday(),
    endDate: formatToday(),
    searchType: 'ALL',
    keyword: '',
    status: '',
    paymentMethod: '',
    page: 1,
  })

  const params = useMemo(() => ({
    ...filter.params,
    searchType: filter.state.searchType !== 'ALL'
      ? filter.state.searchType : undefined,
    size: 20,
  }), [filter.params, filter.state.searchType])

  return { state: filter.state, params, set: filter.set, reset: filter.reset }
}

공통점: 둘 다 useFilter(defaults) -> useMemo로 params 변환 -> 동일한 반환 구조. 필드 수가 3개든 7개든 패턴은 동일합니다.

페이지에서 사용

function OrdersPage() {
  const filter = useOrdersFilter()
  const { data, pagination } = useOrders(filter.params)

  return (
    <>
      <OrdersFilter
        defaults={filter.state}
        onSearch={filter.set}
        onReset={filter.reset}
      />
      <OrdersTable
        data={data}
        onPageChange={(page) => filter.set({ page })}
      />
    </>
  )
}

페이지 컴포넌트는 조합만 담당합니다. 필터 상태 관리, URL 동기화, API 파라미터 변환은 모두 useFilter + useXxxFilter 내부에 있습니다.


Before vs After

항목 Before (useQueryStates 직접 사용) After (useFilter)
파서 정의 매 필터마다 수동 정의 defaults에서 자동 생성
상태 분리 input(useState) + committed(nuqs) 직접 관리 state(폼) + params(API) 자동 분리
‘’ -> undefined 변환 각 필드마다 수동 처리 params에서 자동 변환
뒤로가기 동기화 useEffect로 각 필드를 수동 동기화 nuqs가 URL 변경 자동 감지, 필터 UI에서 defaults 변경 감지
초기화 로직 input 리셋 + committed null 처리 직접 구현 filter.reset() 한 줄
필터 훅 크기 40~60줄 10~20줄
새로고침 필터 초기화 URL에서 복원
URL 공유 불가 필터 상태 포함된 URL 공유 가능

아키텍처 요약

flowchart TD subgraph Shared["shared 레이어"] UF["useFilter(defaults)"] FC["FilterCard / FilterGrid / KeywordSearch"] end subgraph Page["pages 레이어"] PF["useOrdersFilter()"] PP["OrdersPage"] PFU["OrdersFilter"] PT["OrdersTable"] end UF --> PF PF --> PP FC --> PFU PP --> PFU PP --> PT

계층 구조:

  • shared: useFilter, 필터 UI 컴포넌트 -> 모든 페이지에서 재사용
  • pages: useXxxFilter (페이지별 특수 로직만) -> useFilter를 감싸서 API 파라미터 변환

핵심 설계 포인트 정리

포인트 선택 이유
URL 상태 라이브러리 nuqs (useQueryStates) 타입 안전 파서 + 배열 지원 + history 옵션 + useState 유사 API
추상화 수준 defaults 객체만으로 파서 자동 생성 매 페이지마다 파서를 수동 정의하는 보일러플레이트 제거
상태 분리 state(폼 바인딩) / params(API 호출) 자동 분리 폼에는 ''이 자연스럽지만, API에는 필드를 보내지 않아야 함
검색 UX 검색 버튼 클릭 시만 URL 업데이트 드롭다운 변경마다 API 호출하면 불필요한 요청 폭증
렌더링 최적화 startTransition으로 URL 변경 래핑 URL 변경 시 렌더링 우선순위를 낮춰 입력 응답성 유지
URL 깔끔함 기본값 = URL에서 제거 (setState(null)) /orders vs ?startDate=...&endDate=...&page=1&...

정량적 성과

지표 수치
적용된 필터 수 40개+ 페이지에 동일 패턴 적용
필터 훅 코드량 40~60줄 -> 10~20줄 (60~75% 감소)
공통 인프라 useFilter 훅 (58줄) + 필터 UI 컴포넌트 5종
새로 추가된 UX 새로고침 복원, URL 공유, 뒤로가기 지원 (3가지 모두 신규)
보일러플레이트 파서 정의, 상태 분리, useEffect 동기화, 리셋 로직 — 모두 제거

결론

백오피스 필터는 "날짜 + 검색어 + 드롭다운 + 페이지네이션"이라는 동일한 패턴이 수십 개 페이지에 반복됩니다. 이 패턴을 인식하고 추상화하면:

  1. useFilter(defaults): defaults 객체만 넘기면 파서 자동 생성, state/params 분리, 리셋까지 해결
  2. 필터 UI 컴포넌트: FilterCard, KeywordSearch, DateRangePresets 등 조합으로 필터 폼 구성
  3. 페이지별 훅: 해당 페이지만의 특수 로직(검색타입 매핑, size 등)만 작성

새 목록 페이지가 추가될 때, 필터 구현에 들이는 시간은 defaults 객체 정의 + params 변환 로직 정도입니다. URL 동기화, 뒤로가기, 초기화 같은 인프라는 이미 해결되어 있으니까요.