애드블럭 종료 후 사이트를 이용해 주세요.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React Flow·dnd-kit·zundo로 트리 편집기 작업기
    React 2026. 7. 10. 19:02
    728x90
    반응형

    노드를 끌어다 놓아 트리를 편집하고, 순서를 바꾸고, Ctrl+Z로 되돌린다. 요구사항만 보면 평범하다. 노드 캔버스는 React Flow(@xyflow/react), 드래그 정렬은 dnd-kit, undo/redo는 zundo(Zustand 미들웨어)를 쓰면 된다. 각 라이브러리의 README 예제는 5분이면 동작한다.

    문제는 이 넷을 같은 DOM, 같은 마우스 이벤트, 같은 상태 트리 위에 포개는 순간 시작됐다. React Flow는 노드 클릭을 자기가 처리하려 하고, dnd-kit도 같은 mousedown을 노리고, zundo는 store에 일어나는 모든 변경을 히스토리에 쌓으려 하고, 캔버스 컨테이너의 overflow는 드롭다운을 가둔다. 기능을 구현하는 시간보다 이 충돌을 격리하는 시간이 더 길었다.

    아래는 그 과정에서 내린 결정들이다. React Flow에 드래그 앤 드롭과 undo를 같이 얹어야 하는 사람이라면 그대로 가져다 써도 된다.

    되돌릴 건 '노드'지 '화면'이 아니다

    zundo는 기존 store를 미들웨어로 감싸기만 하면 undo/redo가 붙는다. 편했다. 그래서 처음엔 화면 전역 store에 그대로 씌웠다.

    // 처음엔 이렇게 했다 — 전역 store 전체를 추적
    create<AppState>()(temporal((set) => ({ /* ...화면 전체 상태... */ })));

    그랬더니 패널을 열고 탭을 바꾼 다음 Ctrl+Z를 누르면 노드가 아니라 패널이 되돌아갔다. zundo가 store 전체를 추적하니, 편집과 상관없는 UI 상태까지 히스토리에 쌓인 것이다.

    해법은 두 가지였다. 편집 상태를 전역 store에서 떼어 독립 store로 만들고, partialize로 추적 대상을 노드 배열로만 좁혔다. 히스토리 한도(limit)는 500으로 뒀다 — 노드 서른 개 남짓에 스텝당 수백 바이트면 다 합쳐도 몇 MB라, 저사양에서도 신경 쓸 양이 아니다.

    // editor-store.ts — 편집 전용 store (전역 store와 분리)
    import { create } from 'zustand';
    import { temporal } from 'zundo';
    
    interface TreeNode {
      id: string;
      parentId: string | null;
      label: string;
      isDirty: boolean; // 바뀐 노드 자신만 true (뒤에서 설명)
    }
    
    interface EditorState {
      nodes: TreeNode[];
      panelOpen: boolean; // UI 상태 — 히스토리엔 넣지 않는다
      setNodes: (next: TreeNode[]) => void;
      togglePanel: () => void;
    }
    
    export const useEditorStore = create<EditorState>()(
      temporal(
        (set) => ({
          nodes: [],
          panelOpen: false,
          setNodes: (next) => set({ nodes: next }),
          togglePanel: () => set((s) => ({ panelOpen: !s.panelOpen })),
        }),
        {
          partialize: (state) => ({ nodes: state.nodes }), // 노드만 추적
          limit: 500,
        },
      ),
    );
    
    // undo/redo는 temporal store에 있다 (버튼 onClick에서 호출)
    export const undoEdit = () => useEditorStore.temporal.getState().undo();
    export const redoEdit = () => useEditorStore.temporal.getState().redo();

    이제 Ctrl+Z는 노드 구조만 되돌리고, 열어둔 패널은 그대로 남는다.

    같은 클릭을 두 라이브러리가 노린다

    React Flow의 카드 노드는 클릭하면 선택 체크가 토글 되게 돼 있었다. 편집용 노드를 같은 타입으로 만들었더니, 이름을 고치려고 누른 클릭이 체크 토글로 샜다.

    답은 의외로 단순했다. 편집 노드를 EditableNode라는 별도 nodeType으로 등록해서 '클릭의 의미'를 타입 단위로 갈라놨다. React Flow는 nodeTypes의 string key로 컴포넌트를 매칭하니, 타입만 나누면 어느 클릭이 누구 것인지도 자연히 갈린다. 한 컴포넌트 안에서 if (편집모드) 분기로 버티지 않은 게 핵심이다 — 분기는 언젠가 또 샌다.

    드롭다운이 캔버스에 갇힌다

    편집 노드 위에 드롭다운을 열었는데, 바깥을 클릭해도 닫히지 않았다. 범인은 React Flow 캔버스 컨테이너였다. 이 컨테이너가 mousedownstopPropagation 한다. 흔히 쓰는 outside-click 패턴은 document에서 버블 단계로 mousedown을 듣는데, 그 이벤트가 컨테이너에서 막혀 document까지 올라오질 못한다.

    그래서 리스너를 캡처 단계(capture phase)로 등록했다. 캡처는 바깥에서 안으로 내려가는 단계라, 컨테이너가 전파를 멈추기 전에 document가 먼저 받는다.

    // useOutsideClick.ts — 캡처 단계로 등록해 컨테이너의 stopPropagation을 우회
    import { useEffect, useRef } from 'react';
    
    export function useOutsideClick<T extends HTMLElement>(onOutside: () => void) {
      const ref = useRef<T>(null);
      useEffect(() => {
        const handler = (e: MouseEvent) => {
          if (ref.current && !ref.current.contains(e.target as Node)) onOutside();
        };
        document.addEventListener('mousedown', handler, true); // 세 번째 인자 true = capture
        return () => document.removeEventListener('mousedown', handler, true);
      }, [onOutside]);
      return ref;
    }

    증상이 하나 더 있었다. 드롭다운이 패널 경계에서 잘려 보였다. 컨테이너의 overflow-hidden이 자식을 가둔 것이다. flex/grid 레이아웃에서 자식이 넘쳐 보이게 하려면, 넘치는 지점까지의 중간 체인에 min-height: 0(Tailwind면 min-h-0)을 걸어줘야 한다. 그걸 넣자 드롭다운이 컨테이너를 벗어나 제대로 떴다.

    곁가지 하나 더. 드롭다운이 사라질 때 한 프레임이 깜빡였다. fade-out 키프레임이 끝나는 순간 opacity가 원래 값으로 복원되면서, visibility가 꺼지기 직전 한 프레임이 노출되는 경쟁이었다. animation-fill-mode: forwards로 종료 상태를 고정해 막았다. 이런 건 코드 리뷰로는 안 잡히고 꼭 눈으로 봐야 잡힌다.

    드래그 정렬 쪽도 한 줄 짚고 넘어가자. dnd-kit에서 드래그 대상의 데이터를 꺼낼 때 as SortableData로 이중 캐스팅하기 쉬운데, 공식 isSortable 가드를 쓰면 타입이 안전하게 좁혀진다.

    import { isSortable } from '@dnd-kit/sortable';
    
    function onDragEnd(source: unknown) {
      if (isSortable(source)) {
        source.index;        // 현재 위치
        source.initialIndex; // 드래그 시작 시 위치
      }
    }

    변경 표시는 번지지 않는다

    여기서부턴 라이브러리가 아니라 백엔드 계약 문제다. 트리에서 노드 하나를 고치면, 직관적으로는 부모까지 '변경됨'으로 번질 것 같다. 그런데 계약은 정반대였다. 바뀐 노드 자신만 isDirty = true, 부모와 자식으로는 전파하지 않는다.

     

    연산마다 규칙도 갈렸다. 순서만 바꾸면 전부 false, 노드를 옮기면 소분류만 true, 중분류 텍스트를 고치면 중분류만, 소분류를 고치거나 추가하면 소분류만 true. 이 규칙 덕에 저장할 때 PATCH 페이로드가 '진짜 바뀐 노드'로 최소화된다. 편하다고 부모로 dirty를 번지게 했다면, 백엔드는 멀쩡한 노드를 변경분으로 받아 처리했을 것이다. FE의 작은 편의가 계약 위반이 되는 지점이다.

    편집 완료엔 순서가 있다

    마지막은 타이밍이다. 편집을 끝내면 백엔드가 변경 결과를 SSE로 흘려준다. 그래서 'SSE 연결을 먼저 열고 → 완료 API를 호출'하는 순서가 강제됐다. 뒤집으면 연결이 열리기 전에 도착한 첫 이벤트를 놓친다. 라이브러리 문서엔 없지만, FE 편집 모델과 백엔드를 잇는 이런 순서가 실제로 화면을 깨거나 살린다.

    정리하면

    결국 전부 '경계를 어디에 긋느냐'의 문제였다. 세 줄로 줄이면 이렇다.

    1. 라이브러리를 묶는 일의 8할은 기능 구현이 아니라, 서로의 가정 충돌을 격리·순서·전파 규칙으로 푸는 설계다.
    2. undo는 partialize로 추적 대상을 좁히고 독립 store에 둬야 UI 상태 오염과 메모리 폭증을 함께 막는다.
    3. 노드별 isDirty(무전파)와 SSE 선행 순서처럼, FE 편집 모델은 백엔드 계약과 한 쌍으로 설계해야 깨지지 않는다.

    지금 비슷한 편집기를 만들고 있다면, 가장 먼저 열어볼 건 undo store다. 되돌림 대상에 UI 상태가 섞여 있지 않은지 partialize부터 확인하길 권한다.

    한 발 물러나서 보면, 캔버스든 드래그 핸들이든 undo 히스토리든 백엔드로 보내는 변경분이든, 결국 사람이 '편집'이라 부르는 하나의 행위를 여러 표면에 일관되게 비추는 일이었다. 좋은 인터페이스를 만드는 기술은 표면이 무엇이든 결국 하나다. 라이브러리를 고르는 게 프런트엔드의 일이 아니라, 그 표면들 사이의 충돌을 설계로 메우는 게 프런트엔드의 일이다.


    참고

    • zundo — partialize/limit 옵션, gzip 700바이트 미만(공식 표기), Zustand v4/v5 지원, 작성 시점 v2.3.0. GitHub · npm
    • dnd-kit — Sortable 개념과 isSortable 타입 가드. 문서
    • React Flow(@xyflow/react) — Custom Nodes와 nodeTypes 등록. 문서

    버전·동작은 작성 시점(2026-06) 기준이며 환경에 따라 다를 수 있다.

    반응형

    댓글

Designed by Tistory.