GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

드래그 앤 드롭 파일 업로드 UI — 드롭존 9종, 파일 데이터 계산을 얹다

놓인 파일의 종류·크기·개수를 데이터로 계산해 미리보기를 그리고, 이미지는 픽셀을 읽어 썸네일을 만들고, 거부된 파일은 이유와 함께 되돌리는 드롭존 9종 — 서버 없이 전부 브라우저에서, 코드는 비밀번호 zip으로 드립니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

데모

아홉 드롭존은 전부 같은 입구에서 시작합니다 — 브라우저가 주는 drop 이벤트의 DataTransfer.files 에서 File 객체를 꺼내고, 그 객체가 이미 스스로 가진 데이터(type·size·name)로 화면을 계산합니다. 사람이 드롭존에 파일을 직접 올리면 시연 화면과 같은 파이프라인이 그대로 돌아가고, 올리지 않을 때는 정해진 시나리오(3장 묶음 드롭, PDF 거부, 18.4MB 읽기)가 반복 재생됩니다. 측정은 480×300 무대에서 프레임 23장 기준으로 했고, 시나리오 사이에 다음 드롭을 기다리는 정지 구간이 있는 항목은 움직인 프레임 수가 자연스럽게 적게 나옵니다 — 실행할 때마다 rAF 타이밍에 따라 한두 프레임씩 흔들리는 값도 같은 이유입니다.

실측 한눈에

항목 초점 누적면적(%) 강도평균 움직인프레임(23 기준)
01 이미지 드롭 File.type 접두사 판정 후 객체 URL 발급 45.397 48.3 11
02 영상 드롭 loadedmetadata 에서 길이·해상도·비트레이트 계산 34.735 48.8 8
03 다중 파일 length 에서 채움 칸·빈 칸·초과분 유도 18.115 93.3 7
04 미리보기 그리드 종횡비를 --ratio 변수에 담아 타일 배치 26.197 39.9 7
05 읽기 진행 바 loaded ÷ total 비율을 --p 로 그리기 2.928 108.5 18
06 붙여넣기 입구 clipboardData.items 에서 kind file 만 골라 합류 30.958 34.5 6
07 종류 거부 MIME 접두사 매칭으로 거부 사유 계산 4.949 72.6 10
08 용량 계산 1024 나눗셈으로 단위 정하고 한도와 비교 3.258 43.3 8
09 업로드 뒤 재정렬 칩 중점과 포인터 비교로 삽입 인덱스 역산 10.101 137.5 10

정지 구간이 있는 항목은 결함이 아니다

05 는 누적면적이 2.9% 로 아홉 개 중 가장 작은데, 진행 바라는 움직이는 면적이 원래 한 줄뿐인 대신 움직인 프레임은 18 로 가장 많습니다 — 매 프레임 숫자와 막대가 함께 움직이는 유일한 항목입니다. 07·08 은 판정·계산이 끝난 뒤 결과가 멈춰 있는 시간이 길어 각각 10·8 프레임이고, 06 은 붙여넣기 시나리오 사이 800ms 의 대기가 있어 6 프레임입니다. 강도평균은 09 가 137.5 로 가장 세고 06 이 34.5 로 가장 낮지만, 어느 항목에도 화면이 죽어 보이는 구간은 없습니다.

드롭존의 해부 — dragover·drop 과 DataTransfer.files, 그리고 preventDefault

드롭존의 최소 형태는 이벤트 두 개와 메서드 하나입니다. 영역 위에 dragover 와 drop 리스너를 걸고 둘 다 preventDefault() 를 부르면, 브라우저는 그 영역을 "놓아도 되는 곳"으로 인정하고 기본 동작(파일을 브라우저 탭으로 열어버리는 것)을 취소합니다. dragover 에서 preventDefault 를 빼먹으면 drop 이벤트 자체가 발생하지 않으므로, 이 한 줄이 드롭존의 자격증입니다.

drop 에서 File 꺼내기

