개발2026-02-20

React 에디터에서 overflow:hidden에 의한 선택 UI 잘림 — Portal 패턴으로 해결하기

Canvas 기반 에디터에서 부모의 overflow: hidden으로 인해 노드 선택 테두리가 잘리는 문제를 해결해 봅니다. React Portal과 ResizeObserver를 활용한 독립적인 오버레이 레이어 구현 및 캔버스 좌표계 변환 방법을 상세히 다룹니다.

# React# Portal# overflow-hidden# 웹-에디터# Canvas# getBoundingClientRect# ResizeObserver# react-rnd# 프론트엔드

웹 기반 비주얼 에디터를 개발하다 보면 노드 선택 테두리가 부모 요소의 overflow: hidden에 잘려서 보이지 않는 문제를 거의 반드시 만나게 됩니다. z-index를 올려도, overflow를 토글해도 완벽하게 해결되지 않습니다.

이 글에서는 Portal 패턴을 도입하여 이 문제를 근본적으로 해결한 경험과 전체 구현 코드를 공유합니다.


TL;DR — 한 줄 요약

선택 오버레이(테두리 + 리사이즈 핸들)를 노드 DOM 트리에서 꺼내 별도 포탈 레이어에 렌더링하면, 어떤 부모의 overflow: hidden이든 영향을 받지 않는다.


문제: 선택 테두리가 부모 overflow:hidden에 의해 잘린다

React로 만든 캔버스 에디터에서 노드를 클릭하면 파란색 선택 테두리(Selection Ring)리사이즈 핸들(Corner Dots) 이 표시됩니다. 그런데 특정 노드에서는 이 UI가 잘려서 보이지 않았습니다.

기존 구조 — DOM 트리 안에 오버레이가 갇혀 있음

codeCanvas
└─ Transform Layer (pan/zoom)
    └─ 부모 노드 (EditorNodeWrapper + react-rnd)
        └─ 부모 컴포넌트 (overflow: hidden 가능 ⚠️)
            └─ 자식 노드 (EditorNodeWrapper + react-rnd)
                ├─ 🔵 선택 테두리 (CSS ring)
                └─ ⚪ 리사이즈 핸들 (Corner Dots)

선택 테두리는 노드 내부 div의 CSS ring으로 렌더링되고 있었습니다:

tsx// EditorNodeWrapper.tsx (변경 전)
<div
  className={clsx(
    "relative h-full w-full",
    isSelected && "ring ring-2 ring-rnd-handle",  // ← 부모 overflow에 잘린다!
  )}
>
  {children}
</div>

왜 z-index나 overflow 토글로는 해결할 수 없는가?

이 문제의 근본 원인을 정리하면 다음과 같습니다:

접근 방법 왜 안 되는가
z-index 높이기 부모의 stacking context 안에 갇혀 있어 효과 없음
부모 overflow 제거 사용자가 의도한 디자인 속성을 건드리게 됨
선택 시 overflow 토글 다중 중첩 시 모든 조상을 제어해야 하고, 깜빡임 발생

결론: 오버레이 UI 자체를 노드의 DOM 계층에서 분리해야 합니다.


해결: Portal 패턴으로 오버레이를 DOM 트리 밖으로 분리

Portal 패턴이란?

Portal은 React에서 컴포넌트를 부모 DOM 트리 밖의 다른 위치에 렌더링하는 기법입니다. ReactDOM.createPortal()이 대표적이지만, 꼭 이 API를 사용할 필요는 없습니다. 렌더링 위치를 물리적으로 분리하면 동일한 효과를 얻을 수 있습니다.

변경 후 구조

codeCanvas
└─ Transform Layer (pan/zoom)
    ├─ 노드 트리 (기존 구조 유지)
    │   └─ 부모 노드
    │       └─ 자식 노드 (오버레이 UI 없음, Rnd 투명 핸들만 인터랙션)
    │
    └─ 🆕 SelectionOverlay (포탈 레이어)
        ├─ pointer-events: none (드래그/리사이즈 간섭 방지)
        ├─ z-index: 9999 (항상 최상위)
        └─ 오버레이 박스
            ├─ 🔵 선택 테두리 (ring-2)
            └─ ⚪ 리사이즈 핸들 (4 corners)

