React

[React] React에서 Drag & Drop 구현 – react-beautiful-dnd 사용

dud9902 2025. 2. 8. 23:10

여행 일정 수정 시 사용자가 더 편리하게 순서를 변경할 수 있도록 드래그 앤 드롭 기능을 추가했다.

 

 

기존에는 체크박스를 이용해 개별 선택하거나 전체 선택 후 삭제하는 방식으로 일정 순서를 조정해야 했다.
그러나 삭제 후 다시 추가하는 방식은 번거로웠고, 보다 직관적인 조작을 위해 드래그 앤 드롭과 함께 휴지통 아이콘을 활용한 즉시 삭제 기능을 추가했다.

 

검색을 통해 react-beautiful-dnd를 알게 되었고, 이를 사용하면 Drag & Drop을 위한 기본 구조를 제공해 복잡한 로직을 간단하게 해결할 수 있었다.
드래그 앤 드롭 기능을 직접 구현하려면 마우스 이벤트를 감지하고, 요소를 이동시키며, 데이터 순서를 조정하는 복잡한 로직을 작성해야 한다.
이러한 복잡성을 줄이기 위해 react-beautiful-dnd 라이브러리를 활용하여 일정을 자유롭게 정렬하고, 불필요한 일정은 휴지통 아이콘을 눌러 즉시 삭제할 수 있도록 구현했다.

react-beautiful-dnd의 주요 기능

  • DragDropContext: 드래그 앤 드롭 기능을 감싸는 최상위 컨텍스트
  • Droppable: 드래그 가능한 리스트(일정 목록)
  • Draggable: 드래그할 수 있는 개별 아이템(여행 일정)

Drag & Drop 기능 구현 과정

우선 DragDropContext 내부에 Droppable을 생성하고, 각 일정 항목을 Draggable로 감싸서 렌더링했다.
이를 통해 사용자가 일정 순서를 변경하면 자동으로 리스트가 재정렬되도록 만들었다.

그런데 width: 100%를 적용하지 않으면 기존 CSS 배치가 깨지면서 여백이 점점 좁아지는 문제가 발생해 추가로 설정했다.

<DragDropContext onDragEnd={handleDragEnd}>
  <Droppable droppableId="droppable">
    {(provided) => (
      <div ref={provided.innerRef} {...provided.droppableProps} style={{ width: "100%" }}>
        {spots
          .filter((spot) => spot.day_x === selectedDay)
          .map((spot, index) => (
            <Draggable key={`${spot.order}-${spot.eng_name}`} draggableId={`${spot.order}-${spot.eng_name}`} index={index}>
              {(dragProvided, snapshot) => (
                <div
                  ref={dragProvided.innerRef}
                  {...dragProvided.draggableProps}
                  {...dragProvided.dragHandleProps}
                  className={styles.travel_plan_card_section}
                  style={{
                    ...dragProvided.draggableProps.style,
                    backgroundColor: snapshot.isDragging ? "rgba(0, 0, 0, 0.05)" : "transparent",
                  }}
                >
                  <h2>{spot.kor_name}</h2>
                </div>
              )}
            </Draggable>
          ))}
        {provided.placeholder}
      </div>
    )}
  </Droppable>
</DragDropContext>

 

드래그 완료 시 순서변경

onDragEnd 이벤트가 발생하면, 기존 리스트에서 항목을 제거한 뒤 새로운 위치에 삽입하는 방식으로 순서를 변경했다.
또한, 변경된 순서를 onSpotsUpdate(updatedSpots)를 호출하여 상태에 반영하도록 만들었다.

const handleDragEnd = (result: any) => {
  if (!result.destination) return;

  const currentDaySpots = spots.filter((spot) => spot.day_x === selectedDay);
  const reorderedSpots = Array.from(currentDaySpots);
  const [removed] = reorderedSpots.splice(result.source.index, 1);
  reorderedSpots.splice(result.destination.index, 0, removed);

  // 변경된 순서 업데이트
  const updatedSpots = spots.map((spot) => {
    if (spot.day_x !== selectedDay) return spot;
    const index = currentDaySpots.indexOf(spot);
    if (index === -1) return spot;
    return {
      ...reorderedSpots[currentDaySpots.indexOf(spot)],
      order: currentDaySpots.indexOf(spot),
    };
  });

  onSpotsUpdate(updatedSpots);
};

 

개발 중 겪은 문제 & 해결 방법

문제 1: 드래그 후 리스트가 제대로 정렬되지 않는 문제

  • 원인: spots 배열을 직접 변경하면 React 상태가 정상적으로 업데이트 되지 않음
  • 해결: Array.from(currentDaySpots)을 사용해 새로운 배열을 생성한 후 조작

문제 코드

const handleDragEnd = (result: any) => {
  if (!result.destination) return;

  const currentDaySpots = spots.filter((spot) => spot.day_x === selectedDay);
  const [removed] = currentDaySpots.splice(result.source.index, 1);
  currentDaySpots.splice(result.destination.index, 0, removed);

  onSpotsUpdate(spots);
};

 

해결 코드

새로운 배열을 생성하여 상태를 변경하면 UI가 정상 업데이트 된다.

