Haylee (이하영)
Haylee (이하영)Author

2026년 2월 12일

nuqs의 자유로움, 그 양날의 검을 길들이다: createSearchProvider로 URL 상태 관리 단방향화하기

안녕하세요, 볼트업에서 프론트엔드 개발을 담당하고 있는 헤일리입니다.

다들 URL에 상태를 저장하고 관리해본 경험이 있으시죠?

저희 챕터는 사내 어드민 프로젝트에 nuqs를 도입하여 URL 상태 관리의 편리함을 누리고 있었습니다. 하지만 프로젝트가 커지면서 nuqs의 "어디서든 상태를 변경할 수 있다"는 장점이 오히려 "어디서 상태가 변경되었는지 추적할 수 없다"는 문제로 돌아왔습니다.

  • 😩 검색 조건이 갑자기 바뀌어서 디버깅을 시작했는데… 대체 어느 컴포넌트에서 search params를 바꾼 거지? 모든 컴포넌트를 뒤져봐야 하나…

저희는 이 문제를 해결하기 위해 createSearchProvider라는 팩토리 함수를 만들었고, 이를 통해 상태 변경의 흐름을 단방향으로 제한하여 예측 가능한 상태 관리를 구현했습니다.

본격적인 설명에 앞서 nuqs가 무엇인지 간략하게 소개하겠습니다.

nuqs란?

nuqs(Next.js URL Query State)는 URL 쿼리 파라미터를 React 상태처럼 다룰 수 있게 해주는 라이브러리입니다.

jsx
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));

위 코드 한 줄로:

  • page는 항상 URL의 값을 반영합니다
  • setPage는 React 상태와 URL을 동시에 업데이트합니다
  • 타입 안전성이 보장됩니다

nuqs의 핵심 장점:

특징

설명

선언적 API

두 줄의 코드로 URL과 상태를 바인딩

타입 안전성

내장 파서로 런타임 + 컴파일 타임 검증

SSR 친화적

Next.js App Router와 완벽 호환

자동 직렬화

문자열 ↔ 타입 변환 자동 처리

Before: nuqs의 자유로움이 만든 '추적 불가능한 상태 변경'

nuqs는 분명히 뛰어난 라이브러리입니다. nuqs를 도입한 초기에는 모든 것이 순조로웠고 향상된 dx에 모두들 만족했습니다. 하지만 여러 컴포넌트에서 동일한 search params를 다루기 시작하면서 문제가 발생했습니다.

문제점 1: 상태 변경 지점의 분산

nuqs의 useQueryState 훅은 어떤 컴포넌트에서든 호출할 수 있습니다. 이는 편리하지만, 동시에 상태 변경 지점이 코드베이스 전체에 흩어진다는 것을 의미합니다.

markdown
                    ┌─────────────────┐
                    │   URL Params    │
                    │  ?keyword=...   │
                    │  &startDate=... │
                    └────────┬────────┘
                             │
        ┌────────────────────┼────────────────────┐
        │                    │                    │
        ▼                    ▼                    ▼
┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│  SearchBar    │   │   DataTable   │   │  FilterModal  │
│               │   │               │   │               │
│ setKeyword()  │   │ setPage()     │   │ setStartDate()|
│ setStartDate()│   │ setSort()     │   │ setEndDate()  │
└───────────────┘   └───────────────┘   └───────────────┘
        │                    │                    │
        └────────────────────┼────────────────────┘
                             │
                             ▼
               🤯 startDate가 어디서 바뀐 거지?
  • 디버깅의 어려움: 검색 조건이 예상과 다르게 동작할 때, 상태를 변경한 컴포넌트를 찾기 위해 전체 코드를 뒤져야 했습니다.
  • 의도치 않은 상태 변경: 여러 컴포넌트에서 같은 파라미터를 수정하다 보니, 한 컴포넌트의 변경이 다른 컴포넌트의 로직을 방해하는 경우가 발생했습니다.

