Query String의 트레이드오프: URL에 상태를 담을 때 치르는 성능 비용
목록 화면의 필터를 전부 URL로 옮긴 적이 있습니다. 이유는 분명했습니다. 새로고침해도 조건이 살아남고, 링크 하나로 동료에게 같은 화면을 보여줄 수 있으니까요. 실제로 그 부분은 기대한 대로 좋아졌습니다.
그런데 배포하고 며칠 지나서 이런 이야기가 들어왔습니다.
- "가격 슬라이더를 드래그하면 화면이 뚝뚝 끊겨요."
- "검색창에 열 글자 치고 뒤로가기를 누르면 열 번 눌러야 이전 페이지로 나가요."
- "같은 조건인데 어떤 사람 링크로 들어오면 목록이 늦게 떠요."
- "배포했더니 목록 페이지가 정적 페이지 목록에서 사라졌는데요?"
전부 같은 원인에서 나온 증상이었습니다. useState 하나를 바꾸는 비용과 query string 하나를 바꾸는 비용을 같다고 생각한 것입니다. 하지만 URL은 컴포넌트 안의 변수가 아닙니다. URL은 전역 상태이면서 캐시 키이고, 네트워크 트리거이자 히스토리 엔트리이며, 검색엔진에 노출되는 공개 식별자이기도 합니다. 값 하나를 바꾸면 이 다섯 곳이 동시에 움직입니다.
query string이 무엇이고 어떤 값을 URL에 올려야 하는지는 Query String이란 무엇이고 언제 어떻게 써야 할까에서 이미 정리했습니다. 이 글은 그다음 이야기입니다. URL을 상태 저장소로 선택했을 때 실제로 지불하게 되는 비용을 하나씩 뜯어보고, 각각을 어떻게 줄일지 정리해보겠습니다.
한눈에 보면
짧게 정리하면 이렇습니다.
- URL은 라우터 컨텍스트에 붙은 전역 상태라서 값이 바뀌면 구독하는 컴포넌트가 전부 리렌더됩니다.
- 타이핑이나 드래그처럼 고빈도 입력을 그대로 URL에 밀어 넣으면 히스토리 엔트리가 쌓이고 라우팅 비용도 같이 붙습니다.
push와replace의 선택은 성능 문제이기 전에 뒤로가기 UX 문제입니다.- Next.js App Router에서는 query string 하나 때문에 페이지가 정적 최적화에서 이탈할 수 있습니다.
- URL은 캐시 키라서 파라미터 순서·기본값·빈 값만 달라도 다른 캐시 항목이 됩니다.
- URL은 매 요청마다 헤더와 로그로 흘러가므로 길이와 내용 자체가 비용입니다.
- 파라미터 조합이 늘어나면 색인 대상 URL도 같이 늘어나 크롤 예산과 중복 콘텐츠 문제가 생깁니다.
즉, query string은 공짜로 얻는 공유 가능성이 아닙니다. 공유 가능성이라는 이득을 리렌더 범위·히스토리·캐시·색인이라는 네 가지 통화로 나눠서 지불하는 거래에 가깝습니다.
URL 값 하나가 바뀌면 실제로 무슨 일이 일어날까?
먼저 전체 그림을 보겠습니다. ?keyword=re에서 ?keyword=rea로 한 글자가 늘어나는 순간, 앱 안에서는 이런 일이 동시에 벌어집니다.
flowchart TD
CHANGE[query string 한 글자 변경]
CHANGE --> HISTORY[히스토리 엔트리 추가]
CHANGE --> RENDER[useSearchParams 구독 컴포넌트 전부 리렌더]
CHANGE --> CACHE[캐시 키 변경으로 캐시 미스]
CHANGE --> SERVER[App Router라면 서버 라운드트립 가능성]
CACHE --> FETCH[네트워크 요청 발생]
RENDER --> FRAME[프레임 예산 초과]
FETCH --> FRAME
SERVER --> FRAME
HISTORY --> BACK[뒤로가기 UX 저하]useState였다면 이 그림에서 두 번째 화살표 하나만 있었을 것이고, 그것도 해당 컴포넌트 서브트리로 한정됐을 겁니다. 나머지 네 개는 전부 URL을 선택했기 때문에 추가로 생긴 비용입니다.
이제 각 비용을 원인 → 증상 → 처방 순서로 하나씩 보겠습니다.
비용 1: 왜 URL을 바꾸면 리렌더 범위가 넓어질까?
원인: query string은 국소 상태가 아니다
useState는 그 값을 선언한 컴포넌트와 그 아래로만 영향을 줍니다. 반면 query string은 라우터가 들고 있는 하나의 값이고, useSearchParams()는 그 값을 구독하는 훅입니다. 값이 바뀌면 구독하는 모든 컴포넌트가 같은 이유로 함께 리렌더됩니다.
// 처음에는 이렇게 짰습니다. "필터는 URL에 있으니 필요한 곳에서 각자 읽자"
function CategoryTabs() {
const searchParams = useSearchParams(); // page가 바뀌어도 리렌더
const category = searchParams.get('category') ?? 'all';
return <Tabs value={category} />;
}
function SortSelect() {
const searchParams = useSearchParams(); // keyword가 바뀌어도 리렌더
const sort = searchParams.get('sort') ?? 'latest';
return <Select value={sort} />;
}
function ProductGrid() {
const searchParams = useSearchParams(); // 모든 파라미터 변경에 반응
return <Grid params={searchParams} />;
}읽기 좋아 보이지만 문제가 있습니다. useSearchParams()가 돌려주는 값은 파라미터 하나가 아니라 파라미터 집합 전체입니다. page만 바뀌어도 CategoryTabs는 리렌더 대상이 됩니다. 자기가 읽는 category는 그대로인데도 말입니다.
리렌더 자체가 곧 성능 문제는 아닙니다. React가 결과를 비교해서 DOM 변경이 없다고 판단하면 화면은 그대로입니다. 이 판단 과정 자체는 React의 리렌더링은 왜 일어나고 실무에서는 어떻게 줄일까에서 정리한 것과 같습니다. 문제는 리렌더 대상 안에 무거운 계산이나 무거운 자식 트리가 섞여 있을 때입니다.
증상
- 슬라이더를 드래그하면 관계없는 사이드바 필터까지 매 프레임 다시 그려집니다.
- React DevTools Profiler로 보면 하나의 파라미터 변경에 컴포넌트 수십 개가 노란색으로 물듭니다.
React.memo를 걸어도 소용이 없습니다.searchParams객체 참조가 매번 새로 오기 때문입니다.
처방: 구독을 좁히고 값 단위로 잘라서 내려보낸다
구독 지점을 위로 올리고 아래로는 원시값만 내려보내는 편이 낫습니다.
// 구독은 한 곳에서만 하고, 자식에게는 필요한 값만 원시값으로 전달
function ProductListContainer() {
const searchParams = useSearchParams();
const category = searchParams.get('category') ?? 'all';
const sort = searchParams.get('sort') ?? 'latest';
const page = Number(searchParams.get('page') ?? '1');
return (
<>
<CategoryTabs value={category} />
<SortSelect value={sort} />
<ProductGrid category={category} sort={sort} page={page} />
</>
);
}
const CategoryTabs = memo(function CategoryTabs({ value }: { value: string }) {
return <Tabs value={value} />;
});이렇게 하면 page가 바뀌었을 때 ProductListContainer는 리렌더되지만, category 문자열은 그대로이므로 memo가 걸린 CategoryTabs는 렌더를 건너뜁니다. 객체를 통째로 내려보내면 참조가 매번 바뀌어 memo가 무력해지므로, 원시값 단위로 자르는 것이 핵심입니다.
비용 2: 고빈도 입력과 URL은 왜 궁합이 나쁠까?
원인: 입력 한 번이 라우팅 한 번이 된다
가장 자주 만나는 문제입니다. 검색어 타이핑, 슬라이더 드래그, 색상 피커처럼 초당 수십 번 값이 바뀌는 입력을 URL에 직접 연결하면 이런 코드가 나옵니다.
// 위험한 패턴: 입력 이벤트마다 라우팅
function KeywordInput() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const keyword = searchParams.get('keyword') ?? '';
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const params = new URLSearchParams(searchParams.toString());
params.set('keyword', e.target.value);
router.push(`${pathname}?${params.toString()}`); // 글자마다 히스토리 1개
};
return <input value={keyword} onChange={handleChange} />;
}"프론트엔드"라고 여섯 글자를 입력하면 히스토리 엔트리가 여섯 개 쌓입니다. 게다가 이 코드는 제어 컴포넌트의 값을 URL에서 읽기 때문에 입력 → 라우팅 → 리렌더 → 화면 반영이라는 긴 경로를 거친 뒤에야 글자가 보입니다. 라우팅이 조금이라도 느려지면 그대로 입력 지연으로 느껴집니다. 제어 컴포넌트에서 값의 출처를 어디에 둘지는 제어 컴포넌트와 비제어 컴포넌트 쪽과 같은 이야기입니다.
App Router라면 여기에 하나가 더 붙습니다. 서버 컴포넌트가 searchParams를 사용하는 구조라면 URL 변경이 서버 라운드트립을 유발할 수 있습니다. 글자 하나에 네트워크 요청 하나가 붙는 셈입니다.
증상
- 타이핑할 때 글자가 한 박자 늦게 보입니다.
- 슬라이더를 놓기 전까지 화면 전체가 멈춘 것처럼 보입니다.
- 뒤로가기를 눌러도 같은 페이지에서 검색어만 한 글자씩 줄어듭니다.
처방: 로컬 state를 source of truth로 두고 URL은 나중에 맞춘다
원칙은 하나입니다. 사용자가 조작하는 값은 로컬 state로 즉시 반영하고, URL 동기화는 디바운스해서 replace로 처리합니다. 실제로 쓰는 훅으로 옮기면 이렇게 됩니다.
'use client';
import { useCallback, useEffect, useRef, useState, useTransition } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
type Options = {
key: string;
defaultValue?: string;
delay?: number;
historyMode?: 'push' | 'replace';
resetKeys?: string[];
};
export function useUrlSyncedInput({
key,
defaultValue = '',
delay = 300,
historyMode = 'replace',
resetKeys = [],
}: Options) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
// 1. 입력의 source of truth는 로컬 state다. 화면은 여기에만 의존한다.
const urlValue = searchParams.get(key) ?? defaultValue;
const [value, setValue] = useState(urlValue);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 2. 뒤로가기처럼 외부에서 URL이 바뀌면 로컬 값을 맞춰준다.
useEffect(() => {
setValue(urlValue);
}, [urlValue]);
// 3. URL 반영은 별도 함수로 분리한다.
const commit = useCallback(
(next: string) => {
const params = new URLSearchParams(searchParams.toString());
if (next) {
params.set(key, next);
} else {
params.delete(key); // 빈 값은 아예 URL에서 지운다
}
// 검색어가 바뀌면 페이지 번호처럼 무효해지는 값은 같이 정리한다
resetKeys.forEach((resetKey) => params.delete(resetKey));
const queryString = params.toString();
const nextUrl = queryString ? `${pathname}?${queryString}` : pathname;
startTransition(() => {
if (historyMode === 'push') {
router.push(nextUrl, { scroll: false });
} else {
router.replace(nextUrl, { scroll: false });
}
});
},
[historyMode, key, pathname, resetKeys, router, searchParams]
);
// 4. 입력은 즉시 반영하고, URL 반영만 지연시킨다.
const onChange = useCallback(
(next: string) => {
setValue(next);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => commit(next), delay);
},
[commit, delay]
);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return { value, onChange, commit, isPending };
}이 훅 안에서 무슨 일이 일어나는지 짚어보면 이렇습니다.
value는 로컬 state이므로 타이핑은 라우터를 거치지 않고 즉시 화면에 반영됩니다.urlValue가 바뀌는 경우는 대부분 뒤로가기나 링크 진입이므로, 그때만 로컬 값을 URL 쪽으로 맞춥니다.commit은 디바운스 뒤에 한 번만 실행되어 히스토리와 네트워크 비용을 한 번으로 압축합니다.startTransition으로 감싸서 URL 반영에 따른 리렌더를 덜 긴급한 작업으로 표시합니다. 이 우선순위 구분이 왜 필요한지는 React 18의 렌더링 방식은 무엇이 달라졌을까에서 다룬 그대로입니다.
사용하는 쪽은 이렇게 단순해집니다.
function ProductSearchBar() {
const { value, onChange, isPending } = useUrlSyncedInput({
key: 'keyword',
delay: 300,
historyMode: 'replace',
resetKeys: ['page'],
});
return (
<div>
<input value={value} onChange={(e) => onChange(e.target.value)} placeholder="상품 검색" />
{isPending && <Spinner size="sm" />}
</div>
);
}디바운스 지연값을 얼마로 둘지, 스로틀이 더 맞는 상황은 언제인지는 Throttle vs Debounce vs requestAnimationFrame 쪽 기준을 그대로 적용하면 됩니다. 타이핑은 디바운스, 드래그 중 미리보기는 rAF, URL 반영은 드래그가 끝나는 시점에 한 번이 대체로 무난합니다.
비용 3: push와 replace를 어떻게 나눠야 할까?
원인: 히스토리는 사용자의 것이다
router.push는 히스토리 스택에 항목을 하나 쌓고, router.replace는 현재 항목을 덮어씁니다. 성능 차이보다 중요한 것은 뒤로가기 버튼의 의미가 달라진다는 점입니다.
사용자는 뒤로가기를 "이전 화면으로"라는 뜻으로 씁니다. 그런데 타이핑 중간값이 전부 push되어 있으면 뒤로가기는 "글자 하나 지우기" 버튼이 됩니다. 이건 성능 이전에 신뢰가 깨지는 문제입니다. 히스토리 스택 자체의 동작은 History API란 무엇이고 언제 어떻게 써야 할까에서 다뤘습니다.
처방: "사용자가 의도적으로 확정한 상태인가"로 나눈다
판단 기준은 하나면 충분합니다. 사용자가 명시적으로 선택을 끝낸 상태는 push, 조작 중간에 지나가는 값은 replace.
| 동작 | 히스토리 | 이유 |
|---|---|---|
| 카테고리 탭 클릭 | push |
사용자가 확정한 선택이고, 뒤로가기로 되돌리고 싶어함 |
| 페이지 번호 이동 | push |
목록 탐색 흐름 자체가 뒤로가기 대상 |
| 정렬 기준 변경 | push |
클릭 한 번으로 확정되는 이산적 선택 |
| 검색어 타이핑 중간값 | replace |
확정 전 상태라 되돌릴 가치가 없음 |
| 슬라이더 드래그 중 | replace |
프레임마다 생기는 중간값 |
| 잘못된 파라미터 정규화 | replace |
사용자가 만든 상태가 아님 |
같은 훅에서 상황별로 나눠 쓰는 형태는 이렇게 됩니다.
function ProductFilters() {
const { value, onChange, commit } = useUrlSyncedInput({
key: 'keyword',
historyMode: 'replace', // 타이핑 중간값은 replace
});
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// 확정된 선택은 디바운스 없이 즉시 push
const selectCategory = (category: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('category', category);
params.delete('page');
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
return (
<>
<SearchInput value={value} onChange={onChange} onSubmit={() => commit(value)} />
<CategoryTabs onSelect={selectCategory} />
</>
);
}검색창에서 엔터를 눌렀을 때만 commit을 직접 호출해 확정 시점을 만들어주면, "타이핑은 흔적 없이, 엔터는 뒤로가기 가능하게"라는 자연스러운 동작이 나옵니다.
비용 4: Next.js App Router에서는 어떤 비용이 추가로 붙을까?
여기서부터는 프레임워크 버전에 따라 동작이 달라지는 영역입니다. 아래 내용은 App Router 기준의 일반적인 경향이며, 실제 프로젝트의 Next.js 버전에서 어떻게 동작하는지는 빌드 로그로 직접 확인하는 것을 권합니다.
원인 1: useSearchParams는 CSR bailout을 부른다
useSearchParams()가 돌려주는 값은 서버가 프리렌더 시점에 알 수 없습니다. 그래서 이 훅을 사용하는 클라이언트 컴포넌트는 프리렌더 대상에서 빠지고 클라이언트에서 채워져야 합니다. 이때 Suspense 경계가 없으면 이 이탈이 위쪽 트리 전체로 번집니다. 빌드 시점에 경고가 뜨거나, 페이지 전체가 클라이언트 렌더링으로 밀려나는 결과가 나올 수 있습니다.
// 위험: 경계가 없으면 이탈 범위가 페이지 전체로 번질 수 있다
export default function ProductsPage() {
return (
<main>
<PageHeader />
<ProductFilters /> {/* 내부에서 useSearchParams 사용 */}
<ProductList />
</main>
);
}처방 1: Suspense로 이탈 범위를 가둔다
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<main>
{/* 정적으로 남길 수 있는 영역 */}
<PageHeader />
{/* URL에 의존하는 영역만 경계 안에 가둔다 */}
<Suspense fallback={<FiltersSkeleton />}>
<ProductFilters />
</Suspense>
<Suspense fallback={<ListSkeleton />}>
<ProductList />
</Suspense>
</main>
);
}contain 속성으로 reflow 전파를 막는 것과 같은 발상입니다. 비용 자체를 없애는 게 아니라 번지는 범위를 좁히는 것입니다. 대신 fallback이 실제 콘텐츠와 크기가 비슷해야 레이아웃이 흔들리지 않습니다.
원인 2: 서버 컴포넌트의 searchParams는 정적 최적화를 제한한다
페이지 컴포넌트가 searchParams를 받는 순간, 그 페이지는 요청마다 값이 달라지는 페이지가 됩니다. 빌드 시점에 HTML을 만들어 둘 수 없으니 요청 시점 렌더링으로 내려갑니다.
// 이 페이지는 요청 시점에 렌더링됩니다
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string; page?: string }>;
}) {
const { category = 'all', page = '1' } = await searchParams;
const products = await fetchProducts({ category, page: Number(page) });
return <ProductGrid products={products} />;
}이 글의 예제는 searchParams가 Promise로 넘어오는 Next.js 15 기준으로 작성했습니다. 14 이하에서는 동기 객체로 전달되므로 await 없이 searchParams.category처럼 바로 읽습니다. 정적 최적화를 잃는다는 결론 자체는 두 버전에서 같습니다.
query string 하나를 얻기 위해 페이지 전체의 정적 생성을 포기한 셈입니다. 이게 항상 나쁜 거래는 아닙니다. 애초에 조합이 무한한 검색 페이지라면 정적 생성은 의미가 없습니다. 문제는 "필터 하나 추가했더니 원래 정적이던 페이지가 통째로 동적이 되는" 경우입니다.
처방 2: 정적인 껍데기와 동적인 알맹이를 분리한다
껍데기는 정적으로 두고 URL에 의존하는 부분만 경계 안으로 내리는 구조가 대안이 됩니다.
// 페이지 자체는 searchParams를 직접 받지 않고, 아래로 넘기기만 한다
export default function ProductsPage({ searchParams }: PageProps) {
return (
<main>
<StaticHero /> {/* 빌드 시점 결과를 그대로 재사용 */}
<Suspense fallback={<ListSkeleton />}>
<ProductListSection searchParams={searchParams} />
</Suspense>
</main>
);
}파라미터 조합이 유한하고 예측 가능하다면, 애초에 query string 대신 path segment로 올려서 정적 생성 대상으로 만드는 선택도 있습니다. /products?category=shoes보다 /products/shoes가 나은 경우가 그렇습니다. 이 경계 설계는 Server/Client Component 조합 패턴과 Next.js App Router 데이터 페칭과 스트리밍에서 다룬 원칙과 이어집니다.
비용 5: URL이 캐시 키라는 사실은 왜 중요할까?
원인: 문자열이 1비트만 달라도 다른 항목이다
URL은 여러 계층에서 캐시 키로 쓰입니다.
| 계층 | 키로 쓰는 것 | 파라미터가 달라지면 |
|---|---|---|
| 브라우저 HTTP 캐시 | 전체 URL | 별개 항목으로 저장 |
| CDN 엣지 캐시 | URL과 설정된 vary 규칙 | 엣지 캐시 미스, 오리진까지 요청 |
| Next.js 데이터 캐시 | 요청 URL과 옵션 | 별개 항목으로 저장 |
| React Query | queryKey 배열 |
별개 쿼리로 취급, 재요청 발생 |
자주 놓치는 함정이 두 가지 있습니다.
첫째, 순서만 달라도 다른 키입니다. 아래 두 URL은 사람에게는 같은 조건이지만 캐시에게는 다른 항목입니다.
/api/products?category=shoes&sort=latest&page=1
/api/products?sort=latest&page=1&category=shoes둘째, 의미 없는 파라미터가 캐시를 쪼갭니다. 마케팅 링크의 utm_source, 값이 빈 brand=, 기본값 그대로인 sort=latest가 붙어 있으면 그만큼 캐시 키가 갈라집니다. 사용자 열 명이 서로 다른 캠페인 링크로 들어오면 같은 상품 목록을 열 번 새로 만들게 됩니다.
증상
- 같은 조건인데 유입 경로에 따라 응답 속도가 다릅니다.
- CDN 캐시 히트율이 이상하게 낮습니다.
- React Query 캐시가 계속 미스가 나서 목록이 매번 깜빡입니다.
처방: 직렬화 단계에서 정규화한다
URL을 만드는 곳과 캐시 키를 만드는 곳 양쪽에서 정규화(canonicalization) 를 거치게 합니다. 규칙은 세 가지입니다. 화이트리스트에 없는 키는 버리고, 기본값과 빈 값은 지우고, 나머지는 정렬합니다.
type ParamSpec = {
/** 캐시 키에 영향을 줘야 하는 파라미터만 허용한다 */
allowed: readonly string[];
/** 기본값과 같으면 URL에서 생략한다 */
defaults: Readonly<Record<string, string>>;
};
export const PRODUCT_LIST_SPEC: ParamSpec = {
allowed: ['category', 'brand', 'sort', 'page'],
defaults: { sort: 'latest', page: '1', category: 'all' },
};
export function canonicalize(input: URLSearchParams, spec: ParamSpec = PRODUCT_LIST_SPEC): string {
const result = new URLSearchParams();
// 1. 허용 목록 기준으로만 순회한다 → utm 같은 잡음이 자동으로 제거된다
for (const key of [...spec.allowed].sort()) {
const values = input
.getAll(key)
.map((value) => value.trim())
.filter((value) => value !== ''); // 2. 빈 값 제거
if (values.length === 0) continue;
// 3. 다중 값도 정렬해서 선택 순서에 따른 차이를 없앤다
const sorted = [...new Set(values)].sort();
// 4. 단일 값이 기본값과 같으면 생략한다
if (sorted.length === 1 && spec.defaults[key] === sorted[0]) continue;
sorted.forEach((value) => result.append(key, value));
}
return result.toString();
}이제 아래 두 입력이 같은 문자열로 수렴합니다.
canonicalize(new URLSearchParams('sort=latest&category=shoes&page=1&utm_source=kakao'));
// → 'category=shoes'
canonicalize(new URLSearchParams('category=shoes&brand=&sort=latest'));
// → 'category=shoes'같은 함수를 쿼리 키에도 그대로 씁니다.
function useProductList() {
const searchParams = useSearchParams();
const canonical = canonicalize(searchParams);
return useQuery({
// 정규화된 문자열 하나만 키로 쓰면 순서·잡음에 흔들리지 않는다
queryKey: ['products', 'list', canonical],
queryFn: () => fetchProducts(canonical),
});
}쿼리 키를 어떤 단위로 쪼개고 어떤 팩토리로 관리할지는 React Query 쿼리 키 관리에 더 자세히 정리해뒀습니다. 서버 쪽 캐시와 revalidation 관점은 Next.js App Router 캐시와 revalidation과 함께 보면 그림이 맞춰집니다.
한 가지 주의할 점이 있습니다. 정규화한 URL로 replace를 걸어 주소창까지 정리하고 싶을 수 있는데, 마케팅 파라미터를 즉시 지우면 분석 도구가 유입 경로를 놓칠 수 있습니다. 주소창 정리와 캐시 키 정규화는 분리해서 판단하는 편이 안전합니다.
비용 6: URL 길이와 크기는 어디까지 감당할 수 있을까?
원인: URL은 매 요청마다 전송되고 어딘가에 기록된다
URL은 주소창에만 있는 문자열이 아닙니다. 요청 라인에 실려 나가고, Referer 헤더로 다음 요청에 따라붙고, 서버 액세스 로그·CDN 로그·분석 도구에 그대로 적재됩니다. 상태를 URL에 넣는다는 것은 그 상태를 매 요청마다 전송하고 영구 기록에 남기겠다는 선택입니다.
길이 한계는 환경마다 다릅니다. 브라우저·서버·프록시·CDN의 상한이 서로 다르고 흔히 언급되는 수치도 제각각입니다. 대략적인 감각으로는 2,000자 안쪽이면 대체로 안전하고, 8,000자 근처부터는 어딘가에서 잘릴 가능성이 커진다는 정도로 잡습니다. 다만 이 숫자는 사용하는 서버와 CDN 설정에 따라 달라지므로, 긴 URL을 쓸 계획이라면 실제 인프라에서 직접 확인하는 것이 필요합니다.
여기에 인코딩이 얹힙니다. 한글이나 JSON을 넣으면 길이가 눈에 보이는 것보다 훨씬 커집니다.
# 원래 값 (16자)
{"tags":["프론트엔드"]}
# 인코딩 후 (60자 이상)
%7B%22tags%22%3A%5B%22%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%22%5D%7D한글 한 글자는 UTF-8에서 3바이트이고, 각 바이트가 %XX 세 글자로 바뀌므로 글자당 아홉 글자가 됩니다. 한글 검색어 100자면 URL에서는 900자입니다. 여기에 JSON 구조 문자의 인코딩까지 더해지면 체감보다 훨씬 빨리 한계에 닿습니다.
처방: 상태가 커지면 참조만 남긴다
URL에는 식별자만 두고 실체는 서버나 스토리지에 두는 방식으로 전환합니다.
// URL이 감당하기 어려운 크기의 필터를 다룰 때
type SavedFilter = {
id: string;
tags: string[];
priceRange: [number, number];
excludeBrands: string[];
};
// 저장: 서버에 올리고 짧은 id만 받는다
async function shareFilter(filter: Omit<SavedFilter, 'id'>): Promise<string> {
const { id } = await api.post<{ id: string }>('/filters', filter);
return `/products?f=${id}`; // URL은 20자 안쪽으로 유지된다
}
// 복원: URL의 id로 실제 필터를 조회한다
async function restoreFilter(searchParams: URLSearchParams) {
const id = searchParams.get('f');
if (!id) return DEFAULT_FILTER;
return api.get<SavedFilter>(`/filters/${id}`);
}공유 가능성은 그대로 유지하면서 URL 길이는 상수로 묶입니다. 대신 서버 저장소와 만료 정책이 필요하고, 링크의 수명이 서버 데이터의 수명에 묶인다는 점이 새로운 트레이드오프입니다.
공유가 필요 없고 이 브라우저에서만 복원하면 되는 상태라면 sessionStorage가 더 단순한 선택입니다. 어떤 저장소가 어떤 수명·범위를 갖는지는 Web Storage API는 무엇이고 localStorage와 sessionStorage는 언제 어떻게 써야 할까에서 비교한 적이 있습니다.
비용 7: 파라미터가 늘어나면 검색엔진 쪽에서는 무슨 일이 생길까?
원인: 조합 수만큼 URL이 생긴다
카테고리 10개, 브랜드 20개, 정렬 4개, 페이지 50개라면 이론상 조합은 4만 개입니다. 검색엔진 입장에서는 거의 같은 내용의 URL이 4만 개 생긴 것입니다. 문제는 두 갈래로 나타납니다.
- 중복 콘텐츠:
?sort=latest와?sort=popular는 정렬만 다르고 내용이 거의 같습니다. 어느 쪽을 대표로 볼지 검색엔진이 판단해야 합니다. - 크롤 예산 낭비: 크롤러가 필터 조합을 헤매는 동안 정작 새로 올라온 상품 상세 페이지는 늦게 발견됩니다.
처방: 대표 URL을 명시하고 색인 대상을 좁힌다
앞서 만든 정규화 함수를 다시 씁니다. 필터가 걸린 페이지에서도 canonical은 정규화된 대표 URL을 가리키게 합니다.
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const params = new URLSearchParams(await searchParams);
// 색인 가치가 있는 파라미터만 남겨 대표 URL을 만든다
const canonical = canonicalize(params, {
allowed: ['category'], // 정렬·페이지·utm은 대표 URL에서 제외
defaults: { category: 'all' },
});
const hasNoiseParams = params.has('utm_source') || params.has('sort');
return {
alternates: {
canonical: canonical ? `/products?${canonical}` : '/products',
},
robots: hasNoiseParams ? { index: false, follow: true } : undefined,
};
}index: false, follow: true는 "이 URL 자체는 색인하지 말고 링크는 따라가라"는 뜻입니다. 필터 조합 페이지에 자주 쓰는 조합입니다.
다만 여기에도 판단이 필요합니다. 검색 유입 가치가 큰 조합(예: "나이키 러닝화")은 오히려 색인되기를 원할 수 있습니다. 그런 조합은 앞서 말한 것처럼 path segment로 승격시켜 별도 페이지로 다루는 편이 낫습니다. 모든 필터 조합을 일괄로 막는 것도, 전부 여는 것도 답이 아닙니다.
비용 8: URL에 넣으면 안 되는 값은 왜 끝까지 안 될까?
이건 성능 비용이 아니라 회복 불가능한 비용입니다. URL은 다음 경로로 새어 나갑니다.
- 브라우저 히스토리와 자동완성에 평문으로 남습니다.
Referer헤더에 실려 외부 도메인으로 전달될 수 있습니다.- 서버·CDN·프록시의 액세스 로그에 그대로 기록됩니다.
- 사용자가 링크를 복사해 메신저에 붙여넣는 순간 제3자에게 넘어갑니다.
- 브라우저 확장이나 분석 스크립트가 읽을 수 있습니다.
// 절대 하면 안 되는 형태
`/callback?access_token=eyJhbGciOi...`;
`/reset?email=user@example.com&code=482913`;
`/admin?impersonate_user_id=1042`;토큰이 URL에 한 번 실리면 로그에서 지우기가 사실상 불가능합니다. 로그는 여러 시스템으로 복제되고 보존 기간도 제각각이기 때문입니다. "실수로 노출됐으니 지우면 된다"가 성립하지 않는 영역입니다.
인증 정보를 어디에 두어야 하는지, HttpOnly 쿠키와 메모리 저장이 각각 어떤 공격 표면을 갖는지는 토큰 저장 전략과 XSS/CSRF와 쿠키는 무엇이고 언제 어떻게 써야 할까에 정리해뒀습니다.
useState, query string, 서버 상태를 어떻게 나눌까?
여기까지의 비용을 한 표로 모으면 선택 기준이 선명해집니다.
| 관점 | useState |
query string | 서버 상태 |
|---|---|---|---|
| 링크 공유 | 불가 | 가능 | 식별자 공유로 가능 |
| 새로고침 생존 | 사라짐 | 유지 | 유지 |
| 값 변경 비용 | 해당 서브트리 리렌더 | 라우팅 + 전역 리렌더 + 히스토리 | 네트워크 왕복 |
| 고빈도 변경 | 적합 | 부적합, 디바운스 필수 | 부적합 |
| 크기 한계 | 거의 없음 | 대략 2,000자 안쪽 권장 | 거의 없음 |
| 민감 정보 | 메모리에만 있어 비교적 안전 | 부적합 | 접근 제어 가능 |
| 캐시 영향 | 없음 | 캐시 키를 직접 바꿈 | 서버 캐시 정책에 따름 |
| 디버깅 난이도 | 도구 필요 | 주소창으로 즉시 확인 | 서버 로그 필요 |
이 표에서 읽어야 할 것은 "무엇이 더 좋은가"가 아니라 각각이 무엇을 대가로 무엇을 주는가입니다. query string은 공유와 복원을 주고 변경 비용과 캐시 파편화를 가져갑니다.
이 상태는 URL에 둘까? 결정 흐름
실제로 판단할 때는 이 순서로 내려가면 대부분 정리됩니다.
flowchart TD
START[이 상태를 어디에 둘까]
START --> Q1{링크 공유나 새로고침 복원 가치가 있나}
Q1 -- 아니오 --> LOCAL[useState 로컬 상태로 충분]
Q1 -- 예 --> Q2{공개되어도 괜찮은 값인가}
Q2 -- 아니오 --> SECURE[HttpOnly 쿠키 또는 서버 세션]
Q2 -- 예 --> Q3{짧은 문자열로 직렬화되나}
Q3 -- 아니오 --> REF[서버에 저장하고 URL에는 식별자만]
Q3 -- 예 --> Q4{값이 초당 여러 번 바뀌나}
Q4 -- 예 --> HYBRID[로컬 state를 기준으로 두고 디바운스 후 replace 동기화]
Q4 -- 아니오 --> Q5{검색 유입 가치가 큰 조합인가}
Q5 -- 예 --> PATH[path segment로 승격 고려]
Q5 -- 아니오 --> QS[query string에 push로 반영]실무 체크리스트
배포 전에 이 정도만 확인해도 앞서 나온 증상 대부분은 걸러집니다.
- 고빈도 입력이
router.push에 직접 연결되어 있지 않은가? - 타이핑 중간값이
replace로 처리되어 히스토리를 오염시키지 않는가? useSearchParams를 쓰는 컴포넌트가Suspense경계 안에 있는가?- 필터를 바꿀 때
page같은 무효 파라미터를 함께 정리하는가? - 빈 값과 기본값이 URL에 남지 않는가?
- 캐시 키를 만들 때 파라미터를 정렬하고 잡음을 제거하는가?
- 한글 검색어가 들어갔을 때 URL 길이가 감당 가능한 범위인가?
- canonical과 robots 설정으로 색인 대상을 좁혔는가?
- 토큰·개인 식별 정보가 URL에 실릴 경로가 없는가?
- 빌드 로그에서 원래 정적이던 페이지가 동적으로 바뀌지 않았는가?
자주 하는 오해
1. URL에 넣으면 상태 관리가 필요 없어진다
오히려 늘어납니다. URL은 문자열이라 타입이 없고, 사용자가 직접 편집할 수 있어 항상 신뢰할 수 없는 입력입니다. 파싱·기본값·유효성 보정·정규화 로직이 새로 필요해집니다. useState 하나가 사라지는 대신 직렬화 계층이 하나 생기는 거래에 가깝습니다.
2. replace를 쓰면 성능이 좋아진다
replace가 줄이는 것은 히스토리 엔트리이지 라우팅 비용이 아닙니다. 리렌더도, 데이터 재요청도, 서버 라운드트립도 push와 동일하게 일어납니다. 드래그할 때 화면이 끊기는 문제를 replace로 바꿔서 해결하려 했다가 그대로인 것을 보고 당황하는 경우가 많습니다. 진짜 해법은 호출 횟수를 줄이는 디바운스입니다.
3. query string은 그냥 문자열이라 공짜다
문자열 조작은 공짜에 가깝습니다. 하지만 그 문자열이 캐시 키이고 히스토리 엔트리이고 네트워크 요청 트리거라는 점이 비용입니다. 값 자체가 아니라 값이 붙어 있는 위치가 비싼 것입니다.
4. 파라미터 순서는 아무 의미가 없다
사람에게는 그렇지만 캐시에게는 아닙니다. ?a=1&b=2와 ?b=2&a=1은 CDN·브라우저 캐시·queryKey 모두에서 별개 항목입니다. 정규화 없이는 캐시 히트율이 조용히 떨어집니다.
5. 필터를 URL에 올리면 SEO에 무조건 유리하다
색인 가능한 URL이 늘어나는 것과 검색 성과가 좋아지는 것은 다른 이야기입니다. 내용이 거의 같은 URL이 수만 개 생기면 중복 콘텐츠 판정과 크롤 예산 낭비로 오히려 손해일 수 있습니다. 검색 유입 가치가 있는 소수 조합만 골라 여는 편이 낫습니다.
6. 로컬 state와 URL을 함께 쓰면 상태가 두 개라 위험하다
두 개인 것은 맞지만, 역할이 다르면 문제가 되지 않습니다. 로컬 state는 "지금 사용자가 조작 중인 값", URL은 "확정되어 공유 가능한 값"입니다. 앞의 훅처럼 URL이 외부에서 바뀔 때만 로컬로 동기화하는 단방향 규칙을 지키면 충돌은 잘 생기지 않습니다.
정리하면
query string으로 상태를 관리한다는 것은 공유 가능성을 사는 대신 네 가지를 지불하는 거래입니다.
- 리렌더 범위: URL은 전역 상태라 구독하는 컴포넌트가 함께 리렌더됩니다. 구독을 한 곳으로 모으고 원시값 단위로 내려보내 범위를 좁힙니다.
- 변경 비용: 값 변경 한 번이 라우팅·히스토리·네트워크로 번집니다. 고빈도 입력은 로컬 state를 source of truth로 두고 디바운스 후
replace로 동기화합니다. - 캐시와 색인: URL은 캐시 키이자 색인 단위입니다. 키 정렬, 기본값·빈 값 제거로 정규화하고 canonical로 대표 URL을 명시합니다.
- 노출과 크기: URL은 로그와 헤더로 흘러가고 길이 상한도 있습니다. 커지면 서버에 저장하고 식별자만 남기고, 민감 정보는 애초에 올리지 않습니다.
판단 기준 하나만 남긴다면 이것입니다. "이 값이 초당 여러 번 바뀔 수 있는가"를 먼저 묻고, 그렇다면 URL은 확정 시점에만 건드립니다. 이 질문 하나가 앞에서 본 증상의 절반 이상을 미리 막아줍니다.
