개발/프론트엔드 (JS & React)

React Beautiful DND (리액트 드래그 앤 드롭) 타입스크립트로 구현하기, 리액트 18 버전 Strict Mode 오류 해결 방법

jungwon_ 2023. 1. 11. 22:34

이 글에서는 Jira와 같은 Task Management 프로젝트에서 사용될 수 있는 react-beautiful-dnd 라이브러리에 대해 알아보겠다.

 


 

리액트 프로젝트에서 드래그 앤 드롭 구현하는 방법으로는 직접 구현하거나 라이브러리를 사용하는 방법이 있다.

 

드래그 앤 드롭의 여러 가지 라이브러리가 있지만 각자 용도에 따라 사용하기 적절한 라이브러리가 다르다.

 

Jira를 사용해 본 적이 있는가? 꼭 Jira가 아니더라도 Asana나 Trello, 혹은 Notion의 보드 기능에서 아래와 같이 생긴 툴을 본 적이 있을 것이다.

출처 : https://www.atlassian.com/jira

 

대부분의 회사에서 이런 형태의 프로젝트 관리를 선호하는 편이고, 나도 현재 회사에서 Jira는 아니지만 비슷한 형태의 보드를 사용 중이다.

 

그러다 보니 기존 Jira와 같은 앱의 스무스한 드래그 앤 드롭 기능에 대해 관심이 생겨, Jira 팀에서 관리 중인 react-beautiful-dnd라는 라이브러리를 살펴보았다.

 

react-beautiful-dnd 예시 / 출처 : https://github.com/atlassian/react-beautiful-dnd

 


 

API 살펴보기

 

react-beautiful-dnd 의 API는 크게 세 가지로 이루어져 있다.

 

DragDropContext, Droppable, 그리고 Draggable이다.

 

- DragDropContext는 드래그 앤 드롭을 활성화시킬 전체 앱을 감싸는 것

 

- Droppable : Draggable 요소를 놓을 수 있는 곳

 

- Draggable : 드래그할 수 있는 요소

 

 

튜토리얼

 

react-beautiful-dnd는 공식 튜토리얼을 제공한다.

 

해당 코스는 마지막 업데이트가 2년 전인 만큼 JavaScript, React 16 버전의 클래스형 컴포넌트와 styled-components를 사용해 설명하고 있는데,

 

나는 타입스크립트, React 18 버전, 함수형 컴포넌트, module css를 통해 구현하고 싶었다.

 

그래서 이 블로그 글은 공식 튜토리얼과는 다르게 구현을 하며 예기치 못한 에러가 발생했던 부분들을 중점으로 다뤄볼 예정이다.

 

블로그 글에서는 코드의 일부만 복붙 했으므로 전체 코드를 확인하고 싶다면 여기서 확인할 수 있다.

 

설치

우선 타입스크립트를 사용한 리액트 boiler plate를 만든다.

 

CRA를 사용해도 되고 Vite를 사용해도 괜찮다. (나는 Vite를 사용했다.)

 

dependency로 @types/react-beautiful-dnd와 react-beautiful-dnd를 추가해 준다.

npm i react-beautiful-dnd @types/react-beautiful-dnd

 


 

DragDropContext

DragDropContext API는 별거 없다. 그냥 보드를 만드려고 하는 element를 DragDropContext로 감싸주면 된다.

 

DragDropContext에는 여러 Responders가 있는데, 이 responders란 상태 변화, 스타일 업데이트, 스크린 리더 안내를 수행하는 데 사용할 수 있는 top level 애플리케이션 이벤트이다.

 

Responders 중 드래그가 끝났을 때 이벤트인 `onDragEnd` 는 반드시 포함되어야 한다.

 

DragDropContext API의 공식 문서 더 자세히 살펴보기

 

 


 

Droppable

Droppable은 Draggable 요소를 드롭할 수 있는 곳으로, Droppable 내부에 Draggable를 포함하거나 다른 Droppable 안에 중첩되어 있을 수 있다.

 

이게 무슨 말이냐면 아래 예시를 보자.

이 예시에서 각 column은 Droppable임과 동시에 Draggable이다.

 

각 task인 Draggable을 드롭할 수 있는 공간과 동시에, 다른 column 과의 위치도 바꿀 수 있는 Draggable 요소 자체도 되는 것이다.

 

 

