OPEN HYPER STEP
← 목록으로 (master-project)
MASTER-PROJECT · 18 / 50
master-project
CHAPTER 18 / 50
읽기 약 2
FUNCTION

검색 + 필터 + 정렬 + 페이지네이션


핵심 개념

URL 기반 상태 + Debounce + 전문 검색 + 정렬 옵션 + 무한 스크롤 — 실전 목록 페이지의 핵심.

본문

URL 기반 상태 (공유 가능)

TSX📋 코드 (31줄)
// src/app/(app)/history/page.tsx
export default async function HistoryPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string; sort?: string; filter?: string; cursor?: string }>
}) {
  const params = await searchParams
  const supabase = await createClient()

  let q = supabase.from('generations').select('*')

  // 검색
  if (params.q) q = q.ilike('prompt', `%${params.q}%`)

  // 필터
  if (params.filter === 'today') {
    const today = new Date().toISOString().split('T')[0]
    q = q.gte('created_at', today)
  }

  // 정렬
  const sortBy = params.sort === 'oldest' ? { ascending: true } : { ascending: false }
  q = q.order('created_at', sortBy)

  // 페이지네이션
  if (params.cursor) q = q.lt('created_at', params.cursor)
  q = q.limit(20)

  const { data: gens } = await q
  return <HistoryClient gens={gens} params={params} />
}

검색 박스 (Debounce)

TSX📋 코드 (34줄)
// src/components/search-input.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { Search } from 'lucide-react'

export function SearchInput() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const [value, setValue] = useState(searchParams.get('q') ?? '')

  useEffect(() => {
    const t = setTimeout(() => {
      const params = new URLSearchParams(searchParams)
      if (value) params.set('q', value)
      else params.delete('q')
      router.replace(`${pathname}?${params.toString()}`)
    }, 300)
    return () => clearTimeout(t)
  }, [value])

  return (
    <div className="relative max-w-md">
      <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
      <input
        value={value}
        onChange={e => setValue(e.target.value)}
        placeholder="검색..."
        className="w-full pl-9 pr-3 py-2 border rounded-md"
      />
    </div>
  )
}

정렬·필터 드롭다운

TSX📋 코드 (29줄)
'use client'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { ChevronDown } from 'lucide-react'

export function SortDropdown() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const current = searchParams.get('sort') ?? 'newest'

  const setSort = (sort: string) => {
    const params = new URLSearchParams(searchParams)
    params.set('sort', sort)
    router.replace(`${pathname}?${params.toString()}`)
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger className="border px-3 py-1.5 rounded text-sm flex items-center gap-1">
        {current === 'newest' ? '최신순' : '오래된순'} <ChevronDown className="h-3 w-3" />
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem onClick={() => setSort('newest')}>최신순</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setSort('oldest')}>오래된순</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

전문 검색 (PostgreSQL FTS)

SQL📋 코드 (5줄)
-- 마이그레이션
alter table generations add column search_vec tsvector
  generated always as (to_tsvector('simple', coalesce(prompt,'') || ' ' || coalesce(result,''))) stored;

create index idx_gens_fts on generations using gin(search_vec);
TS📋 코드 (5줄)
// 검색 사용
const { data } = await supabase
  .from('generations')
  .select()
  .textSearch('search_vec', `${query}:*`)

무한 스크롤 (IntersectionObserver)

TSX📋 코드 (19줄)
'use client'
import { useEffect, useRef } from 'react'

export function LoadMoreSentinel({ onLoad }: { onLoad: () => void }) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const io = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting) onLoad()
      },
      { rootMargin: '100px' }
    )
    if (ref.current) io.observe(ref.current)
    return () => io.disconnect()
  }, [onLoad])

  return <div ref={ref} className="h-10" />
}

결과 없음 (No Results)

TSX📋 코드 (8줄)
{gens && gens.length === 0 && (
  <div className="text-center py-12">
    <p className="text-muted-foreground">검색 결과가 없습니다.</p>
    <button onClick={resetFilters} className="text-primary hover:underline mt-2">
      필터 초기화
    </button>
  </div>
)}

다음 챕터

CH.19 "파일 업로드 UI: 드래그앤드롭".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 마스터 프로젝트의 검색·필터·페이지네이션 부분을 분석해서
실전 적용 + 개선 우선순위 3가지를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

검색·필터·페이지네이션 관련 모범 사례·안티패턴 5개를
비교 분석해서 실전 적용를 위한 추천 방안을 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 프로젝트 전체에서 검색·필터·페이지네이션
최적화 가능 위치와 리스크를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 1인 개발자 시장의
검색·필터·페이지네이션 트렌드와 차별화 포인트를 정리해줘.

⭐ 이것만 기억하세요
검색 + 필터 + 정렬 + 페이지네이션 이 3가지만 확실히 잡으세요
1.URL searchParams = 공유 가능한 검색 상태
2.PostgreSQL FTS = 빠른 전문 검색
3.다음 챕터에서 파일 업로드


공유하기
진행도 18 / 50