D5ng

React

점진적으로 드롭다운 메뉴 개선하기

당신의 드롭다운 메뉴는 재사용성과 확장성을 고려하고 있나요?

DH
DongHyun Lee·FE Developer
19 min read·October 10, 2024
thumnnail

드롭다운 메뉴는 웹 애플리케이션에서 흔히 볼 수 있는 UI다. 누구나 한 번쯤 만들어 본 경험이 있을 것이다. 만드는 것도 크게 어렵지 않다. 내가 부트캠프를 하면서 사용했던 드롭다운 메뉴는 재사용성과 확장성이 떨어져, 남의 코드에 if문이 덕지덕지 붙거나 다시 만든 경험이 있었다. 프론트엔드를 배운 대부분의 사람은 이를 구현할 수 있다. 하지만 구현에만 초점을 둔 것과 확장성과 유연성을 고려한 것과는 시간이 지남에 따라 큰 차이가 나타난다. 왜냐하면 처음부터 완벽한게 만들 수 없기 때문에 기능을 수정하거나 추가할 때 구조가 제 역할을 하지 못할 가능성이 커진다. 결국 복잡성이 증가하면서 코드를 이해하고 해석하는 데 더 많은 시간이 소요되게 된다. 따라서, 구현에만 초점을 둔 초안을 바탕으로 점진적으로 어떻게 개선하는지에 대한 나의 방법을 공유하겠다.

알아두면 좋은 것.

우선, 만들기 전에 용어 정리를 할 필요가 있다. 바로 Dropdown Menu와 Select의 차이다. 둘 다 비슷한 UI지만 서로 다른 성격을 띤다. 우리는 디자이너와 소통도 해야 하고, 명칭을 제대로 알아야 올바른 요구사항을 만들 수 있다.

  • Dropdown Menu: 메뉴의 항목을 클릭했을 때 특정 동작(페이지 이동, 특정 기능)을 수행하거나 중첩된 하위 메뉴들을 보여주는 특징이 있다.
  • Select: HTML의 select 태그를 생각하면 된다. 즉, Form에서 사용자에게 입력 받기 위해 여러 옵션을 제공하여 선택하게 하는 하나의 입력 요소를 말한다.

즉, 서로 비슷한 UI이지만, 달성하려는 목적이 다르기 때문에 요구사항 또한 다르다. 실제로 나도 드롭다운 메뉴와 셀렉트를 동시에 사용하려다 보니 코드가 많이 지저분해졌던 경험이 있었다.

드롭다운 만들기

요구사항 정의하기

드롭다운 메뉴를 닫힌 상태와 열린 상태로 정의해 보았다.

메뉴가 닫혀있는 상태

  • Dropdown 버튼을 클릭하면 메뉴가 나타난다.

메뉴가 열려있는 상태

  • Dropdown 버튼을 클릭하면 메뉴를 닫을 수 있다.
  • 하위 메뉴를 클릭하면 특정 동작이 수행되고, 메뉴는 닫혀야 한다.
  • Dropdown 바깥을 클릭했을 때 메뉴는 닫혀야 한다.
  • 중첩된 메뉴를 가진 하위 메뉴에 마우스를 올리면 중첩된 메뉴가 보여야 한다.

일반적으로 구현하는 드롭다운의 형태

구현에 집중하다 보면 다음과 같은 형태로 만들곤 한다. 구글에 검색해보면 비슷한 방식으로 만든 포스트들이 있다.

import { FaAngleDown } from "react-icons/fa6"
import useToggle from "../hooks/useToggle"
import useOutSideClick from "../hooks/useOutSideClick"
 