오버레이가 노드 트리 바깥에 위치하므로, 부모가 overflow: hidden이든 border-radius로 클리핑하든 **절대 잘리지 않습니다 **.


구현 코드: React + TypeScript + Tailwind CSS

1단계: SelectionOverlay 컴포넌트 생성

getBoundingClientRect()로 선택된 노드의 실제 위치를 측정하고, 캔버스 좌표계로 변환하여 오버레이를 렌더링합니다.

tsx// SelectionOverlay.tsx (핵심 부분만 발췌)

export default function SelectionOverlay({ selectedNodeId, canvas }) {
  const [rect, setRect] = useState<OverlayRect | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef<number>(0);

  useEffect(() => {
    if (!selectedNodeId) { setRect(null); return; }

    // data-component-id 속성으로 대상 DOM 요소 탐색
    const nodeEl = document.querySelector(
      `[data-component-id="${selectedNodeId}"]`,
    ) as HTMLElement | null;
    if (!nodeEl) { setRect(null); return; }

    function updateRect() {
      const containerRect = containerRef.current!.getBoundingClientRect();
      const nodeRect = nodeEl!.getBoundingClientRect();

      // 화면 좌표 → 캔버스 좌표계 변환 (scale 역변환 필요)
      setRect({
        x: (nodeRect.left - containerRect.left) / canvas.scale,
        y: (nodeRect.top - containerRect.top) / canvas.scale,
        width: nodeRect.width / canvas.scale,
        height: nodeRect.height / canvas.scale,
      });
    }

    rafRef.current = requestAnimationFrame(updateRect);

    // 실시간 동기화: 크기/위치 변화 감지
    const ro = new ResizeObserver(() => {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(updateRect);
    });
    ro.observe(nodeEl);

    // 드래그 시 react-rnd의 transform 변화 감지
    const mo = new MutationObserver(() => {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = requestAnimationFrame(updateRect);
    });
    const rndWrapper = nodeEl.closest(".react-draggable");
    if (rndWrapper) {
      mo.observe(rndWrapper, { attributes: true, attributeFilter: ["style"] });
    }

    return () => {
      cancelAnimationFrame(rafRef.current);
      ro.disconnect();
      mo.disconnect();
    };
  }, [selectedNodeId, canvas.scale]);

  return (
    <div
      ref={containerRef}
      className="pointer-events-none absolute inset-0"
      style={{ zIndex: 9999 }}
    >
      {rect && (
        <div
          className="absolute ring-2 ring-rnd-handle rounded-[1px]"
          style={{ left: rect.x, top: rect.y, width: rect.width, height: rect.height }}
        >
          {/* 리사이즈 핸들 — 시각 전용 */}
          <div className="absolute -left-1 -top-1 h-2 w-2 rounded-full
                          border-2 border-rnd-handle bg-white" />
          <div className="absolute -right-1 -top-1 h-2 w-2 rounded-full
                          border-2 border-rnd-handle bg-white" />
          <div className="absolute -bottom-1 -left-1 h-2 w-2 rounded-full
                          border-2 border-rnd-handle bg-white" />
          <div className="absolute -bottom-1 -right-1 h-2 w-2 rounded-full
                          border-2 border-rnd-handle bg-white" />
        </div>
      )}
    </div>
  );
}

2단계: EditorNodeWrapper — ring 제거 + 핸들 투명화

기존 시각적 UI를 제거하되, react-rnd의 리사이즈 인터랙션은 유지합니다. 핸들을 opacity-0으로 투명하게 만들어 클릭 영역만 살려둡니다.