Droppable 은 반드시 `droppableId` 값이 있어야 하며, `type`을 지정할 수 있다.

 

예를 들어 위 예시에서는 전체 DragDropContext에 Droppable이 두 개 있는 것을 확인할 수 있다. (진한 파란색과 연한 파란색)

 

만약 서로 다른 Droppable 타입임을 표시하지 않는다면 Task와 같은 Draggable 아이템은 Column에도 드롭할 수 있고, Column을 감싸는 진한 파란색 부분에도 드롭할 수 있게 되는데, 이는 원치 않는 기능이다.

 

따라서 각 Droppable에 다른 이름의 `type` 값을 줘서 위와 같은 예기치 않은 이벤트를 방지할 수 있다.

 

Droppable 내부에는 반드시 React Element를 리턴하는 함수를 포함해야 한다.

<Droppable droppableId="droppable-1">
  {(provided, snapshot) => ({
    /* React Element */
  })}
</Droppable>

여기서 `provided`는 해당 Droppable의 element를 가리키는 `innerRef`와, Droppable의 props를 나타내는 `droppableProps`를 포함한다.

 

`snapshot`은 Droppable 내부에서의 드래그 이벤트 상태를 포함하고 있고, 드래그 이벤트가 끝났을 때를 의미하는 isDraggingOver 등을 포함한다.

 

Column을 예시로 살펴보면 아래와 같이 구현할 수 있다.

<Droppable droppableId={column.id} type="task">
    {(provided, snapshot) => ( 
      <div
        className={classes.taskList}
        style={{ backgroundColor: snapshot.isDraggingOver ? "lightgrey": "inherit" }}
        ref={provided.innerRef}
        {...provided.droppableProps}
      >
        <InnerList tasks={tasks} />
        {provided.placeholder}
      </div>  
    )}
</Droppable>

 

❓ React 18 버전을 사용 중인데 Droppable에서 'Unable to find draggable with id' 오류가 발생해요

 

React 18 버전을 사용 중이면 분명 공식 문서의 예시를 따라 했는데도 불구하고 위와 같은 오류가 나며 드래그가 작동하지 않는다.

 

이는 React 18 버전의 Strict Mode를 아직 react-beautiful-dnd가 지원하지 않아서 생기는 문제로, 이 문제를 해결할 수 있는 방법은 두 가지가 있다.

 

1. React Strict Mode를 지운다.

`main.tsx` 혹은 `index.tsx` 에서 전체 App 컴포넌트를 감싸고 있는 React.StrictMode를 지워주면 된다.

 

하지만 Strict Mode는 잠재적인 문제점을 찾아주는데 도움이 되므로, 라이브러리를 사용하기 위해 지우는 것이 해결책은 아니라는 생각이 들었다.

 

2. StrictMode 버전의 custom Droppable를 사용한다.

`react-beautiful-dnd`의 issue에서 발견한 방법인데, Strict Mode에서도 동작하는 custom Droppable을 추가하여 사용하는 방법이다. (해당 issue 보기)

 

`react-beautiful-dnd`의 현재 최신 버전은 v13.1인데, 활발하게 업데이트가 이뤄지는 프로젝트가 아니므로 리액트 18 Strict Mode를 지원하는 버전 15가 업데이트되기 전에는 이 커스텀 Droppable을 사용하는 것이 최선의 해결책으로 보인다.

 

 


 

Draggable

마지막으로 draggable API를 살펴보자.

 

Draggable 은 반드시 `draggableId`, `index`, React Element를 리턴하는 함수를 포함해야 한다.

 

import { Draggable } from "react-beautiful-dnd";
import classes from "./Task.module.css";

function Task({ task, index }: any) {
  return (
    <Draggable draggableId={task.id} index={index}>
      {(provided, snapshot) => (
        <div
          className={classes.container}
          ref={provided.innerRef}
          {...provided.draggableProps}
          {...provided.dragHandleProps}
          style={{
            ...provided.draggableProps.style,
            backgroundColor: snapshot.isDragging ? 'lightgreen': 'white' 
          }}
          
        >
          {task.content}
        </div>
      )}
    </Draggable>
  )
}

export default Task;

 

Droppable도 Draggable과 마찬가지로 provided에서 innerRef와 props를 포함한다.

 

