[TIL] Next.js 15 블로그 검색 기능 구현: 실시간 검색부터 최적화까지
![Cover image for [TIL] Next.js 15 블로그 검색 기능 구현: 실시간 검색부터 최적화까지](/_next/image?url=https%3A%2F%2Fapp.notion.com%2Fimages%2Fpage-cover%2Frijksmuseum_avercamp_1608.jpg&w=3840&q=75)
Next.js 15와 TypeScript를 활용해 블로그에 강력한 검색 기능을 구현하는 과정을 상세히 정리해봤습니다. 단순한 텍스트 검색에서 시작해 다차원 필터링, 실시간 검색, 성능 최적화까지 실전에서 바로 적용할 수 있는 내용들을 다룹니다.
핵심 개념 미리보기:
- Server/Client Components: Next.js 15의 핵심 아키텍처 패턴
- Debounce: 실시간 검색 성능 최적화의 핵심 기법
- Memoization: React의 리렌더링 최적화 전략
- TypeScript Generics: 타입 안전성과 재사용성을 높이는 고급 패턴
구현 목표
이번 프로젝트의 핵심 목표는 다음과 같습니다:
- 전체 포스트 페이지와 실시간 검색 기능 구현
- Server/Client 컴포넌트 분리를 통한 최적화
- 다차원 검색 (제목, 내용, 태그, 카테고리)
- 성능 최적화를 위한 디바운스와 메모이제이션
핵심 아키텍처: Client-Server 분리
1. Server Component (데이터 페칭)
Next.js 15 App Router의 가장 혁신적인 기능은 Server/Client 컴포넌트를 명확히 분리할 수 있다는 점입니다.
왜 분리가 중요한가?
- Server Component: 서버에서 실행되어 SEO 최적화, 빠른 초기 로딩
- Client Component: 브라우저에서 실행되어 실시간 인터랙션 처리
- 하이드레이션 최소화: 필요한 부분만 클라이언트 사이드로 전송
// Server Component (데이터 페칭) async function PostsData() { const { posts, tags, categories } = await getPostsWithMetadata() // 서버에서 excerpt 생성 const postsWithExcerpts = await Promise.all( posts.map(async (post) => ({ ...post, excerpt: await generateExcerpt(post.content) })) ) return ( <SearchInterface posts={postsWithExcerpts} tags={tags} categories={categories} /> ) }
2. Client Component (인터랙션)
사용자 인터랙션이 필요한 부분만 클라이언트 컴포넌트로 분리합니다.
분리 원칙:
- 상태 관리 (
useState,useEffect) → Client Component
- 데이터 페칭 (
async/await) → Server Component
- 이벤트 핸들러 (
onClick,onChange) → Client Component
'use client' export function SearchInterface({ posts, onResultsChange }) { const [searchState, setSearchState] = useState<SearchState>(defaultState) // 클라이언트에서 실시간 검색 처리 }
이런 분리를 통해 SEO 최적화와 인터랙티브한 사용자 경험을 동시에 확보할 수 있습니다.
실시간 검색 최적화
Debounce 패턴으로 성능 개선
실시간 검색에서 가장 중요한 것은 불필요한 연산을 줄이는 것입니다.
Debounce 패턴이 필요한 이유:
- 사용자가 타이핑할 때마다 검색하면 성능 저하 (예: "React" 입력 시 5번의 검색 실행)
- 서버 요청이 많으면 Rate Limit 위험
- 불필요한 리렌더링으로 UI 끊김 현상
TypeScript 제네릭을 활용한 debounce 함수를 구현했습니다.
export function debounce<T extends (...args: never[]) => void>( func: T, delay: number ): T { let timeoutId: NodeJS.Timeout | null = null return ((...args: Parameters<T>) => { if (timeoutId) clearTimeout(timeoutId) // 이전 타이머 취소 timeoutId = setTimeout(() => func(...args), delay) // 새 타이머 설정 }) as T }
제네릭 타입 분석:
<T extends (...args: never[]) => void>: 함수 타입만 허용하는 제약 조건
Parameters<T>: 함수 T의 매개변수 타입들을 추출
as T: 반환 함수가 원본과 동일한 타입임을 보장
300ms 디바운스를 적용해 사용자가 타이핑을 멈춘 후에만 검색이 실행되도록 최적화했습니다.
다차원 검색 알고리즘
검색 기능은 단순한 제목 매칭을 넘어서 포괄적인 필터링 시스템으로 구현했습니다.
export function applyFilters( posts: NotionPost[], filters: SearchFilters, excerpts?: Map<string, string> ): NotionPost[] { let filteredPosts = posts // 1. 텍스트 검색 (제목, 내용, 태그, 카테고리) if (filters.query.trim()) { filteredPosts = searchPosts(filteredPosts, filters.query, excerpts) } // 2. 카테고리 필터링 if (filters.selectedCategories.length > 0) { filteredPosts = filterByCategories(filteredPosts, filters.selectedCategories) } // 3. 태그 필터링 if (filters.selectedTags.length > 0) { filteredPosts = filterByTags(filteredPosts, filters.selectedTags) } // 4. 정렬 return sortPosts(filteredPosts, filters.sortBy) }
순차적 필터링을 통해 각 단계에서 검색 범위를 좁혀가며 성능을 최적화했습니다.
React 상태 관리 최적화
복합 상태를 단일 객체로 관리
여러 필터링 조건을 하나의 상태 객체로 관리해 상태 업데이트를 단순화했습니다.
interface SearchState extends SearchFilters { viewMode: ViewMode postsPerPage: number } const [searchState, setSearchState] = useState<SearchState>(defaultSearchState) const updateSearchState = useCallback((updates: Partial<SearchState>) => { const newState = { ...searchState, ...updates } setSearchState(newState) // 즉시 반영 vs 디바운스 처리 분기 if (updates.sortBy || updates.viewMode) { // 정렬이나 뷰 모드는 즉시 반영 onResultsChange(newState) } else { // 검색어는 디바운스 처리 debouncedSearch(newState) } }, [searchState, debouncedSearch])
메모이제이션으로 리렌더링 최적화
비용이 큰 계산들을
useMemo로 최적화했습니다.useMemo 최적화 원리:
- 의존성 배열이 변경되지 않으면 이전 결과 재사용
- 배열 연산(slice, map, filter)은 매번 새로운 배열 생성 → 메모이제이션 필수
- 얕은 비교로 의존성 체크 → 객체/배열 참조 변경 시에만 재계산
const paginatedPosts = useMemo(() => { const startIndex = (currentPage - 1) * postsPerPage return filteredPosts.slice(startIndex, startIndex + postsPerPage) }, [filteredPosts, currentPage, postsPerPage]) const postsWithExcerpts = useMemo(() => { return paginatedPosts.map(post => ({ ...post, excerpt: excerpts.get(post.id) || '' })) }, [paginatedPosts, excerpts])
TypeScript로 타입 안전성 확보
유니온 타입과 인터페이스 설계
명확한 타입 정의로 개발 과정에서 발생할 수 있는 오류를 사전에 방지했습니다.
export type SortOption = 'newest' | 'oldest' | 'title-asc' | 'title-desc' export type ViewMode = 'grid' | 'list' | 'compact' interface SearchFilters { query: string selectedCategories: string[] selectedTags: string[] sortBy: SortOption }
제네릭 함수로 재사용성 높이기
function paginatePosts<T>( posts: T[], page: number, perPage: number ): { posts: T[]; totalPages: number; hasNext: boolean; hasPrev: boolean } { const totalPages = Math.ceil(posts.length / perPage) const startIndex = (page - 1) * perPage const paginatedPosts = posts.slice(startIndex, startIndex + perPage) return { posts: paginatedPosts, totalPages, hasNext: page < totalPages, hasPrev: page > 1 } }
사용자 경험 개선
반응형 뷰 모드 전환
사용자가 선호하는 방식으로 포스트를 볼 수 있도록 다양한 뷰 옵션을 제공했습니다.
const viewModeOptions = [ { value: 'grid', label: 'Grid', icon: 'Grid' }, { value: 'list', label: 'List', icon: 'List' }, { value: 'compact', label: 'Compact', icon: 'Rows' }, ] // 동적 아이콘 렌더링 const IconComponent = mode.icon === 'Grid' ? Grid : mode.icon === 'List' ? List : Rows
에러 처리와 로딩 상태
Next.js 15의 에러 바운더리 패턴을 활용해 사용자 친화적인 에러 처리를 구현했습니다.
에러 바운더리 동작 원리:
- 자동 감지: 컴포넌트 트리에서 JavaScript 에러 발생 시 캐치
- 폴백 UI: 에러 대신 사용자에게 친화적인 메시지 표시
- 에러 격리: 전체 앱 크래시 대신 해당 컴포넌트만 에러 처리
- 복구 메커니즘:
reset()함수로 컴포넌트 재시도 가능
'use client' export default function PostsError({ error, reset }: ErrorProps) { useEffect(() => { console.error('Posts page error:', error) }, [error]) return ( <Button onClick={reset}> <RefreshCw className="w-4 h-4 mr-2" /> 다시 시도 </Button> ) }
성능 최적화 전략
데이터 캐싱
// Notion API 캐싱 let postsCache: NotionPost[] | null = null const CACHE_DURATION = 5 * 60 * 1000 // 5분 // 검색용 excerpt Map 캐싱 (O(1) 검색) const excerpts = new Map( postsWithExcerpts.map(post => [post.id, post.excerpt]) )
Map 캐싱의 성능 이점:
- 배열 검색:
array.find()→ O(n) 시간 복잡도
- Map 검색:
map.get()→ O(1) 시간 복잡도
- 메모리 효율: 키-값 쌍으로 정확한 데이터만 저장
실제 성능 차이 예시:
// 느림: 1000개 포스트에서 매번 검색 const excerpt = posts.find(p => p.id === postId)?.excerpt // O(n) // 빠름: Map에서 즉시 검색 const excerpt = excerpts.get(postId) // O(1)
클라이언트 사이드 페이지네이션
const totalPages = Math.ceil(filteredPosts.length / postsPerPage) const paginatedPosts = useMemo(() => { const startIndex = (currentPage - 1) * postsPerPage return filteredPosts.slice(startIndex, startIndex + postsPerPage) }, [filteredPosts, currentPage, postsPerPage])
향후 개선 방향
현재 구현을 바탕으로 다음과 같은 기능들을 추가할 수 있습니다:
- 고급 검색 엔진: Fuse.js를 활용한 fuzzy search
- 무한 스크롤: Intersection Observer API 활용
- 검색 기록: localStorage를 활용한 최근 검색어 저장
- 가상화: react-window로 대량 데이터 처리 최적화
핵심 학습 내용
이번 구현을 통해 얻은 주요 인사이트들입니다:
아키텍처 설계
- Server/Client 컴포넌트 분리로 SEO와 인터랙션 최적화
- 단방향 데이터 플로우로 상태 관리 단순화
성능 최적화
- Debounce 패턴으로 불필요한 연산 방지
- Memoization으로 리렌더링 최적화
- 클라이언트 사이드 필터링으로 빠른 응답
타입 안전성
- TypeScript 제네릭과 유니온 타입 활용