컴파운드 패턴으로 Dropdown Component 만들기

2025년 01월 16일

드롭다운 컴포넌트를 처음 구현했을 때 하나의 컴포넌트에서 모든 기능을 처리하도록 개발하였습니다.

하지만 다중 선택, 키보드 제어 등 다양한 기능이 추가되었을 때, 하나의 컴포넌트에 모든 로직이 몰리게 되었고, 코드가 복잡해져서 유지보수가 어려워졌습니다.

이 글에서는 컴파운드 패턴(Compound Component Pattern)을 적용하여 Dropdown을 설계하고, 주요 로직을 Hooks로 분리함으로써 확장성과 재사용성을 높이는 방법을 알아보겠습니다.


기존 Dropdown 컴포넌트의 문제점

아래의 Dropdown 컴포넌트는 버튼을 누르면 리스트가 나오고, 옵션을 선택할 수 있는 기본적인 컴포넌트입니다.

const Dropdown = ({ options, selectedValue, onChange }) => {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <div>
      <button onClick={() => setIsOpen((prev) => !prev)}>
        {selectedValue || "선택하세요."}
      </button>
      {isOpen && (
        <ul>
          {options.map((option) => (
            <li key={option} onClick={() => onChange(option)}>
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

이 컴포넌트는 간단하지만, 새로운 요구사항이 추가될수록 코드가 점점 복잡해지는 문제가 발생합니다.

다중 선택 기능 추가

새로운 요구사항으로 다중 선택 기능이 필요해졌습니다.
이제 여러 개의 값을 선택하고 해제할 수 있도록 구현해야 합니다.

const Dropdown = ({ options, selectedValues, onChange, multiple = false }) => {
  const [isOpen, setIsOpen] = useState(false);
 
  const handleSelect = (option) => {
    if (multiple) {
      if (selectedValues.includes(option)) {
        onChange(selectedValues.filter((item) => item !== option));
      } else {
        onChange([...selectedValues, option]);
      }
    } else {
      onChange(option);
    }
  };
 
  return (
    <div>
      <button onClick={() => setIsOpen((prev) => !prev)}>
        {multiple
          ? selectedValues.length > 0
            ? selectedValues.join(", ")
            : "선택하세요."
          : selectedValues || "선택하세요."}
      </button>
      {isOpen && (
        <ul>
          {options.map((option) => (
            <li key={option} onClick={() => handleSelect(option)}>
              {option} {multiple && selectedValues.includes(option) ? "✔" : ""}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};
 

문제점

  • props가 계속 추가됨
    • 검색, 그룹화, 비활성화 옵션 등 새로운 기능이 추가될 때마다 props가 늘어나고, 어떤 prop이 어떤 역할을 하는지 파악하기 어려워집니다.
  • 로직의 복잡성 증가
    • 단일 선택과 다중 선택을 동시에 지원하기 위해 조건 분기가 많아지고, 코드 가독성이 떨어집니다.
  • 유지보수가 어려워짐
    • 새로운 요구사항이 생길 때마다 기존 컴포넌트를 직접 수정해야 하며, 이는 기존 로직에 영향을 줄 가능성이 큽니다.

이러한 문제를 해결하기 위해 컴파운드 패턴(Compound Pattern)을 적용하면, Dropdown을 작은 컴포넌트로 분리하여 보다 유연하고 확장성 있는 설계를 할 수 있고, 각 컴포넌트가 단일 책임 원칙을 준수한다면 새로운 요구사항을 더 쉽게 반영할 수 있습니다.


컴파운드 패턴이란?

컴파운드 패턴은 하나의 완결된 UI 컴포넌트를 여러 개의 작은 컴포넌트로 분리하여, 각 컴포넌트가 단일 책임을 갖도록 하는 디자인 패턴입니다.

메인 컴포넌트에서 활용하는 하위 컴포넌트들은 기능 단위로만 나누어지지 않으며 상황에 따라 액션, UI 등 다른 기준으로 나뉘어 조합될 수 있습니다. 재사용성을 극대화하기 위해 하위 컴포넌트는 단일 책임 원칙(SRP)에 따라 하나의 책임 · 역할만 맡아야합니다.

UI 컴포넌트의 역할 분리

하나의 컴포넌트가 UI 렌더링, 사용자 인터랙션 처리, 상태 관리 등 모든 기능을 동시에 담당하도록 설계하면 코드가 복잡해지고 유지보수가 어려워집니다. 컴파운드 패턴에서는 각 기능을 독립된 컴포넌트로 분리하여, 각각의 컴포넌트가 단일 책임만 수행하도록 합니다.

Dropdown 컴포넌트를 컴파운드 패턴으로 설계할 경우 다음과 같이 역할을 분리할 수 있습니다.

Dropdown: 전체 상태를 관리하며, 하위 컴포넌트들에게 상태와 로직을 Context로 전달합니다.
Dropdown.Trigger: 드롭다운을 열고 닫는 버튼 역할을 담당하며, 사용자의 클릭 이벤트를 처리합니다.
Dropdown.OptionList: 옵션 목록을 렌더링하며, 사용자가 선택할 수 있는 항목들을 표시합니다.
Dropdown.Option: 개별 옵션을 나타내며, 선택되었을 때의 동작을 정의합니다.

이렇게 역할을 분리함으로써, 각 컴포넌트는 자신의 역할에만 집중할 수 있으며, 다른 컴포넌트에 의존하지 않고 독립적으로 동작할 수 있습니다.

상황에 따른 조합

컴파운드 패턴에서는 컴포넌트가 단순히 기능 단위로만 나누어지지 않으며, 상황에 따라 유연하게 조합될 수 있습니다. 이는 사용자 상호작용 방식이나 UI 구성에 따라 컴포넌트들이 서로 다른 방식으로 동작할 수 있음을 의미합니다.

Dropdown은 다음과 같은 다양한 상황에 맞춰 동작할 수 있습니다

기본적으로 클릭을 통해 열리고 닫힐 수 있어야 합니다.
키보드로 탐색할 수 있어야 하며(위/아래 화살표, Enter, Esc).
옵션 목록은 동적으로 렌더링될 수 있으며, 검색 기능이나 그룹화도 지원할 수 있습니다.
이러한 상황별 동작은 각 컴포넌트가 독립적으로 설계되어 있기 때문에, 새로운 기능을 추가할 때 전체 구조를 변경할 필요 없이 필요한 컴포넌트만 수정하거나 추가할 수 있습니다.

유연성

컴파운드 패턴은 새로운 기능을 추가하거나 기존 기능을 변경할 때도 유연하게 확장할 수 있는 구조를 제공합니다.

Dropdown 컴포넌트에 다중 선택 기능을 추가하고 싶다면 다음과 같은 방법으로 확장할 수 있습니다:

Dropdown.Option: 단일 선택 로직을 다중 선택으로 변경하거나, 새로운 멀티 셀렉트 모드를 추가할 수 있습니다.
Dropdown: 선택된 옵션을 배열 형태로 관리하여 다중 선택을 지원할 수 있습니다.
Dropdown.OptionList: 다중 선택일 경우 선택된 옵션을 시각적으로 표시할 수 있도록 스타일을 변경할 수 있습니다.

드롭다운 컴포넌트 요구사항 정리

이제 직접 컴포넌트를 만들어 봅시다. Dropdown이 가져야 할 기능은 무엇이 있을까요?

  • 드롭다운 버튼 클릭 시 옵션 목록 표시
  • 옵션 선택 시 선택된 값이 버튼에 반영
  • 단일 선택과 다중 선택 모두 지원
  • 키보드(Enter, ArrowUp, ArrowDown, Escape)로 탐색 가능
  • 옵션 목록이 버튼 아래 올바른 위치에 표시
  • 부모 요소의 레이아웃 제약(overflow 등)과 관계없이 원하는 위치에 표시
  • 옵션 외부 클릭 시 드롭다운이 닫힘
  • 단일 선택과 다중 선택 모두 지원

역할별 컴포넌트 분리

컴파운드 패턴을 적용하여 Dropdown 컴포넌트를 다음과 같이 분리합니다.

  • Dropdown (상태 관리 및 Context 제공)
    • Dropdown.Trigger (드롭다운을 열고 닫는 버튼)
    • Dropdown.Bar (선택된 값을 표시)
    • Dropdown.OptionList (옵션 목록을 감싸는 컴포넌트)
    • Dropdown.Option (개별 선택 가능한 항목)
    • Dropdown.ListSubHeader (옵션 그룹 구분을 위한 서브헤더)

이벤트 처리

기능이 많아질수록 로직이 컴포넌트 내에 많아지게 되는데, 마우스, 키보드, 위치 계산은 각기 다른 커스텀 Hook으로 분리하였습니다

useClick - 외부 클릭 감지, 옵션 선택, 트리거 토글 등 마우스 이벤트 처리
useKeyDown - 키보드 탐색 및 단축키 지원 (↑↓ Enter Escape 등)
useOptionListPosition - 옵션 목록이 트리거 아래 정확히 위치되도록 계산

드롭다운 컴포넌트의 설계

  • 단일 책임 원칙 적용: 각 요소는 명확한 역할만 수행
  • 유지보수 용이성: 기능 추가나 수정 시 해당 부분만 영향
  • 재사용성 증가: Hook, Option, Trigger 등 유사 UI에 반복 사용 가능
  • 접근성 고려: 키보드 탐색, role/aria 속성 분리 가능
  • 기능 확장성: 옵션 검색, 가상화 리스트, 애니메이션 등 기능 확장 시에도 구조 변경 최소화

Dropdown

  • 전체 Dropdown의 상태(열림/닫힘, 선택 등)를 관리하고, Context를 통해 하위 컴포넌트에 상태와 로직을 전달합니다.
  • 제어(props를 통한 외부 제어)와 비제어(내부 상태 관리) 모두 지원합니다.
export const Dropdown = <T extends string = DefaultDropdownValue>({
  children,
  onChange,
  disabled = false,
  multiple = false,
  isOpen, // 외부에서 드롭다운 상태 제어
  onClose, // 닫힘 콜백
}: DropdownProps<T>) => {
  const triggerRef = useRef<HTMLElement>(null);
  const optionListRef = useRef<HTMLUListElement>(null);
 
  const [container, setContainer] = useState<DropdownTypes['container']>(null);
  const isControlled = typeof isOpen === 'boolean'; // 외부에서 제어되는지 확인
  const expanded = isControlled ? isOpen : container !== null; // 내부 상태와 props 결합
 
  const uniqueId = useMemo(() => generateUniqueId(), []);
 
  const open = () => {
    if (isControlled) return;
    const newContainer = document.createElement('div');
    newContainer.id = uniqueId;
    document.body.appendChild(newContainer);
    setContainer(newContainer);
  };
 
  const close = () => {
    if (isControlled) {
      onClose?.();
      return;
    }
    const container = document.getElementById(uniqueId);
    container?.remove();
    setContainer(null);
  };
 
  const toggle = () => {
    expanded ? close() : open();
  };
 
  
  return (
    <DropdownContext.Provider
      value={{ container, triggerRef, optionListRef, expanded, disabled, close, onChange }}
    >
      {children}
    </DropdownContext.Provider>
  );
};
 

Dropdown.Trigger

  • 원하는 버튼(또는 트리거 요소)을 그대로 받아, 드롭다운의 상태(열림, 비활성화, ref)를 추가하여 전달합니다.
  • 단순히 버튼을 "전달"하는 추상화된 컴포넌트로, React.cloneElemnt를 사용하여 추가 속성을 주입합니다.
Dropdown.Trigger = ({ children }: { children: React.ReactElement }) => {
  const { triggerRef, expanded, disabled } = useContext(DropdownContext);
 
  return React.cloneElement(children, { ...children.props, 'data-expanded': expanded, disabled, ref: triggerRef });
};

Dropdown.Bar

  • 선택된 값을 보여주는 컴포넌트입니다. 선택된 값이 없을 경우 placeholder를 보여줍니다.
  • 옵션 목록은 Portal을 사용해 동적으로 생성된 컨테이너에 렌더링되어, 부모 요소의 레이아웃 제약을 피합니다.
Dropdown.Bar = React.forwardRef(
  (
    { value, placeholder, disabled = false, suffixForId, ...props }: DropdownBarProps,
    ref: ForwardedRef<HTMLDivElement>,
  ) => {
    const uniqueId = useMemo(() => suffixForId ?? generateUniqueId(), [suffixForId]);
 
    return (
      <Bar 
        ref={ref} 
        role="button" 
        tabIndex={disabled ? -1 : 0} 
        aria-disabled={disabled} 
        {...props}
      >
        <span aria-placeholder={value ? undefined : placeholder}>{value || placeholder}</span>
        <ChevronDownIcon size={12} className="arrowIcon" suffixForId={`dropdown-arrow-icon__${uniqueId}`} />
      </Bar>
    );
  },
);
 

Dropdown.OptionList

  • 옵션목록을 렌더링하는 컴포넌트입니다.
  • 옵션목록이 dropdown 내부가 아니라, container에 추가되도록 합니다.
Dropdown.OptionList = ({ children, zIndex = overHeaderZIndex, ...props }: OptionListProps) => {
  const { container, optionListRef } = useContext(DropdownContext);
 
  return (
    <Portal container={container}>
      <OptionList role="listbox" ref={optionListRef} style={{ zIndex }} {...props}>
        {children}
      </OptionList>
    </Portal>
  );
};

Dropdown.Option

  • 옵션목록에서 개별 옵션을 나타내는 컴포넌트입니다.
Dropdown.Option = ({ value, label, selected = false, disabled = false, ...props }: OptionProps) => {
  return (
    <Option
      role="option"
      data-value={value}
      key={value}
      aria-disabled={disabled}
      aria-selected={selected}
      tabIndex={selected ? 0 : -1}
      {...props}
    >
      {label ?? value}
    </Option>
  );
};

사용 예시

const Example = () => {
  const [selected, setSelected] = useState(null);
 
  return (
    <Dropdown onChange={setSelected}>
      <Dropdown.Trigger>
        <button>{selected || "옵션 선택"}</button>
      </Dropdown.Trigger>
      <Dropdown.OptionList>
        <Dropdown.Option value="apple" label="사과" />
        <Dropdown.Option value="banana" label="바나나" />
        <Dropdown.Option value="grape" label="포도" />
      </Dropdown.OptionList>
    </Dropdown>
  );
};
 

Custom Hook을 통한 이벤트 처리

Dropdown 컴포넌트를 작성할 때 가장 복잡해지는 부분은 마우스 이벤트, 키보드 이벤트, 그리고 옵션 목록의 위치 계산입니다.

만약 이러한 로직을 모두 Dropdown내부에 작성하면, 코드 길이가 너무 길어지고, 이벤트 간 로직이 서로 꼬여 유지보수가 어려워집니다.

따라서 컴포넌트 로직을 분리하기 위해 커스텀 Hooks를 만듭니다.

각 Hook은 하나의 역할만 담당하도록 설계하여, 코드 가독성과 재사용성을 높입니다.

useClick 마우스 이벤트 처리

  • 드롭다운 외부를 클릭하면 닫혀야 하고, 트리거(버튼)를 클릭하면 열리고 닫히는 토글 역할을 해야 합니다.
  • 옵션 목록을 클릭하면 해당 옵션이 선택되어야 하고, 다중 선택 여부에 따라 로직이 달라집니다.
  • 이 로직이 없으면, 마우스 이벤트가 발생할 때마다 Dropdown 컴포넌트 내부에서 여러 조건문으로 처리해야 하므로 코드가 복잡해집니다.

기능

  1. 외부 클릭으로 닫기
    • 사용자가 드롭다운 외부를 클릭하면, 드롭다운을 닫아야 합니다.
  2. 버튼(트리거) 클릭 시 열기/닫기
    • 트리거를 클릭하면 드롭다운이 열림 → 다시 누르면 닫힘
  3. 옵션 클릭 시 선택
    • 옵션 목록을 클릭하면 onChange를 호출해 값을 선택하고,
    • 다중 선택이 아닐 경우 자동으로 닫음

핵심 로직

  • document.addEventListener('click', handleClick)로 마우스 클릭을 전역에서 감지
  • triggerRef와 optionListRef를 사용하여 트리거와 옵션 목록 내부를 구분
  • disabled 상태인 경우는 아무 동작도 하지 않음
export const useClick = <T extends string>({
  triggerRef,
  optionListRef,
  expanded,
  close,
  toggle,
  onChange,
  disabled,
  multiple,
}: DropdownEventParams<T>) => {
  useEffect(() => {
    function handleClick(e: MouseEvent) {
      // 드롭다운이 비활성화( disabled = true )이면 아무 동작도 하지 않음
      if (disabled) return;
 
      // 마우스로 클릭한 요소(target)
      const target = e.target as HTMLElement;
 
      // 트리거 영역과 옵션 목록 영역을 구분하기 위한 ref
      const triggerElement = triggerRef?.current;
      const optionListElement = optionListRef?.current;
 
      const isTriggerClick = triggerElement && triggerElement.contains(target);
      const isOptionClick = optionListElement && optionListElement.contains(target);
 
      // (트리거도 아니고, 옵션 영역도 아니면) → 드롭다운 닫기
      if (!isTriggerClick && !isOptionClick) {
        close();
 
      // 이미 열려있으면 닫고, 닫혀있으면 열기
      else if (isTriggerClick) {
        toggle();
      }
      // 3) 옵션 목록을 클릭한 경우
      else if (isOptionClick) {
        const clickedOption = (Array.from(optionListElement.children) as HTMLLIElement[])
          .filter((el) => el.ariaDisabled !== 'true') // 비활성화된 옵션은 제외
          .find((el) => el.contains(target));         // 실제 클릭한 옵션 찾기
 
        if (clickedOption) {
          // data-value에 옵션 값이 들어있음
          const value = clickedOption.dataset.value as T;
          if (!value) return;
 
          // 옵션 선택 로직
          onChange(e, value);
 
          // multiple이 false면(단일 선택) 옵션 클릭 후 드롭다운 닫기
          if (!multiple) {
            close();
          }
        }
      }
    }
 
    document.addEventListener('click', handleClick);
 
    return () => {
      document.removeEventListener('click', handleClick);
    };
  });
};

useKeyDown (키보드 이벤트 처리)

  • 드롭다운은 마우스뿐만 아니라 키보드로도 열고 닫고, 옵션을 탐색하고 선택할 수 있어야 접근성이 높습니다.

기능

  1. 트리거에 포커스된 상태에서 EnterSpace → 드롭다운 열기/닫기
  2. 옵션 리스트에서 EnterSpace → 해당 옵션 선택
  3. ArrowUp, ArrowDown → 옵션 목록 내에서 포커스 이동
  4. Escape or Tab → 드롭다운 닫고 트리거로 포커스 이동

핵심 로직

  • document.addEventListener('keydown', handleKeyDown)키보드 이벤트 전역 감지
  • 포커스가 트리거에 있는지, 옵션 목록에 있는지에 따라 다른 로직 처리
  • 옵션을 선택하면 onChange를 호출하고, 필요한 경우 드롭다운을 닫고 트리거에 포커스를 다시 줌
export const useOptionListPosition = ({
  container,
  triggerRef,
  optionListRef,
}: Pick<DropdownTypes, 'container' | 'triggerRef' | 'optionListRef'>) => {
  const changeOptionListPosition = useCallback(() => {
    const triggerElement = triggerRef?.current;
    const optionListElement = optionListRef?.current;
 
    if (triggerElement && optionListElement) {
      const rect = triggerElement.getBoundingClientRect();
      const scrollX = window.scrollX;
      const scrollY = window.scrollY;
      const bodyWidth = document.body.scrollWidth;
 
      // 옵션 목록 최소너비 = 트리거의 너비
      optionListElement.style.minWidth = `${rect.width}px`;
      // Y 좌표는 트리거 아래로 약간의 간격 주기 (예: 8px)
      optionListElement.style.top = `${scrollY + rect.bottom + 8}px`;
      // X 좌표는 트리거의 left
      optionListElement.style.left = `${scrollX + rect.left}px`;
 
      // 화면 오른쪽 경계를 넘어간다면 위치 보정
      const optionRect = optionListElement.getBoundingClientRect();
      if (scrollX + optionRect.right > bodyWidth) {
        optionListElement.style.left = `${rect.right - optionListElement.offsetWidth}px`;
      }
 
      // 애니메이션 효과 (살짝 팝업되도록)
      optionListElement.style.transform = 'scale(1)';
      optionListElement.style.opacity = '1';
    }
  }, [triggerRef, optionListRef]);
 
  useEffect(() => {
    // container가 존재한다는 것은 드롭다운이 열렸다는 뜻
    if (container) {
      changeOptionListPosition();
    }
  }, [container, changeOptionListPosition]);
 
  // 창 크기가 바뀔 때마다 위치 재계산
  useEffect(() => {
    window.addEventListener('resize', changeOptionListPosition);
    return () => {
      window.removeEventListener('resize', changeOptionListPosition);
    };
  }, [changeOptionListPosition]);
};
 

useOptionListPosition (옵션 목록 위치 계산)

  • 옵션 목록은 보통 버튼 아래에 표시하지만,
  • 화면이 스크롤되거나 창이 리사이즈되면 위치가 달라질 수 있습니다.
  • 부모 요소가 overflow: hidden 등으로 감싸져 있어도, Portal로 body에 추가된 경우 버튼 위치를 기준으로 옵션 목록을 올바르게 표시해야 합니다.

기능

  • 버튼 크기에 맞춰 옵션 목록의 너비 설정
  • 버튼의 bottom 좌표에 약간의 간격을 두고 옵션 목록 배치
  • 화면 오른쪽 경계를 넘어가면 위치를 자동으로 보정
  • 창을 리사이즈하면 위치 다시 계산
export const useOptionListPosition = ({
  container,
  triggerRef,
  optionListRef,
}: Pick<DropdownTypes, 'container' | 'triggerRef' | 'optionListRef'>) => {
  const changeOptionListPosition = useCallback(() => {
    const triggerElement = triggerRef?.current;
    const optionListElement = optionListRef?.current;
 
    if (triggerElement && optionListElement) {
      const rect = triggerElement.getBoundingClientRect();
      const scrollX = window.scrollX;
      const scrollY = window.scrollY;
      const bodyWidth = document.body.scrollWidth;
 
      // 옵션 목록 최소너비 = 트리거의 너비
      optionListElement.style.minWidth = `${rect.width}px`;
      // Y 좌표는 트리거 아래로 약간의 간격 주기 (예: 8px)
      optionListElement.style.top = `${scrollY + rect.bottom + 8}px`;
      // X 좌표는 트리거의 left
      optionListElement.style.left = `${scrollX + rect.left}px`;
 
      // 화면 오른쪽 경계를 넘어간다면 위치 보정
      const optionRect = optionListElement.getBoundingClientRect();
      if (scrollX + optionRect.right > bodyWidth) {
        optionListElement.style.left = `${rect.right - optionListElement.offsetWidth}px`;
      }
 
      // 애니메이션 효과 (예: 살짝 팝업되도록)
      optionListElement.style.transform = 'scale(1)';
      optionListElement.style.opacity = '1';
    }
  }, [triggerRef, optionListRef]);
 
  useEffect(() => {
    // container가 존재한다는 것은 드롭다운이 열렸다는 뜻
    if (container) {
      changeOptionListPosition();
    }
  }, [container, changeOptionListPosition]);
 
  // 창 크기가 바뀔 때마다 위치 재계산
  useEffect(() => {
    window.addEventListener('resize', changeOptionListPosition);
    return () => {
      window.removeEventListener('resize', changeOptionListPosition);
    };
  }, [changeOptionListPosition]);
};
 
  1. 역할 분리: 마우스 이벤트, 키보드 이벤트, 위치 계산 로직을 각각 다른 Hook으로 분리하여 코드가 깔끔해짐
  2. 재사용성: 프로젝트 내에서 유사한 구조(예: 다른 컴포넌트에서도 리스트를 열고 닫는 로직)를 재사용 가능
  3. 유지보수 용이: 새로운 마우스/키보드 이벤트를 추가하거나, 위치 계산 방식을 바꾸고 싶을 때 Hook만 수정하면 됨
  4. 가독성: 각 Hook이 하나의 역할만 담당하므로, 어떤 로직을 어디서 수정해야 하는지 명확

결과적으로, 컴포넌트는 UI와 상태 관리에 집중하고,

이처럼 공통 로직(이벤트 처리, 위치 계산)은 커스텀 Hooks에 분리함으로써,

큰 규모의 프로젝트에서도 Dropdown과 같은 복잡한 컴포넌트를 효율적으로 유지보수할 수 있습니다.


마무리

Dropdown을 하나의 컴포넌트에서 모든 기능을 처리하던 기존 방식은 기능이 늘어날수록 프로퍼티가 계속 추가되고, 로직이 복잡해져 유지보수와 확장에 어려움을 겪었습니다.

컴파운드 패턴을 적용해 Dropdown, Trigger, OptionList, Option, Bar 같은 작은 컴포넌트로 나누고, 공통 로직(마우스·키보드 이벤트 처리, 옵션 목록 위치 계산)을 커스텀 Hooks로 분리함으로써, 각 컴포넌트는 단일 책임을 가지면서도 필요할 때 유연하게 조합할 수 있게 되었습니다.

이러한 구조 덕분에 코드 가독성이 높아지고, 새로운 기능을 추가할 때도 컴포넌트 전체를 수정하지 않고 필요한 부분만 변경하거나 확장할 수 있어 유지보수가 훨씬 수월해집니다.