|
| 1 | +# 트러블슈팅: Stack Item 리사이즈 후 드래그 시 노드 점프 버그 |
| 2 | + |
| 3 | +> **발생일**: 2026-02-25 |
| 4 | +> **파일**: `packages/ui/src/core/EditorNodeWrapper.tsx` |
| 5 | +> **라이브러리**: `react-rnd@10.5.2` |
| 6 | +
|
| 7 | +--- |
| 8 | + |
| 9 | +## 1. 버그 증상 |
| 10 | + |
| 11 | +Stack 내부의 `position: relative` 아이템 노드를 **리사이즈한 후 드래그를 시작하면**, 노드가 엉뚱한 위치로 순간이동(점프)하는 현상이 발생했다. |
| 12 | + |
| 13 | +- 리사이즈 중에는 정상적으로 flexbox 정렬을 따름 |
| 14 | +- 드래그를 시작하는 순간만 점프 발생 |
| 15 | +- 리사이즈 없이 바로 드래그하면 정상 동작 |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## 2. 원인 분석 |
| 20 | + |
| 21 | +### 2-1. react-rnd의 위치 제어 방식 |
| 22 | + |
| 23 | +`react-rnd`는 노드의 위치를 **CSS `transform: translate(x, y)`** 로 제어한다. |
| 24 | +`position` prop에 `{ x, y }` 값을 전달하면, 내부적으로 해당 값을 `transform`에 반영한다. |
| 25 | + |
| 26 | +### 2-2. Stack Item의 특수 처리 (`!transform-none`) |
| 27 | + |
| 28 | +Stack 내부 아이템(`position: relative`)은 Flexbox 정렬을 유지해야 하므로, |
| 29 | +`isTransformActive`가 `false`일 때 `!transform-none` CSS 클래스를 강제 적용해 |
| 30 | +react-rnd의 transform을 무력화하고 있었다. |
| 31 | + |
| 32 | +```tsx |
| 33 | +className={clsx( |
| 34 | + "group cursor-pointer", |
| 35 | + hasRelativePosition && !isTransformActive && "!transform-none", |
| 36 | +)} |
| 37 | +``` |
| 38 | + |
| 39 | +드래그 시작(`onDragStart`) 시에는 `setIsTransformActive(true)`로 이 클래스가 제거되고, |
| 40 | +react-rnd의 transform이 다시 활성화된다. |
| 41 | + |
| 42 | +### 2-3. 버그의 핵심: 리사이즈 중 내부 position 누적 |
| 43 | + |
| 44 | +**`!transform-none`이 적용되어 DOM에서는 transform이 보이지 않더라도, |
| 45 | +react-rnd 라이브러리는 리사이즈 중에 내부 position 상태를 계속 누적 변경하고 있다.** |
| 46 | + |
| 47 | +특히 **top 방향 / left 방향 핸들**로 리사이즈할 경우, 요소가 시각적으로 이동해야 하므로 |
| 48 | +react-rnd가 내부적으로 `translate(x, y)` 값을 계산해 내부 ref에 저장한다. |
| 49 | + |
| 50 | +``` |
| 51 | +리사이즈 전: 내부 position = { x: 0, y: 0 } |
| 52 | +↓ top-left 방향으로 리사이즈 |
| 53 | +리사이즈 후: 내부 position = { x: -50, y: -30 } ← 누적됨! |
| 54 | + (DOM에서는 !transform-none으로 가려져 있어서 보이지 않음) |
| 55 | +``` |
| 56 | + |
| 57 | +### 2-4. 드래그 시작 시 점프 발생 흐름 |
| 58 | + |
| 59 | +``` |
| 60 | +1. onDragStart 실행 |
| 61 | +2. setIsTransformActive(true) → !transform-none 클래스 제거 |
| 62 | +3. react-rnd가 리사이즈 중 누적된 내부 position { x: -50, y: -30 }을 |
| 63 | + transform에 그대로 반영 → 노드 점프! 💥 |
| 64 | +4. d.node.offsetLeft / offsetTop 읽음 |
| 65 | + → 이미 튀어버린 위치 기준으로 좌표를 읽음 → 잘못된 좌표로 이동 |
| 66 | +``` |
| 67 | + |
| 68 | +--- |
| 69 | + |
| 70 | +## 3. 시도한 해결책과 왜 실패했는가 |
| 71 | + |
| 72 | +### ❌ 시도 1: `onResizeStop`에서 position 초기화 |
| 73 | + |
| 74 | +```tsx |
| 75 | +// onResizeStop 내부 |
| 76 | +if (hasRelativePosition) { |
| 77 | + setDragPosition({ x: 0, y: 0 }); |
| 78 | + rndRef.current?.updatePosition({ x: 0, y: 0 }); |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +**실패 원인:** |
| 83 | +`onResizeStop`에서 초기화해도, 이후 사용자가 리사이즈 핸들을 다시 조작하면 |
| 84 | +또다시 내부 position이 누적된다. |
| 85 | +결정적으로, `onResizeStop` 이후와 `onDragStart` 사이에 사용자 인터랙션이 없어도 |
| 86 | +react-rnd 내부에서 추가적인 position 변화가 일어날 수 있어 근본적인 해결이 어렵다. |
| 87 | +즉, **"리사이즈가 끝날 때" 초기화는 타이밍이 맞지 않는다.** |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +## 4. 최종 해결책 |
| 92 | + |
| 93 | +### ✅ `onDragStart`에서 항상 먼저 초기화 |
| 94 | + |
| 95 | +드래그가 시작되는 바로 그 시점, **transform이 활성화되기 직전**에 |
| 96 | +`rndRef.current?.updatePosition({ x: 0, y: 0 })`을 호출해 |
| 97 | +react-rnd 내부 position을 강제로 `{x:0, y:0}`으로 초기화한다. |
| 98 | + |
| 99 | +```tsx |
| 100 | +onDragStart={(e, d) => { |
| 101 | + e.stopPropagation(); |
| 102 | + setDraggingId(id); |
| 103 | + |
| 104 | + if (hasRelativePosition) { |
| 105 | + // ✅ 핵심: isTransformActive를 true로 바꾸기 전에 |
| 106 | + // react-rnd 내부 position을 반드시 먼저 리셋해야 한다. |
| 107 | + // 리사이즈 중 누적된 내부 position이 드래그 활성화와 동시에 |
| 108 | + // transform에 반영되어 노드가 튀는 버그를 방지. |
| 109 | + rndRef.current?.updatePosition({ x: 0, y: 0 }); |
| 110 | + |
| 111 | + const { offsetLeft, offsetTop } = d.node; |
| 112 | + setIsTransformActive(true); |
| 113 | + // ... |
| 114 | + } |
| 115 | +}} |
| 116 | +``` |
| 117 | + |
| 118 | +### 왜 이 방법이 동작하는가? |
| 119 | + |
| 120 | +| 단계 | 이전(버그) | 이후(수정) | |
| 121 | +|---|---|---| |
| 122 | +| 리사이즈 중 | 내부 position 누적 | 내부 position 누적 (동일) | |
| 123 | +| `onDragStart` 진입 | 누적된 값 그대로 유지 | `updatePosition(0,0)` 으로 강제 리셋 | |
| 124 | +| `setIsTransformActive(true)` | 누적된 position이 transform에 반영 → 점프 | 내부 position이 `{0,0}`이므로 안전하게 transform 활성화 | |
| 125 | +| `offsetLeft/offsetTop` 읽기 | 잘못된 위치 기준 | 올바른 flexbox 위치 기준 | |
| 126 | + |
| 127 | +**타이밍이 핵심이다.** `setIsTransformActive(true)`로 transform을 활성화하기 **이전에** |
| 128 | +내부 position을 초기화해야 React의 렌더링 사이클에서 올바른 순서가 보장된다. |
| 129 | + |
| 130 | +--- |
| 131 | + |
| 132 | +## 5. 핵심 교훈 |
| 133 | + |
| 134 | +### "React 상태 초기화"와 "라이브러리 내부 상태 초기화"는 다르다 |
| 135 | + |
| 136 | +``` |
| 137 | +setDragPosition({ x: 0, y: 0 }) |
| 138 | +→ React가 관리하는 state만 초기화. react-rnd 내부 ref에는 영향 없음. |
| 139 | +
|
| 140 | +rndRef.current?.updatePosition({ x: 0, y: 0 }) |
| 141 | +→ react-rnd가 내부적으로 관리하는 DOM transform 상태까지 직접 초기화. |
| 142 | +``` |
| 143 | + |
| 144 | +react-rnd처럼 **uncontrolled 방식으로 DOM을 직접 조작하는 라이브러리**는 |
| 145 | +React의 state/props와 별도로 자체적인 내부 상태를 가진다. |
| 146 | +Zustand나 useState로 아무리 상태를 바꿔도, **라이브러리 내부 상태는 별도로 초기화**해야 한다. |
| 147 | + |
| 148 | +### 리사이즈 방향에 따라 position이 변한다 |
| 149 | + |
| 150 | +react-rnd는 `bottom-right` 방향 리사이즈만 할 때는 position이 변하지 않는다. |
| 151 | +그러나 **`top`, `left`, `top-left`, `top-right`, `bottom-left` 방향**으로 리사이즈하면 |
| 152 | +요소가 반대 방향으로 이동해야 하므로 내부 position을 자동으로 변경한다. |
| 153 | +이 동작은 CSS의 `transform-origin`과 다른, react-rnd 고유의 position 계산 방식이다. |
| 154 | + |
| 155 | +### 초기화 타이밍은 "활성화 직전"이어야 한다 |
| 156 | + |
| 157 | +- `onResizeStop`: 너무 이름 — 이후 또 다른 조작이 발생할 수 있음 |
| 158 | +- `onDragStart` (transform 활성화 직전): ✅ 가장 안전한 타이밍 |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +## 6. 관련 코드 위치 |
| 163 | + |
| 164 | +| 항목 | 위치 | |
| 165 | +|---|---| |
| 166 | +| 수정 파일 | `packages/ui/src/core/EditorNodeWrapper.tsx` | |
| 167 | +| 수정 함수 | `onDragStart` 핸들러 내부 | |
| 168 | +| 핵심 API | `rndRef.current?.updatePosition({ x: 0, y: 0 })` | |
| 169 | +| react-rnd 타입 정의 | `node_modules/react-rnd/lib/index.d.ts` | |
| 170 | + |
| 171 | +--- |
| 172 | + |
| 173 | +## 7. 최종 적용 diff |
| 174 | + |
| 175 | +```diff |
| 176 | +onDragStart={(e, d) => { |
| 177 | + e.stopPropagation(); |
| 178 | + setDraggingId(id); |
| 179 | + if (hasRelativePosition) { |
| 180 | ++ rndRef.current?.updatePosition({ x: 0, y: 0 }); // 내부 position 강제 리셋 |
| 181 | + const { offsetLeft, offsetTop } = d.node; |
| 182 | + setIsTransformActive(true); |
| 183 | +- flushSync(() => { |
| 184 | +- updateNode(id, { x: offsetLeft, y: offsetTop }); |
| 185 | +- setDragPosition({ x: offsetLeft, y: offsetTop }); |
| 186 | +- }); |
| 187 | + } |
| 188 | +}} |
| 189 | +``` |
0 commit comments