상태 · 데이터
URL 상태 영속 — nuqs로 필터·탭·페이지를 URL에
목록 페이지의 필터·탭·페이지네이션이 새로고침 한 번에 통째로 날아갔다. 가장 빨리 떠오른 해법은 "메모리 스토어를 sessionStorage에 저장하자"였는데, 막상 손에 쥐고 시나리오를 하나씩 돌려보니 "새로고침"과 "다른 페이지 갔다 복귀"를 구분하지 못해 요구사항이 깨졌다. 거기서부터 5가지 접근을 끝까지 비교한 끝에, "URL을 상태의 거울로 두지 말고 URL 자체를 원천으로 삼는다"는 결론에 도달했다. 코드를 짜는 시간보다 "이 접근은 왜 안 되는가"를 따진 시간이 길었다.
nuqs 도입 제안서메모리에만 있던 상태
목록 페이지의 모든 상태 — 탭, 검색어, 채널 필터, 상태 필터, 바이어 필터, 페이지네이션 — 가 Zustand 메모리 스토어에만 있었다. 브라우저 메모리에만 살아 있으니, 새로고침하면 전부 기본값으로 돌아가고 URL을 공유해도 상태가 전혀 전달되지 않았다.
기획이 요청한 동작은 단순했지만, 시나리오마다 기대가 갈렸다.
| 시나리오 | 기대 동작 |
|---|---|
| 새로고침 (F5) | 탭 / 필터 / 페이지 유지 |
| 탭 전환 (Inbox ↔ Sent) | 필터 + 페이지네이션 전체 초기화 |
| 사이드바로 다른 페이지 이동 후 복귀 | 필터 + 페이지네이션 전체 초기화 |
| 상세 → 브라우저 뒤로가기 | 이전 필터 상태 복원 |
| URL 공유 | 받은 사람이 같은 화면으로 진입 |
지금 코드로는 이게 4가지 방향으로 어긋났다.
- 새로고침 시 상태 유실 — 필터를 잡아 두고 F5를 누르면 기본값으로 리셋
- 탭 전환 시 불완전한 리셋 — 상태 필터와 현재 페이지만 리셋되고 나머지 필터가 살아남는 버그
- URL 공유 불가 — 동료에게 "Sent 탭의 DELIVERED 필터 2페이지"를 그대로 보낼 방법이 없음
- 뒤로가기 시 상태 유실 — 상세에서 목록으로 돌아오면 필터가 초기화됨
여기서 함정은 1번(새로고침 유지)과 3번(복귀 시 초기화)이 표면적으로는 정반대라는 점이다. 같은 컴포넌트가 다시 mount되는데, 한쪽은 "복원"을 원하고 다른 쪽은 "초기화"를 원한다. 이 둘을 구분하는 신호가 무엇이냐가 이 작업의 진짜 질문이었다.
5가지 접근을 끝까지 비교하다
각 접근이 "왜 안 되는지"를 먼저 정리하고, 그게 nuqs를 고른 이유가 됐다.
| 접근 | 왜 안 되는가 |
|---|---|
| ① Zustand persist (sessionStorage) | SPA 라우팅에서 "새로고침"과 "이동 후 복귀"를 구분 못 함 — 둘 다 mount 시 storage에서 복원돼, 사이드바 갔다 와도 이전 필터가 살아남음 |
② useSearchParams 양방향 sync | 상태 ↔ URL을 양쪽으로 묶으면 무한 루프·race condition — 상태 변경 → URL 갱신 → URL 감지 → 상태 갱신 → 반복 |
| ③ Zustand + URL 수동 단방향 | 동작은 하지만 ~80줄 동기화 배관 + 모듈 레벨 hydration hack + 수동 파싱 edge case 3건 |
| ④ localStorage 영속 | 새로고침은 살리지만 복귀 시 초기화·URL 공유를 둘 다 못 함 (sessionStorage보다 더 끈질기게 살아남아 ①의 문제가 악화) |
| ⑤ nuqs ✅ | URL이 단일 원천. 양방향 sync·배관 불필요, 타입 안전 파서, Next.js App Router 네이티브 |
① Zustand persist (sessionStorage) — 가장 먼저, 가장 빨리 탈락
제일 간단해서 제일 먼저 손이 갔다. 스토어에 persist 미들웨어를 붙여 sessionStorage에 저장하면
새로고침은 바로 해결된다. 그런데 3번 시나리오(복귀 시 초기화)에서 무너졌다.
문제의 핵심은 SPA에서는 "새로고침"이든 "다른 페이지 갔다 복귀"든 컴포넌트 입장에선 똑같이 mount 이벤트라는 것이다. 둘을 구분할 신호가 mount 시점엔 없다. sessionStorage는 탭이 살아있는 한 값을 들고 있으니, 사이드바로 다른 페이지에 갔다 돌아와도 mount 시 그대로 복원돼 이전 필터가 살아있게 된다. 1번을 만족시키려고 켰는데 3번이 깨지는, 한 손으로 잡으면 다른 손이 빠지는 구조였다.
② useSearchParams 양방향 동기화 — 무한 루프의 씨앗
그럼 URL을 쓰자. Zustand는 그대로 두고 useSearchParams로 URL과 양방향으로 묶는 그림이다.
하지만 양방향 sync는 무한 루프와 race condition 위험이 상존한다.
상태 변경 → URL 업데이트 → URL 변경 감지(effect) → 상태 업데이트 → (다시) URL 업데이트 → …effect 안에서 equality check로 끊어줄 수는 있지만, 그 가드 자체가 또 다른 edge case를 만든다. "양방향으로 묶인 두 source of truth"라는 설계가 애초에 깨지기 쉬운 형태라는 게 핵심이고, 이건 땜질로 닫을수록 코드만 늘어난다.
③ Zustand + URL 수동 단방향 — 동작하지만 ~80줄 배관
양방향이 위험하니 단방향으로 좁힌다. Zustand를 원천으로 유지하면서 URL은 "거울"로만 쓰는, 스토어 → URL 한 방향 동기화다. 실제로 동작은 했다. 문제는 동작시키는 데 드는 비용이었다.
// 이 접근이 요구한 배관 (~80줄)
스토어 subscribe() // 상태 변경 구독
→ debounce 300ms // 타이핑마다 URL 갈리는 것 방지
→ equality check // 같은 값이면 skip (무한 갱신 방지)
→ window.history.replaceState() // 히스토리 오염 없이 URL 갱신
→ whitelist 검증 // 허용 안 된 파라미터 차단
// 반대 방향(URL → 초기 상태)도 직접:
qs.parse() // 쿼리스트링 파싱
→ ensureArray() // 단일 항목을 배열로
→ .map(Number).filter(isInteger) // 숫자 복원
→ 모듈 레벨 동기 hydration // useSuspenseQuery보다 먼저 파싱이걸 직접 짜다 보니 코드 리뷰에서 잡아낸 edge case가 3건이었다. 전부 "URL 문자열 ↔ 타입 있는 상태" 변환에서 새어 나온 것들이다.
| # | 증상 | 원인 | 수동 처리 |
|---|---|---|---|
| 1 | 초기 API가 잘못된 값으로 호출 | useSuspenseQuery가 URL 파싱(hydration)보다 먼저 실행 | 모듈 레벨 동기 실행 hack으로 우회 |
| 2 | ?accounts=gmail 이 배열이 아님 | qs가 단일 항목을 문자열로 파싱 | ensureArray() 헬퍼 직접 구현 |
| 3 | 페이지 번호가 문자열 "2" | URL은 전부 문자열 → 숫자 보장 안 됨 | .map(Number).filter(Number.isInteger) 수동 변환 |
특히 1번이 React 패턴을 정면으로 벗어났다. URL을 React 렌더 lifecycle 안에서 읽으면
useSuspenseQuery가 먼저 떠버려서, 모듈 로드 시점에 동기로 URL을 파싱하는 hack을 써야 했다.
결국 이 접근의 결론은 "URL ↔ 메모리 동기화를 직접 구현하면, 이미 검증된 라이브러리가 내부에서
해결하는 문제를 손으로 재구현하는 것"이었다. 리뷰에서 나온 3건이 그 증거였다.
④ localStorage 영속 — 더 끈질겨서 더 나쁨
sessionStorage 대신 localStorage면 어떨까도 따졌다. 결과는 ①의 악화 버전이다. localStorage는 탭을 닫아도 살아남으니 새로고침은 더 확실히 살리지만, 그게 정확히 독이다. 복귀 시 초기화(3번)는 여전히 못 하고, URL 공유(5번)도 당연히 못 한다. 영속성이 강할수록 "지금 이 진입이 새로고침인지 복귀인지"를 구분 못 하는 ①의 근본 문제가 더 끈질기게 따라붙는다. 영속의 강도는 문제의 방향과 무관했다.
⑤ nuqs — URL을 거울이 아니라 원천으로
다섯 번째에서 전제를 바꿨다. 앞의 네 접근은 전부 메모리가 원천이고 URL·storage는 사본이라는 구도였고, 그래서 둘을 맞추는 동기화 비용이 끝없이 따라왔다. nuqs는 그 구도를 뒤집는다. URL이 곧 single source of truth고, 메모리에 따로 복제하지 않는다.
이 한 줄 전환으로 앞의 문제들이 하나씩 사라진다.
- ①의 "새로고침 vs 복귀 구분 불가" → URL이 원천이니 구분할 필요가 없다. 새로고침은 URL이
그대로라 복원되고, 사이드바로 갔다 오면
/email로 클린 URL이라 기본값이 적용된다. mount 시점에 추론할 필요 없이 URL이 곧 답이다. - ②의 양방향 루프 → 원천이 하나라 양방향 자체가 없다.
- ③의 ~80줄 배관과 hack → 선언적 파서가 파싱·타입 변환·기본값·hydration을 다 가져간다.
- ④의 공유 불가 → URL이 상태 그 자체라, 주소를 복사하면 상태가 그대로 따라간다.
nuqs는 React useState와 같은 API를 주면서, 내부에서 App Router의 useSearchParams와
history.replaceState/pushState를 알아서 처리한다. 핵심 코드는 이렇게 짧다.
// features/email/model/email-page-params.ts
import {
parseAsString, parseAsInteger, parseAsArrayOf, parseAsStringLiteral,
useQueryStates,
} from 'nuqs'
const tabValues = ['inbox', 'sent'] as const
const statusValues = ['ALL', 'PENDING', 'DELIVERED', 'FAILED'] as const
const searchParams = {
tab: parseAsStringLiteral(tabValues).withDefault('inbox'),
q: parseAsString.withDefault(''),
accounts: parseAsArrayOf(parseAsString).withDefault([]), // ② 단일 항목도 항상 배열
status: parseAsStringLiteral(statusValues).withDefault('ALL'),
buyers: parseAsArrayOf(parseAsInteger).withDefault([]), // ③ 숫자 타입 자동 보장
page: parseAsInteger.withDefault(1),
}수동 구현에서 손으로 짜야 했던 게 파서 선언 한 줄에 들어간다.
parseAsArrayOf(parseAsInteger).withDefault([])
↳ 배열 파싱 ── 자동 (② 해결)
↳ 숫자 변환 ── 자동 (③ 해결)
↳ 기본값 처리 ── 자동
↳ URL 동기화 ── 자동
↳ hydration ── React 표준 lifecycle (① CRITICAL hack 제거)탭 전환 시 "필터 전체 atomic 리셋"도 자연스럽게 풀린다. useQueryStates(복수형)는 여러
파라미터를 한 번에 바꾸므로, 7개 필터를 한 호출로 기본값으로 되돌린다. 이게 바로 기획 2번 시나리오
(탭 전환 = 전체 초기화)의 직역이다.
export function useEmailPageParams() {
const [params, setParams] = useQueryStates(searchParams, {
history: 'replace', // 필터 변경이 뒤로가기 히스토리를 오염시키지 않게
throttleMs: 300, // 타이핑마다 URL 갈리는 것 방지 (수동 debounce 대체)
})
// 탭 전환 → 전체 필터를 한 번에 리셋 (불완전 리셋 버그의 정공법)
const setActiveTab = (tab: typeof params.tab) =>
setParams({ tab, q: '', accounts: [], status: 'ALL', buyers: [], page: 1 })
return {
// 기존 필드명을 그대로 alias — 소비자 변경 최소화
activeTab: params.tab,
searchQuery: params.q,
statusFilter: params.status,
currentPage: params.page,
setActiveTab,
setStatusFilter: (s: typeof params.status) => setParams({ status: s, page: 1 }),
setCurrentPage: (p: number) => setParams({ page: p }),
}
}래퍼 훅이 기존 필드명(activeTab, statusFilter, currentPage)을 그대로 내보내게 하니,
소비자 쪽 변경은 import 교체 + useShallow 제거가 전부였다.
// Before — 메모리 스토어 + 셀렉터
const { activeTab, statusFilter } = useEmailPageStore(
useShallow(s => ({ activeTab: s.activeTab, statusFilter: s.statusFilter }))
)
// After — URL이 원천, useShallow도 불필요
const { activeTab, statusFilter } = useEmailPageParams()기획 요구를 다시 표로 맞춰 보면, 이제 각 시나리오가 별도 분기 없이 메커니즘 하나로 떨어진다.
| 시나리오 | 기대 | nuqs 메커니즘 |
|---|---|---|
| 새로고침 (F5) | 유지 | URL 파라미터가 그대로 → 자동 파싱 |
| 탭 전환 | 전체 초기화 | setActiveTab이 기본값으로 atomic 리셋 |
| 사이드바 → 복귀 | 전체 초기화 | 클린 URL /email → withDefault로 기본값 |
| 상세 → 뒤로가기 | 복원 | 히스토리에 /email?tab=sent&… 유지 |
| URL 공유 | 동일 진입 | URL 파라미터 = 상태 그 자체 |
임팩트 · 배운 점
- 새로고침·뒤로가기·URL 공유에서 필터·탭·페이지가 그대로 유지되고, 탭 전환 시 불완전 리셋 버그도 사라졌다.
- ~80줄 동기화 배관(subscribe + debounce + replaceState + equality)과 모듈 레벨 hydration hack을 통째로 걷어냈다. 수동 구현에서 리뷰로 잡았던 edge case 3건은 파서가 내부에서 처리하니 애초에 발생할 자리가 없어졌다.
- 가장 큰 교훈은 "상태를 어디에 둘 것인가"가 코드보다 먼저라는 것이다. sessionStorage, localStorage, Zustand, URL은 "새로고침 vs 복귀 구분", "공유 가능성"이라는 두 축에서 답이 전부 다르다. 앞의 네 접근이 막힌 이유는 전부 같았다 — 메모리를 원천으로 두고 사본을 맞추려 했기 때문이다. 요구사항(공유 가능 + 복귀 시 초기화)을 동시에 만족하는 단일 원천을 고르자, 동기화 문제가 통째로 사라졌다.
- 다만 모든 파라미터를 URL로 보내자는 얘기는 아니다. 반응형으로 양방향이 필요한 필터 같은 list params에만 nuqs를 쓰고, 단순 조회용 파라미터는 page(상위 레이어)에서 읽어 props로 내리는 쪽이 더 단순했다. "URL은 UI의 일부"라는 원칙도, 무엇을 URL에 둘지는 결국 그 상태의 성격을 보고 정한다.