Email Feature — 기술 제안서

Email 필터 영속화를 위한
nuqs 도입 제안

/email 페이지의 필터, 탭, 페이지네이션 상태를 URL에 영속화하여 새로고침·뒤로가기·URL 공유 시 상태를 유지하기 위한 기술 제안서입니다.

날짜  2026-03-18
범위  Email 필터 (향후 확장 가능)
상태  검토 요청

필터 상태가 URL에 남아야 합니다

기획에서 요청된 동작은 다음과 같습니다. 사용자가 필터를 설정한 뒤 새로고침하면 그 상태가 유지되어야 하고, 다른 페이지로 이동했다가 돌아오면 깨끗한 초기 상태여야 합니다.

새로고침 (F5)
탭 / 필터 / 페이지 유지
탭 전환 (Inbox ↔ Sent)
필터 + 페이지네이션 전체 초기화
사이드바로 다른 페이지 이동 후 복귀
필터 + 페이지네이션 전체 초기화
이메일 상세 → 브라우저 뒤로가기
이전 필터 상태 유지

지금은 새로고침하면 필터가 전부 사라집니다

현재 /email 페이지의 모든 상태(탭, 검색어, 채널 필터, 상태 필터, 바이어 필터, 페이지네이션)는 Zustand 메모리 스토어(useEmailPageStore)에만 존재합니다. 브라우저 메모리에만 있기 때문에 새로고침하면 전부 유실되고, URL을 공유해도 상태가 전달되지 않습니다.

현재 문제점
새로고침 시 상태 유실 — 필터를 설정하고 F5를 누르면 기본값으로 돌아감
탭 전환 시 불완전한 리셋 — statusFilter + currentPage만 리셋되고 나머지 필터가 남아있는 버그 존재
URL 공유 불가 — 동료에게 "Sent 탭의 DELIVERED 필터 2페이지" 상태를 공유할 수 없음
뒤로가기 시 상태 유실 — 이메일 상세에서 돌아오면 필터가 초기화됨

5가지 접근법을 비교했습니다

이 문제를 해결하기 위해 5가지 방법을 검토했고, 각각 명확한 단점이 있었습니다.

❌  Zustand persist (sessionStorage)

가장 간단하지만, SPA 라우팅에서 "새로고침"과 "페이지 이동 후 복귀"를 구분할 수 없습니다. 두 경우 모두 컴포넌트가 mount되면서 sessionStorage에서 복원되기 때문에, 사이드바로 갔다 돌아와도 이전 필터가 살아있게 됩니다.

❌  useSearchParams 양방향 동기화

Zustand와 URL을 양방향으로 sync하면 무한 루프와 race condition 위험이 매우 높습니다. 상태 변경 → URL 업데이트 → URL 변경 감지 → 상태 업데이트 → 무한 반복의 가능성이 상존합니다.

⚠️  Zustand + URL 수동 동기화 (단방향)

Zustand를 유지하면서 URL을 "거울"로 사용하는 방법입니다. 동작은 하지만:

~80줄의 동기화 배관 코드가 필요 (subscribe + debounce + replaceState + equality check)
모듈 레벨 동기 hydration — useSuspenseQuery보다 먼저 URL을 파싱해야 해서 React 패턴을 벗어난 hack 필요
수동 파싱의 edge case — qs 라이브러리가 단일 항목 배열을 문자열로 파싱, 숫자를 문자열로 변환하는 등 직접 처리해야 할 이슈 3건 발견

수동 구현의 핵심 문제
결국 URL ↔ 메모리 동기화를 직접 구현하면, 이미 검증된 라이브러리가 내부에서 해결하는 문제를 수동으로 재구현하는 셈입니다. 리뷰에서 CRITICAL 이슈 3건이 발견된 것이 이를 증명합니다.
Library Overview

nuqs란 무엇인가

Next.js App Router 전용 URL search params 상태 관리 라이브러리입니다. URL을 single source of truth로 사용하며, React useState와 동일한 API를 제공합니다.

3.2KB
gzip 번들 사이즈
3.2K+
GitHub Stars
460K+
주간 NPM 다운로드

왜 nuqs인가

🔒  타입 안전 파서

parseAsInteger, parseAsArrayOf, parseAsStringLiteral 등 선언적 파서로 URL 파라미터를 타입 안전하게 처리합니다. 수동으로 Number(), ensureArray()를 작성할 필요가 없습니다.

⚛️  React 표준 패턴

