[React] react-beautiful-dnd 드래그 드랍 라이브러리

2025. 6. 16. 12:54·Cording

사용하게된 배경 

이번 React 사이드 프로젝트, MoodSync에서 컬렉션 기능 아이디어를 제시하자마자 드래그 드랍 기능을 꼭 넣고 싶었다. 사용자 경험을 혁신적으로 개선하는데 좋을 것 같았고, 리스트의 순서를 바꾸거나 요소를 특정 영역으로 이동시키는 기능은 (있어 보였다)

마치 노션에서 드래그해서 순서를 변경하고, 위치를 변경하는 등의 기능인데 역시 라이브러리가 있었다.

 

hello-pangea/dnd란?

@hello-pangea/dnd는 React 애플리케이션을 위한 아름답고 접근성이 뛰어나며 성능이 우수한 드래그 앤 드롭 라이브러리이다.

 

 이 라이브러리는 리스트 내에서 아이템의 순서를 바꾸는 리스트 재정렬에 특히 강력하며, 복잡한 드래그 앤 드롭 시나리오도 깔끔하게 처리할 수 있도록 설계되었다고 한다.

 

npm install @hello-pangea/dnd

 

사용하려면, 커맨드에 npm install 로 간단하게 설치 가능하다!

 

@hello-pangea/dnd의 핵심 컴포넌트

@hello-pangea/dnd를 사용하려면 주로 다음 세 가지 핵심 컴포넌트가 있다. 퍼즐 조각을 맞추듯이 이 세 가지를 조합하면 드래그 앤 드롭 기능을 쉽게 구현할 수 있다.

    1. <DragDropContext>:
      • 역할: 드래그 앤 드롭 기능을 사용할 모든 요소를 감싸는 가장 바깥쪽 래퍼. 이 컴포넌트는 전체 드래그 앤 드롭 작업의 생명 주기를 관리하고, 드래그 작업이 끝났을 때(즉, 아이템을 드롭했을 때) 호출될 콜백 함수를 제공한다.
      • 주요 Props:
        • onDragEnd: 드래그 작업이 완료되었을 때 호출되는 함수. 이 함수는 DropResult 객체를 인자로 받는데, 이 객체에는 드래그된 아이템의 시작 위치와 최종 드롭 위치 등의 정보가 담겨 있다. 이 정보를 바탕으로 리스트의 순서를 실제로 변경하는 로직을 구현.
    2. <Droppable>:
      • 역할: 드래그 가능한 아이템들이 떨어질 수 있는 영역(Drop Zone)을 정의. (예를 들어, 리스트에서 아이템이 이동할 수 있는 공간이 Droppable 영역이 된다.)
      • 주요 Props:
        • droppableId: 고유한 문자열 ID. 한 페이지에 여러 Droppable 영역이 있을 때 이를 구분하는 데 사용.
        • children (렌더 Props 패턴): Droppable 영역의 실제 내용을 렌더링하는 함수. 이 함수는 provided 객체를 제공하며, 이 객체에는 다음과 같은 중요한 속성들이 포함된다:
          • provided.innerRef: 드롭 가능한 DOM 요소에 연결해야 하는 ref.
          • provided.droppableProps: 드롭 가능한 DOM 요소에 적용해야 하는 props.
          • provided.placeholder: 드래그 중인 아이템이 차지하던 공간을 시각적으로 나타내는 데 사용.
    3. <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}" 아이템 삭제`}
                                            >
                                                &times;
                                            </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); // 변경 사항이 있음을 표시하여 "저장" 버튼 활성화
};

 

  1. 드롭 대상이 유효한지 확인
  2. onReorderItems (순서 변경 권한)가 있는지 확인
  3. 현재 items 상태를 복사(Array.from)하여 불변성 유지(React에서 상태를 직접 수정하는 것x)
  4. splice 배열 메서드를 사용하여 드래그된 아이템의 위치를 변경
  5. setItems(reorderedItems)를 통해 UI를 즉시 업데이트 (사용자는 드래그 후 바로 변경된 순서를 볼 수 있게)
  6. 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 등)에서 해당 컬렉션의 상태를 최신화

적용 예시

기존 아이템 순서에서, 드래그를 하면
이렇게 순서를 변경할 수 있다
저장을 누르면, 팝업으로 사용자가 변경사항을 확인 할 수 있고, 부모 컴포넌트에서도 바로 순서가 변경됨
물론 자세히보기를 눌렀을때도 변경된 것을 볼 수 있다!
네트워크 페이로드 탭에서, 데이터를 확인 할 수 있는데
이렇게 itemOrder를 보내서, DB에 저장하는 방식이다!
이제 왼쪽 사이드바에서 컬렉션 제목을 클릭하면 오른쪽에 펼쳐지는데 여기서 드래그 드랍을 통해서도 목록을 수정할 수 있도록 제작하였다

핵심 요약 및 팁

  • @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
'Cording' 카테고리의 다른 글
  • [Oracle]selectkey
  • [JSP]JDBC, DBCP / Connection Pool
  • [Java]객체 비교 equals()와toString()
  • [Java]상속, 인터페이스, 추상클래스
우주녕
우주녕
개발 뉴비
  • 우주녕
    주연이의 일기장
    우주녕
  • 전체
    오늘
    어제
    • 분류 전체보기 (10)
      • Daily (0)
      • Cording (7)
      • Kh-academy (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    생성자
    레퍼런스변수
    java
    This
    자바
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
우주녕
[React] react-beautiful-dnd 드래그 드랍 라이브러리
상단으로

티스토리툴바