이미지 갤러리나 상품 썸네일을 사용자가 직접 순서를 바꿀 수 있도록 만드는 것은 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에 직접적인 영향을 주는 기능이므로 접근성과 성능을 함께 챙기며 구현하는 것을 권장합니다.
'React' 카테고리의 다른 글
| React에서 상태 관리 없이 URL 파라미터로 데이터 전달하기 (0) | 2026.05.26 |
|---|---|
| React 앱에서 Web Push 알림 기능 추가하기 (0) | 2026.05.26 |
| React 컴포넌트에서 성능 저하 원인 분석 및 제거 방법 (0) | 2026.05.25 |
| React와 Firebase Firestore 실시간 데이터 연동하기 (0) | 2026.05.23 |
| React 앱에서 브라우저 히스토리 API 활용하기 (0) | 2026.05.22 |