/email 페이지의 필터, 탭, 페이지네이션 상태를 URL에 영속화하여 새로고침·뒤로가기·URL 공유 시 상태를 유지하기 위한 기술 제안서입니다.
기획에서 요청된 동작은 다음과 같습니다. 사용자가 필터를 설정한 뒤 새로고침하면 그 상태가 유지되어야 하고, 다른 페이지로 이동했다가 돌아오면 깨끗한 초기 상태여야 합니다.
현재 /email 페이지의 모든 상태(탭, 검색어, 채널 필터, 상태 필터, 바이어 필터, 페이지네이션)는 Zustand 메모리 스토어(useEmailPageStore)에만 존재합니다. 브라우저 메모리에만 있기 때문에 새로고침하면 전부 유실되고, URL을 공유해도 상태가 전달되지 않습니다.
이 문제를 해결하기 위해 5가지 방법을 검토했고, 각각 명확한 단점이 있었습니다.
가장 간단하지만, SPA 라우팅에서 "새로고침"과 "페이지 이동 후 복귀"를 구분할 수 없습니다. 두 경우 모두 컴포넌트가 mount되면서 sessionStorage에서 복원되기 때문에, 사이드바로 갔다 돌아와도 이전 필터가 살아있게 됩니다.
Zustand와 URL을 양방향으로 sync하면 무한 루프와 race condition 위험이 매우 높습니다. 상태 변경 → URL 업데이트 → URL 변경 감지 → 상태 업데이트 → 무한 반복의 가능성이 상존합니다.
Zustand를 유지하면서 URL을 "거울"로 사용하는 방법입니다. 동작은 하지만:
• ~80줄의 동기화 배관 코드가 필요 (subscribe + debounce + replaceState + equality check)
• 모듈 레벨 동기 hydration — useSuspenseQuery보다 먼저 URL을 파싱해야 해서 React 패턴을 벗어난 hack 필요
• 수동 파싱의 edge case — qs 라이브러리가 단일 항목 배열을 문자열로 파싱, 숫자를 문자열로 변환하는 등 직접 처리해야 할 이슈 3건 발견
Next.js App Router 전용 URL search params 상태 관리 라이브러리입니다. URL을 single source of truth로 사용하며, React useState와 동일한 API를 제공합니다.
parseAsInteger, parseAsArrayOf, parseAsStringLiteral 등 선언적 파서로 URL 파라미터를 타입 안전하게 처리합니다. 수동으로 Number(), ensureArray()를 작성할 필요가 없습니다.
startTransition 기반 배칭으로 불필요한 리렌더를 최소화합니다. 모듈 레벨 hack이나 subscribe 패턴 없이, React가 권장하는 방식으로 동작합니다.
useQueryStates(복수형)로 여러 파라미터를 한 번에 업데이트합니다. 탭 전환 시 7개 필터를 일괄 리셋하는 패턴이 자연스럽게 지원됩니다.
App Router의 useSearchParams를 내부적으로 사용하면서, history.replaceState / pushState를 자동으로 처리합니다. shallow routing도 지원합니다.
nuqs(구 next-usequerystate)는 Next.js 생태계에서 URL 상태 관리의 사실상 표준으로 자리잡았습니다. 공식 Next.js 문서에서도 search params 관리 시 참조되는 라이브러리입니다.
수동 동기화의 ~80줄 배관 코드와 CRITICAL 이슈 3건이 선언적 파서 한 줄로 대체됩니다.
사용자가 Sent 탭에서 DELIVERED 필터를 적용하고 2페이지를 보고 있다면, URL이 아래처럼 바뀝니다. 이 URL을 복사해서 공유하거나, 새로고침해도 동일한 상태가 복원됩니다.
// 모듈 레벨 동기 hydration hack
// qs.parse() 수동 파싱
// ensureArray() 헬퍼
// .map(Number).filter(isInteger)
// subscribe() + debounce 300ms
// equality check
// window.history.replaceState()
// whitelist 검증
parseAsArrayOf(parseAsInteger)
.withDefault([])
// ✓ 배열 파싱 — 자동
// ✓ 숫자 변환 — 자동
// ✓ 기본값 처리 — 자동
// ✓ URL 동기화 — 자동
// ✓ hydration — React 표준
수동 구현: useSuspenseQuery가 hydration보다 먼저 실행되어 잘못된 초기값으로 API 호출. 모듈 레벨 동기 실행 hack으로 우회해야 함.
nuqs: 라이브러리가 React 표준 lifecycle 내에서 처리. hack 불필요.
수동 구현: ?accounts=gmail → qs가 문자열로 파싱 (배열이 아님). ensureArray() 헬퍼를 직접 구현해야 함.
nuqs: parseAsArrayOf(parseAsString)이 항상 배열 반환. 자동 해결.
수동 구현: ?buyers=42 → 문자열 "42"로 파싱. .map(Number).filter(Number.isInteger) 수동 변환 필요.
nuqs: parseAsInteger가 자동으로 숫자 타입 보장. 자동 해결.
| 시나리오 | 기대 동작 | nuqs 메커니즘 |
|---|---|---|
| 새로고침 (F5) | 탭/필터/페이지 유지 | URL 파라미터 유지 → nuqs가 자동 파싱 |
| Inbox → Sent 탭 전환 | 전체 초기화 | setActiveTab()이 FILTER_DEFAULTS로 atomic 리셋 |
| 사이드바 → /email 복귀 | 전체 초기화 | 클린 URL /email → withDefault()로 기본값 적용 |
| 이메일 상세 → 뒤로가기 | 이전 상태 복원 | 히스토리에 /email?tab=sent&... 유지 |
| URL 공유 | 동일 상태 진입 | URL 파라미터 = 상태 그 자체 |
기존 코드 변경을 최소화하면서 nuqs를 도입하는 구체적인 구현 방법입니다.
| URL 파라미터 | nuqs 파서 | 기본값 | 설명 |
|---|---|---|---|
| tab | parseAsStringLiteral | 'inbox' | Inbox / Sent |
| list | parseAsStringLiteral | 'all' | All / Buyers Only |
| q | parseAsString | '' | 검색어 |
| accounts | parseAsArrayOf(parseAsString) | [] | 채널 필터 |
| status | parseAsStringLiteral | 'ALL' | 상태 필터 |
| buyers | parseAsArrayOf(parseAsInteger) | [] | 바이어 필터 |
| page | parseAsInteger | 1 | 현재 페이지 |
| size | parseAsInteger | 20 | 페이지당 항목 수 |
import {
parseAsString, parseAsInteger, parseAsArrayOf,
parseAsStringLiteral,
} from 'nuqs'
import { useQueryStates } from 'nuqs'
const tabValues = ['inbox', 'sent'] as const
const listValues = ['all', 'buyers'] as const
const statusValues = ['ALL', 'PENDING', 'DELIVERED', 'FAILED'] as const
const emailSearchParams = {
tab: parseAsStringLiteral(tabValues).withDefault('inbox'),
list: parseAsStringLiteral(listValues).withDefault('all'),
q: parseAsString.withDefault(''),
accounts: parseAsArrayOf(parseAsString).withDefault([]),
status: parseAsStringLiteral(statusValues).withDefault('ALL'),
buyers: parseAsArrayOf(parseAsInteger).withDefault([]),
page: parseAsInteger.withDefault(1),
size: parseAsInteger.withDefault(20),
}
export function useEmailPageParams() {
const [params, setParams] = useQueryStates(emailSearchParams, {
history: 'replace',
throttleMs: 300,
})
// 탭 전환 → 전체 필터 atomic 리셋
const setActiveTab = (tab: typeof params.tab) =>
setParams({ tab, list:'all', q:'', accounts:[], status:'ALL', buyers:[], page:1 })
const resetFilters = () =>
setParams({ list:'all', q:'', accounts:[], status:'ALL', buyers:[], page:1 })
return {
// 기존 이름 alias — 소비자 변경 최소화
activeTab: params.tab,
activeListFilter: params.list,
searchQuery: params.q,
accountFilters: params.accounts,
statusFilter: params.status,
buyerFilters: params.buyers,
currentPage: params.page,
perPage: params.size,
// actions
setActiveTab,
resetFilters,
setStatusFilter: (s) => setParams({ status: s, page: 1 }),
setCurrentPage: (p) => setParams({ page: p }),
setPerPage: (s) => setParams({ size: s, page: 1 }),
setBuyerFilters: (b) => setParams({ buyers: b, page: 1 }),
setActiveListFilter: (l) => setParams({ list: l, page: 1 }),
setSearchQuery: (q) => setParams({ q }),
setAccountFilters: (a) => setParams({ accounts: a, page: 1 }),
clearSearch: () => setParams({ q: '' }),
}
}
// Before
const { activeTab, statusFilter } = useEmailPageStore(
useShallow(s => ({ activeTab: s.activeTab, statusFilter: s.statusFilter }))
)
// After — useShallow도 불필요
const { activeTab, statusFilter } = useEmailPageParams()
| 파일 | 변경 내용 |
|---|---|
| email-page-params.ts | 신규 — nuqs 파서 + useEmailPageParams 훅 |
| layout.tsx | NuqsAdapter 래핑 추가 |
| email-list.tsx | import 교체 |
| email-pagination.tsx | import 교체 |
| email-filter-dropdowns.tsx | import 교체 |
| use-email-list.ts | import 교체 + useShallow 제거 |
| email-search-input.tsx | import 교체 |
| index.ts (barrel) | export 변경 |
| email-page-store.ts | 삭제 |
Email 필터 영속화에 nuqs를 도입하고, 향후 다른 페이지의 필터에도 동일한 패턴을 확장하는 것을 제안합니다.
| 기준 | 수동 구현 | nuqs |
|---|---|---|
| 동기화 코드 | ~80줄 배관 코드 | 0줄 — 라이브러리가 처리 |
| CRITICAL 버그 | 3건 — 수동 처리 필요 | 0건 — 라이브러리 내부 처리 |
| React 패턴 준수 | 모듈 레벨 hack 필요 | React 표준 lifecycle |
| 타입 안전성 | 수동 변환 + 검증 | 파서 기반 자동 타입 추론 |
| 번들 영향 | qs 라이브러리 (기존) | +3.2KB gzip |
바이어 목록, 인벤토리 페이지 등에서 동일한 필터 URL 영속화 요구가 있을 때, useQueryStates + 선언적 파서 패턴을 그대로 적용할 수 있습니다. 별도 검토로 진행합니다.
nuqs 도입 후 Zustand는 프로젝트 내에서 "순수 클라이언트 UI 상태"(모달, 사이드바 토글 등)에만 사용하고, 서버 상태에 영향을 주는 필터/검색은 URL로 관리하는 컨벤션을 정리할 수 있습니다.