궁극의 useQueryParam: URL 쿼리 파라미터 관리의 혁신

R정우·2025년 3월 29일
22

React

목록 보기
4/4

React 애플리케이션을 개발하다 보면 URL 쿼리 파라미터 관리는 생각보다 번거로운 작업입니다. 매 페이지마다 인터페이스를 정의하고, 초기값을 설정하고, 타입 검증 코드를 반복해서 작성하는 데 시간을 낭비하고 있지는 않으신가요?

기존 방식의 문제점

일반적으로 React에서 URL 쿼리 파라미터를 다룰 때는 다음과 같은 코드를 작성합니다.

// 페이지마다 인터페이스 정의
interface SearchParams {
  category: 'IT' | 'LIFE'
}

function ProductPage() {
  // useSearchParams 사용
  const [searchParams, setSearchParams] = useSearchParams()
  
  // 파라미터 추출 및 타입 검증
  const category = searchParams.get('category')
  const validCategory = category === 'IT' || category === 'LIFE' ? category : undefined
  
  // 파라미터 변경 함수
  function updateCategory(newCategory: SearchParams['category']) {
    const newParams = new URLSearchParams(searchParams)
    newParams.set('category', newCategory)
    setSearchParams(newParams)
  }
  
  // 다른 파라미터도 비슷한 방식으로 처리
  const sort = searchParams.get('sort') || 'latest'
  const page = Number(searchParams.get('page') || '1')
  
  // ...
}

이 방식에는 다음과 같은 문제점이 있습니다.

  • 페이지마다 동일한 코드를 반복적으로 작성해야 함
  • 타입 안전성이 런타임에만 보장되며 컴파일 타임에는 검증되지 않음
  • 초기값이 코드 여러 곳에 분산되어 관리가 어려움
  • 여러 파라미터를 한 번에 업데이트하기 위해 추가 로직이 필요함

useQueryParam으로 해결하기

이러한 문제를 해결하기 위해 개발한 useQueryParam을 사용하면 다음과 같이 간결하게 코드를 작성할 수 있습니다.

// 중앙 집중식 설정
export const SearchConfig = {
  '/products': {
    category: 'ALL' as 'ALL' | 'IT' | 'LIFE',
    sort: 'latest' as 'latest' | 'popular',
    page: '1' as string,
    inStock: true as boolean
  }
  // 다른 페이지들의 param 설정...
} as const;

// 컴포넌트에서 사용
function ProductPage() {
  // 단일 파라미터 사용
  const [category, setCategory] = useQueryParam('/products', 'category');
  
  // 또는 모든 파라미터를 객체로 사용
  const [params, setParams, resetParams] = useQueryParam('/products');
  
  // 이제 일반 상태처럼 사용
  return (
    <>
      <CategoryFilter 
        value={category} 
        onChange={newCategory => {
          setCategory(newCategory);
          // 카테고리 변경 시 페이지 리셋
          setParams(prev => ({...prev, page: '1'}));
        }}
      />
      
      <ProductList 
        category={category}
        sort={params.sort}
        inStock={params.inStock}
      />
      
      <Pagination 
        page={Number(params.page)}
        onChange={page => setParams({...params, page: String(page)})}
      />
    </>
  );
}

주요 장점

1. 코드 간소화

반복적인 코드가 크게 줄어듭니다. 파라미터 파싱, 타입 검증, 업데이트 로직이 모두 useQueryParam 내부로 캡슐화되어 개발자는 비즈니스 로직에만 집중할 수 있습니다.

2. 타입 안전성

타입스크립트의 타입 추론 기능이 완벽하게 작동하여 컴파일 타임에 오류를 잡아냅니다.

// 자동 완성 지원 및 타입 오류 검출
setCategory('IT');      // 정상
setCategory('LIFE');    // 정상
setCategory('INVALID'); // 컴파일 타임에 타입 오류 발생

hook에 넘겨 줄 매개변수들 당연히 타입 추론을 지원합니다.

3. 자동 타입 변환

URL의 문자열 값이 자동으로 적절한 타입으로 변환됩니다.

// 불리언 타입으로 자동 변환
console.log(typeof params.inStock);  // 'boolean'

// 숫자 타입도 자동 변환
const [limit, setLimit] = useQueryParam('/products', 'limit');
console.log(typeof limit);  // 'number'

4. 함수형 업데이트

React의 useState와 동일한 방식으로 함수형 업데이트를 지원합니다.

// 이전 값 기반으로 업데이트
setParams(prev => ({
  ...prev,
  page: String(Number(prev.page) + 1)
}));

5. 초기값 자동 관리

SearchConfig에 정의된 초기값이 자동으로 사용되므로, URL에 파라미터가 없을 때 별도의 기본값 처리 로직이 필요하지 않습니다.