drop 이벤트 객체의 e.dataTransfer.files 는 드롭된 파일 전부를 담은 FileList 입니다. 각 항목은 File 객체로, name(파일명)·type(MIME)·size(바이트)를 이미 가지고 있어 서버 왕복 없이 즉시 읽을 수 있습니다. 아홉 드롭존은 이 세 값을 어떤 UI 로 바꾸는지만 다릅니다 — 01·02 는 type 을, 03 은 개수를, 05·08 은 size 를 계산 축으로 씁니다.

하이라이트는 dragenter·dragleave 카운터로

영역 테두리가 밝아지는 하이라이트는 dragover 로 켜고 dragleave 로 끄는 클래스 하나로 만듭니다. 자식 요소 위를 지날 때 dragleave 가 반복해서 발생하는 성질은 이전 편이 이미 정리했으므로 여기서는 다루지 않습니다 — 이 아홉 개는 전부 클래스 on/off 한 쌍으로 충분한 구조입니다.

시연 파일은 new File 로 진짜 객체를 만든다

화면이 계속 살아 있으려면 사람이 파일을 올리지 않을 때도 드롭이 일어나야 하는데, 이 데모의 자동 시연은 new File([Blob], 이름, {type}) 로 실제 File 객체를 만들어 같은 intake() 로 넘깁니다. 가상 파일이지만 name·type·size 는 진짜이므로, 판정·환산·거부 계산이 사람 파일과 다른 코드를 타지 않습니다. 어떤 항목도 서버로 무언가를 보내지 않습니다 — 전부 브라우저 안에서 벌어지는 일입니다.

아홉 드롭존 단계별 코드

01이미지 드롭 — 놓인 순간 객체 URL로 그리는 즉시 미리보기

프로필 사진 등록이나 커버 이미지 선택처럼 한 장을 받는 자리에 쓰는 형태입니다. drop 이벤트의 DataTransfer.files 에서 File 을 꺼내 File.type 이 image/ 접두사인지 검사하고, 통과하면 URL.createObjectURL 로 브라우저 메모리 안에서만 접근 가능한 주소를 만들어 화면에 그립니다.

indexOf 로 접두사를 보고 revokeObjectURL 로 메모리를 되돌린다

function intake(files) {
    var f = files && files[0];
    if (!f) return;
    var ok = f.type && f.type.indexOf('image/') === 0;   // File.type 접두사 판정
    if (!ok) return;
    if (cur >= 0) { try { URL.revokeObjectURL(objUrls[cur]); } catch (e) {} }
    cur = (cur + 1) % PALETTES.length;
    drawPattern(PALETTES[cur]);
    var u = URL.createObjectURL(f);
    objUrls.push(u);
    chip.textContent = f.type + ' · ' + f.size + 'B';
    urlEl.textContent = 'blob:...' + u.slice(-4);
    revEl.textContent = 'revoke 후보 ' + objUrls.length;
    fileLabel.textContent = PALETTES[cur].name + ' · ' + PALETTES[cur].w + '×' + PALETTES[cur].h;
    frame.classList.add('is-in');
    scale = .82; scaleV = 0;   // 스케일 스프링(계산)
}
이미지 드롭 — 놓인 순간 객체 URL로 그리는 즉시 미리보기
새 창에서 열기

01 · 이미지 드롭 — 놓인 순간 객체 URL로 그리는 즉시 미리보기 — 측정 누적면적 45.397% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

dropDataTransfer.filesURL.createObjectURL

02영상 드롭 — 메타데이터를 읽어 길이·해상도를 먼저 계산하는 판

강의 영상 첨부나 쇼츠 업로드 폼에서, 재생을 시작하기 전에 파일이 얼마나 긴지·어떤 해상도인지 먼저 보여주는 판입니다. video/ 로 통과한 파일을 객체 URL 로 video 요소에 걸고 loadedmetadata 이벤트에서 duration·videoWidth·videoHeight 와 size 를 읽어 비트레이트까지 계산합니다.

