드롭한 폴더는 0바이트 파일로 온다 — webkitGetAsEntry 로 가려내기
드래그 앤 드롭 업로드 시 폴더를 파일로 인식해 발생하는 오류를 webkitGetAsEntry API로 해결하는 방법과 사용자 경험을 개선하는 UX 전략을 공유합니다

파일 업로드에 드래그 앤 드롭을 붙였다. 사용자가 탐색기에서 폴더를 끌어놓으면 "업로드 실패, 다시 시도해주세요"가 떴다. 폴더 업로드는 아직 만들지 않은 기능인데, 문구는 고장난 것처럼 말하고 있었다.
문의는 이렇게 들어왔다.
당연히 될 거라고 생각했는데 "실패"라고 뜨니까, 되어야 할 게 안 되는 느낌이에요.
폴더도 File 객체로 온다
drop 이벤트에서 흔히 이렇게 받는다.
element.addEventListener("drop", (e) => {
if (e.dataTransfer.files.length > 0) upload(e.dataTransfer.files);
});문제는 브라우저가 드롭한 폴더도 dataTransfer.files 에 넣어 준다는 것이다. size: 0, type: "" 인 File 객체로.
// 폴더 하나를 끌어놓았을 때
e.dataTransfer.files[0];
// File { name: "사진첩", size: 0, type: "" }파일과 구분할 필드가 없다. 그래서 폴더도 그대로 전송되고, 서버가 거절하고, 화면에는 실패 문구만 남는다.
크기로 거르는 휴리스틱을 먼저 떠올리게 되는데 쓸 수 없다. size === 0 은 진짜 빈 파일도 함께 막고, type === "" 은 확장자 없는 파일에서도 그렇다. 추측이 아니라 확정 판정이 필요하다.
webkitGetAsEntry 로 가려낸다
DataTransferItem.webkitGetAsEntry() 가 그 판정을 준다. webkit 접두어가 붙어 있지만 File and Directory Entries API 로 표준화됐고 주요 브라우저가 모두 지원한다.
interface DroppedItems {
files: File[];
folderCount: number;
}
function splitDroppedItems(dt: DataTransfer): DroppedItems {
const items = dt.items ? Array.from(dt.items) : [];
// 미지원 환경 — 폴더를 가려낼 수단이 없으므로 기존 동작으로 폴백
if (!items.some((i) => typeof i.webkitGetAsEntry === "function")) {
return { files: Array.from(dt.files), folderCount: 0 };
}
const files: File[] = [];
let folderCount = 0;
for (const item of items) {
if (item.kind !== "file") continue;
if (item.webkitGetAsEntry()?.isDirectory) {
folderCount++;
continue;
}
const file = item.getAsFile();
if (file) files.push(file);
}
return { files, folderCount };
}여기서 지켜야 할 게 세 가지 있다.
files 가 아니라 items 를 순회한다
폴더 여부는 DataTransferItem 의 entry 에만 있다. files 를 아무리 들여다봐도 답이 없다.
drop 핸들러 안에서 동기로 호출한다
가장 놓치기 쉬운 지점이다. DataTransferItemList 는 이벤트 처리가 끝나면 무효화된다. await 를 하나만 끼워도 webkitGetAsEntry() 가 null 을 돌려주기 시작한다.
// 이렇게 하면 안 된다
element.addEventListener("drop", async (e) => {
const ok = await checkQuota(); // 여기서 items 가 죽는다
const dropped = splitDroppedItems(e.dataTransfer); // 폴더가 파일로 분류됨
});무서운 건 예외가 안 난다는 것이다. null 은 "디렉터리가 아님"으로 읽혀서 폴더가 조용히 일반 파일로 분류되고, 원래 증상으로 그대로 돌아간다. 판정이 필요한 것은 먼저 동기로 뽑아 두고, 비동기 작업은 그 뒤에 한다.
판정 불가를 차단으로 바꾸지 않는다
webkitGetAsEntry 가 없는 환경에서는 폴더를 가려낼 수단이 없다. 그럴 때는 기존 동작(files 그대로 사용)으로 폴백한다. 못 가려낸다고 업로드를 막으면 멀쩡한 파일까지 못 올린다. 판정 실패는 "모른다"이지 "위험하다"가 아니다.
문구를 두 갈래로 나눈 이유
기술 판정이 끝나고 나면 UX 결정이 하나 남는다. 폴더가 섞였을 때 어떻게 할 것인가.
| 드롭한 것 | 동작 | 강도 |
|---|---|---|
| 폴더만 | 전송하지 않고 압축 안내 | error |
| 폴더 + 파일 | 파일은 그대로 올리고 폴더만 안내 | warning |
| 파일만 | 기존 경로 그대로 | — |
섞였다고 통째로 막지 않는다. 여러 개를 한 번에 끌어놓는 건 흔한 동작이라, 폴더 하나 섞였다고 전부 거부하면 함께 놓은 파일까지 사라져 더 혼란스럽다.
그리고 문구에서 "다시 시도해주세요"를 뺐다.
❌ 업로드 실패. 다시 시도해주세요
✅ 폴더는 업로드할 수 없습니다. 폴더를 압축(zip)한 뒤 올려주세요
미지원 기능을 실패로 안내하면 사용자는 재시도를 반복한다. 같은 제약이라도 "지원하지 않으니 이렇게 하세요"라고 말하면 정책으로 읽힌다. 기능을 만들지 않고도 문의가 사라지는 지점이 여기다.
폴더 업로드를 진짜 구현하려면
같은 API 로 트리를 순회할 수 있다. FileSystemDirectoryEntry.createReader().readEntries() 를 재귀 호출하면 된다.
async function readAll(reader: FileSystemDirectoryReader) {
const out: FileSystemEntry[] = [];
// readEntries 는 한 번에 최대 100개만 준다 — 빈 배열이 올 때까지 반복
while (true) {
const batch = await new Promise<FileSystemEntry[]>((res, rej) =>
reader.readEntries(res, rej),
);
if (batch.length === 0) break;
out.push(...batch);
}
return out;
}readEntries() 가 한 번에 최대 100개만 돌려준다는 게 함정이다. 한 번만 부르고 끝내면 101번째부터 조용히 사라진다.
버튼 경로는 훨씬 쉽다. webkitdirectory 속성 하나면 되고, File.webkitRelativePath 에 상대 경로가 담겨 온다.
<input type="file" webkitdirectory multiple />정작 비싼 쪽은 프론트가 아니다.
- API 계약 — 경로를 받아 폴더를 만들어 가며 올리는 엔드포인트가 필요하다. 보통은 부모 ID 하나만 받게 되어 있다
- 충돌 정책 — 같은 이름의 폴더가 이미 있으면 병합인가 새로 만들기인가. 파일 단위 충돌과는 다른 질문이다
- 부분 실패 — 300개 중 50개가 실패하면 폴더는 이미 만들어져 있다. 재시도가 폴더를 다시 만들지 않도록 경로→ID 매핑을 들고 있어야 한다
- 요청 수 — 폴더 업로드는 수백~수천 개가 기본값이라 rate limit 을 정면으로 맞는다
그래서 "폴더를 가려내 안내하기"와 "폴더 업로드 구현하기"는 규모가 다른 일이다. 전자는 하루면 되고 후자는 몇 주가 걸린다. 문의가 온 시점에 필요한 건 대개 전자다.