Draggable이 Droppable과 다른 점은 props가 두 가지라는 것인데, 하나는 `draggableProps`이고 다른 하나는 `dragHandleProps`이다.

 

draggableProps는 Draggable의 children인 React Element에 추가해 주면 되지만,

 

`dragHandleProps`는 드래그 이벤트를 발생시킬 element에 추가해야 한다.

 

column을 예시로 살펴보자.

 

 

앞선 설명에서 Column은 Droppable인 동시에 Draggable이라고 했다.

 

그런데 우리는 노란색 부분인 Title 부분에서만 드래그 이벤트를 발생시키고 싶고, 노란색 이외의 column 요소에서는 드래그 이벤트를 원하지 않는다.

 

그러면 Column 전체의 Draggable에 `dragHandleProps`를 추가하는 것이 아니라, title 엘리먼트에 추가하면 된다.

 

❓ Draggable의 자식인 리액트 Element에 스타일을 추가했는데 오류가 나요

draggableProps안에는 이미 style 프로퍼티가 존재한다.

 

따라서 아래와 같이 draggableProps 이전에 style을 추가하면, draggableProps의 style이 기존에 추가했던 style을 덮어버릴 것이고,

<div
  className={classes.container}
  ref={provided.innerRef}
  style={{
    backgroundColor: snapshot.isDragging ? 'lightgreen': 'white' 
  }}
  {...provided.draggableProps}
  {...provided.dragHandleProps}
>

아래와 같이 스타일을 뒤로 빼면 draggableProps가 포함하고 있는 style 프로퍼티가 사라지며, 드래그 이벤트가 발생함과 동시에 동작을 중단할 것이다.

<div
  className={classes.container}
  ref={provided.innerRef}
  {...provided.draggableProps}
  {...provided.dragHandleProps}
  style={{
    backgroundColor: snapshot.isDragging ? 'lightgreen': 'white' 
  }}
>

이는 draggableProps가 style 프로퍼티 안에 transition을 포함하고 있기 때문이므로, 아래와 같이 draggableProps의 style과 커스텀 style을 같이 추가해줘야 한다.

<div
  className={classes.container}
  ref={provided.innerRef}
  {...provided.draggableProps}
  {...provided.dragHandleProps}
  style={{
    ...provided.draggableProps.style,
    backgroundColor: snapshot.isDragging ? 'lightgreen': 'white' 
  }}

>

 

 

Droppable 퍼포먼스 최적화

 

드래그 이벤트가 발생할 때마다, Droppable는 리렌더링 된다.

 

따라서 실제로 Draggable 아이템의 순서가 바뀌지 않았는데도 Droppable의 snapshot이 업데이트되며 불필요하게 리렌더링 되는 상황이 발생할 수 있다.

 

실제로 아무런 최적화를 하지 않고 Chrome Extension의 React Dev tool에서 "Highlight updates when components render"를 활성화하면 task 하나를 옮기는데 관련이 없는 task까지 계속해서 리렌더링 되는 것을 볼 수 있다.

 

이를 방지하기 위해 react-beautiful-dnd 공식 문서에서는 shouldComponentUpdate를 사용하거나 (클래스형 컴포넌트를 사용하는 경우, Pure 컴포넌트를 사용할 것을 권장한다.

 

함수형 컴포넌트에서는 React.memo를 사용하면 task 리스트가 변경이 되지 않았을 때는 컴포넌트가 리렌더링 되는 것을 방지할 수 있다.

 

const InnerList = React.memo(function InnerList({ tasks }: any) {
  return tasks.map((task: any, index: any) => 
    <Task key={task.id} task={task} index={index} />
  )
});

 


`react-beautiful-dnd`는 현재 활발히 개발 중인 라이브러리는 아니지만, 확실히 task management 툴과 비슷한 기능을 추가할 때는 사용하기 좋은 라이브러리라 생각된다.

 

또한 해당 예제 코드에서는 단순히 API의 사용 방법을 알아보고 빠르게 테스트하기 위해 TypeScript 사용이 무색할 정도로 모든 것에 any 타입 사용했는데, 물론 이것은 좋은 예시는 아니고 올바른 타입을 사용해야 한다.

 

나중에 이 프로젝트를 확장해서 다른 기능도 구현하게 된다면 수정해야겠다.