전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

react-grid-layout 없이 대시보드 그리드 만들기 — 순수 함수 레이아웃 엔진

react-grid-layout 없이 대시보드를 만들었어요. 충돌 밀어내기·상향 압축·최소폭 역산은 전부 React 밖 순수 함수가 합니다. 레이어 분리가 라이브러리를 대체한 사례예요.

2026. 07. 14. 18:17:536분 읽기프로젝트/데이터 플랫폼
#드래그 앤 드롭#레이어 분리#대시보드
조회 31
그리드 엔진 썸네일

대시보드 화면 — 위젯을 드래그해서 배치하고 리사이즈하는 그 화면 — 을 react-grid-layout 없이 만들었어요. 레포에 dnd-kit도 있었는데 그것도 안 썼습니다.

고집이 아니에요. dnd-kit은 정렬(sortable)의 도구고, 대시보드는 2D 자유 배치 + 충돌 해소의 문제거든요. 문제가 다르면 도구도 다릅니다. (7편가상화 때와 같은 결론이에요.)

라이브러리를 검토하긴 했어요

"직접 만들었다"는 문장은 오해를 사기 쉬워서, 뭘 보고 왜 안 썼는지 적어 둘게요.

후보잘하는 것우리 요구와 안 맞은 지점
react-grid-layout2D 자유 배치 · 충돌 해소내부 상태를 들고 있어서 저장 시점을 우리가 못 정함
dnd-kit접근성 좋은 드래그 · 정렬정렬(1D)의 도구 — 2D 충돌 해소가 우리 몫
CSS Grid만 사용구현이 가벼움드래그 중 미리보기와 밀림 계산이 없음
직접 구현원하는 대로만들어야 함

두 번째 줄이 결정적이었어요. 대시보드 편집은 되돌리기가 필요한 화면이거든요. 레이아웃 상태를 라이브러리가 들고 있으면, 우리가 원하는 시점에 스냅샷을 못 뜹니다.

그래서 기준을 이렇게 잡았어요. 상태를 우리가 소유할 수 있으면 라이브러리, 아니면 직접. 이번엔 후자였습니다.

핵심 — 레이아웃 엔진을 React에서 뺐습니다

450줄짜리 순수 함수

구현의 중심은 450줄짜리 순수 함수 서비스예요. 레이아웃은 {id, x, y, w, h, minW, minH, minWidthPx} 셀 단위 모델이고, 드래그·리사이즈 이벤트는 원시 mousemove/touchmove로 받되 좌표 계산과 충돌 해소는 전부 React 밖의 순수 함수가 합니다.

// 전부 (layouts, 변경) → 새 layouts 를 반환하는 순수 함수pushPanelsDown(layouts, moved)      // 겹친 패널을 아래로 밀기compactPanels(layouts)              // 빈 공간 상향 압축compactPanelsWithPinned(layouts, id) // 리사이즈 중인 패널은 고정findRightEmptySpace(layouts, size)   // 새 위젯의 첫 빈 자리

순수 함수로 뺐다는 게 실제로 어떤 모양인지 보면 이래요.

// 전부 (현재 레이아웃, 변경) → 새 레이아웃 을 반환합니다
// DOM도 없고, React도 모르고, 시간도 안 봅니다

pushPanelsDown(layouts, moved)   // 겹친 패널을 아래로 밀기
compactPanels(layouts)           // 위로 당겨 빈 줄 없애기
clampToBounds(layouts, columns)  // 격자 밖으로 못 나가게
resolveCollisions(layouts)       // 연쇄 충돌 안정화

// 이벤트 핸들러가 하는 일은 이게 전부입니다
const next = resolveCollisions(pushPanelsDown(layouts, moved));
setPreview(next);

이렇게 갈라 두면 좋은 게 두 가지예요. 하나는 테스트가 쉬워지는 것, 다른 하나는 되돌리기가 공짜가 되는 것. 레이아웃이 그냥 값이니까 이전 값을 들고 있으면 그게 undo입니다.

충돌 해소도 DOM 없이