startTransition 기반 배칭으로 불필요한 리렌더를 최소화합니다. 모듈 레벨 hack이나 subscribe 패턴 없이, React가 권장하는 방식으로 동작합니다.

🔄  Atomic 업데이트

useQueryStates(복수형)로 여러 파라미터를 한 번에 업데이트합니다. 탭 전환 시 7개 필터를 일괄 리셋하는 패턴이 자연스럽게 지원됩니다.

📦  Next.js App Router 네이티브

App Router의 useSearchParams를 내부적으로 사용하면서, history.replaceState / pushState를 자동으로 처리합니다. shallow routing도 지원합니다.

이미 널리 사용되고 있습니다

nuqs(구 next-usequerystate)는 Next.js 생태계에서 URL 상태 관리의 사실상 표준으로 자리잡았습니다. 공식 Next.js 문서에서도 search params 관리 시 참조되는 라이브러리입니다.

Vercel / Next.js 팀
공식 문서와 예제에서 URL state 관리 시 nuqs를 참조. Next.js Conf에서 소개.
shadcn/ui — Data Table
shadcn의 Data Table 예제에서 필터/정렬/페이지네이션을 nuqs로 관리하는 패턴 채택.
Cal.com
오픈소스 스케줄링 앱. 복잡한 필터와 뷰 전환 상태를 nuqs로 URL에 영속화.
Taxonomy (Vercel 예제)
Vercel의 공식 Next.js 예제 프로젝트에서 필터링/검색 상태 관리에 활용.
프론트엔드 트렌드
"URL은 UI의 일부" — 필터, 검색, 페이지네이션 같은 서버 상태에 영향을 주는 UI 상태는 URL에 두는 것이 현대 프론트엔드의 권장 패턴입니다. TanStack Router, Remix, SvelteKit 모두 이 방향으로 수렴하고 있습니다. Zustand, Jotai 같은 클라이언트 상태 관리는 모달 열림/닫힘 같은 순수 UI 상태에 적합합니다.
Before → After

nuqs로 무엇이 해결되나

수동 동기화의 ~80줄 배관 코드와 CRITICAL 이슈 3건이 선언적 파서 한 줄로 대체됩니다.

필터 상태가 URL에 자동으로 반영됩니다

사용자가 Sent 탭에서 DELIVERED 필터를 적용하고 2페이지를 보고 있다면, URL이 아래처럼 바뀝니다. 이 URL을 복사해서 공유하거나, 새로고침해도 동일한 상태가 복원됩니다.

https://app.example.com/email?tab=sent&status=DELIVERED&page=2

수동 배관 코드가 선언적 파서로 대체됩니다

수동 구현 (배관 코드 ~80줄)

// 모듈 레벨 동기 hydration hack
// qs.parse() 수동 파싱
// ensureArray() 헬퍼
// .map(Number).filter(isInteger)
// subscribe() + debounce 300ms
// equality check
// window.history.replaceState()
// whitelist 검증

nuqs 선언적 파서

parseAsArrayOf(parseAsInteger)
  .withDefault([])

// ✓ 배열 파싱 — 자동
// ✓ 숫자 변환 — 자동
// ✓ 기본값 처리 — 자동
// ✓ URL 동기화 — 자동
// ✓ hydration — React 표준

수동 구현에서 발견된 3가지 CRITICAL 이슈가 사라집니다

CRITICAL #1 — useSuspenseQuery 타이밍

수동 구현: useSuspenseQuery가 hydration보다 먼저 실행되어 잘못된 초기값으로 API 호출. 모듈 레벨 동기 실행 hack으로 우회해야 함.

nuqs: 라이브러리가 React 표준 lifecycle 내에서 처리. hack 불필요.

CRITICAL #2 — 단일 항목 배열 파싱

수동 구현: ?accounts=gmail → qs가 문자열로 파싱 (배열이 아님). ensureArray() 헬퍼를 직접 구현해야 함.

nuqs: parseAsArrayOf(parseAsString)이 항상 배열 반환. 자동 해결.

CRITICAL #3 — 숫자 ↔ 문자열 변환

수동 구현: ?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 파라미터 = 상태 그 자체
Implementation

구현 계획

기존 코드 변경을 최소화하면서 nuqs를 도입하는 구체적인 구현 방법입니다.

URL 파라미터 설계

