이 글은 누구를 위한 것인가
- 검색창이 있는데 사용률이 낮은 팀
- 자동완성 드롭다운을 어떻게 설계해야 할지 모르는 팀
- 검색 결과가 없을 때 사용자가 이탈하는 문제를 해결하고 싶은 팀
들어가며
이커머스에서 검색 사용자의 전환율은 비검색 사용자보다 2-3배 높다. 검색으로 들어온 사람은 구매 의도가 명확하다. 검색 UX를 잘 설계하면 가장 확실한 전환율 향상을 이끌 수 있다.
이 글은 bluefoxdev.kr의 검색 UX 가이드 를 참고하여 작성했습니다.
1. 검색 인터페이스 설계 원칙
[검색창 UX 체크리스트]
입력 전 (빈 상태):
□ 플레이스홀더: "상품명, 브랜드 검색..." (구체적으로)
□ 최근 검색어 표시 (최대 5개)
□ 인기 검색어 표시
□ 카테고리 바로가기 링크
입력 중 (자동완성):
□ 최소 2글자부터 자동완성 실행
□ 검색어 하이라이트 (bold)
□ 카테고리 구분 (상품명 / 브랜드 / 카테고리)
□ 이미지 썸네일 포함 (상품 검색)
□ 키보드 네비게이션 (↑↓ Enter)
결과 없음:
□ "X에 대한 결과 없음" (검색어 표시)
□ 유사 검색어 추천
□ 인기 상품 추천
□ 검색어 교정 제안 ("혹시 이걸 찾으셨나요?")
검색 결과:
□ 결과 수 표시 "총 143개 상품"
□ 검색어 하이라이트
□ 관련도 정렬 (기본)
□ 필터 연동
2. 자동완성 컴포넌트
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
interface SearchSuggestion {
type: "product" | "brand" | "category";
id: string;
text: string;
imageUrl?: string;
url: string;
}
function SearchAutocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<NodeJS.Timeout>();
useEffect(() => {
const saved = localStorage.getItem("recentSearches");
if (saved) setRecentSearches(JSON.parse(saved));
}, []);
const fetchSuggestions = useCallback(async (q: string) => {
if (q.length < 2) {
setSuggestions([]);
return;
}
const res = await fetch(`/api/search/suggest?q=${encodeURIComponent(q)}`);
const data = await res.json();
setSuggestions(data.suggestions);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
setSelectedIndex(-1);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchSuggestions(value), 200);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const items = query.length < 2 ? recentSearches : suggestions;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, -1));
} else if (e.key === "Enter") {
if (selectedIndex >= 0) {
const item = items[selectedIndex];
const text = typeof item === "string" ? item : (item as SearchSuggestion).text;
handleSearch(text);
} else {
handleSearch(query);
}
} else if (e.key === "Escape") {
setIsOpen(false);
}
};
const handleSearch = (searchQuery: string) => {
if (!searchQuery.trim()) return;
// 최근 검색어 저장
const updated = [searchQuery, ...recentSearches.filter((r) => r !== searchQuery)].slice(0, 5);
setRecentSearches(updated);
localStorage.setItem("recentSearches", JSON.stringify(updated));
setIsOpen(false);
window.location.href = `/search?q=${encodeURIComponent(searchQuery)}`;
};
const showEmpty = isOpen && query.length < 2;
const showSuggestions = isOpen && query.length >= 2;
return (
<div className="relative w-full max-w-2xl">
<div className="relative">
<input
ref={inputRef}
type="search"
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
placeholder="상품명, 브랜드, 카테고리 검색..."
className="w-full pl-12 pr-4 py-3 border rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
{/* 자동완성 드롭다운 */}
{isOpen && (
<div className="absolute top-full left-0 right-0 bg-white border rounded-xl shadow-xl z-50 mt-2 overflow-hidden">
{showEmpty && (
<div className="p-4">
{recentSearches.length > 0 && (
<div>
<p className="text-xs text-gray-400 font-medium mb-2">최근 검색</p>
{recentSearches.map((recent, i) => (
<button
key={i}
onMouseDown={() => handleSearch(recent)}
className={`flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-left ${
selectedIndex === i ? "bg-blue-50" : "hover:bg-gray-50"
}`}
>
<span className="text-gray-400">⏱</span>
{recent}
</button>
))}
</div>
)}
</div>
)}
{showSuggestions && suggestions.length === 0 && (
<div className="p-6 text-center text-gray-500 text-sm">
<p>"{query}"에 대한 추천이 없습니다</p>
</div>
)}
{showSuggestions && suggestions.length > 0 && (
<ul role="listbox">
{suggestions.map((item, i) => (
<li key={item.id} role="option" aria-selected={selectedIndex === i}>
<button
onMouseDown={() => handleSearch(item.text)}
className={`flex items-center gap-3 w-full px-4 py-3 text-sm text-left ${
selectedIndex === i ? "bg-blue-50" : "hover:bg-gray-50"
}`}
>
{item.imageUrl && (
<img src={item.imageUrl} alt="" className="w-10 h-10 rounded object-cover" />
)}
<div>
<span
dangerouslySetInnerHTML={{
__html: item.text.replace(
new RegExp(`(${query})`, "gi"),
"<strong>$1</strong>"
),
}}
/>
<span className="text-xs text-gray-400 ml-2">{
item.type === "product" ? "상품" :
item.type === "brand" ? "브랜드" : "카테고리"
}</span>
</div>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
마무리
검색 UX에서 가장 큰 임팩트는 "검색 결과 없음" 처리다. 검색 결과가 없을 때 빈 화면만 보여주면 이탈이다. 유사 검색어 제안과 추천 상품을 함께 보여주면 검색 실패 후에도 구매로 이어질 수 있다.