충돌 해소는 이동한 패널과 겹치는 패널들을 아래로 밀고, 연쇄 충돌은 최대 10회 반복으로 안정화한 뒤, 상향 압축으로 빈 공간을 정리해요. 이 로직에 DOM이 없으니 jsdom 없이 순수 단위 테스트가 돌고, 엣지케이스(연쇄 밀림, 고정 패널 예외)는 표로 만들어 검증했습니다. "레이어 분리"가 라이브러리 하나를 통째로 대체한 사례예요.

(jsdom 없이 테스트가 돈다는 건, 새벽에 CI가 안 깨진다는 뜻이기도 합니다.)

연쇄 충돌은 생각보다 까다로웠어요. A를 밀면 B가 밀리고, B가 밀리면 C가 밀리는 식이라 끝이 안 날 수도 있거든요.

// 안정화될 때까지 반복하되, 상한을 둡니다
let next = layouts;
for (let i = 0; i < MAX_ITERATIONS; i++) {
  const resolved = pushOverlaps(next);
  if (isSameLayout(resolved, next)) return resolved;  // 안정화
  next = resolved;
}
// 상한에 걸리면 마지막 안전한 상태로 되돌립니다
return lastStable;

상한을 둔 이유는 무한 루프 걱정 때문만이 아니에요. 상한에 걸렸다는 건 우리가 예상 못 한 배치가 들어왔다는 뜻이라, 그 자체가 신호거든요. 개발 모드에서는 경고를 띄우게 했습니다.

엣지케이스기대 동작
밀린 패널이 또 다른 패널을 밂연쇄로 전파되되 상한 안에서 안정화
고정 패널과 겹침고정 패널은 안 밀리고 이동 패널이 자리를 못 잡음
격자 맨 아래에서 밀림캔버스 높이가 늘어남
같은 자리에 두 패널이 동시에나중에 놓인 쪽이 밀림 — 순서가 결정적이어야 함

마지막 줄이 중요해요. 같은 입력이면 항상 같은 결과가 나와야 합니다. 그래야 저장된 레이아웃을 다시 열었을 때 화면이 안 달라지거든요.

기능은 조합으로 — services 배열

보기 페이지와 편집 페이지는 같은 대시보드를 렌더하지만 능력이 달라요. 이걸 boolean props 더미로 풀지 않고, 기능 enum 배열로 조합했습니다.

// 보기 전용 — 아무것도 못 함
<DashboardLayout services={[]} />

// 편집 — 필요한 능력만 조합
<DashboardLayout services={[DRAG, RESIZE, COLLISION, COMPACTION, TOUCH]} />

panel 내용은 compound component(Panel.Header / Panel.Content)와 render prop으로 주입해요. 그래서 그리드 엔진은 위젯의 내용을 전혀 모릅니다.

props가 늘어나는 걸 막은 방법도 적어 둘게요. 흔히 이렇게 됩니다.

// 이렇게 자라기 시작하면 끝이 없어요
<DashboardLayout
  draggable
  resizable
  collapsible
  removable
  addable
  readOnly={!canEdit}
  disableDragWhenLocked
/>
// 그래서 능력을 배열로 조합합니다
<DashboardLayout services={[]} />                        // 보기 전용
<DashboardLayout services={[DRAG, RESIZE]} />            // 편집
<DashboardLayout services={[DRAG, RESIZE, COLLAPSE]} />  // 편집 + 접기

// 내용은 compound component 로 주입 — 그리드는 위젯을 모릅니다
<Panel>
  <Panel.Header>{title}</Panel.Header>
  <Panel.Content>{renderWidget(id)}</Panel.Content>
</Panel>

boolean props 여덟 개는 조합이 256가지인데, 실제로 의미 있는 조합은 서너 개예요. 나머지 250여 개는 "정의되지 않은 상태"고, 그중 하나가 언젠가 화면에 나타납니다.

배열로 바꾸면 존재하지 않는 조합이 만들어질 수가 없어요. 그리고 새 능력을 추가할 때 기존 호출부를 하나도 안 건드려도 되고요.

반응형 — 브레이크포인트 대신 최소폭 역산

