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을 사용하면 다음과 같이 간결하게 코드를 작성할 수 있습니다.
// 중앙 집중식 설정
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)})}
/>
</>
);
}
반복적인 코드가 크게 줄어듭니다. 파라미터 파싱, 타입 검증, 업데이트 로직이 모두 useQueryParam 내부로 캡슐화되어 개발자는 비즈니스 로직에만 집중할 수 있습니다.
타입스크립트의 타입 추론 기능이 완벽하게 작동하여 컴파일 타임에 오류를 잡아냅니다.
// 자동 완성 지원 및 타입 오류 검출
setCategory('IT'); // 정상
setCategory('LIFE'); // 정상
setCategory('INVALID'); // 컴파일 타임에 타입 오류 발생
hook에 넘겨 줄 매개변수들 당연히 타입 추론을 지원합니다.


URL의 문자열 값이 자동으로 적절한 타입으로 변환됩니다.
// 불리언 타입으로 자동 변환
console.log(typeof params.inStock); // 'boolean'
// 숫자 타입도 자동 변환
const [limit, setLimit] = useQueryParam('/products', 'limit');
console.log(typeof limit); // 'number'
React의 useState와 동일한 방식으로 함수형 업데이트를 지원합니다.
// 이전 값 기반으로 업데이트
setParams(prev => ({
...prev,
page: String(Number(prev.page) + 1)
}));
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 훅을 구현하기 위해 고려한 핵심 아이디어들 입니다.
모든 쿼리 파라미터 정의를 한 곳에서 관리하는 것이 핵심입니다. SearchConfig 객체를 통해 각 경로별로 사용 가능한 쿼리 파라미터와 초기값을 정의합니다. 이 접근 방식은 다음과 같은 이점을 제공합니다.
타입스크립트의 강력한 타입 시스템을 최대한 활용하여 타입 안전성을 확보합니다.
이러한 기법을 통해 자동 완성과 타입 검증이 완벽하게 작동하게 됩니다.
URL의 모든 값은 문자열이지만, 이를 자동으로 적절한 타입으로 변환해주는 메커니즘을 구현했습니다.
이러한 자동 변환 메커니즘을 통해 개발자는 매번 수동으로 타입 변환을 수행할 필요가 없어집니다.
친숙한 API를 통해 학습 곡선을 낮추고 사용성을 높였습니다.
[value, setValue, resetValue] 형태의 반환값을 제공(setValue(prev => ...))를 지원(setValue(newValue, { push: true }))를 지원이를 통해 기존 React 개발자들이 별도의 학습 없이도 쉽게 사용할 수 있습니다.
두 가지 주요 사용 패턴을 모두 지원하여 유연성을 높였습니다.
const [params, setParams] = useQueryParam('/products')
const [category, setCategory] = useQueryParam('/products', 'category')
이를 통해 개발자는 상황에 맞는 가장 적절한 방식을 선택할 수 있습니다.
URL과 컴포넌트 상태를 자동으로 동기화하는 메커니즘을 구현했습니다.
(push vs replace)을 제공하여 사용자 경험을 세밀하게 제어할 수 있음빈 값을 어떻게 처리할지에 대한 전략을 옵션으로 제공합니다.
remove: 빈 값을 URL에서 완전히 제거(기본값)preserve: 빈 값을 URL에 유지(예: ?filter=)동적 경로 파라미터를 자동으로 처리하는 메커니즘도 포함했습니다.
미래의 요구사항 변화에 대응할 수 있는 확장 가능한 설계를 고려했습니다.
이러한 아이디어들을 바탕으로 구현된 useQueryParam 훅은 React에서 URL 쿼리 파라미터를 관리하는 방식을 근본적으로 개선합니다. 코드의 양을 줄이고, 타입 안전성을 높이며, 개발자 경험을 크게 향상시킵니다.
코드 자체를 공유하고 싶지만 너무 방대하고 복잡해서 음.. 시간 날 때 라이브러리로 정리하여 npm에 배포해보겠음니다...
몇 가지 핵심 구현만 적어보겠습니다.
혹시 전체 코드를 보고싶다면!?
https://github.com/picktoss/picktoss/tree/main/src/shared/lib/router
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;
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];
// 특정 경로와 키에 대한 정확한 타입 추론
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,
];