카카오톡, 디스코드, ChatGPT 등 다양한 웹, 앱 환경에서 파일을 주고 받을 때 정말 유용하게 쓰고 있는 인터렉션 메커니즘이 바로 Drag & Drop이다. 이 기능은 사용자가 마우스나 터치스크린을 사용하여 객체를 한 위치에서 다른 위치로 드래그하여 놓을 수 있는 기술이다. 이 기능은 사용자가 물리적인 세계와 유사한 방식으로 상호작용할 수 있게 하여, 직관적인 환경을 제공한다.
예를 들어, 파일 관리자에서 파일을 한 폴더에서 다른 폴더로 이동시키거나, 온라인 문서 편집기에서 이미지를 문서 내의 다른 위치로 쉽게 옮길 수 있게 한다. 벨로그를 작성할때 이미지 추가할 때도 유용하게 쓰고 있는 기능이다. 이러한 행위는 프런트엔드 개발에서 중요한 사용자 경험을 향상시키고, 작업의 효율성을 높인다.
본문에서는 드래그앤드롭 기능의 구현 방법, 직접 개발과 라이브러리 사용의 차이점, 그리고 각 방법의 장단점을 탐구해보겠다.
드래그앤드롭 기능을 직접 구현하든, 라이브러리를 사용하든, 그 구현의 기반에는 여러 공통적인 원리와 기술이 적용되어 있다. 코드를 작성하기에 앞서 해당 기능들을 이해한 후 코드에 적용해보자.
드래그앤드롭 기능의 핵심은 바로 적절한 이벤트 리스닝에 있다.
지속적으로 사용자의 드래그 액션을 감지하고 반응하기 위해, 다음과 같은 이벤트들이 주로 사용된다.
dragstart
: 사용자가 아이템을 드래그하기 시작할 때 발생한다.drag
: 사용자가 드래그하는 동안 지속적으로 발생한다.dragenter
: 드래그된 요소가 드랍 타겟 위에 들어올 때 발생한다.dragover
: 드래그된 요소가 드랍 타겟 위에서 움직일 때 지속적으로 발생한다. 이 이벤트를 처리하여 preventDefault
를 호출함으로써 드랍을 허용한다.dragleave
: 드래그된 요소가 드랍 타겟을 벗어날 때 발생한다.drop
: 드래그된 요소가 드랍 타겟 위에서 놓아질 때 발생한다.dragend
: 드래그 액션이 완료(성공적으로 드랍되거나 취소된 경우)될 때 발생한다.*드래그앤드롭 인터페이스의 여러 이벤트를 처리하는 함수들의 경우, 각 함수에서 preventDefault()
와 stopPropagation()
을 사용하여 드래그앤드롭 기능이 원활하게 작동하고, 예상치 못한 기본 동작이나 이벤트 전파로 인한 문제를 방지하기 위해 필수적이다.
웹 브라우저는 다양한 이벤트에 대해 기본적으로 정의된 동작을 가지고 있다. 예를 들어, 파일을 브라우저에 드래그하면 대부분의 브라우저는 파일을 열거나 다운로드를 시도한다. preventDefault()
메소드는 이런 기본 이벤트의 실행을 막아주는 역할을 한다. (드랍을 허용!)
stopPropagation()
메소드는 이벤트가 더 이상 전파되지 않도록 중단시키는 역할을 한다. 웹 애플리케이션에서는 많은 요소들이 겹쳐 있거나 이벤트 리스너가 여러 계층에 걸쳐 설정되어 있을 수 있다. 이 메소드를 사용하면 현재 이벤트를 처리하고 추가적인 전파를 막을 수 있어, 다른 요소들에서 동일한 이벤트에 대한 반응을 방지할 수 있다.
드래그앤드롭 작업 중에는 데이터 전송 객체(DataTransfer
)를 사용하여 드래그하는 동안 이동되는 데이터를 관리한다. 이 객체는 드래그된 데이터의 타입과 값을 저장하며, 드랍 이벤트 발생 시 드랍 타겟이 이 데이터를 접근할 수 있게 한다.
드래그앤드롭은 DOM 요소의 위치 변경이나 시각적 스타일 조정을 포함합니다. 드래그 시작 시 요소의 스타일을 변경하거나, 드랍 타겟의 스타일을 변경하여 사용자에게 피드백을 제공한다.
React에서 드래그앤드롭을 구현할 때는 컴포넌트의 상태를 관리할 필요가 있다. 드래그 중인지, 어떤 요소가 드래그되고 있는지 등의 상태 정보를 저장하고 업데이트하여 UI를 적절히 렌더링하는 기능을 제공한다.
React에서는 useState
와 useRef
같은 훅을 사용하여 상태와 DOM 요소를 관리할 수 있다.
const [files, setFiles] = useState([]);
const [isDragging, setIsDragging] = useBoolean();
const dragRef = useRef(null);
const fileId = useRef(0);
files
)과 드래그 상태(isDragging
)를 관리한다. files
는 드래그 앤드 드롭으로 추가된 파일들의 배열이며, isDragging
은 드래그 상태가 활성화/비활성화되는 것을 토글한다.dragRef
)를 생성하고, 파일 ID(fileId
)를 순차적으로 관리하기 위해 사용한다. fileId
는 파일마다 고유 번호를 부여하여 리스트에서 관리할 수 있도록 한다.const onChangeFiles = useCallback((e) => {
let selectFiles = e.type === 'drop' ? e.dataTransfer.files : e.target.files;
let tempFiles = [...files];
for (const file of selectFiles) {
tempFiles.push({
id: fileId.current++,
object: file,
});
}
setFiles(tempFiles);
}, [files]);
파일 선택(input)이 변경되거나 드랍 이벤트가 발생했을 때 호출된다. 드래그앤드롭 또는 파일 입력을 통해 선택된 파일들을 처리하고 상태를 업데이트한다.
event
타입이 'drop'인 경우 dataTransfer.files
를 사용하여 드래그된 파일들을 가져오고, 그렇지 않은 경우 input에서 파일을 가져온다. 각 파일에 고유 ID를 부여하고 파일 배열을 업데이트하는 역할을 한다.
e.preventDefault()
와 e.stopPropagation()
을 호출하여 이벤트의 기본 동작과 전파를 중단한다.const handleDragIn = (e) => {
e.preventDefault();
e.stopPropagation();
};
false
로 설정하여 UI를 업데이트한다.const handleDragOut = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging.off();
};
e.preventDefault()
를 호출하여 드랍을 가능하게 한다. 이벤트의 기본 동작을 방지함으로써 드랍 영역이 파일을 받아들일 수 있도록 한다.const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files) {
setIsDragging.on();
}
};
onChangeFiles
를 호출하여 파일 목록을 업데이트하고, 드래그 상태를 비활성화한다.const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
onChangeFiles(e);
setIsDragging.off();
};
컴포넌트가 마운트될 때 이벤트 리스너를 추가하고, 언마운트될 때 이벤트 리스너를 제거한다.
이를 통해 메모리 누수를 방지하고, 컴포넌트 외부에서 발생할 수 있는 이벤트 처리를 깔끔하게 관리한다.
const initDragEvents = () => {
const dragElement = dragRef.current;
if (dragElement) {
dragElement.addEventListener('dragenter', handleDragIn);
dragElement.addEventListener('dragleave', handleDragOut);
dragElement.addEventListener('dragover', handleDragOver);
dragElement.addEventListener('drop', handleDrop);
}
};
const resetDragEvents = () => {
const dragElement = dragRef.current;
if (dragElement) {
dragElement.removeEventListener('dragenter', handleDragIn);
dragElement.removeEventListener('dragleave', handleDragOut);
dragElement.removeEventListener('dragover', handleDragOver);
dragElement.removeEventListener('drop', handleDrop);
}
};
또한 이번 구현에서는 Chakra UI를 사용하여 드래그 중인 요소, 드랍 영역의 스타일을 변경하고, 사용자에게 행동의 결과를 시각적으로 표현해 보았다.
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Input,
VStack,
Text,
Flex,
IconButton,
useBoolean,
useColorModeValue,
} from '@chakra-ui/react';
import { CloseIcon } from '@chakra-ui/icons';
function DragDrop() {
const [files, setFiles] = useState([]);
const [isDragging, setIsDragging] = useBoolean();
const dragRef = useRef(null);
const fileId = useRef(0);
const onChangeFiles = useCallback(
(e) => {
let selectFiles =
e.type === 'drop' ? e.dataTransfer.files : e.target.files;
let tempFiles = [...files];
for (const file of selectFiles) {
tempFiles.push({
id: fileId.current++,
object: file,
});
}
setFiles(tempFiles);
},
[files],
);
const handleFilterFile = (id) => {
setFiles(files.filter((file) => file.id !== id));
};
const handleDragIn = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragOut = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging.off();
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files) {
setIsDragging.on();
}
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
onChangeFiles(e);
setIsDragging.off();
};
const initDragEvents = () => {
const dragElement = dragRef.current;
if (dragElement) {
dragElement.addEventListener('dragenter', handleDragIn);
dragElement.addEventListener('dragleave', handleDragOut);
dragElement.addEventListener('dragover', handleDragOver);
dragElement.addEventListener('drop', handleDrop);
}
};
const resetDragEvents = () => {
const dragElement = dragRef.current;
if (dragElement) {
dragElement.removeEventListener('dragenter', handleDragIn);
dragElement.removeEventListener('dragleave', handleDragOut);
dragElement.removeEventListener('dragover', handleDragOver);
dragElement.removeEventListener('drop', handleDrop);
}
};
useEffect(() => {
initDragEvents();
return () => resetDragEvents();
}, [initDragEvents, resetDragEvents]);
const dragBg = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
return (
<VStack
width="100%"
height="100vh"
align="center"
justify="center"
bg="gray.800"
>
<Input
type="file"
id="fileUpload"
style={{ display: 'none' }}
multiple
onChange={onChangeFiles}
/>
<Flex
ref={dragRef}
id="fileUploadLabel"
align="center"
justify="center"
width="400px"
height="200px"
borderWidth="2px"
borderColor="gray.500"
borderRadius="10px"
p={4}
cursor="pointer"
transition="all 0.3s ease"
bg={isDragging ? 'whiteAlpha.800' : 'gray.600'}
color={isDragging ? 'gray.800' : 'white'}
_hover={{ bg: hoverBg, color: 'black' }}
borderStyle={isDragging ? 'dashed' : 'solid'}
onClick={() => document.getElementById('fileUpload').click()}
>
{isDragging ? '파일 첨부하기' : '평범한 채팅창'}
</Flex>
<VStack marginTop="1rem">
{files.map((file) => (
<Flex
key={file.id}
width="300px"
padding="8px"
borderWidth="1px"
marginBottom="10px"
justify="space-between"
bg={dragBg}
borderStyle="solid"
>
<Text>{file.object.name}</Text>
<IconButton
icon={<CloseIcon />}
onClick={() => handleFilterFile(file.id)}
aria-label="Remove File"
colorScheme="red"
/>
</Flex>
))}
</VStack>
</VStack>
);
}
export default DragDrop;
이번에는 drag&drop 라이브러리 중 가장 인기가 많은 react-dropzone
을 이용하여 구현해보겠다.
먼저, 프로젝트에 react-dropzone
라이브러리를 추가해야 한다. 역시나 npm 명령어를 사용하여 설치할 수 있다.
npm install react-dropzone
위에서 직접 구현할 때 handleDragIn, handleDragOut, handleDragOver, handleDrop 이 함수들을 각각 처리했다. 이벤트가 발생할 때마다 preventDefault
와 stopPropagation
을 호출하여 기본 이벤트 처리를 방지하고, 이벤트의 추가적인 전파를 막는 작업을 추가했었다. 또한, 드래그 상태 관리(isDragging
)와 파일 배열 업데이트(setFiles
)도 수동으로 관리하는 로직을 구현했었다.
import { useDropzone } from 'react-dropzone';
const { getRootProps, getInputProps, isDragActive } = useDropzone()
그러나 react-dropzone
에서는 useDropzone
의 getRootProps
와 getInputProps
를 제공하여 드래그앤드롭에 필요한 모든 기본 설정과 이벤트 핸들링을 자동으로 처리할 수 있다. 예를 들어, getRootProps
는 드랍 영역에 필요한 이벤트 리스너(onDragOver
, onDrop
등)를 자동으로 설정할 수 있다.
직접 구현한 코드에서는 isDragging
을 사용하여 수동으로 드래그 상태를 관리하였다. 그러나 useDropzone
훅이 제공하는 isDragActive
상태는 사용자가 드랍존 위에 드래그 중인지 여부를 자동으로 관리해준다. 이 값은 드래그 상태에 따라 조건부 스타일링에 사용할 수 있다.
<Box
{...getRootProps()}
borderColor={isDragActive ? 'gray.500' : 'gray.500'}
bg={isDragActive ? 'purple.300' : 'gray.600'}
color={isDragActive ? 'gray.800' : 'white'}
borderStyle={isDragActive ? 'dashed' : 'solid'}
>
<input {...getInputProps()} style={{ display: 'none' }} />
{isDragActive ? '파일 첨부하기' : '평범한 채팅창'}
</Box>
직접 구현한 코드에서는 onChangeFiles, handleFilterFile: 파일 선택 또는 드랍 이벤트에서 파일 배열을 수동으로 업데이트하였다.
그러나 react-dropzone
에서는 파일을 처리하는 로직은 onDrop
콜백 내에서 간단히 처리할 수 있다.
const [files, setFiles] = useState([]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles((prevFiles) => [
...prevFiles,
...acceptedFiles.map((file) => ({
id: prevFiles.length + 1,
object: file,
})),
]);
},
});
Chakra-UI와 융합한 전체 코드는 다음과 같다
import { useState } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Box,
VStack,
Text,
Flex,
IconButton,
useColorModeValue,
Heading,
} from '@chakra-ui/react';
import { CloseIcon } from '@chakra-ui/icons';
function DragDropComponent() {
const [files, setFiles] = useState([]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles((prevFiles) => [
...prevFiles,
...acceptedFiles.map((file) => ({
id: prevFiles.length + 1,
object: file,
})),
]);
},
});
const handleRemoveFile = (fileId) => {
setFiles((prevFiles) => prevFiles.filter((file) => file.id !== fileId));
};
const dragBg = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
console.log(isDragActive);
return (
<VStack
width="100%"
height="100vh"
align="center"
justify="center"
bg="gray.800"
>
<Heading>React Dropzone</Heading>
<Box
{...getRootProps()}
p={4}
display="flex"
justifyContent="center"
alignItems="center"
width="400px"
height="200px"
borderWidth="2px"
borderColor={isDragActive ? 'gray.500' : 'gray.500'}
borderRadius="10px"
bg={isDragActive ? 'purple.300' : 'gray.600'}
color={isDragActive ? 'gray.800' : 'white'}
borderStyle={isDragActive ? 'dashed' : 'solid'}
cursor="pointer"
transition="all 0.3s ease"
_hover={{ bg: hoverBg, color: 'black' }}
>
<input {...getInputProps()} style={{ display: 'none' }} />
{isDragActive ? '파일 첨부하기' : '평범한 채팅창'}
</Box>
<VStack marginTop="1rem">
{files.map((file) => (
<Flex
key={file.id}
width="300px"
padding="8px"
borderWidth="1px"
marginBottom="10px"
justify="space-between"
bg={dragBg}
borderStyle="solid"
>
<Text>{file.object.name}</Text>
<IconButton
icon={<CloseIcon />}
onClick={() => handleRemoveFile(file.id)}
aria-label="Remove File"
colorScheme="red"
/>
</Flex>
))}
</VStack>
</VStack>
);
}
export default DragDropComponent;
직접 구현과 라이브러리를 둘 다 사용해보면서 느낀 장단점들은 다음과 같다 :
장점
단점
장점
단점
라이브러리 사용은 개발을 신속하게 진행하고 안정성을 확보하는 데 큰 도움이 되지만, 이외에도 프로젝트에 특화된 맞춤형 솔루션이 필요하거나 불필요한 의존성을 줄이고 싶을 때는 직접 구현하는 것이 더 좋을 것 같다는 생각이 들었다. 특히 이번처럼 학습 목적이 큰 프로젝트에서는 직접 구현을 해보는 것에 큰 의미가 있었다. 직접 구현해보고 라이브러리를 사용해보니 라이브러리의 원리에 대해서도 잘 이해할 수 있는 계기가 되었다.
또한 그동안 드래그앤드롭은 다양한 서비스에서 이용해보았는데 그 이면에 이러한 원리들이 있다는 것을 깨달아서 앞으로 웹 서비스를 이용할때 해당 기능에 어떠한 원리와 구현이 필요한지 탐구해봐야겠다는 생각이 들었다.
MDN Drag and Drop
React JS: Uploading Files with DRAG and DROP API
react-dropzone
https://react-dropzone.js.org/
Drag and dropping files in React using react-dropzone
https://www.youtube.com/watch?v=eGVC8UUqCBE