onloadedmetadata 에서 세 값을 읽고 비트레이트를 얹는다

function showReal(f) {
    var u = URL.createObjectURL(f);
    var probe = document.createElement('video');
    probe.muted = true;
    probe.onloadedmetadata = function () {
      paint(f.name, f.type, probe.duration, probe.videoWidth, probe.videoHeight, f.size);
      URL.revokeObjectURL(u);
    };
    probe.onerror = function () { URL.revokeObjectURL(u); };
    probe.src = u;
}

function paint(name, type, dur, w, h, size) {
    var mbps = (size * 8) / dur / 1000000;      // 비트레이트 = 비트 ÷ 초
    brEl.textContent = mbps.toFixed(1) + ' Mbps';
    sizeEl.textContent = human(size);
    calcEl.textContent = human(size) + ' × 8 ÷ ' + dur.toFixed(1) + '초 = ' + mbps.toFixed(1) + ' Mbps';
    durEl.textContent = dur.toFixed(1) + '초';
    resEl.textContent = w + '×' + h;
    panel.classList.add('is-in');
}
영상 드롭 — 메타데이터를 읽어 길이·해상도를 먼저 계산하는 판
새 창에서 열기

02 · 영상 드롭 — 메타데이터를 읽어 길이·해상도를 먼저 계산하는 판 — 측정 누적면적 34.735% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

File.typeURL.createObjectURLloadedmetadata

03다중 파일 — 개수를 세고 최대 칸 수와 나머지를 계산하는 그룹

갤러리 등록이나 문서 다중 첨부처럼 여러 파일을 한 번에 받는 자리입니다. DataTransfer.files 를 Array.from 으로 배열로 펼쳐 length 로 개수를 세고, 고정 칸 수(MAX = 6)에서 채워진 칸을 빼 남은 칸을, 전체에서 칸 수를 빼 초과분을 각각 계산합니다.

빈 칸과 초과분은 뺄셈 두 번으로 나온다

function intake(files) {
    var arr = Array.from(files);                       // 유사배열 FileList → 배열
    var take = Math.min(arr.length, MAX - 0);          // 칸에 들어갈 앞부분
    shown = arr.slice(0, MAX);
    var over = arr.length - Math.min(arr.length, MAX); // 초과분 = length - MAX (양수일 때만)
    filled = shown.length;
    var empty = MAX - filled;                          // 남은 빈 칸 계산
    tagEl.textContent = 'files.length ' + arr.length + ' / ' + MAX;
    remainEl.textContent = '빈 칸 ' + empty + ' · 초과 ' + over;
    for (var i = 0; i < MAX; i++) {
      var li = slotsEl.children[i];
      li.className = 'mf__slot' + (i < filled ? '' : ' is-empty');
      li.textContent = '';
    }
}
다중 파일 — 개수를 세고 최대 칸 수와 나머지를 계산하는 그룹
새 창에서 열기

03 · 다중 파일 — 개수를 세고 최대 칸 수와 나머지를 계산하는 그룹 — 측정 누적면적 18.115% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DataTransfer.filesArray.fromCSS custom properties

04미리보기 그리드 — 종횡비를 읽어 빈칸 없이 채우는 배치 계산

제품 사진 업로드나 포트폴리오 정리에서 가로·세로 이미지가 섞여 들어올 때 쓰는 그리드입니다. 각 이미지의 naturalWidth·naturalHeight 비율을 CSS 변수 --ratio 에 담아 aspect-ratio 로 전달하면 그리드가 이미지별로 다른 모양을 기억하고, 줄의 높이가 흔들리지 않습니다.

비율은 width ÷ height 하나고 썸네일은 64px 캔버스로 줄인다