export default function Dropdown() {
  const { handleToggle, handleCloseToggle, isToggle } = useToggle()
  const dropdownRef = useOutSideClick<HTMLDivElement>({ onCloseToggle: handleCloseToggle })
 
  return (
    <div className="relative text-white" ref={dropdownRef}>
      <button
        className="px-3 py-3 bg-gray-800 rounded-lg flex justify-between items-center gap-6"
        onClick={handleToggle}
      >
        드롭다운
        <div>
          <FaAngleDown className={isToggle ? `rotate-180` : `rotate-0`} />
        </div>
      </button>
      {isToggle && (
        <ul className="absolute top-full translate-y-1 bg-gray-800 rounded-lg text-sm w-full p-1 gap-1.5">
          <li className="dropdown-menu-item">
            <button className="w-full p-2">특정 기능</button>
          </li>
          <li className="hover:bg-gray-700 rounded-md relative group">
            <div className="w-full p-2">중첩 메뉴 기능</div>
            <ul className="hidden absolute w-[150px] group-hover:block bg-gray-800 rounded-lg translate-x-full top-0 right-4 p-1 gap-1.5">
              <li className="dropdown-menu-item">
                <button className="w-full p-2">중첩 메뉴 1</button>
              </li>
              <li className="dropdown-menu-item">
                <button className="w-full p-2">중첩 메뉴 2</button>
              </li>
              <li className="dropdown-menu-item">
                <button className="w-full p-2">중첩 메뉴 3</button>
              </li>
            </ul>
          </li>
          <li className="dropdown-menu-item">
            <button className="w-full p-2">페이지 이동 기능</button>
          </li>
        </ul>
      )}
    </div>
  )
}

문제점

위 코드는 다음과 같은 문제가 있다.

  • 재사용이 불가능하고, 유지보수 측면에서 UI가 변경될 때마다 모든 파일을 수정해야 한다.
  • 기존 코드에서 수정을 해야 하므로 확장성이 떨어진다. OCP(개방 폐쇄 원칙) 위반
  • 일관성이 깨지고, 코드를 해석하는 데 시간이 많이 소요된다.
  • 사용할 때마다 필요한 훅을 매번 불러와야 한다.

구현에만 집중할 경우 위와 같이 만드는 경우가 생각보다 흔하며, 훅으로 로직을 분리하지 않는 경우도 많다. 재사용성이 떨어지면 중복된 코드가 많아질 확률이 높아진다. 즉 클라이언트 리소스가 그만큼 소모된다는 말이다. 또한, 확장에 열려있어야하고 수정에 대해서는 폐쇄적이여야 한다는 OCP 원칙에 위반되기도 한다.

이 문제를 해결하기 위해 드롭다운 메뉴를 컴포넌트로 분리하고, 리스트를 props로 전달받는 경우도 있다.(추상화가 어떻게 되냐에 따라 좋은 접근이 될 수 있다) 하지만 드롭다운 메뉴는 버튼만으로 동작하는 것이 아니다. <Link /> 컴포넌트로도 동작할 수 있다. 물론 Router 메서드를 사용할 수 있지만, map을 사용해 렌더링할 때 분기 처리가 필요하기 때문에 가독성 면에서 좋지 않다고 느낀다.

어떻게 개선할 수 있을까 ?

개선을 하기 전에 컴포넌트 설계를 다시 해봐야 한다. 경험이 부족하다면 코드를 개선하는 것이 쉽지 않기 때문에, 구현에 앞서 충분한 구상과 다양한 방법을 시도하는 것이 중요하다. 이 문제는 다음과 같은 방식으로 해결할 수 있다.

  • 재사용성을 높이기 위해 단일 책임 원칙을 지키고, 컴포넌트를 세부적으로 쪼갠다.
  • 세부적으로 쪼갠 컴포넌트를 합성해서 사용하면 재사용성, 확장성, 그리고 유연성을 향상시킬 수 있다.
  • 반복해서 호출해야 하는 훅들은 Context API를 사용해 지역적으로 관리할 수 있다.

