URL SearchParams 상태 관리
리스트 페이지에서 필터, 정렬, 페이지네이션 같은 UI 상태를 관리해야 할 때, 보통 useState로 처리한다.
const [page, setPage] = useState(1);
const [sort, setSort] = useState("latest");
const [category, setCategory] = useState("all");
동작은 하지만 문제가 있다. 사용자가 필터를 설정하고 페이지를 새로고침하면 모든 설정이 초기값으로 돌아간다. 링크를 공유해도 받는 사람은 기본 상태의 페이지를 보게 된다. 브라우저 뒤로가기를 눌러도 이전 필터 상태로 돌아가지 않는다.
이 문제의 근본 원인은 UI 상태가 메모리에만 존재하기 때문이다. URL에 상태를 저장하면 이 모든 문제가 해결된다.
/products?page=3&sort=price&category=shoes
URL 자체가 애플리케이션의 상태를 담고 있으므로, 새로고침해도 상태가 유지되고, 링크를 공유하면 동일한 상태를 재현할 수 있고, 브라우저 히스토리와 자연스럽게 연동된다.
URLSearchParams Web API
React의 useSearchParams를 이해하려면 먼저 브라우저 내장 API인 URLSearchParams를 알아야 한다. URL의 쿼리 문자열(? 뒤의 부분)을 파싱하고 조작하는 인터페이스다.
const params = new URLSearchParams("?page=3&sort=price&category=shoes");
params.get("page"); // "3" (항상 문자열)
params.get("missing"); // null
params.has("sort"); // true
params.toString(); // "page=3&sort=price&category=shoes"
주요 메서드를 정리하면:
| 메서드 | 설명 | 예시 |
|---|---|---|
get(key) | 값 읽기 (없으면 null) | params.get("page") → "3" |
getAll(key) | 같은 키의 모든 값 배열 | params.getAll("tag") → ["a", "b"] |
has(key) | 키 존재 여부 | params.has("sort") → true |
set(key, value) | 값 설정 (기존 덮어쓰기) | params.set("page", "5") |
append(key, value) | 값 추가 (중복 허용) | params.append("tag", "c") |
delete(key) | 키 삭제 | params.delete("sort") |
entries() | 이터레이터 반환 | [...params.entries()] |
주의할 점은 모든 값이 문자열이라는 것이다. get("page")의 결과는 숫자 3이 아니라 문자열 "3"이다. 숫자나 불리언으로 사용하려면 직접 변환해야 한다.
const page = Number(params.get("page")) || 1;
const isActive = params.get("active") === "true";
같은 키로 여러 값을 저장할 수도 있다. 태그 필터처럼 다중 선택이 필요한 경우에 유용하다.
const params = new URLSearchParams("?tag=react&tag=typescript");
params.get("tag"); // "react" (첫 번째 값만)
params.getAll("tag"); // ["react", "typescript"] (모든 값)
React Router의 useSearchParams
React Router v6+에서 제공하는 useSearchParams 훅은 URLSearchParams 객체와 그것을 업데이트하는 setter 함수를 반환한다. useState와 비슷한 인터페이스지만, 상태가 메모리 대신 URL에 저장된다.
import { useSearchParams } from "react-router-dom";
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get("page")) || 1;
const sort = searchParams.get("sort") || "latest";
return (
<div>
<p>현재 페이지: {page}, 정렬: {sort}</p>
<button onClick={() => {
setSearchParams({ page: "2", sort: "price" });
}}>
2페이지 + 가격순
</button>
</div>
);
}
setter의 두 가지 호출 방식
setSearchParams에 객체를 직접 전달하면 기존 파라미터를 모두 교체한다.
// 기존: ?page=3&sort=price&category=shoes
setSearchParams({ page: "4" });
// 결과: ?page=4 ← sort, category가 사라짐!
기존 파라미터를 유지하면서 특정 값만 변경하려면 함수형 업데이트를 사용해야 한다.
setSearchParams(prev => {
const params = new URLSearchParams(prev);
params.set("page", "4");
return params;
});
// 결과: ?page=4&sort=price&category=shoes ← 나머지 유지
이건 useState의 함수형 업데이트(setState(prev => ...))와 같은 패턴이다. 기존 상태를 기반으로 새 상태를 만들기 때문에 다른 파라미터를 실수로 날리지 않는다.
replace 옵션
기본적으로 setSearchParams는 브라우저 히스토리에 새 항목을 추가한다(push). 페이지네이션처럼 매 클릭마다 히스토리가 쌓이면 뒤로가기가 불편해질 수 있다. replace: true를 전달하면 현재 히스토리 항목을 교체한다.
setSearchParams(prev => {
const params = new URLSearchParams(prev);
params.set("page", "4");
return params;
}, { replace: true });
검색어 입력처럼 빠르게 반복되는 업데이트에서는 replace를 사용하는 게 좋다. 사용자가 "r", "re", "rea", "reac", "react"를 입력할 때마다 히스토리에 5개의 항목이 쌓이는 건 의미가 없다.
실전 패턴: 필터/정렬/페이지네이션
상품 목록 페이지에서 필터, 정렬, 페이지네이션을 모두 URL로 관리하는 전체적인 패턴을 보자.
파라미터 읽기 헬퍼
먼저 URL 파라미터를 읽을 때마다 타입 변환과 기본값 처리를 반복하게 되므로, 이를 추상화하는 것이 좋다.
function useTypedSearchParams<T extends Record<string, string>>(
defaults: T
): [T, (updates: Partial<T>, options?: { replace?: boolean }) => void] {
const [searchParams, setSearchParams] = useSearchParams();
const values = useMemo(() => {
const result = { ...defaults };
for (const key in defaults) {
const value = searchParams.get(key);
if (value !== null) {
result[key] = value as T[typeof key];
}
}
return result;
}, [searchParams, defaults]);
const update = useCallback(
(updates: Partial<T>, options?: { replace?: boolean }) => {
setSearchParams(prev => {
const params = new URLSearchParams(prev);
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === undefined || value === defaults[key]) {
params.delete(key); // 기본값이면 URL에서 제거
} else {
params.set(key, value);
}
}
return params;
}, options);
},
[setSearchParams, defaults]
);
return [values, update];
}
기본값과 동일한 값은 URL에서 제거하는 것이 포인트다. /products와 /products?page=1&sort=latest는 결과가 같은데, 전자가 훨씬 깔끔하다. URL에는 기본값에서 벗어난 것만 표시하는 게 좋다.
사용 예시
function ProductList() {
const [params, setParams] = useTypedSearchParams({
page: "1",
sort: "latest",
category: "all",
q: "",
});
const page = Number(params.page);
const { data } = useQuery({
queryKey: ["products", params],
queryFn: () => fetchProducts(params),
});
return (
<div>
<SearchInput
value={params.q}
onChange={q => setParams({ q, page: "1" }, { replace: true })}
/>
<SortSelect
value={params.sort}
onChange={sort => setParams({ sort, page: "1" })}
/>
<CategoryFilter
value={params.category}
onChange={category => setParams({ category, page: "1" })}
/>
<ProductGrid products={data?.items ?? []} />
<Pagination
current={page}
total={data?.totalPages ?? 1}
onChange={p => setParams({ page: String(p) })}
/>
</div>
);
}
필터나 정렬을 변경하면 page를 "1"로 리셋하는 것에 주목하자. 3페이지에서 카테고리를 바꿨는데 새 카테고리에 3페이지가 없으면 빈 결과가 나온다. 필터 변경 시 페이지를 초기화하는 건 거의 모든 리스트 UI에서 필요한 패턴이다.
React Query와의 연동
위 코드에서 useQuery의 queryKey에 params를 포함시켰다. URL 파라미터가 바뀌면 queryKey가 바뀌고, React Query가 자동으로 새 데이터를 fetch한다. URL이 상태이고, 그 상태가 곧 캐시 키가 되는 구조다.
queryKey: ["products", params]
// params가 { page: "2", sort: "price", category: "shoes", q: "" }이면
// queryKey는 ["products", { page: "2", sort: "price", category: "shoes", q: "" }]
이 방식의 장점은 같은 파라미터 조합으로 다시 돌아왔을 때 캐시된 데이터를 즉시 보여줄 수 있다는 것이다. 사용자가 2페이지에서 3페이지로 갔다가 뒤로가기를 누르면 2페이지 데이터가 캐시에서 바로 나온다.
검색어 디바운싱
검색 입력은 URL 상태 관리에서 특별한 케이스다. 키보드를 누를 때마다 URL을 업데이트하면 두 가지 문제가 생긴다.
- 불필요한 API 호출: "react"를 치면 "r", "re", "rea", "reac", "react" 총 5번 요청
- 히스토리 오염: 뒤로가기를 5번 눌러야 이전 페이지로 돌아감
디바운싱으로 해결한다.
import { useDeferredValue } from "react";
function SearchInput({ value, onChange }: {
value: string;
onChange: (value: string) => void;
}) {
const [localValue, setLocalValue] = useState(value);
// URL 업데이트를 300ms 디바운스
useEffect(() => {
const timer = setTimeout(() => {
if (localValue !== value) {
onChange(localValue);
}
}, 300);
return () => clearTimeout(timer);
}, [localValue, value, onChange]);
// URL이 외부에서 변경되면 로컬 상태 동기화
useEffect(() => {
setLocalValue(value);
}, [value]);
return (
<input
value={localValue}
onChange={e => setLocalValue(e.target.value)}
placeholder="검색..."
/>
);
}
이 패턴의 핵심은 이중 상태다. 입력 필드는 localValue로 즉각 반응하고, URL은 300ms 후에 업데이트된다. 사용자는 타이핑할 때 지연을 느끼지 않으면서도, URL과 API 호출은 디바운스된다.
그리고 onChange를 호출할 때 replace: true를 사용해서 히스토리 오염도 방지한다(위 ProductList 코드 참고).
useState와 useSearchParams의 선택 기준
모든 상태를 URL에 넣을 필요는 없다. 상태의 성격에 따라 적절한 저장소를 선택해야 한다.
URL에 넣어야 하는 상태
- 공유 가능해야 하는 상태: 필터, 정렬, 페이지, 검색어, 탭 선택
- 새로고침 후에도 유지되어야 하는 상태: 현재 보고 있는 뷰의 설정
- 뒤로가기로 복원해야 하는 상태: 이전 검색 결과로 돌아가기
URL에 넣지 말아야 하는 상태
- 일시적인 UI 상태: 모달 열림/닫힘, 드롭다운 토글, 툴팁 표시
- 민감한 데이터: 토큰, 비밀번호 (URL은 브라우저 히스토리에 남는다)
- 대용량 데이터: 폼 입력 중간값, 에디터 내용 (URL 길이 제한)
- 서버 상태: React Query가 관리하는 캐시 데이터
간단한 판단 기준: "이 상태가 포함된 URL을 다른 사람에게 보냈을 때, 같은 화면이 나와야 의미가 있는가?" 그렇다면 URL 상태, 아니라면 로컬 상태다.
다중 값 처리
태그 필터처럼 하나의 키에 여러 값을 저장해야 하는 경우가 있다. 두 가지 방식이 있다.
방식 1: 같은 키 반복
?tag=react&tag=typescript&tag=nextjs
const tags = searchParams.getAll("tag"); // ["react", "typescript", "nextjs"]
// 태그 추가
setSearchParams(prev => {
const params = new URLSearchParams(prev);
params.append("tag", "zustand");
return params;
});
// 특정 태그 제거
setSearchParams(prev => {
const params = new URLSearchParams(prev);
const tags = params.getAll("tag").filter(t => t !== "typescript");
params.delete("tag");
tags.forEach(t => params.append("tag", t));
return params;
});
방식 2: 구분자로 연결
?tags=react,typescript,nextjs
const tags = (searchParams.get("tags") || "").split(",").filter(Boolean);
// 태그 추가/제거
const toggleTag = (tag: string) => {
setSearchParams(prev => {
const params = new URLSearchParams(prev);
const current = (params.get("tags") || "").split(",").filter(Boolean);
const next = current.includes(tag)
? current.filter(t => t !== tag)
: [...current, tag];
if (next.length === 0) {
params.delete("tags");
} else {
params.set("tags", next.join(","));
}
return params;
});
};
방식 1은 URLSearchParams API와 자연스럽게 맞고, 방식 2는 URL이 더 짧고 읽기 쉽다. 값에 쉼표가 포함될 가능성이 있으면 방식 1이 안전하다. 일반적인 태그나 ID 목록이면 방식 2가 실용적이다.
초기 상태와 URL 동기화
페이지를 처음 로드할 때 URL에 파라미터가 없으면 기본값을 사용한다. 이때 기본값을 URL에 즉시 쓸 것인지, 아니면 URL 없이 코드에서만 기본값을 적용할 것인지 선택해야 한다.
// ❌ 좋지 않음: 페이지 로드 시 URL에 기본값 강제 주입
useEffect(() => {
if (!searchParams.has("page")) {
setSearchParams(prev => {
const params = new URLSearchParams(prev);
params.set("page", "1");
params.set("sort", "latest");
return params;
}, { replace: true });
}
}, []);
// ✅ 좋음: URL에 없으면 코드에서 기본값 사용
const page = Number(searchParams.get("page")) || 1;
const sort = searchParams.get("sort") || "latest";
URL에 기본값을 강제로 넣으면 /products 대신 /products?page=1&sort=latest가 되어 URL이 불필요하게 길어진다. 기본값은 코드에서 처리하고, URL에는 사용자가 명시적으로 변경한 값만 반영하는 것이 깔끔하다.
Next.js에서의 URL 상태 관리
Next.js App Router에서는 useSearchParams와 useRouter를 함께 사용한다. React Router와 API가 약간 다르다.
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
function ProductList() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const page = Number(searchParams.get("page")) || 1;
const updateParams = (updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
}
router.push(`${pathname}?${params.toString()}`);
};
return (
<button onClick={() => updateParams({ page: String(page + 1) })}>
다음 페이지
</button>
);
}
React Router의 setSearchParams와 달리, Next.js에서는 router.push나 router.replace로 전체 URL을 직접 조합해서 이동해야 한다. 함수형 업데이트가 없으므로 기존 파라미터를 직접 복사해서 수정하는 패턴이 필요하다.
router.push와 router.replace의 차이는 React Router의 히스토리 push/replace와 동일하다. 검색어 업데이트에는 router.replace를, 페이지네이션에는 router.push를 사용하면 된다.
주의사항
URL 길이 제한
브라우저와 서버마다 URL 최대 길이가 다르지만, 일반적으로 2,048자를 넘지 않는 것이 안전하다. 복잡한 필터 조합이라도 보통 이 한도를 넘기지 않지만, 자유 텍스트 입력(검색어 등)이 길어지면 주의해야 한다.
인코딩
URLSearchParams는 자동으로 URL 인코딩/디코딩을 처리한다. 한글이나 특수문자도 별도 처리 없이 사용할 수 있다.
const params = new URLSearchParams();
params.set("q", "리액트 상태 관리");
params.toString(); // "q=%EB%A6%AC%EC%95%A1%ED%8A%B8+%EC%83%81%ED%83%9C+%EA%B4%80%EB%A6%AC"
params.get("q"); // "리액트 상태 관리"
SSR 호환성
서버 사이드 렌더링 환경에서는 초기 렌더링 시 URL 파라미터에 접근할 수 있어야 한다. Next.js의 useSearchParams는 클라이언트 컴포넌트에서만 사용 가능하므로, 서버 컴포넌트에서는 searchParams prop을 통해 접근한다.
// 서버 컴포넌트
export default function Page({
searchParams,
}: {
searchParams: { page?: string; sort?: string };
}) {
const page = Number(searchParams.page) || 1;
// 서버에서 바로 데이터 fetch 가능
}
관련 문서
정리
- URL에 상태를 저장하면 새로고침, 링크 공유, 뒤로가기가 자연스럽게 동작하고 React Query의 queryKey와도 바로 연결된다
- 함수형 업데이트(
setSearchParams(prev => ...))로 기존 파라미터를 보존하고, 기본값은 URL에서 제거해 깔끔하게 유지한다 - 검색어는 이중 상태(로컬 + URL)와 디바운싱으로 처리하고, URL에 넣을 상태와 로컬 상태의 기준은 "공유했을 때 의미가 있는가"다
관련 문서
- React Query - 서버 상태 관리와 URL 파라미터 연동
- Zustand - 클라이언트 전역 상태 관리
- 커서 기반 페이지네이션 - 페이지네이션 구현 패턴