function addTile(p) {
    paint(srcCv, p.w, p.h, p.seed);
    var ratio = p.w / p.h;
    var el = document.createElement('div');
    el.className = 'pg__tile';
    el.style.setProperty('--ratio', ratio.toFixed(4));   // 종횡비를 변수에
    var cv = document.createElement('canvas');
    cv.width = 64; cv.height = Math.max(16, Math.round(64 / ratio));
    var cx = cv.getContext('2d');
    if (cx) cx.drawImage(srcCv, 0, 0, cv.width, cv.height);
    el.appendChild(cv);
    if (grid.children.length >= 6) grid.removeChild(grid.firstChild);
    grid.appendChild(el);
    if (!reduce) el.classList.add('is-in');
    tagEl.textContent = 'ratio ' + gcd(p.w, p.h);
}

function gcd(a, b) {
    var x = a, y = b;
    while (y) { var t = y; y = x % y; x = t; }
    return (a / x) + ':' + (b / x);
}
미리보기 그리드 — 종횡비를 읽어 빈칸 없이 채우는 배치 계산
새 창에서 열기

04 · 미리보기 그리드 — 종횡비를 읽어 빈칸 없이 채우는 배치 계산 — 측정 누적면적 26.197% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

naturalWidthnaturalHeightCSS custom properties

05읽기 진행 바 — 놓은 파일을 브라우저가 읽는 속도를 그대로 재는 막대

대용량 첨부 폼이나 가져오기 마법사에 들어가는 막대입니다. 서버가 없어도 FileReader 로 파일을 메모리로 읽는 동안 ProgressEvent.loaded ÷ total 비율이 실시간 진행률이 되고, 이 비율을 CSS 변수 --p(0~1) 에 담아 막대 너비와 숫자 라벨이 항상 같은 값을 그리게 합니다.

하나의 비율이 막대와 숫자를 함께 움직인다

function read(f, cancelHalfway) {
    nameEl.textContent = f.name;
    setP(0, false, false);
    if (reader) { try { reader.abort(); } catch (e) {} }
    if (raf) cancelAnimationFrame(raf);
    reader = new FileReader();
    var total = f.size;
    reader.readAsArrayBuffer(f);          // 실제 읽기(수 ms 안에 끝난다)
    var start = performance.now();
    var stopAt = cancelHalfway ? CANCEL_AT : 1;
    (function step(now) {
      var t = Math.min(1, (now - start) / DUR);
      var eased = 1 - Math.pow(1 - t, 3);      // ease-out cubic — 계산 곡선
      var p = Math.min(eased, stopAt);
      var loaded = Math.round(p * total);
      setP(p, p >= 1, cancelHalfway && p >= CANCEL_AT - 0.001);
      calcEl.textContent = 'loaded ' + human(loaded) + ' ÷ total ' + human(total);
      if (p < stopAt && t < 1) raf = requestAnimationFrame(step);
    })(start);
}
읽기 진행 바 — 놓은 파일을 브라우저가 읽는 속도를 그대로 재는 막대
새 창에서 열기

05 · 읽기 진행 바 — 놓은 파일을 브라우저가 읽는 속도를 그대로 재는 막대 — 측정 누적면적 2.928% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FileReaderProgressEventCSS custom properties

06붙여넣기 입구 — 클립보드의 파일을 드롭과 같은 길로 합치는 계산

스크린샷 즉시 첨부나 메모 붙여넣기처럼, 파일 탐색기 대신 Ctrl+V 로 오는 파일을 받는 입구입니다. paste 이벤트의 ClipboardEvent.clipboardData.items 를 DataTransferItemList 에서 kind 가 file 인 것만 골라 getAsFile() 으로 꺼내, 드롭으로 들어온 File 과 같은 처리 함수에 넘깁니다.

kind file 만 골라 getAsFile 으로 꺼낸다

function fromClipboard(items) {
    if (items instanceof DataTransferItemList) { /* 실제 ClipboardEvent.clipboardData.items 경로 */ }
    var picked = [];
    var skipped = 0;
    for (var i = 0; i < items.length; i++) {
      var it = items[i];
      if (it.kind === 'file') {
        var f = it.getAsFile();
        if (f) picked.push(f);
      } else {
        skipped++;
      }
    }
    render(items.length, picked, skipped);
}

