Frontend
Next.js App Router에서 URL 쿼리로 필터 상태 다루기
신*진··수정됨 2026.10.06
시작하며
목록 화면에 필터를 붙일 때 그 상태를 어디에 둘지부터 정해야 합니다. 컴포넌트 state에 담으면 구현은 간단하지만 새로고침하면 사라지고, 동료에게 링크로 넘길 수도 없습니다. 저희 팀은 사내 업무 시스템의 목록 화면을 만들면서 필터 상태를 URL 쿼리에 두기로 했습니다. "기한초과인 항목만 모아 둔 화면"을 그대로 공유할 수 있어야 했기 때문입니다.
그렇게 만든 필터가 한동안 잘 동작하다가, 어느 날 "전체" 버튼만 눌러도 아무 일이 일어나지 않는다는 것을 발견했습니다. 주소도 그대로, 칩의 선택 표시도 그대로, 목록도 그대로였습니다. 에러도 없고 네트워크 요청도 없고 콘솔도 조용했습니다.
이 글은 그 원인을 찾아간 과정과, 결국 App Router의 어떤 동작 때문이었는지를 정리한 기록입니다.
필터 상태를 URL에 두기
구조는 단순했습니다. 칩을 누르면 URL 쿼리를 고치고, 화면은 그 쿼리를 읽어 목록을 거릅니다. 쿼리를 고치는 일은 한 곳에 모아 두었습니다.
const writeParams = useCallback(
(mutate: (params: URLSearchParams) => void) => {
const params = new URLSearchParams(searchParams.toString())
mutate(params)const query = params.toString()
// 쿼리가 있으면 붙이고, 없으면 경로만
router.replace(query ? /items?${query} : "/items", { scroll: false })
},
[router, searchParams],
)
각 칩은 "무엇을 바꿀지"만 이 헬퍼에 넘깁니다. 개별 칩은 값을 켜고 끄고, "전체" 칩은 파라미터를 지웁니다.
const toggleStatus = (value: Status | "ALL") => {
writeParams((params) => {
if (value === "ALL") {
params.delete("status")
} else {
const next = statuses.includes(value)
? statuses.filter((item) => item !== value)
: [...statuses, value]
if (next.length === 0) params.delete("status")
else params.set("status", next.join(","))
}
params.delete("page")
})
}읽어 보면 이상한 곳이 없습니다. 저희도 그렇게 생각했습니다.
아무 일도 일어나지 않았습니다
증상은 이랬습니다. 주소가 /items?status=overdue 인 상태에서 "전체" 칩을 눌러도 주소가 바뀌지 않습니다. 마지막으로 남은 칩 하나를 꺼서 필터를 비우는 것도 똑같이 안 됩니다.
문제는 아무 신호가 없다는 점이었습니다. 예외가 던져졌다면 스택 트레이스를 따라가면 됩니다. 요청이 실패했다면 네트워크 탭에 남습니다. 그런데 클릭이 그냥 허공으로 사라졌습니다. 이런 종류의 실패가 가장 오래 걸립니다. 어디부터 의심해야 할지 알려 주는 단서가 없기 때문입니다.

의심한 것들을 하나씩 지웠습니다
먼저 클릭이 실제로 버튼까지 가는지 확인했습니다. 투명한 요소가 위를 덮고 있는 경우가 종종 있어서, 그 좌표에 무엇이 있는지 직접 물어봤습니다.
document.elementFromPoint(x, y)
// → <button class="chip ...">전체</button>정확히 그 버튼이었습니다. 다음은 React 이벤트 핸들러가 붙었는지 봤습니다. DOM 요소에 __reactProps$ 로 시작하는 키가 있었고 disabled 도 false 였습니다. 버튼은 멀쩡했습니다.
그러다 결정적인 단서를 만났습니다. 칩을 추가하는 동작은 잘 됐습니다. "기한초과"가 켜진 상태에서 "오늘 마감"을 누르면 주소가 제대로 바뀌었습니다.
같은 핸들러, 같은 종류의 버튼인데 어떤 조작은 되고 어떤 조작은 안 됩니다. 그렇다면 버튼이 문제가 아니라 그 다음 단계가 문제입니다.
계측: 핸들러는 끝까지 정상이었습니다
여기서 추측을 멈추고 한 줄 심기로 했습니다. 프로덕션 빌드라 브레이크포인트를 걸기가 번거로워서, 계산 결과를 전역 배열에 쌓아 두고 나중에 꺼내 보는 방식을 썼습니다.
const query = params.toString()// 임시: 무엇을 계산했는지 눈으로 본다
window.__trace = [...(window.__trace ?? []),from=${searchParams.toString()} to=${query}]
router.replace(...)
"전체" 칩을 누르고 확인해 보니 이렇게 찍혔습니다.
> window.__trace
["from=status=overdue to="]핸들러는 완벽하게 동작하고 있었습니다. 기존 쿼리를 읽었고, status 를 지웠고, 빈 문자열까지 정확히 계산했고, 라우터를 호출했습니다. 그런데 주소는 바뀌지 않았습니다.
즉 저희 코드가 끝난 자리부터가 문제였습니다.
되는 경우와 안 되는 경우를 갈라 적었습니다
원인을 좁히려고 경우를 나눠 하나씩 눌러 봤습니다. 표로 적어 보니 규칙이 선명하게 드러났습니다.
시작 주소가 ?status=overdue 일 때 칩을 추가하면 ?status=overdue,today 로 바뀝니다. 됩니다.
시작 주소가 ?status=overdue&q=abc 일 때 status 만 지우면 ?q=abc 로 바뀝니다. 됩니다.
쿼리가 없는 상태에서 정렬을 바꾸면 ?sort=name 으로 바뀝니다. 됩니다.
시작 주소가 ?status=overdue 일 때 전부 지우면, 주소가 그대로 남습니다. 안 됩니다.