드롭다운 메뉴, 드롭다운 메뉴 버튼, 드롭다운 메뉴 리스트, 드롭다운 메뉴 리스트 아이템 등으로 컴포넌트를 분리하면 각 컴포넌트가 단일 책임 원칙을 따르며 하나의 역할만 수행하게 되어, 재사용성이 크게 향상된다. 반복적으로 호출되는 훅을 Context API 대신 React.cloneElement를 사용해 children props를 병합할 수 있다. 그러나 드롭다운 메뉴의 개수가 고정되지 않고, 특정 기능이 props로 전달되어 사용될 수 있기 때문에 복잡성이 증가할 수 있다.

라이브러리 분석을 해야하는 이유

스스로 문제를 해결하는 것도 중요하지만, 이미 이러한 문제를 경험한 개발자들이 많다는 점을 기억해야 한다. 바로 라이브러리를 만든 사람들이다. 라이브러리를 만든 사람들은 다양한 요구사항에 대응하며 작업한다. 유명한 라이브러리는 사용하기 쉽고 유연하며 확장성이 뛰어나다. 즉, 우리는 라이브러리의 소스 코드를 분석함으로써 많은 통찰을 얻을 수 있다. Radix UI의 DropdownMenu 소스 코드를 살펴보자.

<DropdownMenu.Root>
  <DropdownMenu.Trigger>
    <Button variant="soft">
      Options
      <DropdownMenu.TriggerIcon />
    </Button>
  </DropdownMenu.Trigger>
  <DropdownMenu.Content>
    <DropdownMenu.Item shortcut="⌘ E">Edit</DropdownMenu.Item>
    <DropdownMenu.Item shortcut="⌘ D">Duplicate</DropdownMenu.Item>
    <DropdownMenu.Separator />
    <DropdownMenu.Item shortcut="⌘ N">Archive</DropdownMenu.Item>
 
    <DropdownMenu.Sub>
      <DropdownMenu.SubTrigger>More</DropdownMenu.SubTrigger>
      <DropdownMenu.SubContent>
        <DropdownMenu.Item>Move to project…</DropdownMenu.Item>
        <DropdownMenu.Item>Move to folder…</DropdownMenu.Item>
        <DropdownMenu.Separator />
        <DropdownMenu.Item>Advanced options…</DropdownMenu.Item>
      </DropdownMenu.SubContent>
    </DropdownMenu.Sub>
 
    <DropdownMenu.Separator />
    <DropdownMenu.Item>Share</DropdownMenu.Item>
    <DropdownMenu.Item>Add to favorites</DropdownMenu.Item>
    <DropdownMenu.Separator />
    <DropdownMenu.Item shortcut="⌘ ⌫" color="red">
      Delete
    </DropdownMenu.Item>
  </DropdownMenu.Content>
</DropdownMenu.Root>

코드를 보면 합성 컴포넌트(Compound Pattern)을 사용한 것을 확인할 수 있다. 합성 컴포넌트를 사용하면 다음과 같은 이점이 있다.

  • 유연한 UI 구조
  • 재사용성
  • 관심사 분리(상태와 UI 분리)

위 코드는 훅을 별도로 호출하지 않는다. 내부적으로 상태를 공유하고 있어, 드롭다운 기능이 하나의 일관된 단위처럼 느껴진다. 합성 컴포넌트를 사용하면 props를 효율적으로 분산시킬 수 있으며, 각 컴포넌트가 본연의 역할만 수행하게 되어 재사용성과 유연성이 높아지고, 확장성 또한 향상된다.

현재 우리가 만든 코드를 합성 컴포넌트를 사용해 만든다면, 재사용 가능하고 유연한 컴포넌트를 구현할 수 있게 된다. 똑같진 않지만 유사한 방식으로 만들어보자.

초안을 Compound Pattern으로 바꿔보자.

기존에 만들었던 초안을 바탕으로 Compound Pattern을 적용하면 Radix UI처럼 유사하게 만들 수 있다. 네이밍은 Radix UI를 그대로 따랐으며, 내부 상태 관리는 Context API를 사용했다.

Compound Pattern에 대해 알고싶다면 여기를 클릭하세요!

