라이브러리 없이 칼럼 드래그 정렬 만들기 — 행 기반 그리드에서

dnd-kit 없이 Pointer Events 만으로 칼럼 순서 변경을 구현했다. 칼럼이 단일 DOM 이 아닌 row-major 그리드에서, 리렌더 없이 60fps 를 유지하는 방법.

여러 역본을 나란히 비교하는 화면에서 칼럼을 드래그해 순서를 바꾸고 싶었다. 아이폰 홈 화면처럼 잡아서 끌면 옆 아이콘이 밀려나며 자리를 양보하는 느낌으로.

dnd-kit 을 쓸 수도 있었지만 세 가지 제약이 라이브러리와 잘 안 맞았다.

  1. 칼럼이 단일 DOM 이 아니다. 행 기반(row-major) 그리드라서 각 절이 grid row 이고 역본 셀이 그 안에 흩어져 있다. 칼럼은 N 개 row 에 퍼진 셀들의 집합이다.
  2. 드래그 중 칼럼이 “카드 여러 장”으로 보인다. 셀마다 배경과 그림자를 주면 한 기둥이 아니라 절별 카드 더미가 된다.
  3. pointermove 마다 리렌더하면 버벅인다. 수백 절짜리 장에서 티가 난다.

결국 직접 만들었다. 네 가지 결정이 핵심이었다.

A. 좌표는 CSS 변수로, 리렌더 없이

드래그 시작 시 dragIndex 만 한 번 setState 한다. 이후 pointermove조상 엘리먼트의 CSS 변수를 직접 갱신한다. React 리렌더가 0 이다.

.drag-col {
  transform: translate3d(var(--drag-x, 0px), var(--drag-y, 0px), 0);
  will-change: transform;
  pointer-events: none;
  z-index: 30;
}
const setDragVars = (dx, dy) => {
  const el = cardRef.current;
  if (!el) return;
  el.style.setProperty('--drag-x', `${dx}px`);
  el.style.setProperty('--drag-y', `${dy}px`);
};

// pointermove
setDragVars(clientX - startX, clientY - startY);   // 리렌더 없음

칼럼 셀이 몇 개든 상관없다. 조상 하나에 변수를 쓰면 자식들이 전부 따라온다. 셀마다 style 을 갱신하는 것보다 훨씬 싸다.

setState드롭 대상(over)이 바뀔 때만 부른다. 칼럼 경계를 넘을 때뿐이니 드물다.

B. 단일 기둥 = 투명 셀 + 뒤에 깔린 카드 한 장

드래그 중인 칼럼의 셀들은 배경을 없앤다. 대신 컨테이너 안에 칼럼 하나를 통째로 덮는 absolute 카드를 깔고, 셀과 같은 --drag-x/y 로 함께 transform 한다.

.drag-pillar {
  position: absolute;
  top: 0;
  z-index: 20;
  transform: translate3d(var(--drag-x, 0px), var(--drag-y, 0px), 0);
  background: var(--surface);
  box-shadow: 0 10px 30px rgba(0, 0, 0, .18);
  pointer-events: none;
}

둘이 한 몸으로 움직여서 기둥 하나처럼 보인다. 같은 CSS 변수를 읽으니 동기화 코드가 따로 필요 없다.

카드의 위치와 크기는 드래그 시작 시 한 번만 측정한다.

const colW = (reading.clientWidth - (n - 1) * gap) / n;
const left = pitch * dragIndex;
const height = reading.scrollHeight;

C. 자리 양보는 translateX + transition

드래그 중이 아닌 칼럼은 끼어들 자리를 translateX(±pitch) 로 양보하고 transition: transform .15s 로 애니메이션한다. pitchcolW + gap 이다.

function shiftFor(i, drag, over, pitch) {
  if (i === drag) return 0;
  if (over > drag && i > drag && i <= over) return -pitch;  // 오른쪽으로 끌면 사이 칼럼은 왼쪽으로
  if (over < drag && i >= over && i < drag) return  pitch;
  return 0;
}

이 함수가 순수 함수라서 좋았다. 드래그 상태 두 개(drag, over)만 주면 모든 칼럼의 위치가 결정된다. 중간 상태를 저장할 필요가 없다.

드롭하면 실제 배열을 reorder 한다. 그러면 각 칼럼이 자연 위치로 돌아가면서 자동으로 스냅된다.

D. over 판정은 컨테이너 슬롯 기준

여기서 한 번 틀렸다. 드래그 중인 셀은 transform 으로 이동해 있으므로 getBoundingClientRect 로 위치를 판정하면 어긋난다. transform 된 위치가 잡히기 때문이다.

컨테이너 rect 를 균등 슬롯으로 나눠서 계산한다.

const slot = container.width / n;
const over = clamp(Math.floor((clientX - container.left) / slot), 0, n - 1);

포인터 좌표만으로 판정하니 transform 의 영향을 안 받는다. 계산도 훨씬 싸다.

잊으면 깨지는 것들

포인터 캡처는 핸들이 아니라 컨테이너에 건다.

containerRef.current.setPointerCapture(e.pointerId);

핸들에 걸면 포인터가 핸들 밖으로 나가는 순간 이벤트가 끊긴다. 드래그는 원래 밖으로 나가는 동작이다.

핸들 셀에 touch-action: none 을 준다. 모바일에서 드래그 중 페이지가 스크롤되는 걸 막는다.

라벨 바와 본문 그리드에 항상 같은 transform 을 적용한다. 둘이 따로 놀면 즉시 티가 난다. 그리고 두 영역이 동일한 grid-template-columnsgap 을 써야 정렬이 유지된다.

시각 효과는 칼럼 레이아웃에서만 켠다. 모바일 세로 스택에서는 reorder 기능만 남기고 끈다.

const isWide = matchMedia('(min-width: 640px)').matches;

세로로 쌓인 상태에서 가로 이동 애니메이션을 켜면 의미가 없다.

정리

라이브러리를 안 쓴 게 정답이었냐면, 이 레이아웃에서는 그랬다. dnd-kit 은 “드래그하는 요소 = 하나의 DOM 노드”를 전제로 잘 동작한다. 칼럼이 여러 row 에 흩어진 구조는 그 전제 밖이라, 맞추는 비용이 직접 만드는 비용과 비슷해진다.

성능의 핵심은 하나로 요약된다. 연속적인 값(좌표)은 CSS 변수로 DOM 에 직접 쓰고, 이산적인 값(어느 칼럼 위인가)만 state 로 관리한다. 이 경계를 그어두면 드래그 인터랙션에서 React 가 방해가 되지 않는다.