본문 바로가기

React

React에서 이미지 드래그 앤 드롭 정렬 기능 구현하기

이미지 갤러리나 상품 썸네일을 사용자가 직접 순서를 바꿀 수 있도록 만드는 것은 UX 개선에 큰 도움이 됩니다. 이 글에서는 React에서 이미지 드래그 앤 드롭 정렬을 두 가지 방식으로 구현하는 방법을 소개합니다: 브라우저 기본 HTML5 Drag and Drop API를 활용한 최소 구현과, 모바일/접근성/성능까지 고려된 dnd-kit 기반 프로덕션 구현입니다.

1. 목표와 UX 기준

- 마우스/터치로 이미지를 드래그해 순서를 변경합니다.

- 키보드로도 순서를 이동할 수 있어야 접근성이 높습니다.

- 변경된 순서를 서버에 저장하고 재진입 시 동일한 순서를 유지합니다.

- 긴 리스트에서도 부드럽게 동작하고 불필요한 렌더링을 최소화합니다.

2. 라이브러리 선택 가이드

- HTML5 Drag and Drop: 의존성 없이 빠르게 구현 가능합니다. 단, 모바일 터치 지원과 접근성, 복잡한 케이스에서 제약이 있습니다.

- dnd-kit: 현재 React 생태계에서 추천되는 드래그 앤 드롭 라이브러리입니다. 터치/키보드/가상화/충돌 감지 등 확장성이 좋습니다.

- react-beautiful-dnd: 오래 사랑받았지만 아카이브 상태입니다. 신규 프로젝트에는 dnd-kit를 권장합니다.

3. HTML5 Drag and Drop으로 최소 구현

의존성 없이 사용 가능한 기본 버전입니다. 작은 리스트나 빠른 프로토타입에 적합합니다.

import React, { useState } from 'react';

// 리스트 재배열 유틸
function reorder(list, startIndex, endIndex) {
  const result = list.slice();
  const [removed] = result.splice(startIndex, 1);
  result.splice(endIndex, 0, removed);
  return result;
}

export default function ImageDnDSort({ initialImages }) {
  const [images, setImages] = useState(initialImages);
  const [dragIndex, setDragIndex] = useState(null);
  const [overIndex, setOverIndex] = useState(null);

  const onDragStart = (idx, e) => {
    setDragIndex(idx);
    // Firefox 호환 및 드롭 허용을 위한 설정
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', String(idx));
  };

  const onDragOver = (idx, e) => {
    // 기본 동작을 막아야 drop 이벤트가 동작합니다
    e.preventDefault();
    setOverIndex(idx);
  };

  const onDrop = (idx, e) => {
    e.preventDefault();
    const from = dragIndex ?? Number(e.dataTransfer.getData('text/plain'));
    const to = idx;
    if (Number.isNaN(from) || Number.isNaN(to) || from === to) {
      cleanup();
      return;
    }
    setImages(prev => reorder(prev, from, to));
    cleanup();
  };

  const onDragEnd = () => cleanup();
  const cleanup = () => { setDragIndex(null); setOverIndex(null); };

  // 키보드 접근성: 버튼으로 순서 이동
  const moveByKeyboard = (idx, delta) => {
    const to = Math.min(images.length - 1, Math.max(0, idx + delta));
    if (to === idx) return;
    setImages(prev => reorder(prev, idx, to));
  };

  // 서버 저장 예시 (버튼 클릭 또는 onDrop 이후 호출)
  const saveOrder = async () => {
    const order = images.map(i => i.id);
    await fetch('/api/images/order', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ order }),
    });
    // 실패 시 롤백/토스트 등 처리 권장
  };

  return (
    
{images.map((img, idx) => ( <div key={img.id} role="listitem" draggable onDragStart={(e) => onDragStart(idx, e)} onDragOver={(e) => onDragOver(idx, e)} onDrop={(e) => onDrop(idx, e)} onDragEnd={onDragEnd} style={{ border: overIndex === idx ? '2px solid #4F46E5' : '1px solid #ddd', borderRadius: 8, padding: 8, background: '#fff', boxShadow: dragIndex === idx ? '0 0 0 2px #4F46E5' : 'none', cursor: 'grab', }} > <img src={img.src} alt={img.alt ?? `이미지 ${idx + 1}`} style={{ width: '100%', height: 100, objectFit: 'cover', pointerEvents: 'none' }} /> <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}> <button type="button" aria-label="위로 이동" onClick={() => moveByKeyboard(idx, -1)} disabled={idx === 0}>▲</button> <button type="button" aria-label="아래로 이동" onClick={() => moveByKeyboard(idx, 1)} disabled={idx === images.length - 1}>▼</button> </div> </div> ))}
<button type="button" onClick={saveOrder} style={{ marginTop: 16 }}>순서 저장</button>
); } // 사용 예시 const initialImages = [ { id: 'a1', src: '/images/a.jpg', alt: '제품 A' }, { id: 'b2', src: '/images/b.jpg', alt: '제품 B' }, { id: 'c3', src: '/images/c.jpg', alt: '제품 C' }, ];