diff // EditorNodeWrapper.tsx

 const selectedNodeGuideClasses = {
-  handle: "bg-white border-2 rounded-full border-rnd-handle !w-2 !h-2",
-  outline: "ring ring-2 ring-rnd-handle",
+  handle: "opacity-0 !w-3 !h-3",  // 투명하지만 클릭 영역은 유지
 };

 <div
-  className={clsx("relative h-full w-full", isSelected && outline)}
+  className="relative h-full w-full"
 >

3단계: Canvas에 오버레이 마운트

노드 트리(DragProvider) 뒤에 오버레이를 배치합니다.

diff // Canvas.tsx
 <div className="relative z-10 h-full w-full">
   <DragProvider value={useDragStore}>
     {renderTree({ id: null })}
   </DragProvider>
+  <SelectionOverlay
+    selectedNodeId={selectedNodeId}
+    canvas={canvasState}
+  />
 </div>

좌표 변환: getBoundingClientRect와 Canvas Scale

이 구현에서 가장 까다로운 부분은 좌표 변환입니다. 캔버스에 pan/zoom이 적용되어 있어 화면 좌표와 캔버스 좌표가 다릅니다.

code화면 좌표 (getBoundingClientRect)
  │
  │  nodeRect.left - containerRect.left
  │  → 포탈 레이어 기준 상대 좌표
  │
  │  / canvas.scale
  │  → 캔버스 좌표계 (scale 역변환)
  ▼
캔버스 좌표 (CSS left/top으로 설정)

SelectionOverlay는 캔버스 transform 레이어 안에 위치합니다. getBoundingClientRect()가 반환하는 값에는 scale이 이미 적용되어 있으므로, CSS position 설정 시에는 scale로 나눠야 정확한 위치에 렌더링됩니다.


결과 비교

시나리오 변경 전 변경 후
부모에 overflow: hidden 테두리 잘림 ❌ 정상 표시 ✅
깊은 중첩 구조 (3단계+) 일부 잘림 ❌ 정상 표시 ✅
border-radius 있는 부모 모서리 잘림 ❌ 정상 표시 ✅
노드 드래그 중 실시간 추적 ✅
노드 리사이즈 중 실시간 추적 ✅
캔버스 Pan/Zoom 정확한 위치 ✅

핵심 구현 포인트 정리

기술 요소 역할
pointer-events: none 오버레이가 드래그/리사이즈 이벤트를 가로채지 않음
getBoundingClientRect() 노드의 실제 화면 위치를 정확히 측정
/ canvas.scale 캔버스 줌(scale)에 따른 좌표 역변환
ResizeObserver 노드 크기 변경 시 오버레이 즉시 동기화
MutationObserver react-rnd 드래그로 인한 style 속성 변경 감지
requestAnimationFrame 연속 변경 배치 처리로 렌더링 성능 최적화

핵심 배운 점

  1. DOM 계층에 종속된 UI는 부모의 CSS에 영향을 받을 수밖에 없다 — 이를 해결하는 가장 근본적인 방법은 해당 UI를 DOM 계층에서 빼내는 것입니다.

  2. Portal은 createPortal API만을 의미하지 않는다 — 같은 React 트리 안에서도 렌더링 위치를 물리적으로 분리하면 동일한 효과를 얻을 수 있습니다.

  3. getBoundingClientRect() + Observer 패턴은 강력한 조합이다 — DOM의 "진짜 위치"를 측정하는 API와 변화를 감지하는 Observer를 조합하면, 어떤 복잡한 레이아웃에서도 정확한 오버레이를 구현할 수 있습니다.

  4. 좌표계 변환을 명확히 이해하자 — transform이 적용된 캔버스에서는 화면 좌표와 캔버스 좌표가 다릅니다. "어디서 측정하고, 어디서 렌더링하는가"를 항상 의식해야 합니다.


📋 Portal 기반 오버레이 Q&A 요약

리팩토링 과정에서 고려했던 기술적 결정 사항들을 카테고리별로 정리했습니다.