// URL에 category가 없으면 'ALL'이 자동으로 사용됨
const [category, setCategory] = useQueryParam('/products', 'category');

실제 사용 예: 상품 필터링

복잡한 필터링 기능도 간결하게 구현할 수 있습니다.

function ProductFilterPage() {
  const [filters, setFilters, resetFilters] = useQueryParam('/products');
  
  function handleFilterChange(key, value) {
    setFilters(prev => ({ ...prev, [key]: value, page: '1' }));
  }
  
  return (
    <div className="filter-page">
      <div className="sidebar">
        <CategoryFilter
          value={filters.category}
          onChange={v => handleFilterChange('category', v)}
        />
        
        <PriceRange
          min={filters.minPrice}
          max={filters.maxPrice}
          onChange={(min, max) => {
            setFilters(prev => ({
              ...prev, 
              minPrice: min, 
              maxPrice: max,
              page: '1'
            }));
          }}
        />
        
        <Checkbox
          label="재고 있는 상품만"
          checked={filters.inStock}
          onChange={v => handleFilterChange('inStock', v)}
        />
        
        <button onClick={resetFilters}>
          필터 초기화
        </button>
      </div>
      
      <div className="product-area">
        <div className="header">
          <span>{productCount}개 상품</span>
          
          <SortSelector
            value={filters.sort}
            onChange={v => handleFilterChange('sort', v)}
            options={[
              { label: '관련도순', value: 'relevance' },
              { label: '가격 낮은순', value: 'price-low' },
              { label: '최신순', value: 'newest' }
            ]}
          />
        </div>
        
        <ProductGrid products={products} />
        
        <Pagination
          page={Number(filters.page)}
          total={totalPages}
          onChange={page => handleFilterChange('page', String(page))}
        />
      </div>
    </div>
  );
}

구현 아이디어

다음은 useQueryParam 훅을 구현하기 위해 고려한 핵심 아이디어들 입니다.

1. 중앙 집중식 설정

모든 쿼리 파라미터 정의를 한 곳에서 관리하는 것이 핵심입니다. SearchConfig 객체를 통해 각 경로별로 사용 가능한 쿼리 파라미터와 초기값을 정의합니다. 이 접근 방식은 다음과 같은 이점을 제공합니다.

  • 경로별 쿼리 파라미터 정의가 한눈에 파악됨
  • 초기값이 코드 전체에 흩어지지 않고 한 곳에서 관리됨
  • 타입스크립트가 타입을 추론하는 기준점이 명확해짐

2. 타입스크립트의 타입 추론 활용

타입스크립트의 강력한 타입 시스템을 최대한 활용하여 타입 안전성을 확보합니다.

  • 조건부 타입(Conditional Types)을 사용하여 경로와 키에 따라 적절한 타입을 추론
  • 함수 오버로드를 통해 다양한 사용 패턴에 대한 타입 정의를 제공
  • 제네릭과 타입 추론을 조합하여 정확한 리터럴 타입을 보존

이러한 기법을 통해 자동 완성과 타입 검증이 완벽하게 작동하게 됩니다.

3. 자동 타입 변환 메커니즘

URL의 모든 값은 문자열이지만, 이를 자동으로 적절한 타입으로 변환해주는 메커니즘을 구현했습니다.

  • SearchConfig에 정의된 초기값의 타입을 기준으로 변환 로직을 결정
  • 숫자 타입으로 정의된 값은 Number() 함수를 사용하여 변환
  • 불리언 타입으로 정의된 값은 문자열 비교(=== 'true')를 통해 변환
  • 특별한 리터럴 타입은 별도의 로직을 통해 타입 정보를 보존

이러한 자동 변환 메커니즘을 통해 개발자는 매번 수동으로 타입 변환을 수행할 필요가 없어집니다.

4. React의 useState와 유사한 API 설계

친숙한 API를 통해 학습 곡선을 낮추고 사용성을 높였습니다.

  • [value, setValue, resetValue] 형태의 반환값을 제공
  • 함수형 업데이트(setValue(prev => ...))를 지원
  • 옵션 오버라이드(setValue(newValue, { push: true }))를 지원

이를 통해 기존 React 개발자들이 별도의 학습 없이도 쉽게 사용할 수 있습니다.

5. 객체 모드와 단일 키 모드 지원

두 가지 주요 사용 패턴을 모두 지원하여 유연성을 높였습니다.

  • 객체 모드:
const [params, setParams] = useQueryParam('/products')
  • 단일 키 모드:
const [category, setCategory] = useQueryParam('/products', 'category')

이를 통해 개발자는 상황에 맞는 가장 적절한 방식을 선택할 수 있습니다.