const handleDragEnd = (result: any) => {
  if (!result.destination) return;

  const currentDaySpots = spots.filter((spot) => spot.day_x === selectedDay);
  const reorderedSpots = Array.from(currentDaySpots); // 새로운 배열 생성
  const [removed] = reorderedSpots.splice(result.source.index, 1);
  reorderedSpots.splice(result.destination.index, 0, removed);

  // 상태 업데이트를 위해 새로운 배열 반환
  const updatedSpots = spots.map((spot) => {
    if (spot.day_x !== selectedDay) return spot;
    return {
      ...reorderedSpots[currentDaySpots.indexOf(spot)],
      order: currentDaySpots.indexOf(spot),
    };
  });

  onSpotsUpdate(updatedSpots);
};

 

문제 2: 드래그할 때 UI가 깜빡이는 문제

  • 원인: draggableProps.style을 설정하지 않으면, 드래그 중에 React가 상태를 변경하면서 컴포넌트가 깜빡이는 문제가 발생
  • 해결: snapshot.isDragging 상태를 활용해 드래그 중인 아이템의 배경색을 변경하고, CSS를 활용해 자연스럽게 처리

문제 코드

<Draggable
  key={`${spot.order}-${spot.eng_name}`}
  draggableId={`${spot.order}-${spot.eng_name}`}
  index={index}
>
  {(dragProvided) => (
    <div
      ref={dragProvided.innerRef}
      {...dragProvided.draggableProps}
      {...dragProvided.dragHandleProps}
    >
      <h2>{spot.kor_name}</h2>
    </div>
  )}
</Draggable>

해결 코드

<Draggable
  key={`${spot.order}-${spot.eng_name}`}
  draggableId={`${spot.order}-${spot.eng_name}`}
  index={index}
>
  {(dragProvided, snapshot) => (
    <div
      ref={dragProvided.innerRef}
      {...dragProvided.draggableProps}
      {...dragProvided.dragHandleProps}
      className={styles.travel_plan_card_section}
      style={{
        ...dragProvided.draggableProps.style,
        backgroundColor: snapshot.isDragging ? "rgba(0, 0, 0, 0.05)" : "transparent",
      }}
    >
      <h2>{spot.kor_name}</h2>
    </div>
  )}
</Draggable>

 

문제 3: 드래그가 끝난 후에도 업데이트가 적용되지 않는 문제

  • 원인: onSpotsUpdate를 호출할 때 기존 배열을 수정하면 React가 변화를 감지하지 못해 UI가 갱신되지 않음
  • 해결: 새로운 배열을 생성하여 상태를 업데이트

문제 코드

const handleDragEnd = (result: any) => {
  if (!result.destination) return;

  const currentDaySpots = spots.filter((spot) => spot.day_x === selectedDay);
  const reorderedSpots = Array.from(currentDaySpots);
  const [removed] = reorderedSpots.splice(result.source.index, 1);
  reorderedSpots.splice(result.destination.index, 0, removed);

  onSpotsUpdate(spots);
};

해결 코드

새로운 배열을 생성하여 상태를 변경하여 UI를 정상 업데이트 했다.

const handleDragEnd = (result: any) => {
  if (!result.destination) return;

  const currentDaySpots = spots.filter((spot) => spot.day_x === selectedDay);
  const reorderedSpots = Array.from(currentDaySpots);
  const [removed] = reorderedSpots.splice(result.source.index, 1);
  reorderedSpots.splice(result.destination.index, 0, removed);

  // 새로운 배열을 생성하여 상태 업데이트
  const updatedSpots = spots.map((spot) => {
    if (spot.day_x !== selectedDay) return spot;
    return {
      ...reorderedSpots[currentDaySpots.indexOf(spot)],
      order: currentDaySpots.indexOf(spot),
    };
  });

  onSpotsUpdate([...updatedSpots]);
};

최종 완성 코드

<DragDropContext onDragEnd={handleDragEnd}>
          <Droppable droppableId="droppable">
            {(provided) => (
              <div
                ref={provided.innerRef}
                {...provided.droppableProps}
                style={{ width: "100%" }}
              >
                {/* 일정 요소 list */}
                {spots
                  .filter((spot) => spot.day_x === selectedDay)
                  .map((spot, index) => (
                    <Draggable
                      key={`${spot.order}-${spot.eng_name}`}
                      draggableId={`${spot.order}-${spot.eng_name}`}
                      index={index}
                    >
                      {(dragProvided, snapshot) => (
                        <div
                          ref={dragProvided.innerRef}
                          {...dragProvided.draggableProps}
                          {...dragProvided.dragHandleProps}
                          className={styles.travel_plan_card_section}
                          onClick={() => handleSpotClick(spot)}
                          style={{
                            ...dragProvided.draggableProps.style,
                            backgroundColor: snapshot.isDragging
                              ? "rgba(0, 0, 0, 0.05)"
                              : "transparent",
                          }}
                        >
                          <div className={styles.travel_plan_card_container}>
                            <div className={styles.teavel_plan_delete}>
                              <Trash2
                                size={30}
                                className={styles.trash_icon}
                                onClick={(e) => {
                                  e.stopPropagation();
                                  handleDeleteClick(index);
                                }}
                              />
                            </div>
                            <div className={styles.travle_image_container}>
                              <div className={styles.travle_image}>
                                <img src={spot.image_url} alt={spot.eng_name} />
                              </div>
                              <div className={styles.place_description}>
                                <h2>{spot.kor_name}</h2>
                                <p>{spot.description}</p>
                              </div>
                            </div>
                          </div>
                        </div>
                      )}
                    </Draggable>
                  ))}
                {provided.placeholder}
              </div>
            )}
          </Droppable>
        </DragDropContext>