1. 렌더링 위치 및 구조 관련

  • 왜 노드 본체(Wrapper/Renderer)는 포탈에 넣지 않나요?
    • 노드는 부모의 레이아웃 맥락(Flex, Grid 등)을 유지해야 하기 때문입니다. 본체가 포탈로 빠지면 부모-자식 간의 레이아웃 관계가 깨져 디자인이 무너집니다.
  • 테두리와 리사이즈 점들의 실제 렌더링 위치는 어디인가요?
    • 시각적인 테두리와 점들은 SelectionOverlay.tsx (포탈) 에서 그려집니다. 원래 노드 위치에는 실제 리사이즈 동작을 감지하기 위한 투명한 핸들만 남겨두어 시각적 요소와 기능을 분리했습니다.
  • React의 createPortal API를 사용하지 않은 이유는 무엇인가요?
    • createPortaldocument.body에 렌더링하면 캔버스의 Zoom/Pan 변환을 수동으로 재계산해야 해서 로직이 매우 복잡해집니다. 같은 Transform 레이어 안에서 형제 위치로 분리하는 것이 캔버스 좌표계를 공유할 수 있는 가장 간결한 해법입니다.
  • Figma 같은 전문 디자인 도구도 이 방식을 사용하나요?
    • 네, Figma와 Framer 등 전문 도구들도 본체는 계층 구조 속에 두고, 선택 가이드만 별도의 Canvas Overlay 레이어에 그리는 방식을 사용합니다. 이는 업계에서 검증된 표준 접근법입니다.

2. 스타일링 및 Clipping 관련

  • Stack 컴포넌트에는 overflow: hidden이 자동으로 적용되나요?
    • 아니오. 코드상 강제는 없으며, 사용자가 에디터의 스타일 레벨에서 ‘Clip content’ 설정을 부여한 경우에만 적용됩니다.
  • overflow: hidden을 안 쓰는 상황에서도 포탈 개념이 필요한가요?
    • 네. z-index 싸움(다른 레이어에 가려짐)을 피하고, 사용자가 어떤 디자인을 시도하든 에디터의 보조 UI는 무조건 최상단에 온전하게 보여야 한다는 '신뢰성’을 보장하기 위해 반드시 필요합니다.

3. 좌표 계산 및 내부 로직 관련

  • 포탈 오버레이를 위한 실시간 좌표 계산은 어디서 하나요?
    • SelectionOverlay.tsxupdateRect 함수에서 담당하며, ResizeObserverMutationObserver를 통해 노드의 변화를 실시간으로 감지합니다.
  • getBoundingClientRect()left 값은 무엇을 기준으로 하나요?
    • 브라우저 화면(Viewport)의 절대적인 왼쪽 끝 기준입니다. 이 덕분에 노드가 아무리 깊게 중첩되어 있어도 조상의 영향을 무시하고 최종적인 결과물 위치를 정확히 끄집어낼 수 있습니다.
  • 노드 데이터에 이미 x, y 좌표가 있는데 왜 직접 사용하지 않나요?
    • 데이터상의 좌표는 “부모 노드 기준 상대 좌표” 이기 때문입니다. 포탈은 노드 트리 밖에 있으므로 모든 조상의 좌표를 합산하는 복잡한 연산 대신, 렌더링된 최종 절대 위치를 측정하는 것이 훨씬 정확하고 효율적입니다.
  • pointer-events: none인데 리사이즈 핸들을 어떻게 드래그하나요?
    • 포탈의 핸들은 시각적 표시 전용입니다. 마우스 이벤트는 포탈 레이어를 통과하여 그 아래에 있는 Rnd의 투명 핸들에 도달하므로, 사용자는 시각적인 점을 잡고 드래그하는 것처럼 느끼지만 실제로는 Rnd의 기능을 사용하는 구조입니다.
  • 매 프레임 위치를 계산하면 성능 이슈는 없나요?
    • 브라우저 네이티브 API인 Observer들을 사용하고, requestAnimationFrame을 통해 프레임당 단 1회만 계산되도록 최적화했기 때문에 매우 가볍게 동작합니다.