실무 팁: img 태그에 pointerEvents: 'none'을 지정하면 기본 이미지 드래그 고스트가 방해하지 않습니다. 또, draggable은 이미지 자체보다 카드 래퍼에 주는 것이 예측 가능한 동작을 만듭니다.

4. dnd-kit로 프로덕션 구현

dnd-kit는 마우스/터치/키보드 센서를 제공하고 충돌 감지 전략을 바꿔 다양한 레이아웃에서 안정적으로 동작합니다.

// 설치: npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
import React, { useState } from 'react';
import { DndContext, closestCenter, useSensor, useSensors, MouseSensor, TouchSensor, KeyboardSensor } from '@dnd-kit/core';
import { SortableContext, useSortable, arrayMove, rectSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

function SortableItem({ img }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: img.id });
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    border: isDragging ? '2px solid #4F46E5' : '1px solid #ddd',
    borderRadius: 8,
    padding: 8,
    background: '#fff',
  };
  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <img src={img.src} alt={img.alt} style={{ width: '100%', height: 100, objectFit: 'cover' }} />
    </div>
  );
}

export default function DndKitImageSort({ initialImages }) {
  const [images, setImages] = useState(initialImages);
  const ids = images.map(i => i.id);

  const sensors = useSensors(
    useSensor(MouseSensor),
    useSensor(TouchSensor),
    useSensor(KeyboardSensor)
  );

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={({ active, over }) => {
        if (!over || active.id === over.id) return;
        const oldIndex = ids.indexOf(active.id);
        const newIndex = ids.indexOf(over.id);
        setImages((prev) => arrayMove(prev, oldIndex, newIndex));
      }}
    >
      <SortableContext items={ids} strategy={rectSortingStrategy}>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12 }}>
          {images.map((img) => (
            <SortableItem key={img.id} img={img} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

실무 팁: KeyboardSensor를 추가하면 키보드만으로도 재배열이 가능합니다. 모달/스크롤 컨테이너 안에서 드래그 시에는 autoScroll 플러그인(dnd-kit의 modifiers) 사용을 검토합니다.

5. 서버와 순서 동기화

드롭 직후 낙관적 업데이트를 하고, 실패 시 롤백하는 패턴이 가장 사용자 친화적입니다.

async function persistOrder(images) {
  const order = images.map(i => i.id);
  const res = await fetch('/api/images/order', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ order }),
  });
  if (!res.ok) throw new Error('순서 저장 실패');
}

// onDragEnd 또는 onDrop 이후
setImages(next);
persistOrder(next).catch(() => {
  // 실패 처리: 롤백, 재시도, 사용자 알림 등
});

6. 접근성 체크리스트

- 각 이미지에 의미 있는 alt 텍스트를 제공합니다.

- role="list"/"listitem"을 사용해 보조기기에 구조를 알립니다.

- 키보드 전용 이동 버튼 또는 KeyboardSensor를 제공합니다.

- 드래그 시작/종료 시 aria-live로 변경 사항을 안내하면 좋습니다.

7. 모바일 대응 팁

- HTML5 Drag and Drop은 터치 지원이 제한적입니다. 모바일을 타깃으로 한다면 dnd-kit을 사용하는 것이 안전합니다.

- 터치 디바이스에서 스크롤과 드래그가 충돌하지 않도록 그립 영역(예: 이미지 상단 바)만 드래그 가능하도록 설정을 고려합니다.

8. 성능 최적화

- 이미지가 많다면 카드 컴포넌트를 React.memo로 감싸 불필요한 렌더를 줄입니다.

- 매우 긴 리스트는 react-window/react-virtualized로 가상 스크롤을 적용합니다.

- 프리뷰 이미지는 적절히 압축하고 width/height를 지정해 레이아웃 시프트를 줄입니다.

- 객체 URL(URL.createObjectURL) 사용 시 컴포넌트 언마운트에 revoke를 호출합니다.

9. 흔한 이슈와 해결

- 드롭이 안 되는 경우: onDragOver에서 e.preventDefault를 누락했을 가능성이 큽니다.

- 이미지 자체가 끌려가며 이상한 고스트가 보이는 경우: img에 pointerEvents: 'none'을 주고 래퍼 div에 draggable을 적용합니다.

- 스크롤 컨테이너 안에서 드래그가 어려운 경우: dnd-kit의 modifiers로 autoScroll을 활성화합니다.

- 인덱스 기반 키 사용으로 순서 꼬임: 고유 id를 키 및 드래그 id로 사용합니다.

10. 테스트 체크리스트

- 드래그로 순서 변경이 반영되는지 확인합니다.

- 키보드만으로 순서 이동이 가능한지 확인합니다.

- 모바일 터치로 정상 작동하는지 확인합니다.

- 서버 저장 실패 시 롤백/알림이 동작하는지 확인합니다.

11. 마무리

빠르게 구현하려면 HTML5 Drag and Drop으로 시작하고, 모바일/접근성/확장성을 요구한다면 dnd-kit로 전환하는 전략이 실무에서 안전합니다. 이미지 정렬은 UX에 직접적인 영향을 주는 기능이므로 접근성과 성능을 함께 챙기며 구현하는 것을 권장합니다.