URL 파라미터nuqs 파서기본값설명
tabparseAsStringLiteral'inbox'Inbox / Sent
listparseAsStringLiteral'all'All / Buyers Only
qparseAsString''검색어
accountsparseAsArrayOf(parseAsString)[]채널 필터
statusparseAsStringLiteral'ALL'상태 필터
buyersparseAsArrayOf(parseAsInteger)[]바이어 필터
pageparseAsInteger1현재 페이지
sizeparseAsInteger20페이지당 항목 수

실제 구현 코드

1. URL 파서 정의

features/email/model/email-page-params.ts
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),
}

2. 래퍼 훅 — 기존 인터페이스 유지

features/email/model/email-page-params.ts (계속)
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: '' }),
  }
}

3. 소비자 변경 — import 한 줄

// Before
const { activeTab, statusFilter } = useEmailPageStore(
  useShallow(s => ({ activeTab: s.activeTab, statusFilter: s.statusFilter }))
)

// After — useShallow도 불필요
const { activeTab, statusFilter } = useEmailPageParams()
Alias 덕분에
래퍼 훅이 기존 필드명(activeTab, statusFilter, currentPage 등)을 그대로 제공하므로, 소비자 파일의 변경은 import 교체 + useShallow 제거가 전부입니다.

수정 파일 목록

파일변경 내용
email-page-params.ts신규 — nuqs 파서 + useEmailPageParams 훅
layout.tsxNuqsAdapter 래핑 추가
email-list.tsximport 교체
email-pagination.tsximport 교체
email-filter-dropdowns.tsximport 교체
use-email-list.tsimport 교체 + useShallow 제거
email-search-input.tsximport 교체
index.ts (barrel)export 변경
email-page-store.ts삭제

마이그레이션 타임라인

Step 1
pnpm add nuqs
~1분
Step 2
email-page-params.ts 작성 (파서 + 래퍼 훅)
~30분
Step 3
layout.tsx에 NuqsAdapter 추가
~5분
Step 4
소비자 6파일 import 교체 + barrel 수정
~30분
Step 5
email-page-store.ts 삭제
~5분
Step 6
테스트 (새로고침, 탭전환, 뒤로가기, 사이드바, URL 공유)
~30분
총 예상
2시간 이내. 이미 실제 diff가 확인된 상태로, 구조적 리스크는 낮습니다.
Conclusion

결론 및 향후 확장

Email 필터 영속화에 nuqs를 도입하고, 향후 다른 페이지의 필터에도 동일한 패턴을 확장하는 것을 제안합니다.

왜 nuqs인가 — 한 눈에

기준수동 구현nuqs
동기화 코드 ~80줄 배관 코드 0줄 — 라이브러리가 처리
CRITICAL 버그 3건 — 수동 처리 필요 0건 — 라이브러리 내부 처리
React 패턴 준수 모듈 레벨 hack 필요 React 표준 lifecycle
타입 안전성 수동 변환 + 검증 파서 기반 자동 타입 추론
번들 영향 qs 라이브러리 (기존) +3.2KB gzip

도입 제안 요약

1
당장 해결: Email 페이지 필터 새로고침 유실, 탭 전환 시 불완전 리셋 버그, URL 공유 불가 — 모두 해결됩니다.
2
코드 품질: ~80줄 동기화 배관 코드를 제거하고 선언적 파서로 대체합니다. 모듈 레벨 hack, 수동 파싱, subscribe 패턴이 모두 사라집니다.
3
안전한 범위: Email 페이지에만 한정 적용합니다. 다른 페이지의 Zustand 스토어(LNB 등)는 건드리지 않습니다.
4
최소 변경: 래퍼 훅의 alias 패턴으로 소비자 변경을 import 교체 수준으로 최소화합니다. 기존 인터페이스(activeTab, statusFilter 등)를 그대로 유지합니다.
5
향후 확장: 바이어, 인벤토리 등 다른 페이지에서도 필터 URL 영속화가 필요할 때 동일한 nuqs 패턴을 재사용할 수 있습니다. 한 번 도입하면 팀 전체의 URL 상태 관리 표준이 됩니다.

향후 검토 사항

🔮  다른 페이지 확산

바이어 목록, 인벤토리 페이지 등에서 동일한 필터 URL 영속화 요구가 있을 때, useQueryStates + 선언적 파서 패턴을 그대로 적용할 수 있습니다. 별도 검토로 진행합니다.

🧹  Zustand 역할 재정의

nuqs 도입 후 Zustand는 프로젝트 내에서 "순수 클라이언트 UI 상태"(모달, 사이드바 토글 등)에만 사용하고, 서버 상태에 영향을 주는 필터/검색은 URL로 관리하는 컨벤션을 정리할 수 있습니다.