[중급 프로젝트] 검색 컴포넌트 만들기

ANN·2026년 2월 16일

Taskify

목록 보기
1/1
post-thumbnail

공통 컴포넌트 정리: SearchDropdown 설계와 구현

1) 왜 별도 SearchDropdown이 필요했나

기존 Dropdown 공통 컴포넌트는 "목록 열기/닫기 + 아이템 선택"에는 적합했지만,
아래 요구사항을 한 번에 만족시키기엔 역할이 부족했다.

  • 목록 안에서 사용자 이름 검색
  • 선택된 사용자 아바타/이름을 트리거 입력창에 표시
  • 바깥 영역 클릭 또는 Esc 입력으로 닫기

그래서 단순 드롭다운을 확장하는 대신, 검색과 선택 상태를 포함한 SearchDropdown을 별도 컴포넌트로 분리했다.

2) 구조: Compound Component + Context

SearchDropdown은 compound component 패턴으로 구성했다.

  • SearchDropdown: 상태 소유 (searchData, isOpen, selectedUser)
  • SearchDropdown.Trigger: 입력/트리거 렌더링
  • SearchDropdown.UserList: 필터링된 목록 렌더링
  • SearchDropdown.UserItem: 항목 선택 처리

상위에서 Context로 상태/핸들러를 공유하고, 하위는 필요한 값만 꺼내 사용한다.

// src/components/searchDropdown/SearchDropdown.tsx
export const SearchDropdown = ({ children, className = BASE }: SearchDropdownProps) => {
  const [searchData, setSearchData] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  const [selectedUser, setSelectedUser] = useState<User | null>(null);

  const handleSearch = (data: string) => setSearchData(data);

  const handleToggle = useCallback(() => {
    setSearchData('');
    setIsOpen((prev) => !prev);
  }, []);

  const handleClose = useCallback(() => {
    setIsOpen(false);
    setSearchData('');
  }, []);

  const dropdownRef = useDropdownClose(handleClose);

  return (
    <SearchDropdownContext.Provider
      value={{
        searchData,
        handleSearch,
        isOpen,
        handleToggle,
        selectedUser,
        setSelectedUser,
      }}
    >
      <div ref={dropdownRef} className={className}>
        {children}
      </div>
    </SearchDropdownContext.Provider>
  );
};

SearchDropdown.Trigger = SearchDropdownTrigger;
SearchDropdown.UserList = SearchDropdownUserList;
SearchDropdown.UserItem = SearchDropdownUserItem;

핵심은 루트에서 상태를 모아두고, 사용 시점에는 아래처럼 선언적으로 조합하는 점이다.

<SearchDropdown>
  <SearchDropdown.Trigger label="담당자 선택" />
  <SearchDropdown.UserList items={users}>
    {(it) => (
      <SearchDropdown.UserItem
        key={it.id}
        user={it}
        onSelect={() => setSelected(it)}
        isSelected={selected?.id === it.id}
      />
    )}
  </SearchDropdown.UserList>
</SearchDropdown>

3) 동작 포인트

3-1) Trigger: 검색어 입력 + 선택 사용자 오버레이

selectedUser가 있고 검색어가 비어 있으면, 입력창 위에 선택 사용자 정보를 오버레이로 표시한다.

// src/components/searchDropdown/SearchDropdownTrigger.tsx
const showSelectedPlaceholder = !!selectedUser && searchData === '';

{showSelectedPlaceholder && (
  <div className="absolute ... pointer-events-none">
    <Image src={selectedUser.profileImg} alt={selectedUser.content} width={22} height={22} />
    <span>{selectedUser.content}</span>
  </div>
)}

<input
  type="text"
  onClick={handleToggle}
  value={searchData}
  placeholder={!selectedUser ? label : ''}
  onChange={(e) => handleSearch(e.target.value)}
/>

3-2) UserList: 조건부 렌더링 + 필터링

드롭다운이 열렸을 때만 목록을 렌더링하고, 검색어로 필터링한다.

// src/components/searchDropdown/SearchDropdownUserList.tsx
if (!isOpen) return null;

const filtered =
  searchData === ''
    ? items
    : items.filter((it) => it.content.toLowerCase().includes(searchData.toLowerCase()));

if (filtered.length === 0) return null;

return <div className={className}>{filtered.map(children)}</div>;

3-3) UserItem: 선택 이벤트 + 닫기 처리

항목을 클릭하면 상위 선택 로직(onSelect) 실행 후, 컨텍스트 상태를 업데이트하고 드롭다운을 닫는다.

// src/components/searchDropdown/SearchDropdownUserItem.tsx
onClick={() => {
  onSelect();
  setSelectedUser(user);
  handleToggle();
}}

3-4) useDropdownClose: 공통 닫기 훅 재사용

SearchDropdown은 바깥 클릭/Esc 처리 로직을 커스텀 훅으로 분리해 재사용한다.

// src/hooks/useDropdownClose.tsx
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handlePressEsc);

3-5) 그외 고려한 것

  • 선택한 게 없을 때의 렌더링
  • 선택한 게 있을 때의 렌더링
  • 위 각각의 경우에 드롭다운 리스트 렌더링
  • 선택과 미선택 시의 렌더링 등

4) 마무리

이번 SearchDropdown은 단일 UI를 넘어서,
"검색 + 선택 + 닫힘 제어"를 공통 패턴으로 묶어 재사용성을 높인 사례다.

핵심은 다음 두 가지였다.

  • 루트 컴포넌트에서 상태를 집중 관리하고, 하위 컴포넌트를 조합형으로 노출한다.
  • 닫힘 처리 같은 횡단 관심사는 훅으로 분리해 일반 Dropdown과 동일하게 재사용한다.

이렇게 분리해 두면 이후 멀티 선택, 비동기 검색, 키보드 네비게이션 같은 요구사항도 비교적 작은 변경으로 확장할 수 있다.

5) 예시

0개의 댓글