6. URL과 상태의 자동 동기화

URL과 컴포넌트 상태를 자동으로 동기화하는 메커니즘을 구현했습니다.

  • 값이 변경되면 URL이 자동으로 업데이트됨
  • URL이 변경되면 컴포넌트 상태도 자동으로 업데이트됨
  • 브라우저 히스토리 관리 옵션(push vs replace)을 제공하여 사용자 경험을 세밀하게 제어할 수 있음

7. 빈 값 처리 전략

빈 값을 어떻게 처리할지에 대한 전략을 옵션으로 제공합니다.

  • remove: 빈 값을 URL에서 완전히 제거(기본값)
  • preserve: 빈 값을 URL에 유지(예: ?filter=)

8. 경로 파라미터 추출 및 처리

동적 경로 파라미터를 자동으로 처리하는 메커니즘도 포함했습니다.

  • 현재 URL 경로에서 동적 파라미터 값을 정확히 추출
  • URL 업데이트 시 이 파라미터 값을 보존하여 일관성을 유지
  • 경로 패턴과 실제 URL을 매칭하는 정교한 로직을 구현

9. 확장 가능한 설계

미래의 요구사항 변화에 대응할 수 있는 확장 가능한 설계를 고려했습니다.

  • 옵션 객체를 통한 기능 확장 가능성을 열어둠
  • 타입 시스템을 통한 API 안정성을 확보
  • 기존 React Router와의 호환성을 유지하여 점진적 도입이 가능

이러한 아이디어들을 바탕으로 구현된 useQueryParam 훅은 React에서 URL 쿼리 파라미터를 관리하는 방식을 근본적으로 개선합니다. 코드의 양을 줄이고, 타입 안전성을 높이며, 개발자 경험을 크게 향상시킵니다.

코드

코드 자체를 공유하고 싶지만 너무 방대하고 복잡해서 음.. 시간 날 때 라이브러리로 정리하여 npm에 배포해보겠음니다...

몇 가지 핵심 구현만 적어보겠습니다.

혹시 전체 코드를 보고싶다면!?
https://github.com/picktoss/picktoss/tree/main/src/shared/lib/router

1. 중앙 집중식 설정 (SearchConfig)

export const SearchConfig = {
  '/products': {
    category: 'ALL' as 'ALL' | 'IT' | 'LIFE',
    sort: 'latest' as 'latest' | 'popular',
    page: '1' as string,
    inStock: true as boolean
  },
  '/search': {
    query: '' as string,
    filter: 'all' as 'all' | 'posts' | 'users',
  },
  // 다른 경로들...
} as const;

2. 핵심 타입 정의

export interface QueryParamOptions {
  /**
   * 브라우저 히스토리에 새 항목을 추가할지 여부
   * - true: pushState 사용 (새 히스토리 항목 생성)
   * - false: replaceState 사용 (현재 히스토리 항목 대체)
   */
  push?: boolean;

  /**
   * 빈 값 처리 방법
   * - 'remove': 빈 값을 URL에서 완전히 제거
   * - 'preserve': 빈 값을 URL에 유지 (예: ?param=)
   */
  emptyHandling?: 'remove' | 'preserve';
}

// 타입 추론을 위한 유틸리티 타입들
type RouteNames = keyof typeof SearchConfig;
type StrictQueryParamKeys<R extends RouteNames> = keyof (typeof SearchConfig)[R];
type StrictQueryParamValue<R extends RouteNames, K extends StrictQueryParamKeys<R>> = 
  (typeof SearchConfig)[R][K];

3. useQueryParam 함수 시그니처 (오버로드)

// 특정 경로와 키에 대한 정확한 타입 추론
export function useQueryParam<R extends RouteNames, K extends StrictQueryParamKeys<R>>(
  path: R,
  key: K,
  options?: QueryParamOptions,
): [
  StrictQueryParamValue<R, K>,
  (value: StrictQueryParamValue<R, K>, overrideOptions?: QueryParamOptions) => void,
  (overrideOptions?: QueryParamOptions) => void,
];

// 경로만 제공될 경우, 해당 경로의 모든 쿼리 파라미터 반환
export function useQueryParam<R extends RouteNames>(
  path: R,
  options?: QueryParamOptions,
): [
  QueryParamObject<R>,
  (
    value: QueryParamObject<R> | ((prev: QueryParamObject<R>) => QueryParamObject<R>),
    overrideOptions?: QueryParamOptions,
  ) => void,
  (overrideOptions?: QueryParamOptions) => void,
];![](https://velog.velcdn.com/images/rjw0907/post/6a48e6e4-ee7b-488d-bd8a-1404828d10e4/image.png)
profile
시행착오를 즐기는 프론트엔드 개발자입니다!

0개의 댓글