// ui/DropdownMenu.tsx
import { createContext, ReactNode, useContext } from "react"
import { FaAngleDown } from "react-icons/fa6"
import useToggle from "../hooks/useToggle"
import useOutSideClick from "../hooks/useOutSideClick"
 
export interface ChildrenProps {
  children: ReactNode
}
 
export type DropdownMenuContextType = {
  isOpen: boolean
  toggle: () => void
  close: () => void
}
 
export const DropdownMenuContext = createContext<DropdownMenuContextType>({
  isOpen: false,
  toggle: () => {},
  close: () => {},
})
 
function useDropdownMenu() {
  const ctx = useContext(DropdownMenuContext)
  if (!ctx) throw new Error("Dropdown Menu에서 사용해주세요.")
  return ctx
}
 
export default function DropdownMenu({ children }: ChildrenProps) {
  const toggleState = useToggle()
  const dropdownRef = useOutSideClick<HTMLDivElement>({ callback: toggleState.close })
 
  return (
    <DropdownMenuContext.Provider value={toggleState}>
      <div className="relative text-white" ref={dropdownRef}>
        {children}
      </div>
    </DropdownMenuContext.Provider>
  )
}
 
function DropdownMenuTrigger({ children }: ChildrenProps) {
  const { toggle } = useDropdownMenu()
  return (
    <button className="px-3 py-3 bg-gray-800 rounded-lg flex justify-between items-center gap-6" onClick={toggle}>
      {children}
    </button>
  )
}
 
function DropdownMenuTriggerIcon() {
  const { isOpen } = useDropdownMenu()
  return (
    <div>
      <FaAngleDown className={isOpen ? `rotate-180` : `rotate-0`} />
    </div>
  )
}
 
function DropdownMenuContent({ children }: ChildrenProps) {
  const { isOpen } = useDropdownMenu()
  if (!isOpen) return null
  return (
    <ul className="absolute top-full translate-y-1 bg-gray-800 rounded-lg text-sm w-full p-1 gap-1.5">{children}</ul>
  )
}
 
function DropdownMenuItem({ children }: ChildrenProps) {
  return <li className="dropdown-menu-item w-full p-2">{children}</li>
}
 
function DropdownMenuSub({ children }: ChildrenProps) {
  return <li className="hover:bg-gray-700 rounded-md relative group">{children}</li>
}
 
function DropdownMenuSubTrigger({ children }: ChildrenProps) {
  return <div className="w-full p-2">{children}</div>
}
 
function DropdownMenuSubContent({ children }: ChildrenProps) {
  return (
    <ul className="hidden absolute w-[150px] group-hover:block bg-gray-800 rounded-lg translate-x-full top-0 right-4 p-1 gap-1.5">
      {children}
    </ul>
  )
}
 
DropdownMenu.Root = DropdownMenu
DropdownMenu.Trigger = DropdownMenuTrigger
DropdownMenu.Content = DropdownMenuContent
DropdownMenu.Item = DropdownMenuItem
DropdownMenu.TriggerIcon = DropdownMenuTriggerIcon
// Sub
DropdownMenu.Sub = DropdownMenuSub
DropdownMenu.SubTrigger = DropdownMenuSubTrigger
DropdownMenu.SubContent = DropdownMenuSubContent
// components/Dropdown.tsx
import DropdownMenu from "@/ui/DropdownMenu.tsx"
export default function Dropdown() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        드롭다운
        <DropdownMenu.TriggerIcon />
      </DropdownMenu.Trigger>
      <DropdownMenu.Content>
        <DropdownMenu.Item>특정 기능</DropdownMenu.Item>
        <DropdownMenu.Sub>
          <DropdownMenu.SubTrigger>중첩 메뉴 기능</DropdownMenu.SubTrigger>
          <DropdownMenu.SubContent>
            <DropdownMenu.Item>중첩 메뉴 기능1</DropdownMenu.Item>
            <DropdownMenu.Item>중첩 메뉴 기능2</DropdownMenu.Item>
            <DropdownMenu.Item>중첩 메뉴 기능3</DropdownMenu.Item>
          </DropdownMenu.SubContent>
        </DropdownMenu.Sub>
        <DropdownMenu.Item>페이지 이동 기능</DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  )
}

