백오피스 필터 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=...&...) |
검색 버튼의 데이터 흐름
핵심: 사용자가 드롭다운을 바꿀 때마다 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 공유 가능 |
아키텍처 요약
계층 구조:
- 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 동기화, 리셋 로직 — 모두 제거 |
결론
백오피스 필터는 "날짜 + 검색어 + 드롭다운 + 페이지네이션"이라는 동일한 패턴이 수십 개 페이지에 반복됩니다. 이 패턴을 인식하고 추상화하면:
- useFilter(defaults): defaults 객체만 넘기면 파서 자동 생성, state/params 분리, 리셋까지 해결
- 필터 UI 컴포넌트: FilterCard, KeywordSearch, DateRangePresets 등 조합으로 필터 폼 구성
- 페이지별 훅: 해당 페이지만의 특수 로직(검색타입 매핑, size 등)만 작성
새 목록 페이지가 추가될 때, 필터 구현에 들이는 시간은 defaults 객체 정의 + params 변환 로직 정도입니다. URL 동기화, 뒤로가기, 초기화 같은 인프라는 이미 해결되어 있으니까요.