๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
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. ๋””๋ฐ”์šด์Šค ํ•„์ˆ˜: 300~500ms ๊ถŒ์žฅ
  2. ์ตœ์†Œ ๊ฒ€์ƒ‰ ๊ธธ์ด ์„ค์ • (๋ณดํ†ต 2~3์ž)
  3. ๋นˆ ๋ฌธ์ž์—ด ์ฒ˜๋ฆฌ: ๋ชจ๋“  ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ ๋ฐฉ์ง€
  4. ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ง€์› (์ ‘๊ทผ์„ฑ)
  5. ๊ฒ€์ƒ‰ API๋Š” ์ธ๋ฑ์Šค ์ตœ์ ํ™” ํ•„์ˆ˜
  6. ๊ฒฐ๊ณผ๊ฐ€ ๋งŽ์œผ๋ฉด ํŽ˜์ด์ง€๋„ค์ด์…˜ ๊ณ ๋ ค

๋‹ค์Œ ๋‹จ๊ณ„