Drag & Drop 파일 업로드
파일 업로드 UI를 만들 때 가장 기본적인 방법은 <input type="file">을 사용하는 것이다. 클릭해서 파일을 선택하는 방식인데, 사용자 경험 측면에서 한계가 있다. 탐색기를 열고, 폴더를 찾고, 파일을 선택하고, 확인을 눌러야 한다. 파일이 바탕화면이나 다른 앱에 이미 보이는 상태라면 그냥 끌어다 놓는 게 훨씬 직관적이다.
브라우저는 HTML5 Drag and Drop API를 통해 이 기능을 네이티브로 지원한다. 외부에서 파일을 브라우저 창 위로 끌어오면 일련의 드래그 이벤트가 발생하고, 드롭 시점에 파일 데이터에 접근할 수 있다.
드래그 이벤트의 종류
파일을 브라우저 위로 끌어올 때 발생하는 이벤트는 네 가지다.
| 이벤트 | 발생 시점 |
|---|---|
dragenter | 드래그 중인 요소가 대상 영역에 처음 진입할 때 |
dragover | 드래그 중인 요소가 대상 영역 위에 있는 동안 반복 발생 |
dragleave | 드래그 중인 요소가 대상 영역을 벗어날 때 |
drop | 대상 영역에 파일을 놓을 때 |
중요한 점은 dragover가 지속적으로 반복 발생한다는 것이다. 마우스가 움직이지 않아도 수십 밀리초 간격으로 계속 호출된다. 이 이벤트에서 무거운 작업을 하면 성능 문제가 생길 수 있다.
또 하나 중요한 점: dragover와 drop 이벤트 모두에서 preventDefault()를 호출해야 한다. 기본 동작을 막지 않으면 브라우저가 파일을 직접 열어버린다. 이미지를 드롭했는데 새 탭에서 이미지가 열리는 경험은 누구나 한 번쯤 겪어봤을 것이다.
element.addEventListener('dragover', (e) => {
e.preventDefault(); // 이걸 안 하면 drop 이벤트 자체가 발생하지 않음
});
element.addEventListener('drop', (e) => {
e.preventDefault(); // 이걸 안 하면 브라우저가 파일을 직접 열어버림
const files = e.dataTransfer.files;
});
DataTransfer 객체
드래그 이벤트에는 dataTransfer라는 특별한 객체가 포함되어 있다. 드롭된 파일 데이터에 접근하는 핵심 인터페이스다.
element.addEventListener('drop', (e) => {
e.preventDefault();
// FileList 형태로 파일 접근
const files = e.dataTransfer.files;
console.log(files[0].name); // "photo.jpg"
console.log(files[0].type); // "image/jpeg"
console.log(files[0].size); // 2048000
// 또는 items로 접근 (더 다양한 데이터 타입 처리 가능)
const items = e.dataTransfer.items;
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
}
}
});
files와 items의 차이를 알아두면 유용하다. files는 순수 파일만 담고 있는 FileList다. 반면 items는 DataTransferItemList로, 파일뿐 아니라 텍스트, URL 등 다양한 데이터 타입을 처리할 수 있다. 폴더 드롭까지 지원하려면 items를 사용해야 한다.
DataTransfer의 보안 제한
dragenter와 dragover 이벤트에서는 dataTransfer.files에 접근할 수 없다. 파일 정보는 drop 이벤트에서만 읽을 수 있다. 브라우저가 보안상 드래그 중인 파일의 내용을 미리 노출하지 않기 위해서다.
element.addEventListener('dragover', (e) => {
e.preventDefault();
console.log(e.dataTransfer.files.length); // 항상 0
console.log(e.dataTransfer.types); // ["Files"] ← 이건 확인 가능
});
다만 dataTransfer.types로 드래그 중인 데이터가 파일인지 정도는 확인할 수 있다. 파일을 끌어올 때 types에 "Files"가 포함된다.
React에서 구현하기
React에서는 DOM 이벤트를 onDragEnter, onDragOver, onDragLeave, onDrop props로 처리한다. 핵심은 드래그 상태(dragActive)를 관리해서 드롭존의 시각적 피드백을 제공하는 것이다.
기본 드롭존 컴포넌트
import { useCallback, useState } from 'react';
function FileDropzone({ onFileDrop }: { onFileDrop: (files: File[]) => void }) {
const [dragActive, setDragActive] = useState(false);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
onFileDrop(files);
}
}, [onFileDrop]);
return (
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
style={{
border: dragActive ? '2px solid #4A90D9' : '2px dashed #ccc',
background: dragActive ? '#f0f7ff' : 'transparent',
padding: '40px',
textAlign: 'center',
transition: 'all 0.2s ease',
}}
>
{dragActive
? '여기에 놓으세요'
: '파일을 드래그하거나 클릭하여 업로드'}
</div>
);
}
handleDrag 함수 하나로 dragenter, dragover, dragleave 세 이벤트를 모두 처리한다. e.type으로 어떤 이벤트인지 구분해서 dragActive 상태를 토글한다. 이렇게 하면 이벤트 핸들러를 세 개 만들 필요가 없어서 코드가 깔끔해진다.
stopPropagation()을 호출하는 이유는 드래그 이벤트가 상위 요소로 버블링되는 것을 막기 위해서다. 드롭존이 다른 드래그 가능한 요소 안에 있을 때 이벤트 충돌을 방지한다.
dragleave의 함정
위 코드에는 미묘한 버그가 숨어있다. 드롭존 안에 자식 요소(텍스트, 아이콘 등)가 있으면, 자식 요소 위로 마우스가 이동할 때 dragleave가 발생한다. 부모에서 자식으로 진입하면 부모 입장에서는 "나갔다"고 판단하기 때문이다. 그래서 드래그 중인데 하이라이트가 깜빡거리는 현상이 생긴다.
해결 방법은 여러 가지가 있다.
방법 1: 카운터 사용
function FileDropzone({ onFileDrop }: { onFileDrop: (files: File[]) => void }) {
const [dragActive, setDragActive] = useState(false);
const dragCounter = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
setDragActive(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setDragActive(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragActive(false);
// ...
}, []);
// ...
}
dragenter가 발생할 때마다 카운터를 증가시키고, dragleave가 발생할 때마다 감소시킨다. 카운터가 0이 될 때만 진짜로 영역을 벗어난 것이다. 자식 요소로 진입하면 자식의 dragenter가 부모의 dragleave보다 먼저 발생하므로 카운터가 0이 되지 않는다.
방법 2: pointer-events 차단
.dropzone-content {
pointer-events: none;
}
드롭존 내부의 자식 요소에 pointer-events: none을 적용하면 자식 요소가 이벤트를 받지 않으므로 dragleave 깜빡임이 사라진다. 가장 간단한 해결책이지만, 내부에 클릭 가능한 요소가 있으면 그것도 클릭이 안 되는 단점이 있다.
파일 유효성 검증
사용자가 아무 파일이나 드롭할 수 있으므로 유효성 검증은 필수다. 타입, 크기, 개수를 검증하는 유틸리티를 만들어두면 재사용하기 좋다.
interface ValidationOptions {
acceptedTypes?: string[]; // ["image/jpeg", "image/png", "video/mp4"]
maxSize?: number; // 바이트 단위
maxFiles?: number;
}
function validateFiles(files: File[], options: ValidationOptions) {
const errors: string[] = [];
const validFiles: File[] = [];
if (options.maxFiles && files.length > options.maxFiles) {
errors.push(`최대 ${options.maxFiles}개까지 업로드할 수 있습니다.`);
return { validFiles: [], errors };
}
for (const file of files) {
if (options.acceptedTypes && !options.acceptedTypes.includes(file.type)) {
errors.push(`${file.name}: 지원하지 않는 파일 형식입니다.`);
continue;
}
if (options.maxSize && file.size > options.maxSize) {
errors.push(`${file.name}: 파일 크기가 너무 큽니다.`);
continue;
}
validFiles.push(file);
}
return { validFiles, errors };
}
MIME 타입 검증 시 주의할 점이 있다. file.type은 파일 확장자 기반으로 결정되기 때문에, .jpg를 .txt로 바꾸면 text/plain으로 인식된다. 보안이 중요한 경우에는 서버에서 파일 시그니처(매직 바이트)를 추가로 확인해야 한다.
클릭 업로드와 병행
드래그 앤 드롭만 지원하면 모바일 환경이나 파일 탐색이 필요한 경우에 불편하다. 보통 드롭존을 클릭하면 파일 선택 다이얼로그가 열리도록 같이 구현한다.
function FileDropzone({ onFileDrop }: { onFileDrop: (files: File[]) => void }) {
const [dragActive, setDragActive] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.click();
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onFileDrop(Array.from(files));
}
// 같은 파일을 다시 선택할 수 있도록 값 초기화
e.target.value = '';
};
// ... 드래그 핸들러들
return (
<div
onClick={handleClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
className={cn(
'cursor-pointer rounded-lg border p-6 transition-colors',
dragActive
? 'border-blue-500 bg-blue-50'
: 'border-dashed border-gray-300 hover:border-gray-400',
)}
>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept="image/*,video/*,.pdf"
/>
<p>파일을 드래그하거나 클릭하여 업로드</p>
</div>
);
}
핵심 트릭은 <input type="file">을 숨겨두고(hidden), 드롭존 클릭 시 inputRef.current?.click()으로 프로그래밍적으로 파일 선택 다이얼로그를 연다. 이렇게 하면 하나의 UI로 드래그와 클릭 두 가지 방식을 모두 지원할 수 있다.
e.target.value = ''로 초기화하는 것도 중요하다. 이걸 안 하면 같은 파일을 다시 선택했을 때 onChange가 발생하지 않는다. 값이 변하지 않았다고 판단하기 때문이다.
input의 accept 속성 vs 수동 검증
<input accept="image/*">를 설정하면 파일 선택 다이얼로그에서 해당 타입만 보여준다. 하지만 이것만으로는 충분하지 않다.
- 드래그 앤 드롭으로 파일을 놓을 때는
accept속성이 적용되지 않는다 - 사용자가 파일 선택 다이얼로그에서 "모든 파일"로 필터를 바꿀 수 있다
- 브라우저마다
accept해석이 다를 수 있다
따라서 accept는 사용자 편의를 위한 힌트이고, 실제 유효성 검증은 JavaScript에서 별도로 해야 한다.
업로드 진행률 표시
파일을 드롭한 후 서버에 업로드하는 과정에서 진행률을 표시하려면 XMLHttpRequest의 upload.onprogress를 사용한다. fetch는 아직 업로드 진행률을 네이티브로 지원하지 않기 때문이다.
function uploadFile(
file: File,
url: string,
onProgress: (percent: number) => void
): Promise<Response> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent);
}
});
xhr.addEventListener('load', () => {
resolve(new Response(xhr.response, { status: xhr.status }));
});
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
xhr.open('POST', url);
xhr.send(formData);
});
}
e.lengthComputable을 체크하는 이유는 서버 설정에 따라 전체 크기를 알 수 없는 경우가 있기 때문이다. 이 경우 진행률 대신 스피너나 "업로드 중..." 메시지를 보여주는 것이 적절하다.
전체 페이지 드롭존
특정 영역이 아니라 페이지 전체를 드롭존으로 만들고 싶을 때가 있다. 파일을 브라우저 창 어디에 놓든 업로드가 시작되게 하는 패턴이다.
function useGlobalDropzone(onFileDrop: (files: File[]) => void) {
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);
useEffect(() => {
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
dragCounter.current++;
if (e.dataTransfer?.types.includes('Files')) {
setIsDragging(true);
}
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
dragCounter.current = 0;
setIsDragging(false);
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
onFileDrop(Array.from(files));
}
};
document.addEventListener('dragenter', handleDragEnter);
document.addEventListener('dragleave', handleDragLeave);
document.addEventListener('dragover', handleDragOver);
document.addEventListener('drop', handleDrop);
return () => {
document.removeEventListener('dragenter', handleDragEnter);
document.removeEventListener('dragleave', handleDragLeave);
document.removeEventListener('dragover', handleDragOver);
document.removeEventListener('drop', handleDrop);
};
}, [onFileDrop]);
return isDragging;
}
이 훅을 사용하면 파일이 브라우저 위로 드래그될 때 isDragging이 true가 되므로, 전체 화면에 오버레이를 띄워서 "여기에 놓으세요"라는 안내를 표시할 수 있다. dataTransfer.types.includes('Files')로 파일 드래그인지 확인해서, DOM 요소를 드래그하는 것과 구분한다.
커스텀 훅으로 추상화
드롭존 로직을 재사용 가능한 커스텀 훅으로 분리하면 여러 컴포넌트에서 활용할 수 있다.
interface UseDropzoneOptions {
onDrop: (files: File[]) => void;
accept?: string[];
maxSize?: number;
maxFiles?: number;
disabled?: boolean;
}
function useDropzone({
onDrop,
accept,
maxSize,
maxFiles,
disabled = false,
}: UseDropzoneOptions) {
const [isDragActive, setIsDragActive] = useState(false);
const dragCounter = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
dragCounter.current++;
setIsDragActive(true);
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragActive(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setIsDragActive(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
const { validFiles } = validateFiles(files, {
acceptedTypes: accept,
maxSize,
maxFiles,
});
if (validFiles.length > 0) {
onDrop(validFiles);
}
}, [onDrop, accept, maxSize, maxFiles, disabled]);
const getDropzoneProps = useCallback(() => ({
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
}), [handleDragEnter, handleDragLeave, handleDragOver, handleDrop]);
return {
isDragActive,
getDropzoneProps,
};
}
getDropzoneProps 패턴을 사용하면 드롭존 컴포넌트에서 이벤트 핸들러를 스프레드로 간단하게 적용할 수 있다.
function UploadArea() {
const { isDragActive, getDropzoneProps } = useDropzone({
onDrop: (files) => uploadFiles(files),
accept: ['image/jpeg', 'image/png'],
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
});
return (
<div {...getDropzoneProps()}>
{isDragActive ? '놓으세요!' : '드래그 또는 클릭'}
</div>
);
}
모바일 환경 고려
모바일 브라우저에서는 드래그 앤 드롭이 지원되지 않는다. 터치 기반이기 때문에 파일을 "끌어서 놓는" 동작 자체가 불가능하다. 따라서 반드시 클릭 기반 파일 선택을 함께 제공해야 한다. 드롭존에 "탭하여 파일 선택" 같은 안내 문구를 보여주는 것도 좋다.
@media (hover: hover)로 호버 가능한 환경(데스크톱)에서만 드래그 관련 안내를 표시하는 것도 방법이다.
.drag-hint {
display: none;
}
@media (hover: hover) {
.drag-hint {
display: block;
}
}
왜 직접 구현인가
react-dropzone이라는 라이브러리가 있다. useDropzone 훅을 제공하고, 파일 유효성 검증, 다중 파일, 디렉토리 업로드까지 지원한다. 빠르게 구현해야 한다면 좋은 선택이다. 하지만 HTML5 Drag and Drop API 자체가 단순한 편이라, 직접 구현해도 코드량이 크게 늘지 않는다. 드롭존 스타일이나 유효성 검증 로직을 프로젝트에 맞게 세밀하게 제어하고 싶다면 직접 만드는 게 오히려 편하다. 번들 사이즈도 줄일 수 있고, dragleave 깜빡임 같은 엣지 케이스를 이해하고 있으면 유지보수도 어렵지 않다.
정리
- HTML5 Drag and Drop API는 네 가지 이벤트(dragenter, dragover, dragleave, drop)로 구성되며, dragover와 drop 모두에서 preventDefault()를 호출해야 브라우저 기본 동작을 막을 수 있다
- dragleave 깜빡임은 카운터 패턴이나 pointer-events: none으로 해결하고, 모바일에서는 드래그가 불가능하므로 클릭 업로드를 반드시 병행해야 한다
- 파일 유효성 검증은 accept 속성이 아닌 JavaScript에서 별도로 처리해야 하며, 업로드 진행률은 fetch 대신 XMLHttpRequest의 upload.onprogress를 사용한다