minyeong.xyz

드래그 앤 드롭으로 다시 보는 key와 mount의 관계

정민영
11분

@dnd-kit/react에서 @atlaskit/pragmatic-drag-and-drop으로 옮기며

북마크 새 탭 확장앱을 만들던 중, 북마크를 다른 폴더로 옮길 때 드래그 앤 드롭이 깨지는 문제가 있었다.

당시에는 @dnd-kit/react로 항목을 드래그해 순서를 바꾸고 있었다. 북마크를 옮기면 앱이 브라우저의 북마크 이동 API를 호출하고, 이동 결과를 확인한 뒤 트리를 다시 읽어 React 화면에 반영했다. 저장과 화면 갱신은 이미 앱에서 처리하고 있었다.

이동하지 않을 때 바뀌는 key

기존 이동 코드를 따라가면, 북마크를 옮기지 않는 경로에도 처리가 남아 있었다. 이동을 건너뛰는 일부 경로에서 숫자 값을 증가시키고, 그 값을 목록 컨테이너의 key로 넘겼다.

같은 부모 아래 같은 타입의 목록 컨테이너라도 key가 바뀌면 React는 기존 트리를 unmount하고 새로 mount한다. 이때 하위 컴포넌트의 로컬 state와 DOM은 새로 만들어지지만, 브라우저에 저장된 북마크 데이터는 바뀌지 않는다. key를 유지한 채 업데이트 렌더링을 할 수도 있다.

이동을 저장하지 않았다면 목록의 순서도 그대로여야 한다. 그런데 같은 데이터를 다시 mount하는 처리까지 있었다면, 라이브러리가 항목의 순서와 위치를 저장 데이터와 따로 관리하고 있었던 것은 아닐까.

아래는 이동 훅과 목록 컴포넌트에서 관련 부분만 모은 코드다. 목록의 스타일 속성은 생략했다.

const [forceReconcileKey, forceReconcile] =
  useReducer((version: number) => version + 1, 0);

// 이동을 건너뛰는 경로
forceReconcile();

// 목록 컨테이너
<div key={movement.forceReconcileKey}>

key를 바꾸는 처리 옆에는 이동을 건너뛴 뒤에도 라이브러리가 계산한 임시 배치(projection)가 남을 수 있다는 주석이 있었다. 드래그 중에 항목을 어디에 보여줄지 계산한 결과다. 북마크를 실제로 옮기지 않아도, 화면에는 드래그 중 바뀐 순서가 남을 수 있었다.

사용하던 라이브러리 소스에서도 기본 플러그인이 드래그 중 DOM을 재배치하고 그룹과 인덱스를 갱신하는 것을 확인할 수 있다. 사용자가 놓을 위치를 미리 보여주기 위한 동작이지만, 외부에서 데이터를 다시 읽어 렌더링할 때는 라이브러리가 바꾼 화면의 순서와 데이터의 순서를 맞춰야 한다.

앱은 드롭 결과를 해석한 뒤 브라우저에 저장된 북마크를 바꾸고 있었다. 그런데 라이브러리는 화면의 순서와 위치를 별도로 바꾸고 있었으므로, 이동을 건너뛰는 경우에도 데이터를 그대로 두는 것만으로는 화면을 되돌릴 수 없었다.

key를 바꾸는 코드는 화면을 원래 순서로 되돌리려는 처리였다. 저장된 데이터로 목록을 새로 mount해, 항목을 드래그하기 전 위치에 다시 표시하려 한 것이다.

이동 여부를 판단하는 코드에도 같은 복잡함이 있었다. 드래그한 항목과 드롭 대상의 ID가 같아도, 라이브러리가 계산한 그룹이나 인덱스가 앱이 가진 현재 위치와 다르면 이동으로 해석했다. 폴더 간 이동에서는 부모까지 달라지므로, 이벤트의 ID만으로는 어디로 옮겨졌는지 판단할 수 없었다.

이 보정이 크래시의 직접 원인이었다고 단정할 수는 없다. 다만 기존 구현에서는 북마크를 저장하는 일 외에, 라이브러리가 계산한 항목의 순서와 위치를 해석하고 이동하지 않을 때는 화면을 원래대로 되돌리는 일까지 앱이 맡고 있었다.

