사용하게된 배경
이번 React 사이드 프로젝트, MoodSync에서 컬렉션 기능 아이디어를 제시하자마자 드래그 드랍 기능을 꼭 넣고 싶었다. 사용자 경험을 혁신적으로 개선하는데 좋을 것 같았고, 리스트의 순서를 바꾸거나 요소를 특정 영역으로 이동시키는 기능은 (있어 보였다)
마치 노션에서 드래그해서 순서를 변경하고, 위치를 변경하는 등의 기능인데 역시 라이브러리가 있었다.
hello-pangea/dnd란?
@hello-pangea/dnd는 React 애플리케이션을 위한 아름답고 접근성이 뛰어나며 성능이 우수한 드래그 앤 드롭 라이브러리이다.
이 라이브러리는 리스트 내에서 아이템의 순서를 바꾸는 리스트 재정렬에 특히 강력하며, 복잡한 드래그 앤 드롭 시나리오도 깔끔하게 처리할 수 있도록 설계되었다고 한다.
npm install @hello-pangea/dnd
사용하려면, 커맨드에 npm install 로 간단하게 설치 가능하다!
@hello-pangea/dnd의 핵심 컴포넌트
@hello-pangea/dnd를 사용하려면 주로 다음 세 가지 핵심 컴포넌트가 있다. 퍼즐 조각을 맞추듯이 이 세 가지를 조합하면 드래그 앤 드롭 기능을 쉽게 구현할 수 있다.
- <DragDropContext>:
- 역할: 드래그 앤 드롭 기능을 사용할 모든 요소를 감싸는 가장 바깥쪽 래퍼. 이 컴포넌트는 전체 드래그 앤 드롭 작업의 생명 주기를 관리하고, 드래그 작업이 끝났을 때(즉, 아이템을 드롭했을 때) 호출될 콜백 함수를 제공한다.
- 주요 Props:
- onDragEnd: 드래그 작업이 완료되었을 때 호출되는 함수. 이 함수는 DropResult 객체를 인자로 받는데, 이 객체에는 드래그된 아이템의 시작 위치와 최종 드롭 위치 등의 정보가 담겨 있다. 이 정보를 바탕으로 리스트의 순서를 실제로 변경하는 로직을 구현.
- <Droppable>:
- 역할: 드래그 가능한 아이템들이 떨어질 수 있는 영역(Drop Zone)을 정의. (예를 들어, 리스트에서 아이템이 이동할 수 있는 공간이 Droppable 영역이 된다.)
- 주요 Props:
- droppableId: 고유한 문자열 ID. 한 페이지에 여러 Droppable 영역이 있을 때 이를 구분하는 데 사용.
- children (렌더 Props 패턴): Droppable 영역의 실제 내용을 렌더링하는 함수. 이 함수는 provided 객체를 제공하며, 이 객체에는 다음과 같은 중요한 속성들이 포함된다:
- provided.innerRef: 드롭 가능한 DOM 요소에 연결해야 하는 ref.
- provided.droppableProps: 드롭 가능한 DOM 요소에 적용해야 하는 props.
- provided.placeholder: 드래그 중인 아이템이 차지하던 공간을 시각적으로 나타내는 데 사용.
- <Draggable>:
- 역할: 드래그할 수 있는 개별 아이템(Draggable Item)을 정의.
- 주요 Props:
- draggableId: 드래그 가능한 아이템의 고유한 문자열 ID.
- index: 리스트 내에서 이 아이템의 현재 인덱스(순서).
- children (렌더 Props 패턴): Draggable 아이템의 실제 내용을 렌더링하는 함수. 이 함수는 provided 객체와 snapshot 객체를 제공한다:
- provided.innerRef: 드래그 가능한 DOM 요소에 연결해야 하는 ref.
- provided.draggableProps: 드래그 가능한 DOM 요소에 적용해야 하는 props.
- provided.dragHandleProps: 드래그를 시작할 수 있는 특정 영역(handle)에 적용해야 하는 props. 모든 영역을 드래그 가능하게 하려면 아이템 전체에 적용하고, 특정 아이콘이나 버튼을 통해서만 드래그하고 싶으면 그 요소에 적용.
- snapshot: 드래그 상태에 대한 정보를 제공 (예: snapshot.isDragging은 현재 드래그 중인지 여부를 알려준다).
아래 코드는 프로젝트에서 사용한 드래그 기능이다.
<div className="flex-1 overflow-y-auto -mx-8 px-8">
{items.length > 0 ? (
<DragDropContext onDragEnd={onDragEnd}>
{/* onReorderItems가 없을 때는 드롭 기능 비활성화 */}
<Droppable droppableId="collection-items" isDropDisabled={!onReorderItems}>
{(provided) => (
<ul
className="space-y-3 pt-2"
{...provided.droppableProps}
ref={provided.innerRef}
>
{items.map((item: CollectionItem, index: number) => (
<Draggable
key={String(item.collectionItemId)} // key는 반드시 string이어야 합니다.
draggableId={String(item.collectionItemId)} // draggableId도 string
index={index}
// onReorderItems가 없을 때는 드래그 기능 비활성화
isDragDisabled={!onReorderItems}
>
{(provided, snapshot) => (
<li
ref={provided.innerRef}
{...provided.draggableProps}
// onReorderItems가 있을 때만 dragHandleProps 적용 (드래그 가능한 영역)
{...(onReorderItems ? provided.dragHandleProps : {})}
className={`
bg-gray-50 p-3 rounded-md flex items-center gap-3 relative
${snapshot.isDragging ? 'shadow-lg bg-blue-100' : ''}
${!onReorderItems ? 'cursor-default' : ''} {/* 드래그 불가능할 때 커서 변경 */}
`}
>
<span className="text-xl">
{item.contentType === 'music' && <Music className="w-4 h-4" />}
{item.contentType === 'activity' && <Activity className="w-4 h-4" />}
{item.contentType === 'book' && <Book className="w-4 h-4" />}
</span>
<div className="flex-1">
<p className="text-lg font-medium">{item.contentTitle}</p>
<p className="text-sm text-gray-500">
추가됨: {new Date(item.addedAt).toLocaleDateString()}
</p>
</div>
{onDeleteItem && (
<button
className="absolute top-2 right-2 text-gray-400 hover:text-red-500 text-xl p-1 rounded-full hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
// alert 대신 커스텀 모달 사용 권장
if (window.confirm(`"${item.contentTitle}" 아이템을 정말 삭제하시겠습니까?`)) {
handleItemDelete(String(item.collectionItemId));
}
}}
aria-label={`"${item.contentTitle}" 아이템 삭제`}
>
×
</button>
)}
</li>
)}
</Draggable>
))}
{provided.placeholder} {/* 드래그 중인 아이템이 차지하던 공간 */}
</ul>
)}
</Droppable>
</DragDropContext>
) : (
<p className="text-gray-500 text-center py-8">이 컬렉션에는 아이템이 없습니다.</p>
)}
</div>
1. DragDropContext로 전체 감싸기
모달의 아이템 목록을 포함하는 전체 섹션(ul 요소의 부모 div)은 <DragDropContext onDragEnd={onDragEnd}> 로 감쌈
- onDragEnd={onDragEnd}: 드래그가 끝나고 아이템이 드롭될 때 onDragEnd 함수가 호출
- 이 함수 안에서 아이템의 새로운 순서를 결정하고 서버에 저장하는 로직 처리
2. Droppable로 드롭 가능한 영역 만들기
아이템들이 실제로 드롭될 수 있는 <ul> 태그는 <Droppable droppableId="collection-items" isDropDisabled={!onReorderItems}>로 감쌈
- droppableId="collection-items": 이 Droppable 영역의 고유 ID
- isDropDisabled={!onReorderItems}: onReorderItems라는 prop이 CollectionDetailModal에 전달되지 않았다면(즉, 현재 사용자가 컬렉션 수정 권한이 없다면), 드롭 기능을 비활성화( 공유 링크를 탔을때, 타 사용자는 사용하지 못하도록 )
- {(provided) => (...)}: 렌더 Props 패턴을 사용하여, Droppable 영역의 <ul>에 provided.droppableProps와 ref={provided.innerRef}를 적용
- 드래그 중인 아이템이 사라진 공간을 채워주는 provided.placeholder도 <ul> 내부에 포함
3. Draggable로 드래그 가능한 아이템 만들기
각 컬렉션 아이템(<li> 태그)은 <Draggable key={String(item.collectionItemId)} draggableId={String(item.collectionItemId)} index={index} isDragDisabled={!onReorderItems}>로 감쌈
- key={String(item.collectionItemId)}: React 리스트 렌더링을 위한 고유 key ( string 타입 )
- draggableId={String(item.collectionItemId)}: 드래그 가능한 각 아이템의 고유 ID ( string 타입 )
- index={index}: map 함수의 index를 사용하여 현재 아이템의 리스트 내 순서를 정확하게 전달
- isDragDisabled={!onReorderItems}: Droppable과 마찬가지로, onReorderItems prop이 없다면 개별 아이템의 드래그 기능을 비활성화
- {(provided, snapshot) => (...)}: 렌더 Props 패턴을 사용하여, Draggable 아이템의 <li>에 provided.innerRef, provided.draggableProps를 적용
- {...(onReorderItems ? provided.dragHandleProps : {})}: 이 부분이 매우 유연한 기능을 제공
- onReorderItems prop이 존재할 때만 provided.dragHandleProps를 <li> 요소에 적용 -> <li> 전체가 드래그 핸들이 되어 <li> 어디를 클릭해도 드래그를 시작할 수 있게 함
- 만약 onReorderItems가 없다면 {} 빈 객체를 적용하여 dragHandleProps가 적용되지 않으므로 드래그가 불가능
- snapshot.isDragging ? 'shadow-lg bg-blue-100' : '': snapshot 객체를 활용하여 아이템이 드래그 중일 때 시각적인 피드백(그림자, 배경색)을 제공하였다.
4. onDragEnd 로직
onDragEnd 함수는 드래그 앤 드롭이 완료된 후 호출되며, DropResult 객체(result)를 인자로 받는다.
const onDragEnd = (result: DropResult) => {
if (!result.destination) { // 드롭된 위치가 유효하지 않으면 (원래 위치로 돌아오거나 영역 밖)
return;
}
if (!onReorderItems) { // 순서 변경 권한이 없으면 경고 후 종료
console.warn("순서 변경 기능이 활성화되지 않았습니다.");
return;
}
const reorderedItems = Array.from(items); // 현재 아이템 배열 복사
const [movedItem] = reorderedItems.splice(result.source.index, 1); // 원본 위치에서 아이템 제거
reorderedItems.splice(result.destination.index, 0, movedItem); // 새 위치에 아이템 삽입
setItems(reorderedItems); // UI 즉시 업데이트 (로컬 상태 변경)
setIsChanged(true); // 변경 사항이 있음을 표시하여 "저장" 버튼 활성화
};
- 드롭 대상이 유효한지 확인
- onReorderItems (순서 변경 권한)가 있는지 확인
- 현재 items 상태를 복사(Array.from)하여 불변성 유지(React에서 상태를 직접 수정하는 것x)
- splice 배열 메서드를 사용하여 드래그된 아이템의 위치를 변경
- setItems(reorderedItems)를 통해 UI를 즉시 업데이트 (사용자는 드래그 후 바로 변경된 순서를 볼 수 있게)
- setIsChanged(true)를 호출하여 "저장" 버튼을 활성화함으로써 사용자가 변경 사항을 서버에 반영하도록 유도
4. handleSave 로직
handleSave 함수는 "저장" 버튼 클릭 시 호출되며, 실제 서버에 변경된 순서를 전송하는 역할
const handleSave = async () => {
if (!onReorderItems || !collection || !isChanged) { // 저장할 조건이 아니면 그냥 모달 닫기
onClose();
return;
}
const itemsToSave: CollectionItem[] = items.map((item, index) => ({
...item,
itemOrder: index // 현재 UI 순서대로 itemOrder를 0부터 다시 매김
}));
try {
// 부모 컴포넌트로부터 받은 onReorderItems prop을 호출하여 백엔드에 저장 요청
await onReorderItems(String(collection.collectionId), itemsToSave);
setIsChanged(false); // 저장 완료했으니 변경 상태 초기화
// 모달을 닫으면서, 변경된 아이템 목록을 포함한 업데이트된 컬렉션 객체를 부모에게 전달
const updatedCollection: Collection = {
...collection,
items: itemsToSave
};
onClose(updatedCollection); // 부모 컴포넌트의 collection 상태도 업데이트되도록
} catch (error) {
console.error("아이템 순서 저장 중 오류 발생:", error);
window.alert('아이템 순서 저장에 실패했습니다. 다시 시도해주세요.');
}
};
- items.map((item, index) => ({ ...item, itemOrder: index })): 현재 UI에 보이는 순서(0부터 시작하는 index)를 itemOrder 필드에 다시 매핑하여 서버에 보낼 데이터(itemsToSave) 준비
- await onReorderItems(...): prop으로 받은 onReorderItems 함수를 호출하여 백엔드 API에 순서 변경 요청
- onClose(updatedCollection): 서버 저장이 성공하면 onClose 함수를 호출하면서 업데이트된 Collection 객체를 함께 전달 -> 모달을 연 부모 컴포넌트(CollectionPage 등)에서 해당 컬렉션의 상태를 최신화
적용 예시
핵심 요약 및 팁
- @hello-pangea/dnd는 <DragDropContext>, <Droppable>, <Draggable> 세 가지 컴포넌트가 퍼즐처럼 맞춰져야 작동한다
- 불변성 유지: items 배열을 수정할 때는 항상 Array.from()이나 slice()를 사용하여 원본 배열을 복사한 후 수정해야 한다
- ID의 중요성: droppableId, draggableId, key는 모두 고유한 string 타입이어야 한다(이것 때문에 캐스팅 하느라 힘들었다)
- 조건부 드래그/드롭 활성화: isDropDisabled와 isDragDisabled, 그리고 dragHandleProps의 조건부 적용을 통해 사용자 권한이나 모드에 따라 드래그 앤 드롭 기능을 유연하게 제어 (들어오는 매핑에 따라 차별을 두었다)
- 로컬 상태와 서버 상태 동기화: onDragEnd에서는 빠르게 UI를 업데이트(로컬 상태)하고, handleSave에서 실제 서버에 변경 사항을 반영(서버 상태)하는 분리된 접근 방식은 사용자 경험과 데이터 일관성을 모두 잡을 수 있다.
'Cording' 카테고리의 다른 글
[Oracle]selectkey (2) | 2025.06.16 |
---|---|
[JSP]JDBC, DBCP / Connection Pool (0) | 2025.03.19 |
[Java]객체 비교 equals()와toString() (0) | 2025.02.26 |
[Java]상속, 인터페이스, 추상클래스 (0) | 2025.02.21 |
[Java] 객체배열 (0) | 2025.02.19 |