각 컴포넌트를 세분화하여 단일 책임 원칙을 적용한 덕분에 재사용성이 크게 향상되었고, 중복을 줄여 DRY 원칙을 지키게 되었다. 또한, 새로운 요구사항이 들어와도 기존 컴포넌트를 수정하지 않고 추가할 수 있어 확장성이 보장되며, 요구사항이 변화할 때도 쉽게 수정할 수 있어 유연성도 확보되었다. 초안에 비해 훨씬 안정적인 컴포넌트로 개선되었다.

Compound Pattern을 사용할 때 주의할 점

현재 코드에서는 모든 컴포넌트를 한 번에 import하는 방식으로, 사용하지 않는 드롭다운 컴포넌트들도 함께 불러오는 문제가 있다. Radix UI를 기반으로 한 Shadcn UI는 필요한 컴포넌트만 개별적으로 import하여 불필요한 코드가 포함되지 않으며, 이로 인해 성능이 더욱 유리하게 최적화되어 있다. 따라서 현재 코드를 각 컴포넌트별로 import할 수 있도록 수정하면, 필요한 컴포넌트만 불러와 파일 크기를 줄이고 성능을 개선할 수 있다.

오해하면 안되는 부분

여기서 오해하지 말아야 할 점이 있다. 위 코드는 컴포넌트의 형태를 UI 라이브러리와 비슷하게 구현한 것이지, Radix UI처럼 완전히 구현한 것은 아니다. 개선된 코드를 살펴보면, <DropdownMenuTrigger /> 컴포넌트 내부에 버튼이 포함되어 있다. 하지만 버튼은 중첩될 수 없기 때문에, 버튼 태그를 포함한 컴포넌트를 사용할 수 없는 문제가 발생한다. Radix UI는 Render Delegation 기법을 사용해 이 문제를 해결하고 있다.

// Radix UI의 특징
// Trigger에는 button이 내장되어 있지만 asChild를 사용하여 아래 버튼과 병합하고. Trigger에 기본 상태인 드롭다운의 메뉴를 열고, 닫는 상태들을 포함하고 있다.
<DropdownMenuTrigger asChild>
  <Button variant="outline">Open</Button>
</DropdownMenuTrigger>

Render Delegation은 컴포넌트가 자신이 직접 UI를 렌더링하는 대신, 렌더링을 다른 컴포넌트나 함수로 위임하는 패턴입니다. 이 패턴은 코드의 재사용성을 높이고, 컴포넌트의 책임을 분리함으로써 유지보수성을 향상시키는 데 도움을 줍니다.

마치며

일반적인 방식으로 만든 드롭다운의 문제점을 파악하고, 합성 컴포넌트를 사용해 단일 책임 원칙을 지키며 중복 코드를 방지해 DRY 원칙을 준수함으로써 개선 과정을 보여주었다. 그러나 합성 컴포넌트가 항상 최선의 선택은 아닐 수 있으므로, 상황에 따라 더 적합한 방법이 있을 수 있음을 염두에 두자.

또한, UI 컴포넌트를 만들 때 무의식적으로 간단하게 구현하려는 경향이 있다. 하지만 자신이 만든 컴포넌트가 진정으로 좋은지 한 번 더 고민해볼 필요가 있다. 다양한 라이브러리에서 컴포넌트의 네이밍과 구현 방식을 살펴보며 분석하는 과정은 더 나은 컴포넌트를 설계하는 데 큰 도움이 된다.

이 글에서는 Radix UI의 특징인 Render Delegation까지 적용해 보여 드리고 싶었지만, 라이브러리의 소스 코드를 그대로 사용하면 되기에 생략하겠다. 관심 있는 분들은 아래의 링크를 참고하면 좋을 것이다.

레퍼런스