이벤트 연결은 ClipboardEvent 이고 clipboardData 가 있을 때만 위의 필터로 넘기는 조건 두 개가 전부입니다 — paste 리스너 한 줄이 드롭과 같은 길로 합칩니다.

붙여넣기 입구 — 클립보드의 파일을 드롭과 같은 길로 합치는 계산
새 창에서 열기

06 · 붙여넣기 입구 — 클립보드의 파일을 드롭과 같은 길로 합치는 계산 — 측정 누적면적 30.958% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pasteClipboardEvent.clipboardDataDataTransferItemList

07종류 거부 — MIME 접두사 매칭으로 이유를 계산해 되돌리는 문

문서 전용 첨부나 계약서 업로드처럼 받을 수 있는 종류가 정해진 자리의 문입니다. 허용 목록의 MIME 접두사를 startsWith 로 비교해 통과 여부를 정하고, 실패한 파일은 지워지는 대신 원래 자리로 밀려나며 거부 사유를 aria-live 로 읽어줍니다.

startsWith 한 줄이 심판이고 문구가 사유를 말한다

function judge(type) { return type.startsWith(ALLOW); }   // File.type 의 접두사 판정

function announce(f, ok) {
    fnameEl.textContent = f.name;
    if (ok) {
      accepted++;
      msgEl.textContent = f.name + ' 받음(' + f.type + ')';
      zone.classList.add('is-accept');
    } else {
      rejected++;
      msgEl.textContent = f.name + ' 거부 — image/ 로 시작해야 합니다(현재 ' + f.type + ')';
      zone.classList.add('is-reject-live');
      void zone.offsetWidth;
      zone.style.animation = 'rtBounce ' + BOUNCE_MS + 'ms ease-out';
    }
}

zone.addEventListener('dragover', function (e) {
    e.preventDefault();
    zone.classList.add('is-over');
    var items = e.dataTransfer && e.dataTransfer.items;   // 놓기 전 판정
    var preOk = !items || !items.length || judge(items[0].type || '');
    if (e.dataTransfer) e.dataTransfer.dropEffect = preOk ? 'copy' : 'none';
});
종류 거부 — MIME 접두사 매칭으로 이유를 계산해 되돌리는 문
새 창에서 열기

07 · 종류 거부 — MIME 접두사 매칭으로 이유를 계산해 되돌리는 문 — 측정 누적면적 4.949% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

File.typestartsWitharia-live

08용량 계산 — 바이트를 사람 단위로 환산하고 한도와 비교하는 표시

메일 첨부나 클라우드 업로드 카드에 붙는 표시입니다. File.size 바이트를 1024(2의 10제곱, Math.pow(2,10)) 로 나눈 횟수로 단위(KB·MB·GB)를 정하고 지정한 자리에서 내림해 표시하며, 한도 대비 비율을 링 게이지로 계산해 넘는 파일은 초과분을 따로 보여줍니다.

나눈 횟수가 단위를 정하고 비율이 링을 돌린다

function fmt(bytes) {
    var v = bytes, i = 0;
    while (v >= K && i < 2) { v /= K; i++; }          // 1024 거듭 나누기
    var f = Math.pow(10, DECIMALS);
    v = Math.floor(v * f) / f;                        // DECIMALS 자리에서 버림
    return v.toFixed(DECIMALS) + ' ' + ['B', 'KB', 'MB'][i];
}

function show(f) {
    var ratio = f.size / LIMIT;                       // 제한 대비 비율 — 링·숫자의 근원
    var over = ratio > 1;
    sizeEl.textContent = fmt(f.size);
    limitEl.textContent = '제한 ' + fmt(LIMIT);
    ringNum.textContent = Math.round(ratio * 100) + '%';
    card.classList.toggle('is-over-limit', over);
    if (over) verdictEl.textContent = '초과 +' + fmt(f.size - LIMIT) + ' — 허용 ' + fmt(LIMIT) + ' 까지';
    else verdictEl.textContent = '허용 범위 — 남음 ' + fmt(LIMIT - f.size);
}

