본문으로 건너뛰기
wooncloud

드롭한 폴더는 0바이트 파일로 온다 — webkitGetAsEntry 로 가려내기

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

·8 min read· views·

드롭한 항목에서 파일과 폴더를 가려낸다

파일 업로드에 드래그 앤 드롭을 붙였다. 사용자가 탐색기에서 폴더를 끌어놓으면 "업로드 실패, 다시 시도해주세요"가 떴다. 폴더 업로드는 아직 만들지 않은 기능인데, 문구는 고장난 것처럼 말하고 있었다.

문의는 이렇게 들어왔다.

당연히 될 거라고 생각했는데 "실패"라고 뜨니까, 되어야 할 게 안 되는 느낌이에요.

폴더도 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 을 정면으로 맞는다

그래서 "폴더를 가려내 안내하기"와 "폴더 업로드 구현하기"는 규모가 다른 일이다. 전자는 하루면 되고 후자는 몇 주가 걸린다. 문의가 온 시점에 필요한 건 대개 전자다.