상황
도면 위에 lamp 요소를 수십 개 배치하는 작업이 잦았다.
열 맞춰서 나열하다보면 1px, 2px로 같은 가로인데 두번 세번 반복작업이 짜증이 나더라,,
사용자 입장에서도 이건 손떨림 방지라던지, 가이드 표시를 해주는 게 나을 것 같단 생각이 들었다.
다른 요소와 좌표가 가까워지면 자동으로 스냅 + Figma 처럼 흰 점선 가이드가 짠 하고 뜰 수 있도록..?
다만 문제가 하나 있었다. 캔버스가 transform: scale(N) 으로 줌이 적용된 상태였다는 것.
구체적 배경
캔버스 안의 요소 좌표는 unscaled 좌표 (예: bodyX: 100)고, 시각적 위치는 scale 만큼 곱해져서 화면에 그려진다.
가이드 라인은 두 요소를 잇는 흰 점선이어야 하는데, 어디에 그릴 것인가가 문제였다.
선택지는 둘이었다.
- 캔버스 안에 그리기: element 들이랑 같은 좌표계니
transform: scale의 영향을 같이 받음 - 캔버스 밖, viewport 좌표로 그리기: 캔버스 transform 과 분리됨
1번을 처음 시도했더니 줌이 1x 가 아닌 상태에서 가이드 위치가 계속 어긋났다. 캔버스 transform-origin 이 center 인데, 가이드를 그릴 unscaled 좌표를 viewport 로 변환하려면 매번 scale + 캔버스 위치 + transform-origin 까지 다 계산해야 했다. 코드도 지저분해지고 디버깅도 어려웠다.
해결 - Teleport + position: fixed
가이드 라인을 캔버스 밖, body 직속 자식으로 띄우는 쪽으로 잡았다.
Vue 3 의 Teleport 를 쓰면 컴포넌트 안에서 선언하지만 실제 DOM 은 다른 곳에 마운트된다.
<Teleport to="body">
<div
v-if="$store.state.smartGuides.vertical !== null"
class="smart-guide smart-guide-v"
:style="{
left: `${$store.state.smartGuides.vertical}px`,
top: `${$store.state.smartGuides.bounds.top}px`,
height: `${$store.state.smartGuides.bounds.height}px`,
}"
></div>
</Teleport>
.smart-guide {
position: fixed;
pointer-events: none;
z-index: 9999;
}
.smart-guide-v {
width: 0;
border-left: 1px dashed rgba(255, 255, 255, 0.55);
}
position: fixed + viewport 좌표로 그리니 캔버스의 transform: scale 과 무관하게 정확히 원하는 위치에 점선이 떴다. 캔버스가 줌인 / 팬되어도 가이드 좌표 계산을 다시 하지 않아도 된다.
Vue에서 Teleport는 React(Next)에서 뭐였던가..? 싶어서 조금 찾아봤다.
[Vue3] Teleport 와 React createPortal — 같은 개념, 다른 이름
캔버스 편집기에 정렬 가이드를 만들면서 Vue 3 의 Teleport 를 처음 진지하게 써봤다.
익숙한 React 출신 머리로는 "어 이거 뭐였더라" 싶은 기억이 나서 짚어봤다.
결론은 createPortal 의 Vue 버전이었다.
이거.. 내가 썼던가.. 싶은 생각이 드는데 써봤던가.. 너무 오래전이라 기억을 되살려야만 했다.
컴포넌트 트리상의 위치와 실제 DOM 마운트 위치를 분리하는 기능
작성하는 코드 입장에선 자식 컴포넌트지만, 브라우저가 그릴 때는 다른 곳(보통 <body> 직속)에 마운트된다. 컴포넌트의 props/state 흐름은 그대로 유지되면서 시각적 위치만 분리하는 게 핵심.
코드 비교
React - createPortal
import { createPortal } from "react-dom";
function Modal({ children }) {
return createPortal(
<div className="modal-backdrop">
<div className="modal">{children}</div>
</div>,
document.body
);
}
함수 호출 형태. 첫 인자는 JSX, 두 번째 인자는 마운트할 DOM 노드.
Vue 3 — <Teleport>
<template>
<Teleport to="body">
<div class="modal-backdrop">
<div class="modal"><slot /></div>
</div>
</Teleport>
</template>
컴포넌트 형태. to prop 에 셀렉터 문자열이나 노드 직접 전달.
API 차이
| 항목 | React Portal | Vue Teleport |
|---|---|---|
| 도입 시기 | 2017년 (React 16) | 2020년 (Vue 3) |
| 사용 형태 | 함수 (createPortal) |
컴포넌트 (<Teleport>) |
| target 지정 | DOM 노드 직접 전달 | 셀렉터 문자열 또는 노드 |
| 비활성화 | 분기 처리 직접 작성 | disabled prop 한 줄 |
| SSR | 잘 됨 | 잘 됨 (Vue 3.2+) |
Vue 가 컴포넌트로 감싼 형태라 더 직관적이긴 하다. React 는 함수 호출 패턴이 처음 보면 낯설다.
실제 쓰이는 용도
1. 모달 / 다이얼로그
가장 흔한 케이스.
부모 컴포넌트 CSS 영향(특히 overflow: hidden, transform, z-index)에서 벗어나기 위해 body 직속에 마운트한다.
// React
function ConfirmDialog({ open, message }) {
if (!open) return null;
return createPortal(
<div className="dialog">{message}</div>,
document.body
);
}
<!-- Vue -->
<Teleport to="body">
<div v-if="open" class="dialog">{{ message }}</div>
</Teleport>
아무래도 확실히 vue가 간편하다.
2. 툴팁 / 팝오버
요소 위에 떠야 하는데, 트리거 요소의 부모 컨테이너가 overflow: hidden 이거나 좁은 영역인 경우 클리핑되어 잘릴 수 있다. portal 로 body 에 띄우면 화면 어디든 자유롭게 그려진다.
3. 컨텍스트 메뉴
마우스 우클릭 시 화면 임의 위치에 뜨는 메뉴. 클릭한 지점의 viewport 좌표로 그려야 하는데, 부모 transform 이 끼면 좌표 계산이 꼬인다. portal 로 빼면 viewport 좌표 그대로 사용 가능.
4. 드롭다운 / Select 옵션 리스트
옵션 리스트가 부모 밖으로 펼쳐져야 하는데 부모에 overflow: auto 면 클리핑됨. Material UI, Radix, Headless UI 등 거의 모든 React 컴포넌트 라이브러리는 내부적으로 createPortal 을 쓴다. 그래서 사용자가 직접 호출할 일이 거의 없다.
5. 캔버스 위 정렬 가이드 / 미니맵 / 측정 도구
내가 이번에 진짜 필요하다고 느낀 케이스. 캔버스가 transform: scale 로 줌이 적용된 상태에서, 캔버스 위에 떠 있어야 하지만 transform 의 영향은 받지 말아야 하는 UI. 가이드 라인 / 미니맵 / 좌표 표시 / 호버 카드 같은 것들. body 직속에 portal 로 띄우면 좌표 계산이 깔끔해진다.
왜 평소엔 안 보이는가
본인이 모달을 만든 적 있어도 portal 을 직접 호출 안 했다면, 부모 구조가 운 좋게
- overflow: hidden 없음
- transform 없음
- 새로운 stacking context 없음
조건이었을 가능성이 크다. 이러면 그냥 <div style={{ position: fixed }}> 박아도 잘 뜬다.
라이브러리(Material UI, Antd, Chakra 등) 를 쓰는 환경이라면 그 안에 portal 이 이미 박혀 있어서 사용자는 영영 못 보고 지나간다.
진짜 필요해지는 순간은:
- 디자인 시스템 도입하면서 부모에
overflow가 생기는 순간 - 사이드바 / 모달 안에 또 다른 floating UI 를 띄울 때
- 캔버스 위에 transform 영향 안 받는 layer 가 필요할 때
이때 portal/Teleport 의 존재를 알고 있어야 빨리 떠올릴 수 있다.
한 줄 요약
Teleport(Vue) ≈ createPortal(React).
부모 CSS 영향 (overflow, transform, stacking context) 에서 벗어나야 하는 floating UI 의 탈출구.
Q. 스냅 좌표는 어떻게 잡는가
스냅 후보가 될 다른 요소의 viewport 좌표를 받아오기만 하면 된다. getBoundingClientRect() 가 그걸 그대로 알려준다.
const getElementScreenCenter = (id) => {
const node = document.getElementById(`divMonitorElem_${id}`);
if (!node) return null;
// .point2 는 숨겨진 텍스트/툴까지 포함해 bounding box 가 큼
// → 보이는 영역(.point2_Inbox) 기준으로 중심 계산
const inbox = node.querySelector(".point2_Inbox");
const target = inbox || node;
const rect = target.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
};
드래그 중인 요소의 좌표가 다른 요소와 5px 이내로 가까워지면 좌표 보정 + 가이드 라인을 그 viewport 좌표에 표시한다.
const SNAP_THRESHOLD = 5;
let bestX = null;
let bestY = null;
for (const other of elements) {
if (other.id === draggedId) continue;
const dx = other.bodyX - $ui.position.left;
if (Math.abs(dx) < SNAP_THRESHOLD && (!bestX || Math.abs(dx) < Math.abs(bestX.delta))) {
bestX = { target: other, delta: dx };
}
// dy 도 동일
}
if (bestX) $ui.position.left = bestX.target.bodyX; // 스냅
X 와 Y 를 독립적으로 추적하니 가로/세로 동시 스냅도 자연스럽게 된다.
Q. 가이드 영역 클리핑
가이드 라인이 화면 끝까지 무한정 그어지면 헤더/하단 바를 침범해 어색하다. 편집 모드 상하단 바의 getBoundingClientRect 로 캔버스 가시 영역을 잡고 그 안으로 클리핑했다.
const topBar = document.querySelector(".edit_mode_top_bar");
const bottomBar = document.querySelector(".edit_mode_bottom_bar");
const sceneContainer = el.closest(".sceneContainer");
const scRect = sceneContainer?.getBoundingClientRect();
const topRect = topBar?.getBoundingClientRect();
const bottomRect = bottomBar?.getBoundingClientRect();
const bounds = {
left: topRect?.left ?? scRect.left,
top: topRect ? topRect.bottom : scRect.top,
width: (topRect?.right ?? scRect.right) - (topRect?.left ?? scRect.left),
height: (bottomRect?.top ?? scRect.bottom) - (topRect?.bottom ?? scRect.top),
};
배운 점
캔버스 위에 transform 과 무관한 layer 를 따로 두면, 좌표 변환 로직 없이도 정확한 시각 표시가 가능하다.
Teleport 로 body 직속에 올리고 viewport 좌표(getBoundingClientRect) 로 그리면 끝이다.
같은 패턴은 미니맵, 측정 도구, 호버 툴팁, 컨텍스트 메뉴같이 "캔버스 위에 떠 있어야 하지만 캔버스 transform 의 영향은 받지 말아야 하는" 모든 UI 요소에 적용된다.
'Dev > Vue' 카테고리의 다른 글
| [Vue3] 캔버스 편집 모드 드래그가 자꾸 원위치로 튀는 이유 - reactive 재렌더와 jQuery UI 충돌 (0) | 2026.05.07 |
|---|---|
| [Vue3] ref(객체)에 watch 안 걸어도 되는 이유 (0) | 2026.04.20 |
| #Vue #Generic 안녕하세요 Next에서 넘어왔습니다 (0) | 2026.03.06 |