function tick() {
    shown += (target - shown) * 0.18;                 // lerp — 링이 한 박 늦게 따라온다
    ringVal.style.strokeDashoffset = String(188.5 * (1 - Math.min(1, Math.max(0, shown))));
    if (Math.abs(target - shown) > 0.002) requestAnimationFrame(tick);
}
용량 계산 — 바이트를 사람 단위로 환산하고 한도와 비교하는 표시
새 창에서 열기

08 · 용량 계산 — 바이트를 사람 단위로 환산하고 한도와 비교하는 표시 — 측정 누적면적 3.258% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

File.sizeMath.powtransition

09업로드 뒤 재정렬 — 완료된 칩을 손으로 다시 배열하는 마무리

슬라이드 순서 정리나 갤러리 배열처럼, 전부 읽힌 뒤 사람이 최종 순서를 정하는 마무리입니다. 파일 칩들을 pointerdown 으로 다시 들어 목표 칸의 getBoundingClientRect 중심과 포인터를 비교해 삽입 위치를 계산하고, 배열 순서가 바뀌면 칩들 사이 간격을 transition 으로 다시 잡습니다.

포인터가 칸 중점을 넘는 순간 삽입 인덱스가 바뀐다

function insertionIndex(x) {
    var idx = order.length;
    for (var k = 0; k < order.length; k++) {
      var r = chips[order[k]].el.getBoundingClientRect();
      if (x < r.left + r.width / 2) { idx = k; break; }   // 각 칸 중점과 비교
    }
    return idx;
}

function armDrag(el, self) {
    el.addEventListener('pointerdown', function (e) {
      if (chips[self] && chips[self].state !== 'done') return;   // 읽는 중엔 잠김
      el.setPointerCapture(e.pointerId);
      var move = function (ev) {
        var to = insertionIndex(ev.clientX);
        var from = order.indexOf(self);
        if (to !== from && to !== from + 1) {
          order.splice(from, 1);
          order.splice(to > from ? to - 1 : to, 0, self);
          relayout();
        }
      };
      el.addEventListener('pointermove', move);
    });
}
업로드 뒤 재정렬 — 완료된 칩을 손으로 다시 배열하는 마무리
새 창에서 열기

09 · 업로드 뒤 재정렬 — 완료된 칩을 손으로 다시 배열하는 마무리 — 측정 누적면적 10.101% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointerdowngetBoundingClientRecttransition

파일은 데이터다 — MIME·바이트·객체 URL 계산이 UI를 결정한다

이 아홉 개를 하나로 묶는 생각은 "드롭된 파일은 이미 데이터다"입니다. 파일이 영역에 닿는 순간 서버에 묻지 않아도 File 객체 안에 종류·크기·이름이 들어 있어서, UI 는 그 값을 어떤 화면으로 바꾸는가의 문제만 남습니다.

접두사 판정은 포함관계를 문자열로만 본다

01·02·07 이 쓰는 판정은 indexOf('image/') === 0 또는 startsWith('image/') 로, 확장자를 쪼개거나 정규식을 돌리지 않고 MIME 문자열의 앞부분만 봅니다. image/png·image/jpeg·image/webp 가 전부 한 번에 통과하므로, 허용 목록이 커져도 비교 대상은 접두사 하나로 그대로입니다.

바이트 환산은 1024 나눗셈과 내림 한 쌍이다

08 의 fmt 는 v >= 1024 인 동안 나누기를 반복해 그 횟수로 단위 인덱스를 정하고, Math.pow(10, 자릿수) 배해서 버리고 되나눠 소수를 자릅니다. KB 를 1000 으로 계산하는 표기도 있지만 여기서는 2의 10제곱인 1024 를 밑으로 썼고, 화면의 두 값(파일 크기·남은 한도)이 같은 규칙에서 나오므로 뺄셈 결과가 어긋나지 않습니다.