드래그 중에는 놓을 위치만 표시하기

북마크를 옮기고 결과를 검증하는 코드는 이미 있었다. 드래그한 항목과 놓인 위치만 알면 기존 코드로 이동을 처리할 수 있었다. 그렇다면 드래그 중에는 놓을 위치만 표시하고 최종 드롭에서만 저장해, 이동하지 않을 때 순서를 되돌리는 보정을 없앨 수 있지 않을까.

@atlaskit/pragmatic-drag-and-drop은 브라우저 기본 드래그 앤 드롭을 바탕으로 드래그할 요소와 드롭 영역을 등록하고 이벤트를 받을 수 있게 해준다. 목록을 어떤 순서로 그릴지는 앱에서 정할 수 있다. 드롭 영역과 시각적 피드백, 이동 제한은 앱에서 정해야 하고, 키보드나 스크린 리더를 위한 동작도 별도로 설계하고 검증해야 한다.

새 구현에서는 드래그 중인 항목의 투명도와 삽입 위치 표시선, 폴더의 드롭 영역 표시로 어디에 놓을 수 있는지 알려주었다.

기존 구현에도 드래그한 항목과 드롭 대상 정보로 이동을 처리하는 경로가 있었지만, 라이브러리가 계산한 그룹과 인덱스로 이동을 보정하는 경로도 함께 있었다. 새 구현에서는 이 보정 없이 드래그한 항목과 드롭 대상, 앞뒤 정보를 바탕으로 이동 위치를 결정했다.

드롭에서 이동을 확정하기

각 항목과 폴더는 드롭 대상의 종류와 수락 조건을 등록하고, 실제 북마크 이동을 처리하는 핸들러는 중앙 monitor에 연결했다. 먼저 드래그한 항목과 최종 선택된 대상의 정보를 읽고 검증했다.

export function resolveBookmarkDrop(
  event: ElementEventBasePayload,
): BookmarkDrop | null {
  const dragged = resolveBookmarkDragData(event.source.data);
  const target = resolveBookmarkDropData(
    event.location.current.dropTargets[0]?.data,
  );

  if (!dragged || !target) {
    return null;
  }

  return { dragged, target };
}

둘 중 하나라도 유효하지 않거나 검색 중이면 이동하지 않았다. 이동 가능한 드롭은 대상 종류에 따라 목적 폴더와 필요한 삽입 위치를 결정해 저장 경로로 넘겼다.

항목의 앞뒤는 포인터가 대상의 세로 중앙선보다 위에 있는지 아래에 있는지로 구분했다. 여기에 대상의 실제 저장 인덱스를 사용했다. 폴더와 북마크를 나누어 표시하면 화면의 순서와 브라우저에 저장된 순서가 다를 수 있기 때문이다.

저장된 자식 목록: [폴더 A, 북마크 B, 북마크 C]
북마크만 표시한 목록: [북마크 B, 북마크 C]

저장된 목록에는 폴더 A도 포함되므로 북마크 B의 인덱스는 1이다. 반면 북마크만 표시한 화면에서는 B가 첫 번째 항목이므로 인덱스가 0이다.

폴더 자체를 옮기는 영역과 폴더 안으로 북마크를 넣는 영역은 구분했다. 중첩된 영역에서는 항목이 아닌 상위 컨테이너가 드롭 대상이 될 수 있어, 최종 선택된 대상과 목적지를 기준으로 이동을 계산했다.

저장 경로에서는 기존처럼 이동 전후의 자식 목록을 읽고 예상 위치와 결과를 비교했다. 같은 폴더에서 뒤쪽으로 이동할 때 달라지는 최종 인덱스를 고려해, 저장 결과를 검증할 기대 위치를 계산하는 처리도 유지했다.

변경 후에는 드래그 중에 목록의 순서를 바꾸지 않고, 놓을 위치만 표시했다. 실제 북마크 이동은 드롭할 때 처리하고, 저장된 결과를 다시 읽어 화면에 반영했다. 이동하지 않는 경우에는 원래 순서로 되돌릴 필요도 없어 목록의 key를 바꾸는 코드를 제거할 수 있었다.

참고 자료