PopoverMenu 컴포넌트의 createPortal과 합성 이벤트 문제 해결 및 자식 요소 컴포넌트 렌더러 만들기
1. createPortal로 구현된 PopoverMenu 이벤트 처리 문제 해결
React에서 createPortal을 활용해 팝오버 메뉴(Popover Menu)를 구현할 때는, 스타일과 이벤트 처리에 대한 고려가 필요합니다. 이번 포스트에서는 기존 CSS hover를 사용하는 코드에서, React 상태(useState)로 이벤트를 관리하는 코드로의 전환 과정을 소개하고, createPortal 사용 시 발생하는 이벤트 버블링 문제 해결 방법을 다룹니다.
기존 서비스에서 Popover Menu는 특정 요소에 종속되지 않고 자유롭게 위치하도록, createPortal을 활용해 body에 렌더링했습니다. createPortal을 사용하면 overflow: hidden과 같은 부모 요소의 스타일 영향을 받지 않아, Popover Menu가 자유롭게 표시될 수 있습니다.
import { PropsWithChildren, useEffect, useRef } from "react"; import ReactDOM from "react-dom"; import { PopoverProvider } from "@contexts/PopoverContext"; import checkElementPosition from "@utils/checkElementPosition"; import S from "./style"; interface PopoverProps extends PropsWithChildren { isOpen: boolean; onClose: () => void; anchorEl: HTMLElement | null; } export default function Popover({ isOpen, onClose, anchorEl, children }: PopoverProps) { const popoverRef = useRef<HTMLDivElement>(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (popoverRef.current && !popoverRef.current.contains(event.target as Node) && event.target !== anchorEl) { onClose(); } }; if (isOpen) { document.addEventListener("mouseup", handleClickOutside); document.addEventListener("scroll", onClose, true); } return () => { document.removeEventListener("mouseup", handleClickOutside); document.removeEventListener("scroll", onClose, true); }; }, [isOpen, onClose, anchorEl]); useEffect(() => { if (isOpen && popoverRef.current && anchorEl) { const anchorRect = anchorEl.getBoundingClientRect(); const { isRight, isBottom } = checkElementPosition(anchorEl); if (isBottom) { popoverRef.current.style.bottom = `${window.innerHeight - anchorRect.bottom + window.screenY}px`; } else { popoverRef.current.style.top = `${anchorRect.bottom + window.scrollY}px`; } if (isRight) { popoverRef.current.style.right = `${window.innerWidth - anchorRect.right + window.scrollX}px`; } else { popoverRef.current.style.left = `${anchorRect.left + window.scrollX}px`; } } }, [isOpen, anchorEl]); if (!isOpen) return null; return ReactDOM.createPortal( <PopoverProvider onClose={onClose}> <S.PopoverWrapper ref={popoverRef} role="dialog" aria-modal="true" onClick={(event: React.MouseEvent) => event.stopPropagation()} > {children} </S.PopoverWrapper> </PopoverProvider>, document.body ); }
❗️ createPortal 사용의 문제점
Popover Menu가 body에 렌더링되면서, 기존 카드 요소의 hover 이벤트가 버블링되지 않습니다. createPortal로 렌더링된 Popover는 원래 위치한 DOM 트리의 계층 구조에서 분리되어 hover 이벤트가 예상대로 작동하지 않게 됩니다.
이 문제를 해결하기 위해 React의 상태 관리(useState)를 통해 hover 상태를 제어하도록 리팩토링했습니다. 기존 CSS에서 hover를 통해 직접 스타일을 적용하는 대신, React의 상태로 hover 여부를 판별하여 필요한 스타일을 동적으로 렌더링합니다.
const CardContainer = styled.div` display: flex; justify-content: space-between; align-items: center; width: 100%; min-width: 15rem; padding: 1rem 1.6rem; border-radius: 0.8rem; user-select: none; background-color: ${({ theme }) => theme.baseColors.grayscale[50]}; border: 1px solid ${({ theme }) => theme.baseColors.grayscale[400]}; transition: all 0.2s; &:hover { scale: 1.01; transform: translateY(-0.1rem); box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1); border: 1px solid ${({ theme }) => theme.baseColors.grayscale[500]}; cursor: pointer; z-index: 9; } `;
이 코드에서는 hover 스타일이 CardContainer에 직접 적용되지만, createPortal을 사용해 Popover Menu를 렌더링하면 해당 카드 요소의 hover 상태를 인식하지 못합니다.
const CardContainer = styled.div < { isHover: boolean } > ` display: flex; justify-content: space-between; align-items: center; width: 100%; min-width: 15rem; padding: 1rem 1.6rem; border-radius: 0.8rem; user-select: none; background-color: ${({ theme }) => theme.baseColors.grayscale[50]}; border: 1px solid ${({ theme }) => theme.baseColors.grayscale[400]}; transition: all 0.2s; ${({ theme, isHover }) => isHover && css` scale: 1.01; transform: translateY(-0.1rem); box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1); border: 1px solid ${theme.baseColors.grayscale[500]}; cursor: pointer; z-index: 9; `} `;
isHover라는 상태를 정의하고, onMouseEnter 및 onMouseLeave 이벤트 핸들러를 통해 hover 여부를 감지하여 상태 기반으로 스타일을 변경했습니다.
❓ 리액트는 왜 이런 독립적인 이벤트 구조를 가지고 있는걸까?
- Virtual DOM의 장점과 이벤트
리액트의 독립적인 이벤트 구조는 Virtual DOM과 밀접한 관련이 있습니다. Virtual DOM은 변경 사항을 메모리에 유지하며 실제 DOM에 접근하는 빈도를 줄여, 빠르고 효율적인 UI 업데이트가 가능하도록 돕는 중요한 메커니즘입니다.
하지만, DOM이 아닌 Virtual DOM에서 변경 사항을 감지하고 적용하려면 DOM 이벤트를 직접적으로 사용하기보다, Virtual DOM 내에서의 이벤트 관리가 필요합니다.
리액트의 합성 이벤트(Synthetic Event) 시스템은 이러한 구조를 가능하게 합니다. 합성 이벤트 시스템은 브라우저 간의 이벤트 불일치를 최소화하고, 다양한 이벤트를 통합하여 일관된 API로 제공함으로써 효율적인 이벤트 관리를 가능하게 합니다.
특히, 리액트가 하나의 이벤트 리스너로 다양한 이벤트를 관리할 수 있게 함으로써 메모리와 성능을 최적화합니다.
공통 컴포넌트 (예: <div>) – ReactThe library for web and native user interfaces
리액트의 독립적인 이벤트 시스템은 코드의 일관성과 유지보수를 용이하게 합니다. 리액트에서 모든 이벤트는 합성 이벤트로 감싸져 제공되기 때문에, 개발자는 브라우저에 따른 이벤트 처리 차이점을 걱정하지 않고 작성할 수 있습니다. 또한, 합성 이벤트는 모든 이벤트를 일관된 방식으로 취급하므로, 이벤트 처리가 더욱 직관적이며 메모리 누수 방지와 성능 최적화를 돕습니다. 이 시스템 덕분에 이벤트 위임이 가능한데, 이는 특정 DOM 노드마다 개별 이벤트 리스너를 할당하는 것이 아닌 최상위 DOM 요소에 이벤트 리스너 하나를 할당하여 효율성을 더욱 높일 수 있습니다.
결론!
이러한 과정을 통해 리액트의 이벤트 구조는 단순히 독립적인 체계가 아니라, 효율성과 일관성, 그리고 성능을 위한 중요한 설계임을 이해하게 되었습니다. 특히, 합성 이벤트 시스템을 통해 Virtual DOM과의 상호작용을 매끄럽게 유지하고, 상태와 이벤트가 동기화되도록 설계함으로써, 리액트 컴포넌트는 더욱 직관적이고 유지보수하기 쉬운 구조로 관리될 수 있음을 느꼈습니다.
2. PopOverMenu의 자식 요소 컴포넌트 렌더러 만들기
우리 서비스 크루루에서 PopOverMenu는 다양한 자식 요소를 지원해야 했습니다. 특히, 메뉴 항목 내부에서 하위 메뉴를 트리거하는 SubTrigger 요소가 필요하다는 점을 인지하게 되었고, 이를 구성하기 위해 고민한 내용을 공유합니다.
기존 PopOverMenu는 단일 레벨의 메뉴를 지원했으나, 단계적으로 이동하는 메뉴를 복합적으로 구성할 필요성이 제기되었습니다. 특히 사용자가 메뉴를 통해 여러 옵션을 계층적으로 탐색하는 상황에서, 하위 트리거(SubTrigger)를 제공하여 자연스럽게 동작하는 UI가 요구되었습니다.
2. SubTrigger 구현 방식의 선택: Prop Drilling vs 합성 컴포넌트 패턴
SubTrigger를 구현하는 방법으로 두 가지 접근 방식을 고려했습니다.
-
Prop Drilling
컴포넌트 간 데이터를 전달하기 위해 상위 컴포넌트에서 하위 컴포넌트로 props를 계속 전달하는 방식입니다. 하지만 이 방식은 깊은 트리 구조에서 비효율적이며, 유지보수가 어려워질 가능성이 큽니다.
-
합성 컴포넌트 패턴
Shadcn/ui의 Dropdown 컴포넌트처럼, 합성 컴포넌트를 활용하여 메뉴를 계층적으로 구성하는 방식을 참고했습니다. 이 방식은 유연성과 확장성이 뛰어나지만, 컴포넌트 설계 및 사용에 있어 복잡성이 증가할 수 있습니다.
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 데이터를 기반으로 항목을 렌더링합니다. 주요 역할은 다음과 같습니다:
- Clickable 컴포넌트: 클릭 가능한 항목은 DropdownItem을 통해 렌더링됩니다.
- SubTrigger 컴포넌트: 하위 메뉴를 트리거하는 항목은 DropdownSubTrigger를 통해 렌더링되며, 내부에서 재귀 호출로 하위 항목을 처리합니다.
- 화면 경계를 초과하지 않도록 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. 결론
객체 기반 트리구조를 활용한 재귀 렌더링 방식을 통해 개발자 경험을 크게 향상시킬 수 있는 경험을 했습니다. 데이터 기반의 렌더링 구조를 가져가면서, 유지보수성을 높힐 수 있는 방법을 고안했다 생각합니다.