메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
Sonamu의 Search Input μ»΄ν¬λ„ŒνŠΈλ‘œ μ‹€μ‹œκ°„ 검색과 μžλ™μ™„μ„± κΈ°λŠ₯을 κ΅¬ν˜„ν•˜λŠ” 방법을 μ•Œμ•„λ΄…λ‹ˆλ‹€.

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>
  );
}
문제점:
  1. λ²„νŠΌ 클릭 ν•„μš”: μ—”ν„°ν‚€λ‚˜ λ²„νŠΌμ„ λˆŒλŸ¬μ•Όλ§Œ 검색
  2. ν”Όλ“œλ°± μ§€μ—°: κ²°κ³Όλ₯Ό 보렀면 검색 μ‹€ν–‰ ν•„μš”
  3. μžλ™μ™„μ„± μ—†μŒ: μ‚¬μš©μžκ°€ λͺ¨λ“  것을 타이핑해야 함
  4. μ˜€νƒ€ μˆ˜μ • 어렀움: κ²°κ³Όλ₯Ό 보기 μ „κΉŒμ§€ μ˜€νƒ€ 발견 λΆˆκ°€

ν•΄κ²°: Search Input μ»΄ν¬λ„ŒνŠΈ

Sonamu의 Search Input은 νƒ€μ΄ν•‘ν•˜λ©΄μ„œ μ‹€μ‹œκ°„μœΌλ‘œ κ²€μƒ‰ν•˜κ³  μžλ™μ™„μ„±μ„ μ œκ³΅ν•©λ‹ˆλ‹€.
// βœ… μ‹€μ‹œκ°„ 검색 - νŽΈλ¦¬ν•¨
<SearchInput
  onSearch={(query) => {
    // νƒ€μ΄ν•‘ν•˜λ©΄μ„œ μžλ™μœΌλ‘œ 호좜됨
  }}
  placeholder="Search..."
  showSuggestions
/>
μž₯점:
  1. ✨ 즉각적인 ν”Όλ“œλ°±: νƒ€μ΄ν•‘ν•˜λ©΄μ„œ κ²°κ³Ό 확인
  2. ✨ μžλ™μ™„μ„±: μΆ”μ²œ κ²€μƒ‰μ–΄λ‘œ λΉ λ₯Έ μž…λ ₯
  3. ✨ λ””λ°”μš΄μŠ€: λΆˆν•„μš”ν•œ API 호좜 μ΅œμ†Œν™”
  4. ✨ ν‚€λ³΄λ“œ λ„€λΉ„κ²Œμ΄μ…˜: λ°©ν–₯ν‚€λ‘œ μ œμ•ˆ ν•­λͺ© 선택

κΈ°λ³Έ μ‚¬μš©λ²•

λ‹¨μˆœ 검색

κ°€μž₯ 기본적인 검색 κΈ°λŠ₯μž…λ‹ˆλ‹€.
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 μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­: 1. λ””λ°”μš΄μŠ€ ν•„μˆ˜: 300500ms ꢌμž₯ 2. μ΅œμ†Œ 검색 길이 μ„€μ • (보톡 23자) 3. 빈 λ¬Έμžμ—΄ 처리: λͺ¨λ“  κ²°κ³Ό λ°˜ν™˜ λ°©μ§€ 4. ν‚€λ³΄λ“œ λ„€λΉ„κ²Œμ΄μ…˜ 지원 (μ ‘κ·Όμ„±) 5. 검색 APIλŠ” 인덱슀 μ΅œμ ν™” ν•„μˆ˜ 6. κ²°κ³Όκ°€ 많으면 νŽ˜μ΄μ§€λ„€μ΄μ…˜ κ³ λ €

λ‹€μŒ 단계

ID Async Select

관계 데이터 선택

μ»€μŠ€ν…€ μ»΄ν¬λ„ŒνŠΈ

μ»΄ν¬λ„ŒνŠΈ μ»€μŠ€ν„°λ§ˆμ΄μ§•

View Scaffolding

μžλ™ λ·° 생성

TanStack Query Hook

데이터 캐싱