URL 기반 상태 관리의 이점을 활용하면서도 타입 안전성과 중앙화를 달성하는 방법
들어가며
현대 웹 애플리케이션에서 상태 관리는 항상 고민거리입니다. 특히 검색, 필터링, 페이지네이션과 같은 UI 상태를 어떻게 관리할지는 사용자 경험과 개발자 경험 모두에 큰 영향을 미칩니다.
이런 상황에서 URL SearchParams를 활용한 상태 관리는 매력적인 대안으로 떠오르고 있습니다. 하지만 기존 방식은 타입 안전성과 중앙화 측면에서 여러 문제점을 안고 있었죠.
이 글에서는 SearchParams 기반 상태 관리의 이점과 문제점을 살펴보고, Context 기반 중앙화 솔루션과 nuqs 라이브러리를 비교 분석하여 최적의 해결책을 제시하겠습니다.
🎯 SearchParams 기반 상태 관리의 이점
1. 공유 가능한 URL
// 사용자가 필터를 적용한 상태
https://mystore.com/products?category=electronics&minPrice=100&sort=price
// 이 URL을 공유하면 동일한 필터 상태가 그대로 재현됨
2. 브라우저 히스토리 지원
- 뒤로가기/앞으로가기로 이전 필터 상태 복원
- 북마크를 통한 특정 상태 저장
3. SEO 친화적
- 검색 엔진이 다양한 필터 조합을 크롤링 가능
- 각 필터 상태가 고유한 URL을 가짐
4. 서버사이드 렌더링 지원
// 서버에서 URL 파라미터를 읽어 초기 데이터 fetch 가능
export const getServerSideProps: GetServerSideProps = async (context) => {
const { category, minPrice } = context.query;
const products = await fetchProducts({ category, minPrice });
return { props: { products } };
};
5. 새로고침 시 상태 유지
- localStorage나 sessionStorage 없이도 상태 지속
- 네트워크 오류 후 복구 시에도 상태 보존
⚠️ 기존 방식의 문제점
1. 타입 안전성 부재
기존 Next.js의 useSearchParams나 router.query는 모든 값을 string | string[] | undefined로 반환합니다:
// ❌ 기존 방식 - 타입 안전하지 않음
const searchParams = useSearchParams();
const page = searchParams.get('page'); // string | null
const minPrice = searchParams.get('minPrice'); // string | null
// 매번 타입 변환과 검증이 필요
const pageNumber = page ? parseInt(page, 10) : 1;
const minPriceNumber = minPrice ? parseFloat(minPrice) : undefined;
// 오타나 잘못된 키 사용 시 컴파일 타임에 발견 불가
const typoValue = searchParams.get('mnPrice'); // 오타지만 에러 없음
2. 중앙화되지 않은 상태 정의
각 컴포넌트마다 searchParams 처리 로직이 분산됩니다:
// ❌ 각 컴포넌트마다 중복된 로직
function ProductFilter() {
const searchParams = useSearchParams();
const router = useRouter();
const updateCategory = (category: string) => {
const params = new URLSearchParams(searchParams);
params.set('category', category);
router.replace(`${pathname}?${params.toString()}`);
};
}
function ProductList() {
const searchParams = useSearchParams();
const category = searchParams.get('category');
// 또 다른 컴포넌트에서 동일한 파싱 로직 반복
}
3. 불일치하는 기본값 처리
컴포넌트마다 다른 기본값을 사용할 위험:
// ❌ 컴포넌트 A에서는 page 기본값이 1
const pageA = searchParams.get('page') || '1';
// ❌ 컴포넌트 B에서는 page 기본값이 0
const pageB = searchParams.get('page') || '0';
4. 복잡한 URL 동기화
URL 업데이트 로직이 복잡하고 실수하기 쉽습니다:
// ❌ 복잡하고 실수하기 쉬운 URL 업데이트
const updateMultipleParams = (newParams: Record<string, string>) => {
const params = new URLSearchParams(searchParams);
Object.entries(newParams).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
router.replace(`${pathname}?${params.toString()}`);
};
5. 성능 문제
모든 컴포넌트가 useSearchParams를 직접 사용하면 URL 변경 시 불필요한 리렌더링이 발생합니다:
// ❌ 필터 변경 시 모든 컴포넌트가 리렌더링
function FilterComponent() {
const searchParams = useSearchParams(); // URL 변경 시 리렌더링
// 실제로는 필터 액션만 필요한데...
}
function DisplayComponent() {
const searchParams = useSearchParams(); // URL 변경 시 리렌더링
// 실제로는 데이터 표시만 필요한데...
}
🛠️ Context 기반 중앙화 해결 가이드
위 문제들을 해결하기 위해 Context와 useReducer를 활용한 중앙화된 SearchParams 관리 시스템을 구축해보겠습니다.
Step 1: 타입 정의 및 기본 구조
// types/searchParams.ts
export interface BaseSearchParams {
[key: string]: string | string[] | undefined;
}
// types/searchParamsActions.ts
export type SearchParamsAction<T extends BaseSearchParams> =
| { type: 'SET_SEARCH_PARAMS'; payload: Partial<T> }
| { type: 'UPDATE_SEARCH_PARAM'; payload: { key: keyof T; value: T[keyof T] } }
| { type: 'REPLACE_SEARCH_PARAMS'; payload: T }
| { type: 'CLEAR_SEARCH_PARAMS' }
| { type: 'INITIALIZE_FROM_URL'; payload: T };
Step 2: 컨텍스트 팩토리 함수 생성
// lib/createSearchParamsContext.tsx
import React, {
createContext,
useContext,
useCallback,
useEffect,
useReducer,
useMemo,
Dispatch
} from 'react';
import { useRouter } from 'next/router';
interface SearchParamsValue<T extends BaseSearchParams> {
searchParams: T;
isInitialized: boolean;
}
interface SearchParamsActions<T extends BaseSearchParams> {
setSearchParams: (params: Partial<T>) => void;
updateSearchParam: <K extends keyof T>(key: K, value: T[K]) => void;
clearSearchParams: () => void;
replaceSearchParams: (params: T) => void;
}
function createSearchParamsReducer<T extends BaseSearchParams>(defaultParams: T) {
return function searchParamsReducer(
state: SearchParamsValue<T>,
action: SearchParamsAction<T>
): SearchParamsValue<T> {
switch (action.type) {
case 'INITIALIZE_FROM_URL':
return {
...state,
searchParams: action.payload,
isInitialized: true,
};
case 'SET_SEARCH_PARAMS':
return {
...state,
searchParams: { ...state.searchParams, ...action.payload },
};
case 'UPDATE_SEARCH_PARAM':
return {
...state,
searchParams: {
...state.searchParams,
[action.payload.key]: action.payload.value,
},
};
case 'REPLACE_SEARCH_PARAMS':
return {
...state,
searchParams: action.payload,
};
case 'CLEAR_SEARCH_PARAMS':
return {
...state,
searchParams: defaultParams,
};
default:
return state;
}
};
}
export function createSearchParamsContext<T extends BaseSearchParams>(
defaultParams: T,
contextName: string = 'SearchParams'
) {
// Value Context - 상태값만 포함
const SearchParamsValueContext = createContext<SearchParamsValue<T> | null>(null);
// Dispatch Context - 액션 함수들만 포함
const SearchParamsDispatchContext = createContext<SearchParamsActions<T> | null>(null);
function SearchParamsProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const searchParamsReducer = useMemo(
() => createSearchParamsReducer(defaultParams),
[defaultParams]
);
// useReducer로 상태 관리
const [state, dispatch] = useReducer(searchParamsReducer, {
searchParams: defaultParams,
isInitialized: false,
});
// URL 동기화 로직
const syncToUrl = useCallback((params: T) => {
const query = { ...router.query };
// 기존 searchParams 제거
Object.keys(defaultParams).forEach((key) => {
delete query[key];
});
// 새로운 searchParams 추가 (undefined가 아닌 값만)
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
query[key] = value;
}
});
router.replace(
{ pathname: router.pathname, query },
undefined,
{ shallow: true }
);
}, [router]);
// Action creators - useMemo로 메모화하여 불필요한 리렌더링 방지
const actions = useMemo<SearchParamsActions<T>>(() => ({
setSearchParams: (newParams: Partial<T>) => {
dispatch({ type: 'SET_SEARCH_PARAMS', payload: newParams });
},
updateSearchParam: <K extends keyof T>(key: K, value: T[K]) => {
dispatch({ type: 'UPDATE_SEARCH_PARAM', payload: { key, value } });
},
clearSearchParams: () => {
dispatch({ type: 'CLEAR_SEARCH_PARAMS' });
},
replaceSearchParams: (params: T) => {
dispatch({ type: 'REPLACE_SEARCH_PARAMS', payload: params });
},
}), []);
// Value context value - useMemo로 메모화
const valueContextValue = useMemo(() => ({
searchParams: state.searchParams,
isInitialized: state.isInitialized,
}), [state.searchParams, state.isInitialized]);
return (
<SearchParamsValueContext.Provider value={valueContextValue}>
<SearchParamsDispatchContext.Provider value={actions}>
{children}
</SearchParamsDispatchContext.Provider>
</SearchParamsValueContext.Provider>
);
}
// 분리된 hooks
function useSearchParamsValue(): SearchParamsValue<T> {
const context = useContext(SearchParamsValueContext);
if (!context) {
throw new Error(`useSearchParamsValue must be used within a ${contextName}Provider`);
}
return context;
}
function useSearchParamsActions(): SearchParamsActions<T> {
const context = useContext(SearchParamsDispatchContext);
if (!context) {
throw new Error(`useSearchParamsActions must be used within a ${contextName}Provider`);
}
return context;
}
function useSearchParams() {
const value = useSearchParamsValue();
const actions = useSearchParamsActions();
return useMemo(() => ({ ...value, ...actions }), [value, actions]);
}
return {
SearchParamsProvider,
useSearchParams,
useSearchParamsValue,
useSearchParamsActions,
};
}
Step 3: 페이지별 스키마 정의
// contexts/productSearchParams.ts
export interface ProductSearchParams {
category?: string;
minPrice?: string;
maxPrice?: string;
sort?: 'price' | 'name' | 'date';
page?: string;
}
export const defaultProductSearchParams: ProductSearchParams = {
category: undefined,
minPrice: undefined,
maxPrice: undefined,
sort: undefined,
page: '1',
};
// 타입이 주입된 컨텍스트 생성
export const {
SearchParamsProvider: ProductSearchParamsProvider,
useSearchParams: useProductSearchParams,
useSearchParamsValue: useProductSearchParamsValue,
useSearchParamsActions: useProductSearchParamsActions
} = createSearchParamsContext(defaultProductSearchParams, 'ProductSearchParams');
Step 4: 최적화된 컴포넌트 사용
// components/ProductFilters.tsx
import React from 'react';
import { useProductSearchParamsActions } from '../contexts/productSearchParams';
// 🎯 Actions만 사용하는 컴포넌트 - searchParams 변경시 리렌더링되지 않음!
export default function ProductFilters() {
const { updateSearchParam, setSearchParams, clearSearchParams } = useProductSearchParamsActions();
return (
<div className="filters">
<select onChange={(e) => updateSearchParam('category', e.target.value)}>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<input
type="number"
placeholder="Min Price"
onChange={(e) => updateSearchParam('minPrice', e.target.value)}
/>
<button onClick={clearSearchParams}>Clear Filters</button>
</div>
);
}
// components/ProductList.tsx
import React from 'react';
import { useProductSearchParamsValue } from '../contexts/productSearchParams';
// 🎯 Value만 사용하는 컴포넌트 - searchParams 변경시에만 리렌더링됨
export default function ProductList() {
const { searchParams, isInitialized } = useProductSearchParamsValue();
if (!isInitialized) {
return <div>Loading...</div>;
}
return (
<div className="products">
<p>Filtering by: {JSON.stringify(searchParams, null, 2)}</p>
{/* 실제 제품 목록 렌더링 */}
</div>
);
}
Step 5: 페이지에서 Provider 설정
// pages/products.tsx
import React from 'react';
import { ProductSearchParamsProvider } from '../contexts/productSearchParams';
import ProductList from '../components/ProductList';
import ProductFilters from '../components/ProductFilters';
export default function ProductsPage() {
return (
<ProductSearchParamsProvider>
<div>
<h1>Products</h1>
<ProductFilters />
<ProductList />
</div>
</ProductSearchParamsProvider>
);
}
🚀 nuqs 라이브러리 사용 방법
nuqs는 위 문제들을 해결하기 위해 만들어진 전문 라이브러리입니다.
설치 및 기본 설정
npm install nuqs
// app/layout.tsx (App Router)
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({ children }) {
return (
<html>
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
)
}
기본 사용법
// hooks/useProductFilters.ts
import { useQueryState, useQueryStates, parseAsString, parseAsInteger } from 'nuqs'
// 개별 hook 사용
export function useProductSearch() {
const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''))
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
return { search, setSearch, page, setPage }
}
// 여러 파라미터 동시 관리
export function useProductFilters() {
const [filters, setFilters] = useQueryStates({
category: parseAsString,
minPrice: parseAsString,
maxPrice: parseAsString,
sort: parseAsString.withDefault('name'),
page: parseAsInteger.withDefault(1)
})
return { filters, setFilters }
}
서버-클라이언트 타입 공유
// lib/searchParams.ts
import { parseAsString, parseAsInteger, createSearchParamsCache } from 'nuqs/server'
export const productSearchParams = {
category: parseAsString,
minPrice: parseAsString,
maxPrice: parseAsString,
sort: parseAsString.withDefault('name'),
page: parseAsInteger.withDefault(1)
}
// 서버 컴포넌트용
export const productSearchParamsCache = createSearchParamsCache(productSearchParams)
// app/products/page.tsx (서버 컴포넌트)
import { productSearchParamsCache } from '@/lib/searchParams'
export default async function ProductsPage({ searchParams }) {
const { category, sort, page } = await productSearchParamsCache.parse(searchParams)
const products = await fetchProducts({ category, sort, page })
return (
<div>
<ProductFilters />
<ProductList products={products} />
</div>
)
}
// components/ProductFilters.tsx (클라이언트 컴포넌트)
'use client'
import { useQueryStates } from 'nuqs'
import { productSearchParams } from '@/lib/searchParams'
export function ProductFilters() {
const [filters, setFilters] = useQueryStates(productSearchParams)
return (
<select
value={filters.category || ''}
onChange={(e) => setFilters({ category: e.target.value })}
>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
</select>
)
}
고급 기능
// 커스텀 파서
const sortOrderParser = parseAsStringLiteral(['asc', 'desc'] as const)
// JSON 객체 파싱 (with Zod)
import { z } from 'zod'
const coordinatesSchema = z.object({
lat: z.number(),
lng: z.number()
})
const [coordinates, setCoordinates] = useQueryState(
'coords',
parseAsJson(coordinatesSchema.parse)
)
// URL 키 매핑
const coordinatesCache = createSearchParamsCache(coordinatesParsers, {
urlKeys: {
latitude: 'lat',
longitude: 'lng'
}
})
📊 Context 기반 vs nuqs 비교 분석
| 측면 | Context 기반 구현 | nuqs 라이브러리 |
|---|---|---|
| 🎯 타입 안전성 | ✅ 완전한 타입 추론 | ✅ 완전한 타입 추론 |
| 🏗️ 아키텍처 | Context + useReducer | Hook 기반 |
| ⚡ 성능 최적화 | ✅ Value/Dispatch 분리로 세밀한 제어 | ✅ 내부 최적화 |
| 🔄 상태 관리 | ✅ 예측 가능한 Reducer 패턴 | useState 스타일 |
| 🖥️ 서버 지원 | ❌ 클라이언트만 | ✅ 서버+클라이언트 통합 |
| 📦 번들 크기 | 0 (직접 구현) | 4.35kB gzipped |
| 🔧 커스터마이징 | ✅ 완전한 제어 | ⚠️ 라이브러리 제약 |
| 📚 학습 곡선 | 중간 (Context/Reducer 이해 필요) | 낮음 (useState와 유사) |
| 🧪 테스트 | 직접 구현 필요 | ✅ 내장 테스트 어댑터 |
| 🔍 디버깅 | Redux DevTools 연동 가능 | ✅ 내장 디버그 로그 |
| 📖 문서화 | 직접 작성 | ✅ 풍부한 공식 문서 |
| 🔄 마이그레이션 | 직접 계획 필요 | ✅ 점진적 도입 가능 |
성능 비교
Context 기반의 성능 장점
// 🚀 필터 컴포넌트는 상태 변경 시 리렌더링 안됨
function ProductFilters() {
const { updateSearchParam } = useProductSearchParamsActions(); // 리렌더링 X
return (
<input onChange={(e) => updateSearchParam('search', e.target.value)} />
);
}
// 🚀 목록 컴포넌트는 필요할 때만 리렌더링
function ProductList() {
const { searchParams } = useProductSearchParamsValue(); // 필요시에만 리렌더링
return <div>{/* 제품 목록 */}</div>;
}
nuqs의 성능 특징
// nuqs도 내부적으로 최적화되어 있지만, Context만큼 세밀하지는 않음
function ProductComponent() {
const [search, setSearch] = useQueryState('search') // URL 변경 시 리렌더링
// 컴포넌트 전체가 리렌더링됨
return <input onChange={(e) => setSearch(e.target.value)} />
}
개발 경험 비교
Context 기반의 개발 경험
// ✅ 장점: 완전한 제어권
const { searchParams, updateSearchParam, clearSearchParams } = useProductSearchParams();
// ✅ 타입 안전성
updateSearchParam('category', 'electronics'); // ✅ OK
updateSearchParam('invalidKey', 'value'); // ❌ 타입 에러
// ❌ 단점: 초기 설정이 복잡
// 각 페이지마다 Context Provider 설정 필요
nuqs의 개발 경험
// ✅ 장점: 간단한 설정
const [category, setCategory] = useQueryState('category')
// ✅ 풍부한 내장 파서
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
const [tags, setTags] = useQueryState('tags', parseAsArrayOf(parseAsString))
// ✅ 서버-클라이언트 통합
const { category } = await productSearchParamsCache.parse(searchParams) // 서버
const [category] = useQueryState('category') // 클라이언트
🎯 언제 어떤 방식을 선택할까?
Context 기반 구현을 선택하는 경우
🎯 복잡한 상태 로직이 필요한 경우
// 예: 의존성이 있는 필터들
const productReducer = (state, action) => {
switch (action.type) {
case 'SET_CATEGORY':
return {
...state,
category: action.payload,
subcategory: undefined, // 카테고리 변경 시 서브카테고리 초기화
page: 1 // 페이지도 1로 리셋
};
// 복잡한 비즈니스 로직...
}
};
⚡ 극한의 성능 최적화가 필요한 경우
// 대용량 리스트에서 필터만 변경해도 전체 리렌더링을 피하고 싶을 때
function HeavyProductList() {
const { searchParams } = useProductSearchParamsValue(); // 필요시에만 리렌더링
const expensiveCalculation = useMemo(() => {
return heavyProcessing(searchParams);
}, [searchParams]);
return <VirtualizedList data={expensiveCalculation} />;
}
🔧 특별한 커스터마이징이 필요한 경우
- 특정 파라미터는 로컬 스토리지에도 저장
- 복잡한 URL 변환 로직
- 특별한 히스토리 관리
nuqs를 선택하는 경우
🚀 빠른 개발과 프로토타이핑
// 단 몇 줄로 완성되는 검색 기능
function QuickSearch() {
const [search, setSearch] = useQueryState('q')
return (
<input
value={search || ''}
onChange={(e) => setSearch(e.target.value)}
/>
)
}
🖥️ 서버-클라이언트 통합이 중요한 경우
// 서버 컴포넌트에서 필터링된 데이터 fetch
export default async function Page({ searchParams }) {
const { category, sort } = await productCache.parse(searchParams)
const products = await fetchProducts({ category, sort })
return <ProductList products={products} />
}
👥 팀 개발 시 일관성이 중요한 경우
- 검증된 라이브러리로 팀 내 혼동 방지
- 공식 문서와 커뮤니티 지원
- 표준화된 패턴
📦 작은 번들 크기가 중요한 경우
- 4.35kB gzipped는 대부분의 프로젝트에서 무시할 수 있는 크기
- 직접 구현 시 발생할 수 있는 버그 위험 제거
🔮 최종 결론 및 권장사항
두 방식 모두 기존 Next.js의 SearchParams 관리 문제를 훌륭하게 해결하지만, 프로젝트 특성에 따라 선택해야 합니다.
🏆 nuqs를 추천하는 경우 (80%의 프로젝트)
// 대부분의 일반적인 용도
const [filters, setFilters] = useQueryStates({
search: parseAsString.withDefault(''),
category: parseAsString,
page: parseAsInteger.withDefault(1)
})
선택 이유:
- ✅ 검증된 솔루션: 많은 개발자들이 사용하고 검증함
- ✅ 빠른 개발: 학습 곡선이 낮고 즉시 사용 가능
- ✅ 서버 통합: App Router와의 완벽한 호환성
- ✅ 유지보수: 라이브러리 업데이트로 새로운 기능과 버그 수정 혜택
🎯 Context 기반을 추천하는 경우 (20%의 프로젝트)
// 복잡한 비즈니스 로직이 필요한 경우
const productReducer = (state, action) => {
switch (action.type) {
case 'APPLY_SMART_FILTER':
// 복잡한 필터 조합 로직
return smartFilterLogic(state, action.payload);
case 'SYNC_WITH_USER_PREFERENCES':
// 사용자 설정과 동기화
return syncWithPreferences(state, action.payload);
}
};
선택 이유:
- 🎯 완전한 제어: 모든 로직을 원하는 대로 구현
- ⚡ 극한 최적화: Value/Dispatch 분리로 세밀한 성능 제어
- 🔧 특수 요구사항: 복잡한 비즈니스 로직 구현 가능
- 📚 학습 효과: Context와 useReducer 패턴 깊이 이해
🛣️ 단계적 접근 방법
- 시작: nuqs로 빠르게 프로토타입 개발
- 성장: 복잡해지면 Context 기반으로 마이그레이션 고려
- 성숙: 팀과 프로젝트 요구사항에 맞는 최적 솔루션 선택
💡 하이브리드 접근
실제로는 두 방식을 함께 사용할 수도 있습니다:
// 간단한 페이지는 nuqs
function SimplePage() {
const [search, setSearch] = useQueryState('search')
return <SimpleSearch search={search} onSearchChange={setSearch} />
}
// 복잡한 페이지는 Context 기반
function ComplexPage() {
return (
<ComplexSearchParamsProvider>
<ComplexFilters />
<ComplexProductList />
</ComplexSearchParamsProvider>
)
}
마무리
SearchParams 기반 상태 관리는 현대 웹 애플리케이션에서 점점 중요해지고 있습니다. 타입 안전성과 중앙화를 통해 개발자 경험을 크게 개선할 수 있으며, 사용자에게는 더 나은 UX를 제공할 수 있습니다.
nuqs는 대부분의 경우에 탁월한 선택이며, 복잡한 요구사항이 있을 때만 Context 기반 구현을 고려하는 것이 현실적인 접근법입니다.
중요한 것은 프로젝트의 특성과 팀의 상황을 고려하여 적절한 도구를 선택하고, 일관된 패턴을 유지하는 것입니다.
이 글이 Next.js에서 SearchParams 상태 관리를 고민하는 개발자들에게 도움이 되기를 바랍니다. 질문이나 피드백이 있다면 댓글로 남겨주세요! 🚀
'react' 카테고리의 다른 글
| 대규모 프로젝트에서 확장 가능한 의존성 설계 feat. Feature-Sliced Design (1) | 2025.08.24 |
|---|---|
| 리액트에서 API 통신 추상화하기 (3) | 2025.08.23 |
| 추상화와 추상화 수준: 깔끔한 코드의 핵심 (0) | 2025.08.17 |
| 컴포넌트 설계: 시나리오별 인터페이스 분리 전략 (2) | 2025.08.17 |
| React에서 선언적 표현을 위한 모듈 격리: 클로저 디펜던시 문제 해결하기 (3) | 2025.08.09 |