파라미터가 하나라도 남으면 이동합니다. 전부 비우면 이동하지 않습니다. 같은 경로로 가면서 쿼리만 사라지는 이동이 조용히 무시되고 있었습니다.
통하지 않은 시도가 두 가지 있었습니다
원인을 알았으니 금방 끝날 줄 알았는데 그렇지 않았습니다.
첫 번째로 빈 쿼리에도 물음표를 붙여 봤습니다. /items 대신 /items? 로 보내면 "쿼리가 있는 주소"가 되니 될 거라고 봤습니다. 안 됐습니다. 정규화 과정에서 빈 물음표는 없는 것과 같게 취급됩니다.
두 번째로 replace 대신 push 를 써 봤습니다. 히스토리를 다루는 방식이 달라서 혹시 다르게 동작할까 싶었는데, 역시 안 됐습니다. 두 API 모두 같은 이동 경로를 타기 때문에 당연한 결과였습니다.
여기서 방향을 바꿨습니다. 프레임워크의 동작을 이기려 하는 대신, 이동이 일어나지 않는 조건 자체를 피하기로 했습니다.
해결: 쿼리를 비우지 않기로 했습니다
마침 이 화면에는 기본값이라 URL에서 생략하던 파라미터가 하나 있었습니다. 정렬이 기본값일 때는 sort 를 적지 않고 있었는데, 쿼리가 비게 되는 순간에만 그 값을 명시하도록 했습니다.
const writeParams = useCallback(
(mutate: (params: URLSearchParams) => void) => {
const params = new URLSearchParams(searchParams.toString())
mutate(params)// 쿼리를 전부 비우는 이동은 일어나지 않는다.
// 생략하던 기본값을 이때만 적어 쿼리를 비우지 않는다.
if (params.toString() === "") params.set("sort", sort)
router.replace/items?${params.toString()}, { scroll: false })
},
[router, searchParams, sort],
)
이제 필터를 해제하면 ?status=overdue 에서 ?sort=risk 로 바뀝니다. 다시 켜면 ?sort=risk&status=overdue 가 되고, 다시 끄면 ?sort=risk 로 돌아옵니다.
주소에 파라미터가 하나 늘었지만 의미 없는 더미 값이 아니라 실제로 뜻이 있는 값입니다. 덕분에 링크를 공유했을 때 정렬 상태까지 함께 재현된다는 부수 효과도 생겼습니다.
물론 기본값으로 쓸 만한 파라미터가 없는 화면이라면 다른 방법을 찾아야 합니다. 그때는 필터 상태를 URL이 아니라 컴포넌트 state로 옮기는 편이 나을 수도 있습니다. 저희는 공유 가능한 링크가 이 화면의 요구사항이었기 때문에 URL을 유지하는 쪽을 택했습니다.
정리하며
돌아보면 배운 것이 세 가지 있습니다.
첫째, 이 버그가 오래 숨어 있었던 이유입니다. 같은 화면이 한동안 서버 타임아웃으로 떠 있지도 못했습니다. 화면이 안 뜨니 필터를 누를 일도 없었고, 그래서 아무도 몰랐습니다. 큰 장애는 작은 버그를 가립니다. 장애를 복구한 뒤에 그 화면 전체를 다시 확인해야 하는 이유가 여기 있습니다.
둘째, 조용한 실패가 가장 비쌉니다. 예외를 던졌다면 5분이면 끝났을 일이, 아무 일도 일어나지 않는 바람에 클릭이 전달되는지부터 의심하게 됐습니다. 저희 코드에서도 아무것도 하지 않고 지나가는 분기를 만들 때는 한 번 더 생각하게 됐습니다.
셋째, 추측보다 계측이 빠릅니다. "핸들러가 도는지 안 도는지"를 두고 고민하는 것보다 한 줄 심어서 확인하는 편이 훨씬 빨랐습니다. 프로덕션 빌드에서도 전역 배열 하나면 충분했습니다.
그리고 마지막으로, 되는 경우와 안 되는 경우를 나란히 적는 순간 원인이 드러났습니다. 디버깅의 절반은 경계를 찾는 일이라는 걸 다시 확인했습니다.
확인 환경은 Next.js 16 App Router, 프로덕션 빌드, Chromium입니다. 코드는 실제 사례를 일반화해 다시 썼습니다.
읽기 도구
약 10분 읽기
이 글이 도움이 되었나요?
Work with GIWorks
프로젝트에 GIWorks의 경험이 필요하신가요?
전시·콘텐츠·엔지니어링 프로젝트의 아이디어와 고민을 들려주세요. 필요한 기술과 실행 방법을 함께 찾겠습니다.
문의하기