문제점 2: 검색 실행 시점의 불명확성

nuqs의 기본 동작은 상태가 변경될 때마다 즉시 URL을 업데이트합니다. 하지만 대부분의 검색 UI에서는 사용자가 여러 필터를 조정한 후 "검색" 버튼을 눌렀을 때 실제 검색이 실행되어야 합니다.

tsx
// ❌ 문제: 필터를 바꿀 때마다 URL이 변경되고 API가 호출됨
const [keyword, setKeyword] = useQueryState('keyword');
const [startDate, setStartDate] = useQueryState('startDate');

// 사용자가 키워드를 입력할 때마다 URL이 변경됨
<Input onChange={(e) => setKeyword(e.target.value)} />

문제점 3: 날짜 필터의 복잡한 검증 로직

날짜 범위 검색은 추가적인 검증이 필요합니다:

  • 시작일이 종료일보다 뒤면 안 됨
  • 미리 정의된 기간(오늘, 7일, 30일 등)과의 매칭
  • 시작일/종료일의 시간 정규화 (00:00:00 / 23:59:59)

각 컴포넌트에서 이 로직을 중복 구현하면 일관성을 유지하기 어려웠습니다.

After: 'createSearchProvider'로 단방향 흐름 구축

이 문제들을 해결하기 위한 저희들의 목표는 아래와 같았습니다.

  1. 단일 진실 공급원(Single Source of Truth): 상태 변경은 한 곳에서만 관리한다.
  2. Deferred Search: 사용자가 "검색" 버튼을 누를 때만 실제 검색을 실행한다.
  3. 선언적 구성: 새로운 검색 페이지를 쉽게 만들 수 있는 재사용 가능한 패턴을 만든다.

저희는 createSearchProvider라는 팩토리 함수를 만들어 이 목표를 달성했습니다.

핵심 아이디어: Local State와 URL State의 분리
hljs
                    ┌─────────────────────────────────────────┐
                    │         SearchProvider (Context)        │
                    │                                         │
                    │  ┌─────────────┐   ┌─────────────┐      │
                    │  │ Local State │   │  URL State  │      │
                    │  │  (편집 중)    │──▶│  (확정됨)    │      │
                    │  └─────────────┘   └─────────────┘      │
                    │         ▲              search()         │
                    │         │                               │
                    │    setFilters()                         │
                    └─────────┬───────────────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
        ▼                     ▼                     ▼
┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│  SearchBar    │   │   DataTable   │   │  FilterModal  │
│               │   │               │   │               │
│ useFilters()  │   │ useDto()      │   │ useFilters()  │
│ (로컬 수정)     │   │ (읽기 전용)      │   │ (로컬 수정)     │
└───────────────┘   └───────────────┘   └───────────────┘

핵심 설계 원칙

1. Two-Phase Commit 패턴

상태 변경을 로컬 편집URL 커밋의 두 단계로 분리했습니다.

tsx
// 로컬 상태만 변경 (URL 변경 없음)
setFilters({ keyword: '새로운 검색어' });

// 검색 버튼 클릭 시 URL로 커밋
search();

이 패턴으로 사용자는 여러 필터를 자유롭게 조정하고, 최종적으로 "검색" 버튼을 눌렀을 때만 실제 검색이 실행됩니다.

2. Context 기반 상태 집중화

모든 검색 관련 상태와 액션을 하나의 Context에 집중시켜 상태 변경 지점을 명확히 했습니다.

tsx
const searchProvider = createSearchProvider({
  name: 'BoardSearch',
  filtersConfig: {
    schema: {
      keyword: parseAsString,
      startDate: parseAsNullableIsoDateTime, //custom parser
      endDate: parseAsNullableIsoDateTime,
    },
    toDto: (filters) => convertFiltersToDto(filters),
  },
  defaultFilters: {
    keyword: '',
    startDate: null,
    endDate: null,
  },
});

