Search Input 개요
실시간 검색
타이핑하면서 검색즉각적인 결과
자동완성
추천 검색어빠른 선택
디바운스
API 호출 최적화불필요한 요청 제거
하이라이팅
검색어 강조가독성 향상
Search Input이란?
문제: 기본 검색의 한계
기본 HTML<input type="search">는 검색 버튼을 눌러야만 검색이 실행됩니다.
복사
// ❌ 전통적인 검색 - 불편함
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
const { results } = await searchService.search({ query });
setResults(results);
}
return (
<form onSubmit={handleSearch}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">Search</button>
</form>
);
}
- 버튼 클릭 필요: 엔터키나 버튼을 눌러야만 검색
- 피드백 지연: 결과를 보려면 검색 실행 필요
- 자동완성 없음: 사용자가 모든 것을 타이핑해야 함
- 오타 수정 어려움: 결과를 보기 전까지 오타 발견 불가
해결: Search Input 컴포넌트
Sonamu의 Search Input은 타이핑하면서 실시간으로 검색하고 자동완성을 제공합니다.복사
// ✅ 실시간 검색 - 편리함
<SearchInput
onSearch={(query) => {
// 타이핑하면서 자동으로 호출됨
}}
placeholder="Search..."
showSuggestions
/>
- ✨ 즉각적인 피드백: 타이핑하면서 결과 확인
- ✨ 자동완성: 추천 검색어로 빠른 입력
- ✨ 디바운스: 불필요한 API 호출 최소화
- ✨ 키보드 네비게이션: 방향키로 제안 항목 선택
기본 사용법
단순 검색
가장 기본적인 검색 기능입니다.복사
import { SearchInput } from "@/components/common/SearchInput";
import { useState } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
return (
<div>
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search posts..."
/>
{query && (
<div>Searching for: {query}</div>
)}
</div>
);
}
자동완성 포함
제안 항목을 표시합니다.복사
function SearchWithSuggestions() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
// 검색어 변경 시 제안 로드
useEffect(() => {
if (query) {
searchService.getSuggestions({ query }).then(({ suggestions }) => {
setSuggestions(suggestions);
});
} else {
setSuggestions([]);
}
}, [query]);
return (
<SearchInput
value={query}
onChange={setQuery}
suggestions={suggestions}
onSelectSuggestion={(suggestion) => {
setQuery(suggestion);
// 선택한 제안으로 검색 실행
}}
placeholder="Type to search..."
/>
);
}
Service와 통합
Sonamu Service를 사용하여 검색합니다.복사
import { searchService } from "@/services/search.service";
import { useDebounce } from "@/hooks/useDebounce";
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebounce(query, 300);
// 디바운스된 쿼리로 검색
useEffect(() => {
if (debouncedQuery) {
searchService.search({ query: debouncedQuery }).then(({ results }) => {
setResults(results);
});
} else {
setResults([]);
}
}, [debouncedQuery]);
return (
<div>
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search..."
/>
<div className="results">
{results.map((result) => (
<div key={result.id}>{result.title}</div>
))}
</div>
</div>
);
}
고급 기능
엔티티별 검색
특정 엔티티를 검색합니다.복사
function EntitySearch() {
const [entityType, setEntityType] = useState<"users" | "posts">("users");
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
useEffect(() => {
if (query) {
const service = entityType === "users" ? userService : postService;
service.search({ query }).then(({ results }) => {
setResults(results);
});
}
}, [query, entityType]);
return (
<div>
<div className="entity-selector">
<button
className={entityType === "users" ? "active" : ""}
onClick={() => setEntityType("users")}
>
Users
</button>
<button
className={entityType === "posts" ? "active" : ""}
onClick={() => setEntityType("posts")}
>
Posts
</button>
</div>
<SearchInput
value={query}
onChange={setQuery}
placeholder={`Search ${entityType}...`}
/>
<div className="results">
{results.map((result) => (
<div key={result.id}>
{entityType === "users"
? result.username
: result.title
}
</div>
))}
</div>
</div>
);
}
필터와 통합
검색어와 함께 필터를 적용합니다.복사
function AdvancedSearch() {
const [query, setQuery] = useState("");
const [filters, setFilters] = useState({
category: null,
dateRange: null,
author: null,
});
const { results, isLoading } = useSearch({
query,
...filters,
});
return (
<div>
<div className="search-bar">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search posts..."
/>
</div>
<div className="filters">
<select
value={filters.category || ""}
onChange={(e) => setFilters({ ...filters, category: e.target.value || null })}
>
<option value="">All Categories</option>
<option value="tech">Tech</option>
<option value="news">News</option>
</select>
</div>
<div className="results">
{isLoading ? (
<div>Loading...</div>
) : (
results.map((result) => (
<div key={result.id}>{result.title}</div>
))
)}
</div>
</div>
);
}
검색 히스토리
최근 검색어를 저장하고 표시합니다.복사
function SearchWithHistory() {
const [query, setQuery] = useState("");
const [history, setHistory] = useState<string[]>([]);
// 로컬 스토리지에서 히스토리 로드
useEffect(() => {
const saved = localStorage.getItem("search-history");
if (saved) {
setHistory(JSON.parse(saved));
}
}, []);
function handleSearch(searchQuery: string) {
// 히스토리에 추가
const newHistory = [searchQuery, ...history.filter(h => h !== searchQuery)].slice(0, 10);
setHistory(newHistory);
localStorage.setItem("search-history", JSON.stringify(newHistory));
// 검색 실행
setQuery(searchQuery);
}
return (
<SearchInput
value={query}
onChange={setQuery}
onSearch={handleSearch}
suggestions={query ? [] : history} // 입력 없으면 히스토리 표시
placeholder="Search..."
/>
);
}
하이라이팅
검색어를 결과에서 강조 표시합니다.복사
function SearchResults({ results, query }: {
results: any[];
query: string;
}) {
function highlightText(text: string, highlight: string) {
if (!highlight) return text;
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
return parts.map((part, i) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<mark key={i}>{part}</mark>
) : (
part
)
);
}
return (
<div>
{results.map((result) => (
<div key={result.id}>
<h3>{highlightText(result.title, query)}</h3>
<p>{highlightText(result.content, query)}</p>
</div>
))}
</div>
);
}
커스텀 Hook 패턴
useSearch Hook
검색 로직을 재사용 가능한 Hook으로 추상화합니다.복사
// hooks/useSearch.ts
import { useState, useEffect } from "react";
import { useDebounce } from "./useDebounce";
export function useSearch<T>(
searchFn: (query: string) => Promise<T[]>,
debounceMs: number = 300
) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debouncedQuery = useDebounce(query, debounceMs);
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
setIsLoading(true);
setError(null);
searchFn(debouncedQuery)
.then(setResults)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, [debouncedQuery]);
return {
query,
setQuery,
results,
isLoading,
error,
};
}
복사
function SearchPage() {
const { query, setQuery, results, isLoading } = useSearch(
(q) => postService.search({ query: q }).then(r => r.posts)
);
return (
<div>
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search posts..."
/>
{isLoading ? (
<div>Searching...</div>
) : (
<div>
{results.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
)}
</div>
);
}
useSearchSuggestions Hook
자동완성 제안을 관리합니다.복사
// hooks/useSearchSuggestions.ts
import { useState, useEffect } from "react";
import { useDebounce } from "./useDebounce";
export function useSearchSuggestions(
getSuggestionsFn: (query: string) => Promise<string[]>,
minLength: number = 2
) {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const debouncedQuery = useDebounce(query, 200);
useEffect(() => {
if (debouncedQuery.length < minLength) {
setSuggestions([]);
return;
}
getSuggestionsFn(debouncedQuery).then(setSuggestions);
}, [debouncedQuery]);
return {
query,
setQuery,
suggestions,
};
}
실전 예제
글로벌 검색
사이트 전체를 검색합니다.복사
function GlobalSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<{
users: User[];
posts: Post[];
comments: Comment[];
}>({
users: [],
posts: [],
comments: [],
});
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
// 여러 엔티티 동시 검색
Promise.all([
userService.search({ query: debouncedQuery }),
postService.search({ query: debouncedQuery }),
commentService.search({ query: debouncedQuery }),
]).then(([users, posts, comments]) => {
setResults({
users: users.users,
posts: posts.posts,
comments: comments.comments,
});
});
}
}, [debouncedQuery]);
return (
<div className="global-search">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search everything..."
/>
{query && (
<div className="results">
{results.users.length > 0 && (
<div className="result-section">
<h3>Users ({results.users.length})</h3>
{results.users.map((user) => (
<div key={user.id}>{user.username}</div>
))}
</div>
)}
{results.posts.length > 0 && (
<div className="result-section">
<h3>Posts ({results.posts.length})</h3>
{results.posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
)}
{results.comments.length > 0 && (
<div className="result-section">
<h3>Comments ({results.comments.length})</h3>
{results.comments.map((comment) => (
<div key={comment.id}>{comment.content}</div>
))}
</div>
)}
</div>
)}
</div>
);
}
네비게이션 검색
헤더에 통합된 검색입니다.복사
function Header() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const { query, setQuery, results, isLoading } = useSearch(
(q) => searchService.globalSearch({ query: q })
);
return (
<header>
<div className="logo">My App</div>
<div className="search-container">
{/* 검색 아이콘 */}
<button onClick={() => setIsSearchOpen(!isSearchOpen)}>
🔍
</button>
{/* 검색 모달 */}
{isSearchOpen && (
<div className="search-modal">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search..."
autoFocus
/>
{isLoading && <div>Loading...</div>}
{results.length > 0 && (
<div className="quick-results">
{results.slice(0, 5).map((result) => (
<a
key={result.id}
href={`/${result.type}/${result.id}`}
onClick={() => setIsSearchOpen(false)}
>
{result.title}
</a>
))}
</div>
)}
</div>
)}
</div>
<nav>{/* 네비게이션 메뉴 */}</nav>
</header>
);
}
성능 최적화
디바운스 최적화
검색 빈도에 따라 디바운스 시간을 조정합니다.복사
// 빠른 타이핑: 짧은 디바운스
const fastDebounce = useDebounce(query, 200);
// 느린 타이핑: 긴 디바운스
const slowDebounce = useDebounce(query, 500);
// API 응답이 느린 경우: 더 긴 디바운스
const longDebounce = useDebounce(query, 1000);
캐싱 전략
TanStack Query로 검색 결과를 캐싱합니다.복사
import { useQuery } from "@tanstack/react-query";
function useSearchWithCache(query: string) {
const debouncedQuery = useDebounce(query, 300);
const { data, error, isLoading } = useQuery({
queryKey: ["search", debouncedQuery],
queryFn: () => searchService.search({ query: debouncedQuery }),
enabled: !!debouncedQuery,
staleTime: 5000, // 5초간 fresh 상태 유지
refetchOnWindowFocus: false, // 포커스 시 재검증 안함
});
return {
results: data?.results || [],
isLoading,
error,
};
}
요청 취소
컴포넌트 언마운트 시 진행 중인 요청을 취소합니다.복사
useEffect(() => {
const abortController = new AbortController();
if (debouncedQuery) {
searchService.search(
{ query: debouncedQuery },
{ signal: abortController.signal }
).then(setResults);
}
return () => {
abortController.abort();
};
}, [debouncedQuery]);
주의사항
Search Input 사용 시 주의사항:
- 디바운스 필수: 300~500ms 권장
- 최소 검색 길이 설정 (보통 2~3자)
- 빈 문자열 처리: 모든 결과 반환 방지
- 키보드 네비게이션 지원 (접근성)
- 검색 API는 인덱스 최적화 필수
- 결과가 많으면 페이지네이션 고려