대시보드의 반응형은 모바일 재배치가 아니라 "좁은 창에서도 위젯이 뭉개지지 않기"예요. 그래서 브레이크포인트별 레이아웃 대신, ResizeObserver로 컨테이너 폭을 관찰해 컬럼 폭을 유동 계산하고, 패널별 최소 픽셀 폭이 있으면 컬럼별 최소폭을 역산합니다.

총합이 컨테이너를 넘으면 나머지 컬럼을 균등 축소하고, 그래도 안 되면 캔버스 자체를 키워 가로 스크롤로 전환해요. 뭉개짐 대신 스크롤 — 데이터 밀도가 생명인 화면에서는 이게 맞는 트레이드오프였습니다.

반응형 계산의 뼈대는 이렇습니다. 브레이크포인트가 아니라 역산이에요.

// 1. 컨테이너 폭을 관찰
const containerWidth = observed;

// 2. 패널의 최소 픽셀 폭 → 컬럼 최소폭으로 역산
const columnMinPx = Math.max(
  ...panels.map(p => Math.ceil(p.minWidthPx / p.w))
);

// 3. 다 담기면 균등 분배, 안 담기면 캔버스를 키운다
const naturalWidth = containerWidth / columns;
const columnWidth  = Math.max(naturalWidth, columnMinPx);
const canvasWidth  = columnWidth * columns;

// canvasWidth > containerWidth 이면 가로 스크롤

브레이크포인트를 안 쓴 이유는 단순해요. 대시보드는 위젯 구성이 사용자마다 다르거든요. 화면 폭이 같아도 어떤 사용자는 위젯이 셋이고 어떤 사용자는 열둘입니다. 폭만 보고 결정할 수가 없어요.

뭉개지느니 스크롤을 택한 것도 같은 이유예요. 데이터 밀도가 생명인 화면에서 축소는 "읽을 수 없음"이지만, 스크롤은 "조금 불편함"이니까요.

저장은 UI 밖에서

드래그 중에는 미리보기 레이아웃을 로컬 상태로만 렌더하고, 마우스를 놓는 순간 onChange 한 번으로 확정 레이아웃을 배출하는 완전 제어 컴포넌트예요. 직렬화·영속화는 제품 레이어의 adapter가 담당하고, 저장 포맷에는 스키마명과 버전을 붙여 마이그레이션에 대비했습니다. 그리드는 그리는 것까지만. 저장부터는 도메인의 일이에요.


엣지케이스가 절반이라는 말

이 모듈의 절반이 엣지케이스라고 했는데, 그게 무슨 뜻인지 표로 보여드릴게요. 전부 실제로 겪고 나서 넣은 것들이에요.

상황증상대응
드래그가 화면 밖으로 나감더 못 움직임가장자리 근처에서 자동 스크롤
터치에서 손가락 두 개드래그와 확대가 동시에포인터 수를 보고 드래그 취소
숨겨진 탭에서 렌더컨테이너 폭이 0이라 레이아웃 붕괴폭이 0이면 다음 프레임에 재측정
드래그 중 창 크기 변경좌표 기준이 어긋남드래그 시작 시점의 기준을 고정
빠르게 놓았다 다시 잡기미리보기 상태가 남음포인터 해제에서 항상 정리

세 번째 줄이 제일 오래 걸렸어요. 재현이 안 됐거든요. 탭을 전환하면서 열어야만 나오는데, 개발 중엔 항상 그 탭을 직접 열어서 봤습니다.

그리드 대시보드의 난이도는 알고리즘이 아니라 여기 있어요. 밀림 계산은 하루면 되는데, 이 표는 몇 달에 걸쳐 채워졌습니다.

덤으로 남은 것들 — 드래그 중 가장자리 자동 스크롤, 멀티터치 구분, 숨겨진 탭(width=0)에서 rAF 재시도로 측정 복구. 이런 엣지케이스 처리가 이 모듈의 절반을 차지해요. 그리드 대시보드의 난이도는 알고리즘이 아니라 엣지케이스에 있습니다.

(이 모듈의 절반이 엣지케이스 처리라는 말, 과장이 아니에요.)

시리즈 이어읽기

직접 만든 엔진들

3 / 3

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.