export const { Provider, useFilters, useDto } = searchProvider;

3. 역할별 Hook 분리

상태를 읽기쓰기로 명확히 분리했습니다.

Hook

역할

사용 위치

useFilters()

로컬 상태 읽기/쓰기, 검색 실행

SearchBar, FilterModal

useDto()

URL 기반 DTO 읽기 (읽기 전용)

DataTable, API 호출

useDateActions()

날짜 관련 액션

DateRangePicker

tsx
// SearchBar: 필터 수정과 검색 실행
const SearchBar = () => {
  const { filters, setFilters, search, reset } = useFilters();

  return (
    <Input value={filters.keyword} onChange={(e) => setFilters({ ...filters, keyword: e.target.value })} />
    <Button onClick={search}>검색</Button>
  );
};

// DataTable: URL 기반 데이터 조회만 담당
const DataTable = () => {
  const dto = useDto(); // 읽기 전용
  const { data } = useQuery(Queries.search(dto));

  return <Table data={data} />;
};

구현 상세: createSearchProvider 내부 구조

hljs
┌──────────────────────────────────────────────────────────────┐
│                     createSearchProvider                     │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  Input:                                                      │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │ • name: Provider 이름                                    │ │
│  │ • filtersConfig: { schema, toDto }                      │ │
│  │ • defaultFilters: 초기값                                  │ │
│  └─────────────────────────────────────────────────────────┘ │
│                              │                               │
│                              ▼                               │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │              ProviderInner Component                    │ │
│  │  ┌───────────────────┐  ┌───────────────────┐           │ │
│  │  │   useUrlFilters   │  │  useState (local) │           │ │
│  │  │   (nuqs 래핑)      │  │   (편집 상태)        │          │ │
│  │  └─────────┬─────────┘  └─────────┬─────────┘           │ │
│  │            │                      │                     │ │
│  │            │    ┌─────────────────┘                     │ │
│  │            │    │                                       │ │
│  │            ▼    ▼                                       │ │
│  │  ┌───────────────────────────────────────┐              │ │
│  │  │           Context Value               │              │ │
│  │  │  • localFilters    (편집 중인 값)        │              │ │
│  │  │  • urlFilters      (확정된 값)          │              │ │
│  │  │  • setLocalFilters (로컬 수정)          │              │ │
│  │  │  • handleSearch    (URL 커밋)          │              │ │
│  │  │  • handleReset     (초기화)             │             │ │
│  │  │  • dto             (API용 변환값)       │              │ │
│  │  └───────────────────────────────────────┘              │ │
│  └─────────────────────────────────────────────────────────┘ │
│                              │                               │
│                              ▼                               │
│  Output:                                                     │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │ • Provider      (Context Provider 컴포넌트)               │ │
│  │ • useFilters()  (로컬 상태 + 액션)                         │ │
│  │ • useDto()      (URL 기반 DTO)                           │ │
│  │ • useDateActions() (날짜 액션)                            │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                              │
└──────────────────────────────────────────────────────────────┘

실제 사용 예시

1. Provider 정의

tsx
// board-search-provider.tsx
const searchProvider = createSearchProvider<BoardFilters, BoardDto>({
  name: 'BoardSearch',
  filtersConfig: {
    schema: {
      keyword: parseAsString,
      ...dateParserSchema,
    },
    toDto: convertBoardFiltersToDto,
  },
  defaultFilters: boardDefaultFilters,
});

export const {
  useFilters: useBoardSearchFilters,
  useDto: useBoardSearchDto
} = searchProvider;

export default searchProvider.Provider;

2. 페이지에서 Provider로 감싸기

jsx
// page.tsx
const BoardPage = () => (
  <BoardSearchProvider> 
    <PageHeader items={[{ label: '게시판' }]} /> 
    <BoardSearchBar /> 
    <BoardTable /> 
  </BoardSearchProvider>
);

