오랜만에 기술블로그로 돌아왔는데, 오늘은 URL Search Params에 대한 글을 써보려고 한다.
최근 흥미롭게 읽은 Tanstack Blog: Search Params Are State 글을 계기로, SearchParams를 사용하며 겪었던 경험과 고민들을 정리해보려고 한다.
웹 개발에 하면서 URL은 전역에서 사용할 수 있는 아주 좋은 도구이지만 전역 변수를 사용했을 때의 단점뿐만 아니라 "제대로" 사용하지 않으면 코드를 더 파악하기 어렵게 만드는 습성도 가지고 있다.
URLSearchParams
간단하게 알아보기진짜 간단하게 URLSearchParams
가 하는 역할을 알아보자
출처: https://developer.mozilla.org/ko/docs/Learn_web_development/Howto/Web_mechanics/What_is_a_URL
url 뒤에 "key/value" 모두 보내어 이를 활용하여 여러가지에 사용한다. 이를 처리하기 위해서 URLSearchParams
를 활용한다. URL
인스턴스를 활용해서 searchParams를 꺼내오면 URLSearchParams
인스턴스가 나오게 된다.
URLSearchParams에서 지원하는 메소드들은 append
, delete
, entries
, forEach
, get
, getAll
, has
, keys
, set
, sort
등 MDN에서 확인이 가능하다. (사용법은 생략하겠다.)
여기서 URLSearchParams
를 사용할 때 버그를 일으킬 수 있는 것들이 숨겨져 있다.
const currentSearchParams = "is_show=true";
const searchParams = new URLSearchParams(currentSearchParams);
const isShow = searchParams.get("is_show");
console.log("isShow", isShow); // "true"
boolean
값을 예상하고 true를 넣었지만 내뱉는건 string 형태의 'true'를 내뱉는다. 이는 얘기치 못한 오류를 낼 수 있다.
const currentSearchParams = "is_show=false";
const isShow = searchParams.get("is_show");
if (isShow) {
// "false" 로 string이 오기 때문에 통과된다.
}
이렇게 사용하는 일은 거의 없겠지만, url에는 중복된 key를 허용하기 때문에 쓰는 쪽에서도 당연하게 중복될 수 있다.
const currentSearchParams = "same_key=value1&same_key=value2";
const searchParams = new URLSearchParams(currentSearchParams);
const getSameKey = searchParams.get("same_key");
const getAllSameKey = searchParams.getAll("same_key");
console.log("getSameKey", getSameKey); // 'value1'
console.log("getAllSameKey", getAllSameKey); // ['value1', 'value2']
이 경우에는 url에 같은 key를 넣은게 문제이지만, 중복된 키를 허용하게 됨으로써 문제를 발생시킬 수도 있다.
Next에서는 searchParams를 직접적으로 수정하는 함수를 제공하지 않는다. useSearchParams
는 client 컴포넌트에서 'read-only'로 제공한다.
https://nextjs.org/docs/app/api-reference/functions/use-search-params#returns
useSearchParams
returns a read-only version of the URLSearchParams interface, which includes utility methods for reading the URL's query string:
searchParams
를 write하여 관리하고 싶으면 router를 수정하는 방법으로 진행해야 한다. Next에서 제공하는 예시를 살펴보면 다음과 같다.
// https://nextjs.org/learn/dashboard-app/adding-search-and-pagination
'use client'
export default function Search() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
// router를 replace처리하여 searchParams를 반영
replace(`${pathname}?${params.toString()}`);
}
}
클라이언트 컴포넌트에서는 이렇게 훅을 사용하면 되지만 서버 컴포넌트에서는 훅 사용이 불가하기 때문에 Next에서 제공하는 Props를 넣어주면 된다.
Next 공식 문서: https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional
interface PageProps {
searchParams?: {
// ...
};
}
export default async function Home({ searchParams }: PageProps) {
const { tab } = await searchParams;
console.log("tab", tab);
return (
// 하위 컴포넌트로 searchParams 전달
);
}
15 버전부터는
searchParams
가 Promise 형태로 변경되었기 때문에async/await
을 사용해서 꺼내쓰는 걸 까먹지 말자.
지금까지는 그냥 간단하게 문법 및 사용 방법에 관해서 설명했다. 이제부터SearchParams
를 사용하며 겪었던 경험들에 대해서 적어보려고 한다.
전역으로 사용할 수 있고 쉽게 어디에서든 꺼내어 사용할 수 있는 것은 매우 큰 장점이다.
searchParams에 들어가는 key는 SnakeCase고, 코드 내부에서 사용하는 방식은 CamelCase라고 할 때 이를 동일하게 어떻게 처리해야 할지에 대한 고민이 한 번 있었다.
먼저 위에서 설명했듯 Next에서는 SearchParams
에 세팅해야 하는 게 매우 번거로우므로 한번 추상화해서 사용하는 게 개발자 경험을 높이는 방법이기에 이를 먼저 구현해야 한다.
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export const useCustomSearchParams = <
T extends Record<string, string | undefined>
>() => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const setNewParams = (newParams: Partial<T>) => {
const updatedParams = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(newParams)) {
if (value !== undefined && value !== null) {
updatedParams.set(key, value);
} else {
updatedParams.delete(key);
}
}
return updatedParams.toString();
};
const setSearchParams = (
newParams: T | ((prev: Partial<T>) => T),
isReplace?: boolean
) => {
const current = Object.fromEntries(searchParams.entries()) as Partial<T>;
const _newParams =
typeof newParams === "function" ? newParams(current) : newParams;
const newUrl = `${pathname}?${setNewParams(_newParams)}`;
return isReplace ? router.replace(newUrl) : router.push(newUrl);
};
const currentParams = Object.fromEntries(
searchParams.entries()
) as Partial<T>;
return [currentParams, setSearchParams] as const;
};
// 사용법
const [searchParams, setSearchParams] = useCustomSearchParams<{ tab: 'tab1' | 'tab2' }>()
다음과 같이 사용하는 곳에서는 useCustomSearchParams를 통해 타입을 지정하고 어느정도 Type이 보장되는 SearchParams를 사용할 수 있게 된다.
그러나 제목에서처럼 서로 다른 네이밍 규칙을 가져갔을 때 이를 처리하는 로직이 들어간다면 복잡도가 더 상승한다.
고려해야 할 점
예를 들어 tabType이라는 값으로 서로 다른 탭을 관리하고, 전달받은 searchParams
에 따라 활성화할 탭을 정한다고 해보자. 그럼 코드에서는 전달할 searchParams
를 바라보고 그에 맞는 탭을 활성화할 것이다.
만약 여기서 tabType이 어떤 네이밍 규칙으로 올지 모른다면?(TapType
, tab_type
, tabType
)
// options Params를 추가하여 대응해야 할 것이다.
type CustomSearchParamsOptionsType = {
// camel case로 변환을 진행
parseCamelCase: boolean;
}
export const useCustomSearchParams = <
T extends Record<string, string | undefined>
>({ parseCamelCase = true }: CustomSearchParamsOptionsType) => {
const _searchParams = useSearchParams();
// parseCamelCase 옵션 추가에 따른 추가적인 처리 필요
// ...
}
위의 상황은 camelcase-keys와 같은 라이브러리를 사용하면 어느 정도 쉽게 해소할 수 있다. (물론 string으로 변환하는 등 코드가 늘어나는 것은 변함없다.)
read는 쉽게 풀렸지만 write에서는 고민해야 할 게 더 많아진다.
그래서 내린 결론은?
이와 같은 상황으로 인해 내린 결론은 다음과 같은데, key가 다르면 다른 값으로 보기로 한 것이다. 생각보다 쉬운 결론이긴 한데 이것이 가장 깔끔한 방식이라고 생각했다. (이에 대해서도 discusson들도 보인다. https://github.com/badges/shields/issues/10804)
다른 FE 개발자들의 의견이 궁금해서 직접 Toss Discussion에 글을 올렸다 😆
Next에서 SearchParams를 다룬다면 서버에서부터 다시 렌더링된다.
이게 무엇이 문제가 될까? => 서버에서 API 호출이 있고 이를 통해 다시 화면을 그린다고 해보자. API 호출이 느리다면 유저 입장에서는 안 좋은 사용자 경험을 느끼게 되는 것이다.(API 속도가 엄청 느리지 않아도 서버에서부터 다시 그리기 때문에 좋지 않음)
그림으로 살펴보자. (cursor 짱! ⌨️)
위의 상황을 간단히 도식화한 그림인데, 서버에서 API 요청이 느린 경우에는 단순히 searchParams를 바꾸었을때 엄청난 지연을 겪게 될 것이다.
간단히 구현한 코드의 시연 영상도 가져왔는데, 탭을 클릭하고 한참이 지나서야 유저가 탭이 바뀌는 것을 경험하게 되는 것이다. 🥹
해결할 수 있는 방법은?
서버에서부터 요청을 다시 하지 않도록 shallow routing
을 사용한다면, 유저가 탭이 바뀌고 무언가 이뤄지고 있구나를 인지할 수 있게 처리할 수 있다.
문제는 page router의 경우 Shallow Routing을 지원하지만, 13 버전에서의 app router가 shallow routing을 지원하지 않는다는 것이다. (14 버전에서도 window.history.pushState
를 통해 진행해야 함 => https://github.com/vercel/next.js/pull/60557)
그래서 결국 nuqs 라이브러리 도입을 통해 해결하였다.
현재 탭을 먼저 바꾸고 컨텐츠를 로딩하기 때문에 UX 측면에서도 개선되었다.
Next 13 버전을 사용중이었어서 shallow routing을 사용할 수 없었다.. 🥹
다행히nuqs
v1로 해결이 가능했다.
nuqs
가 shallow routing을 해결한 방법
nuqs에서는 history 내장 api를 patch하고, shallow 옵션을 보고 history 함수를 실행시킨다.
//https://github.com/47ng/nuqs/blob/next/packages/nuqs/src/adapters/next/impl.app.ts#L85-L106
//...
const updateMethod =
options.history === 'push' ? history.pushState : history.replaceState
mutex = NUM_HISTORY_CALLS_PER_UPDATE
updateMethod.call(
history,
// In next@14.1.0, useSearchParams becomes reactive to shallow updates,
// but only if passing `null` as the history state.
null,
'',
url
)
if (options.scroll) {
window.scrollTo(0, 0)
}
if (!options.shallow) {
// Call the Next.js router to perform a network request
// and re-render server components.
// router.replace는 서버 컴포넌트를 리렌더링한다고 되어있다.
router.replace(url, {
scroll: false
})
}
})
처음에 소개한 블로그 글에서도 얘기하듯이 "Single Source Of Truth"라는 키워드가 등장한다.
But they don’t solve the broader issue of coordination. You still end up with duplicated schemas, disjointed expectations, and no way to enforce consistency between routes or components. Defaults can conflict. Types can drift. And when routes evolve, nothing guarantees all the callers update with them.
즉, "신뢰할 수 있는 데이터는 하나의 출처(소스)가 있어야 한다"는 뜻인데, nuqs
라이브러리만을 사용하게 되면 다양한 Parser를 통해 Type-Safe하도록 도와주지만, SSOT를 완벽하게 지원하지는 않는다.
글에서 이를 해결하기 위해서는 라우팅 계층에서부터 Schema를 구성해야한다고 소개한다.
그 이유가 무엇일까? 코드로 살펴보면 다음과 같다.
// app/products/page.tsx
import { parseAsString, withDefault } from "nuqs/server";
export default function ProductsPage({ searchParams }: { searchParams: URLSearchParams }) {
const sort = withDefault(parseAsString(), "popularity").parse(searchParams.get("sort"));
return (
<>
<h1>상품 목록</h1>
<SortDropdown />
</>
);
}
// SortDropdown.tsx
"use client";
import { useQueryState, withDefault } from "nuqs";
export default function SortDropdown() {
// 서버는 "popularity", 여기선 "latest"
const [sort] = useQueryState("sort").withDefault("latest");
return <div>정렬 기준: {sort}</div>;
}
즉, 서버에서 정한 default 값이 클라이언트 컴포넌트로 오게 되면서(나도 모르게) 바뀐 것이다. (바뀌는 것을 막을 수 없음, 심지어 type을 바꾸는 것 또한 막을 수 없음). 그러면 우리는 출처를 한 곳에서 믿는 것이 아닌 여러 컴포넌트를 오가면서 진실을 알아내야 하는 것이다.
그렇기에 블로그에서 소개했듯이 Tanstack Router
에서는 "계층(hierarchically)"을 통해 한층 더 강화된 하나의 진실만을 믿도록 강제할 수 있게 되는 것이다.
// https://tanstack.com/blog/search-params-are-state#example-safe-hierarchical-search-param-validation
// routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
validateSearch: z.object({
sort: z.enum(['asc', 'desc']).default('asc'),
}),
})
// routes/dashboard/$dashboardId.tsx
export const Route = createFileRoute('/dashboard/$dashboardId')({
validateSearch: z.object({
filter: z.string().optional(),
// ✅ \`sort\` is inherited automatically from the parent
// 부모로 부터 전달받은 기본값('asc')가 자동적으로 적용된다.
}),
})
nuqs에서도 SSOT를 지킬 수 있을까?
당연히 하나의 상수 파일로 관리하면 이를 커버할 수 있다.
// shared/productQuerySchemas.ts
import { parseAsStringEnum, withDefault } from "nuqs";
export const PAGE_QUERY_PARAMS = {
정렬: "sort",
} as const;
export const SORT_OPTIONS = ["popularity", "latest", "oldest"] as const;
// SSOT: sort param schema
export const coordinatesSearchParams = {
[PAGE_QUERY_PARAMS.정렬]: parseAsStringLiteral(SORT_OPTIONS).withDefault("popularity"),
};
// 서버에서 Load 하기 위한 것
export const loadSearchParams = createLoader(coordinatesSearchParams);
// app/products/page.tsx
import { parseAsString, withDefault } from "nuqs/server";
export default function ProductsPage({ searchParams }: { searchParams: URLSearchParams }) {
const { sort } = await loadSearchParams(searchParams);
return (
<>
<h1>상품 목록</h1>
<SortDropdown />
</>
);
}
// SortDropdown.tsx
"use client";
import { coordinatesSearchParams, PAGE_QUERY_PARAMS } from "shared/productQuerySchemas.ts"
import { useQueryState } from "nuqs";
export default function SortDropdown() {
// 정렬 기준을 한 곳에서 관리하게 되었음
const [sort] = useQueryState(PAGE_QUERY_PARAMS.정렬, coordinatesSearchParams.sort);
return <div>정렬 기준: {sort}</div>;
}
이런식으로 하나의 파일 내에서 페이지에 사용되는 SearchParams들을 묶어둔다면 하나의 진실의 원천을 가져가게 된다.
Search Params Are State
마지막으로 "Search Params Are State"라는 말에 깊게 공감한다. 특히 Next.js처럼 서버와 클라이트가 긴밀히 연결된 환경에서는 Search Params를 ‘상태’로 인식하고 일관되게 관리해야 한다.
특히, nuqs와 같은 라이브러리를 활용하여 이러한 복잡성을 완화하고, Search Params를 명확하게 다루는 방식은 추천할 만한 접근이라고 생각이 된다.
비슷한 고민을 하신 적 있다면, 여러분은 어떻게 해결했는지? 피드백이나 다른 접근법도 환영입니다!
출처