React 에디터에서 overflow:hidden에 의한 선택 UI 잘림 — Portal 패턴으로 해결하기
Canvas 기반 에디터에서 부모의 overflow: hidden으로 인해 노드 선택 테두리가 잘리는 문제를 해결해 봅니다. React Portal과 ResizeObserver를 활용한 독립적인 오버레이 레이어 구현 및 캔버스 좌표계 변환 방법을 상세히 다룹니다.
웹 기반 비주얼 에디터를 개발하다 보면 노드 선택 테두리가 부모 요소의 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 |
연속 변경 배치 처리로 렌더링 성능 최적화 |
핵심 배운 점
-
DOM 계층에 종속된 UI는 부모의 CSS에 영향을 받을 수밖에 없다 — 이를 해결하는 가장 근본적인 방법은 해당 UI를 DOM 계층에서 빼내는 것입니다.
-
Portal은
createPortalAPI만을 의미하지 않는다 — 같은 React 트리 안에서도 렌더링 위치를 물리적으로 분리하면 동일한 효과를 얻을 수 있습니다. -
getBoundingClientRect()+ Observer 패턴은 강력한 조합이다 — DOM의 "진짜 위치"를 측정하는 API와 변화를 감지하는 Observer를 조합하면, 어떤 복잡한 레이아웃에서도 정확한 오버레이를 구현할 수 있습니다. -
좌표계 변환을 명확히 이해하자 — transform이 적용된 캔버스에서는 화면 좌표와 캔버스 좌표가 다릅니다. "어디서 측정하고, 어디서 렌더링하는가"를 항상 의식해야 합니다.
📋 Portal 기반 오버레이 Q&A 요약
리팩토링 과정에서 고려했던 기술적 결정 사항들을 카테고리별로 정리했습니다.
1. 렌더링 위치 및 구조 관련
- 왜 노드 본체(Wrapper/Renderer)는 포탈에 넣지 않나요?
- 노드는 부모의 레이아웃 맥락(Flex, Grid 등)을 유지해야 하기 때문입니다. 본체가 포탈로 빠지면 부모-자식 간의 레이아웃 관계가 깨져 디자인이 무너집니다.
- 테두리와 리사이즈 점들의 실제 렌더링 위치는 어디인가요?
- 시각적인 테두리와 점들은
SelectionOverlay.tsx(포탈) 에서 그려집니다. 원래 노드 위치에는 실제 리사이즈 동작을 감지하기 위한 투명한 핸들만 남겨두어 시각적 요소와 기능을 분리했습니다.
- 시각적인 테두리와 점들은
- React의
createPortalAPI를 사용하지 않은 이유는 무엇인가요?createPortal로document.body에 렌더링하면 캔버스의 Zoom/Pan 변환을 수동으로 재계산해야 해서 로직이 매우 복잡해집니다. 같은 Transform 레이어 안에서 형제 위치로 분리하는 것이 캔버스 좌표계를 공유할 수 있는 가장 간결한 해법입니다.
- Figma 같은 전문 디자인 도구도 이 방식을 사용하나요?
- 네, Figma와 Framer 등 전문 도구들도 본체는 계층 구조 속에 두고, 선택 가이드만 별도의 Canvas Overlay 레이어에 그리는 방식을 사용합니다. 이는 업계에서 검증된 표준 접근법입니다.
2. 스타일링 및 Clipping 관련
Stack컴포넌트에는overflow: hidden이 자동으로 적용되나요?- 아니오. 코드상 강제는 없으며, 사용자가 에디터의 스타일 레벨에서 ‘Clip content’ 설정을 부여한 경우에만 적용됩니다.
overflow: hidden을 안 쓰는 상황에서도 포탈 개념이 필요한가요?- 네.
z-index싸움(다른 레이어에 가려짐)을 피하고, 사용자가 어떤 디자인을 시도하든 에디터의 보조 UI는 무조건 최상단에 온전하게 보여야 한다는 '신뢰성’을 보장하기 위해 반드시 필요합니다.
- 네.
3. 좌표 계산 및 내부 로직 관련
- 포탈 오버레이를 위한 실시간 좌표 계산은 어디서 하나요?
SelectionOverlay.tsx의updateRect함수에서 담당하며,ResizeObserver와MutationObserver를 통해 노드의 변화를 실시간으로 감지합니다.
getBoundingClientRect()의left값은 무엇을 기준으로 하나요?- 브라우저 화면(Viewport)의 절대적인 왼쪽 끝 기준입니다. 이 덕분에 노드가 아무리 깊게 중첩되어 있어도 조상의 영향을 무시하고 최종적인 결과물 위치를 정확히 끄집어낼 수 있습니다.
- 노드 데이터에 이미
x, y좌표가 있는데 왜 직접 사용하지 않나요?- 데이터상의 좌표는 “부모 노드 기준 상대 좌표” 이기 때문입니다. 포탈은 노드 트리 밖에 있으므로 모든 조상의 좌표를 합산하는 복잡한 연산 대신, 렌더링된 최종 절대 위치를 측정하는 것이 훨씬 정확하고 효율적입니다.
pointer-events: none인데 리사이즈 핸들을 어떻게 드래그하나요?- 포탈의 핸들은 시각적 표시 전용입니다. 마우스 이벤트는 포탈 레이어를 통과하여 그 아래에 있는 Rnd의 투명 핸들에 도달하므로, 사용자는 시각적인 점을 잡고 드래그하는 것처럼 느끼지만 실제로는 Rnd의 기능을 사용하는 구조입니다.
- 매 프레임 위치를 계산하면 성능 이슈는 없나요?
- 브라우저 네이티브 API인 Observer들을 사용하고,
requestAnimationFrame을 통해 프레임당 단 1회만 계산되도록 최적화했기 때문에 매우 가볍게 동작합니다.
- 브라우저 네이티브 API인 Observer들을 사용하고,