검색 UX·자동완성 설계: 사용자가 원하는 것을 빠르게 찾게 하는 검색 인터페이스

UX 디자인

검색 UX자동완성검색 인터페이스이커머스 검색검색 결과

이 글은 누구를 위한 것인가

  • 검색창이 있는데 사용률이 낮은 팀
  • 자동완성 드롭다운을 어떻게 설계해야 할지 모르는 팀
  • 검색 결과가 없을 때 사용자가 이탈하는 문제를 해결하고 싶은 팀

들어가며

이커머스에서 검색 사용자의 전환율은 비검색 사용자보다 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에서 가장 큰 임팩트는 "검색 결과 없음" 처리다. 검색 결과가 없을 때 빈 화면만 보여주면 이탈이다. 유사 검색어 제안과 추천 상품을 함께 보여주면 검색 실패 후에도 구매로 이어질 수 있다.