객체 URL 은 만든 쪽이 되돌려야 한다

URL.createObjectURL 이 돌려주는 blob: 주소는 브라우저 메모리에 살아 있는 만큼, 미리보기를 지워도 저절로 회수되지 않습니다. 01 은 다음 드롭 직전에 이전 주소를 revokeObjectURL 로 되돌리고(화면의 revoke 후보 숫자가 이 목록의 길이입니다), 02·04 는 메타데이터·이미지 로드가 끝나는 즉시 되돌립니다. 문서를 닫을 때 함께 사라지는 값이지만, 계속 드롭을 받는 화면에서는 정리 시점이 메모리를 가늠합니다.

파일 아홉 종, 실제로 붙는 화면은 어디인가

상품 등록 폼의 대표 이미지 칸, 채용 지원서의 첨부 파일 영역, 팀 자료를 모으는 공유함 — 이 세 곳이 파일 드롭존을 가장 많이 찾는 자리입니다. 01 이미지 드롭은 상품 등록처럼 올리자마자 바로 확인해야 하는 화면에 맞고, 03 다중 파일은 지원서처럼 여러 장을 한 번에 받아야 하는 폼에 맞습니다.

용량 제한이 걸린 공유함에서는 08 용량 계산이 올린 파일이 한도에 얼마나 가까운지를 바로 보여줍니다. 세 화면 모두 사용자가 무엇을, 몇 개를 올릴지 미리 알 수 없다는 점이 같아서, 개수나 형식을 가정하지 않는 이 구조가 그대로 들어맞습니다.

어디서 깨지는가 — 폴더 드롭·대용량 이미지·메모리 정리·모바일

폴더를 드롭하면 files 는 비어 있다

폴더째 끌어다 놓으면 dataTransfer.files.length 는 0 이고 내용물은 items 의 webkitGetAsEntry 로 트리를 탐색해야 나옵니다. 이 아홉 개는 그 경로를 구현하지 않았으므로, 폴더를 놓으면 조용히 아무 일도 일어나지 않습니다 — 받기로 한 것은 파일이고, 그 전제를 벗어난 입력은 거부(07)만큼이라도 안내해 주는 편이 친절합니다.

큰 이미지는 naturalWidth 전에 메모리를 먼저 쓴다

04 가 썸네일을 64px 캔버스에 줄여 그리는 이유는 화면만이 아니라 메모리입니다. 수천 픽셀짜리 사진을 drawImage 로 직접 타일에 그리면 브라우저가 디코딩한 원본 크기만큼 메모리에 올라가는데, 64px 로 먼저 줄여 두면 그 크기를 64px 캔버스 분량으로 묶을 수 있습니다. 미리보기 그리드에서 원본을 그대로 들고 있으면 파일 수가 늘어났을 때 화면이 먼저 무거워집니다.

객체 URL 을 회수하지 않으면 드롭할 때마다 쌓인다

01 을 예로 들면 한 번 드롭할 때마다 blob: 주소가 하나씩 발급되는데, revokeObjectURL 을 빼면 목록이 늘어나는 만큼 주소가 쌓입니다. 실제로 이 데모의 자동 시연은 2.4초마다 가상 파일을 드롭하므로, 회수가 없다면 몇 분 안에 수십 개가 남습니다 — 화면의 revoke 후보 숫자가 1 을 넘지 않는 것이 정리가 되고 있다는 신호입니다.

모바일에서 드롭은 대부분 탭이나 카메라로 갈린다

터치 기기의 길게 누르기 드래그는 파일 앱과의 사이에서 일관되지 않고, 실제 서비스 화면에서는 숨긴 input[type=file] 을 여는 탭이나 카메라 호출이 더 자연스러운 입구가 됩니다. 이 데모도 아홉 개 전부 드롭존 옆에 input[type=file] 을 change 까지 같은 파이프라인으로 물려 두었으므로, 포인터가 없는 환경에서도 같은 계산이 도는 길이 있습니다.

