크루루 서비스의 PopOverMenu 컴포넌트 개선기(2), 자식 요소 컴포넌트 렌더러 만들기

 

크루루 | 쉽고 간편한 리크루팅 솔루션

크루루는 대학 연합동아리 및 스타트업 리크루팅에 최적화된 지원자 관리 솔루션입니다. 공고 제작, 지원자 관리, 평가 등 리크루팅의 모든 단계를 크루루와 함께 해결하세요.

www.cruru.kr

우리 서비스 크루루에서 PopOverMenu는 다양한 자식 요소를 지원해야 했습니다. 특히, 메뉴 항목 내부에서 하위 메뉴를 트리거하는 SubTrigger 요소가 필요하다는 점을 인지하게 되었고, 이를 구성하기 위해 고민한 내용을 공유합니다.

기존 PopOverMenu는 단일 레벨의 메뉴를 지원했으나, 단계적으로 이동하는 메뉴를 복합적으로 구성할 필요성이 제기되었습니다. 특히 사용자가 메뉴를 통해 여러 옵션을 계층적으로 탐색하는 상황에서, 하위 트리거(SubTrigger)를 제공하여 자연스럽게 동작하는 UI가 요구되었습니다.


2. SubTrigger 구현 방식의 선택: Prop Drilling vs 합성 컴포넌트 패턴

SubTrigger를 구현하는 방법으로 두 가지 접근 방식을 고려했습니다.

  1. Prop Drilling. 컴포넌트 간 데이터를 전달하기 위해 상위 컴포넌트에서 하위 컴포넌트로 props를 계속 전달하는 방식입니다. 하지만 이 방식은 깊은 트리 구조에서 비효율적이며, 유지보수가 어려워질 가능성이 큽니다.
  2. 합성 컴포넌트 패턴. Shadcn/ui의 Dropdown 컴포넌트처럼, 합성 컴포넌트를 활용하여 메뉴를 계층적으로 구성하는 방식을 참고했습니다. 이 방식은 유연성과 확장성이 뛰어나지만, 컴포넌트 설계 및 사용에 있어 복잡성이 증가할 수 있습니다.
 

Dropdown Menu

Displays a menu to the user — such as a set of actions or functions — triggered by a button.

ui.shadcn.com

 


3. 객체 기반 트리구조와 재귀 렌더링 선택

크루루 서비스에서는 Prop Drilling의 방식이지만, 유지보수가 조금 더 용이한 객체 기반의 트리구조를 활용한 재귀 렌더링 방식을 선택했습니다. 이 방식의 장점은 다음과 같습니다.

  • 트리 형태의 데이터를 제공하면, 각 메뉴 항목이 자동으로 렌더링되도록 구현하여 사용성을 높였습니다. 이는 개발자 경험의 향상에 큰 도움을 줍니다.
  • 트리구조 데이터를 바탕으로 동작하기 때문에, 데이터만 수정하면 메뉴 구조를 변경할 수 있습니다. 이는 일반적인 Prop Drilling방식의 유지보수성의 단점을 보완할 수 있는 문제라 생각했습니다.

4. DropdownItemRenderer: 객체 기반 재귀 렌더링 구조

DropdownItemRenderer는 트리구조 데이터를 기반으로 Clickable 항목과 SubTrigger 항목을 구분하여 렌더링합니다. 하위 요소가 있을 경우 재귀적으로 호출되어 메뉴가 자동으로 중첩 구조를 가질 수 있습니다.

데이터 구조 정의

메뉴 항목은 다음과 같은 구조를 가집니다:

interface BaseItem {
  id: number | string;
  name: string;
  isHighlight?: boolean;
  hasSeparate?: boolean;
}

interface ClickableItem extends BaseItem {
  type: 'clickable';
  onClick: ({ targetProcessId }: { targetProcessId: number }) => void;
}

interface SubTrigger extends BaseItem {
  type: 'subTrigger';
  items: (ClickableItem | SubTrigger)[];
}

export type DropdownItemType = ClickableItem | SubTrigger;

DropdownItemRenderer 컴포넌트

이 컴포넌트는 items 데이터를 기반으로 항목을 렌더링합니다. 주요 역할은 다음과 같습니다:

  1. Clickable 컴포넌트: 클릭 가능한 항목은 DropdownItem을 통해 렌더링됩니다.
  2. SubTrigger 컴포넌트: 하위 메뉴를 트리거하는 항목은 DropdownSubTrigger를 통해 렌더링되며, 내부에서 재귀 호출로 하위 항목을 처리합니다.
  3. 화면 경계를 초과하지 않도록 checkElementPosition 속성을 활용해 SubTrigger의 위치를 동적으로 결정합니다.
function Clickable({ item, size }: { item: ClickableItem; size: 'sm' | 'md' }) {
  return (
    <DropdownItem
      key={item.id}
      size={size}
      onClick={() => {
        item.onClick({ targetProcessId: Number(item.id) });
      }}
      item={item.name}
      isHighlight={item.isHighlight}
      hasSeparate={item.hasSeparate}
    />
  );
}

function SubTrigger({
  item,
  size,
  subContentPlacement,
}: {
  item: SubTrigger;
  size: 'sm' | 'md';
  subContentPlacement: 'left' | 'right';
}) {
  return (
    <DropdownSubTrigger
      size={size}
      key={item.id}
      item={item.name}
      placement={subContentPlacement}
    >
      <DropdownItemRenderer
        size={size}
        items={item.items}
      />
    </DropdownSubTrigger>
  );
}

export default function DropdownItemRenderer({ items, size = 'sm' }: DropdownItemRendererProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [isRight, setIsRight] = useState(false);

  useEffect(() => {
    if (ref.current) {
      const { isRight: _isRight } = checkElementPosition(ref.current);
      setIsRight(_isRight);
    }
  }, []);

  return (
    <div ref={ref}>
      {items.map((item: DropdownItemType, index: number) => {
        if (item.type === 'clickable') {
          return (
            <Clickable
              key={index}
              item={item}
              size={size}
            />
          );
        }
        if (item.type === 'subTrigger') {
          return (
            <SubTrigger
              key={index}
              item={item}
              size={size}
              subContentPlacement={isRight ? 'left' : 'right'}
            />
          );
        }
      })}
    </div>
  );
}

5. 결론

객체 기반 트리구조를 활용한 재귀 렌더링 방식을 통해 개발자 경험을 크게 향상시킬 수 있는 경험을 했습니다. 데이터 기반의 렌더링 구조를 가져가면서, 유지보수성을 높힐 수 있는 방법을 고안했다 생각합니다.