[React] React에서 Drag & Drop 구현 – react-beautiful-dnd 사용
여행 일정 수정 시 사용자가 더 편리하게 순서를 변경할 수 있도록 드래그 앤 드롭 기능을 추가했다.
기존에는 체크박스를 이용해 개별 선택하거나 전체 선택 후 삭제하는 방식으로 일정 순서를 조정해야 했다.
그러나 삭제 후 다시 추가하는 방식은 번거로웠고, 보다 직관적인 조작을 위해 드래그 앤 드롭과 함께 휴지통 아이콘을 활용한 즉시 삭제 기능을 추가했다.
검색을 통해 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>