진행 속도를 늦추면 접근성 처리가 함께 드러난다

값 하나만 바꾼 세 변형

07 의 거부 애니메이션을 240ms 에서 120ms 로 줄이면 반응이 즉각적이지만 왜 돌아갔는지 놓치기 쉽고, 04 의 썸네일 최대 변을 320px 에서 160px 으로 줄이면 많은 파일을 한 화면에 보여주되 사진 구분이 어려워지며, 08 의 용량 표시를 소수 첫째에서 둘째 자리로 늘리면 비교가 정확해지는 대신 숫자가 길어져 한눈에 들어오지 않습니다. 세 변형은 값 하나만 바꿔 느낌이 달라짐을 보여주는 확인용 코드고, 아홉 드롭존 전체 코드를 묶은 zip 을 열려면 암호 qe2efx37 을 입력하면 됩니다.

이름 바꾼 값 느낌
종류 거부 시 돌려주는 애니메이션 07 240ms → 120ms 거부가 분명하게 보이는 대신 연달아 놓을 때 느리게 느껴진다
미리보기 썸네일 최대 변 04 320px → 160px 많은 파일을 한 화면에 보여주되 사진 구분이 어려워진다
용량 표시 소수 자리 08 소수 첫째 → 둘째 두 파일이 같은 값으로 보이는 일은 줄지만 숫자가 길어진다

reduced-motion 은 계산을 지우지 않는다

prefers-reduced-motion: reduce 환경에서는 아홉 개 전부 자동 시연을 한 시나리오만 실행한 뒤 멈춥니다 — 판정·환산·거부 문구 같은 계산 결과는 그대로 화면에 남고 사라지는 것은 반복되는 움직임뿐입니다. 07 의 rtBounce 애니메이션과 03 의 is-pop 스태거가 조건 뒤로 물러나 있고, 09 는 칩 두 개를 세워 둔 채 재정렬 계산 결과만 보여줍니다.

드롭존을 붙이며 자주 나오는 질문

javascript 드래그 앤 드롭 파일 업로드를 라이브러리 없이 만들어도 되나요?

네, 이 아홉 개가 전부 라이브러리 없는 코드입니다. 드롭존의 자격은 dragover 와 drop 에 preventDefault() 를 거는 두 줄이고, 나머지는 File 객체가 이미 가진 type·size·name 을 계산하는 순수 자바스크립트입니다. 미리보기를 만드는 URL.createObjectURL·FileReader 도 브라우저 기본 API 라서 복붙 즉시 동작합니다.

react 드래그 앤 드롭 파일 업로드 를 만들 때도 이 계산을 그대로 옮기면 되나요?

됩니다. 접두사 판정·1024 나눗셈·naturalWidth 비율은 프레임워크와 무관한 순수 함수라서, 리액트에서는 드롭존 컴포넌트의 onDrop 에서 e.dataTransfer.files 를 같은 검사로 통과시키고 상태로 미리보기 목록을 관리하면 됩니다. 상태 초기값을 채울 때는 이 데모의 시나리오 파일(new File 로 만드는 가상 파일)을 그대로 가져다 쓰면 됩니다.

놓인 파일이 서버로 올라가는 건 언제부터인가요?

이 아홉 개 안에서는 서버로 가는 파일이 하나도 없습니다. 진행 바(05)조차 FileReader 가 브라우저 메모리로 읽는 속도를 재는 것이지 전송이 아니고, 진행률·취소 표시는 클라이언트 시뮬레이션입니다. 실제 업로드는 이 UI 뒤에 fetch 나 폼 전송을 붙이는 단계의 일이고, 그 진행률 표현을 다룬 것이 앞선 파일 업로드 진행 로더 편입니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.