3. 검색 바에서 필터 수정

tsx
// board-search-bar.tsx
const BoardSearchBar = () => {
  const { filters, setFilters, search, reset } = useBoardSearchFilters();
  const { setStartDate, setEndDate } = useBoardSearchDateActions();

  return (
    <SearchBarLayout> 
      <DateRangeSearch startDate={filters.startDate} onStartDateChange={setStartDate}  /> 
      <Input value={filters.keyword} onChange={(e) => setFilters({ ...filters, keyword: e.target.value })} /> 
      <SearchButton onClick={search} /> 
      <ResetButton onClick={reset} /> 
    </SearchBarLayout>
  );
};

4. 테이블에서 데이터 조회

tsx
// board-table.tsx
const BoardTable = () => {
  const dto = useBoardSearchDto(); // 읽기 전용

  const { data } = useQuery(BoardArticleApiQueries.searchArticles({
    params: { query: dto },
  }));

  return <DataTable data={data?.contents ?? []} />;
};

날짜 필터 검증 자동화

날짜 필터는 추가적인 검증 로직이 필요합니다. createSearchProvider는 이를 자동으로 처리합니다.

hljs
┌────────────────────────────────────────────────────────────┐
│                    setLocalFilters 호출                     │
└─────────────────────────┬──────────────────────────────────┘
                          │
                          ▼
            ┌─────────────────────────────┐
            │  날짜 필드 변경 감지?            │
            └─────────────┬───────────────┘
                          │
              ┌───────────┴───────────┐
              │ Yes                   │ No
              ▼                       ▼
┌─────────────────────────┐   ┌───────────────────┐
│   applyDateValidation   │   │   그대로 적용        │
│                         │   └───────────────────┘
│ • 시작일 > 종료일 검증      │
│ • 시간 정규화              │
│ • 미리정의 범위 매칭        │
└─────────────────────────┘

결론: createSearchProvider 도입으로 얻은 장점들

createSearchProvider 도입으로 저희는 아래와 같은 장점들을 얻었습니다.

  1. 추적 가능한 상태 변경: 모든 상태 변경이 Provider를 통해 이루어지므로, 디버깅 시 확인해야 할 지점이 명확해졌습니다.
  2. 예측 가능한 검색 흐름: Deferred Search 패턴으로 "언제 검색이 실행되는가"가 명확해졌습니다. 로컬 수정 → 검색 버튼 클릭 → URL 업데이트 → API 호출의 흐름이 일관됩니다.
  3. 재사용 가능한 패턴: 새로운 검색 페이지를 만들 때 createSearchProvider 한 번 호출로 필요한 모든 것이 준비됩니다. 이로 인해 보일러플레이트가 크게 줄었습니다.
  4. 자동화된 검증: 날짜 필터의 복잡한 검증 로직이 중앙에서 자동으로 처리되어 일관성이 보장됩니다.

사실 이 패턴이 모든 상황에 적합한 것은 아닙니다. 단순한 검색 페이지에서는 오버엔지니어링이 될 수 있고, nuqs의 즉각적인 URL 반영이 필요한 경우에는 기존 방식이 더 적합할 수 있습니다.

하지만 복잡한 검색 조건을 가진 페이지, 여러 컴포넌트에서 동일한 필터를 다루는 경우, 명확한 상태 흐름이 필요한 경우에는 이 패턴이 큰 도움이 될 것입니다.

nuqs의 편리함은 유지하면서, 그 자유로움으로 인한 혼란을 단방향 흐름으로 길들인 저희의 경험이 비슷한 고민을 하고 계시는 팀에게 도움이 되었으면 좋겠습니다!

감사합니다.

Haylee (이하영)

Haylee (이하영)

Tech Innovation Tribe
이 글 공유하기

지금 바로 볼트업에 지원해 보세요

볼트업 채용공고 바로가기