여러 파일을 한 번에 다운로드하기
여러 파일을 한 번에 다운로드하기: 클라이언트에서 ZIP 만들기 (JSZip + Next.js)
요약
<a download>로 다운로드 트리거 → URL 해제. (MDN: Blob)배경/문제
CSV 1개, 이미지 1개처럼 “파일이 몇 개 안 된다”는 상황도 실제로는 귀찮다.
해결 방향은 “여러 파일을 ZIP으로 묶어 한 번에 다운로드”다.
핵심 개념
Blob (Binary Large Object)
브라우저 메모리에서 “파일처럼 다룰 수 있는 바이너리 덩어리”다. 텍스트/바이너리/이미지 등을 Blob으로 만들 수 있다. (MDN: Blob)
Object URL (URL.createObjectURL)
URL.createObjectURL(blob)는 Blob을 가리키는 임시 URL을 만든다. 이 URL을 <a href="...">에 넣고 클릭하면 다운로드를 트리거할 수 있다. 사용이 끝났다면 URL.revokeObjectURL(url)로 해제해 브라우저가 메모리를 정리하도록 돕는다. (MDN: URL.createObjectURL)
JSZip
JSZip은 브라우저에서 ZIP을 만들 수 있는 라이브러리다. zip.file(), zip.folder()로 구조를 구성하고, generateAsync({ type: "blob" })로 ZIP Blob을 생성한다. (JSZip)
해결 접근
<a download>로 ZIP을 저장한다.구현(코드)
브라우저 API(window, document, canvas, URL)를 사용하므로 Next.js에서는 Client Component에서 실행한다. (Next.js: Server/Client Components)
0) 설치
npm i jszip기대 결과 - 프로젝트에서 JSZip을 import해서 ZIP 생성이 가능해진다.
1) 다운로드 유틸: Blob → <a download> 다운로드
// app/download-zip/downloadBlob.ts
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
// 일부 환경에서 DOM에 붙어 있어야 안정적으로 동작하는 케이스를 줄이기 위해 append
document.body.appendChild(a);
a.click();
a.remove();
// 너무 빨리 revoke 하면 다운로드가 끊기는 환경이 있어, 다음 tick에 해제
setTimeout(() => URL.revokeObjectURL(url), 0);
}기대 결과 / 무엇이 달라졌는지 - 어떤 Blob이든 파일로 저장하는 공통 함수를 갖게 된다. - Object URL을 해제해 메모리 누수 가능성을 낮춘다. (MDN: URL.revokeObjectURL)
2) ZIP 생성 + 다운로드 (CSV + Canvas 이미지)
// app/download-zip/DownloadZipClient.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { downloadBlob } from "./downloadBlob";
const CSV_TEXT =
"policyID,statecode,county\n" +
"119736,FL,CLAY COUNTY\n" +
"448094,FL,CLAY COUNTY\n";
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("canvas.toBlob() failed"))),
type,
quality
);
});
}
export default function DownloadZipClient() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [downloading, setDownloading] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 데모용 간단한 그림
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "red";
ctx.font = "24px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Hello World", canvas.width / 2, canvas.height / 2);
}, []);
async function downloadAllAsZip() {
if (downloading) return;
const canvas = canvasRef.current;
if (!canvas) return;
setDownloading(true);
try {
// 필요할 때만 로드(초기 번들 부담을 줄이기 위한 선택)
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
// 1) CSV: 문자열 → Blob
const csvBlob = new Blob([CSV_TEXT], { type: "text/csv;charset=utf-8" });
zip.file("test.csv", csvBlob);
// 2) 이미지: canvas → Blob
const imageBlob = await canvasToBlob(canvas, "image/jpeg", 0.92);
const imgFolder = zip.folder("images");
imgFolder?.file("resized.jpeg", imageBlob);
// 3) ZIP 생성
const zipBlob = await zip.generateAsync({ type: "blob" });
// 4) 다운로드
downloadBlob(zipBlob, "example.zip");
} finally {
setDownloading(false);
}
}
return (
<div style={{ padding: 16 }}>
<h1>ZIP 다운로드 데모</h1>
<canvas
ref={canvasRef}
width={600}
height={240}
style={{ border: "1px solid #ccc" }}
/>
<div style={{ marginTop: 12 }}>
<button onClick={downloadAllAsZip} disabled={downloading}>
{downloading ? "ZIP 만드는 중..." : "CSV + 이미지 ZIP으로 다운로드"}
</button>
</div>
</div>
);
}기대 결과 / 무엇이 달라졌는지
- 버튼 1번으로 example.zip이 다운로드되고, ZIP 내부에 test.csv, images/resized.jpeg가 들어간다.
- 이미지는 toDataURL()로 base64 문자열을 만들지 않고 toBlob()로 바로 Blob을 생성한다. (MDN: canvas.toBlob)
- ZIP은 generateAsync({ type: "blob" })로 Blob 형태로 생성된다. (JSZip: generateAsync)
3) 페이지에서 Client Component 사용
// app/download-zip/page.tsx
import DownloadZipClient from "./DownloadZipClient";
export default function Page() {
return <DownloadZipClient />;
}기대 결과
- /download-zip에서 ZIP 다운로드가 동작한다.
- 브라우저 API 사용 코드를 Client Component로 분리해 서버 렌더링 오류를 피한다. (Next.js: use client)
검증 방법(체크리스트)
example.zip 1개만 다운로드되는가?test.csv와 images/resized.jpeg가 존재하는가?a.download는 동작을 “보장”하지 않는다) (MDN: a.download)흔한 실수/FAQ
Q1. URL.revokeObjectURL()을 바로 호출했더니 다운로드가 가끔 실패한다.
Object URL은 “사용 후 해제”가 맞지만, 너무 빠르게 해제하면 환경에 따라 다운로드가 끊기는 케이스가 있다. 예제처럼 다음 tick으로 미루는 패턴이 흔하다. (MDN: URL.revokeObjectURL)
Q2. Safari/iOS에서 다운로드 UX가 기대와 다르다.
a.download 값은 다운로드 동작을 보장하지 않는다. 플랫폼/브라우저 정책에 따라 저장 흐름이 달라질 수 있으므로, 지원 범위를 정하고 실제 기기에서 확인하는 편이 안전하다. (MDN: a.download)
Q3. 파일이 많거나 크면 브라우저가 버벅인다.
JSZip은 ZIP 생성 과정에서 메모리/CPU 사용량이 늘 수 있다. 필요 시 진행률 UI 제공, 생성 대상 축소, 서버 압축 전환 등을 검토한다. (JSZip: generateAsync)
Q4. 사용자 입력 파일명을 ZIP 내부 파일명으로 그대로 써도 되나?
권장하지 않는다. ZIP 내부 파일명에 ../ 같은 패턴이 섞이면 일부 압축 해제 도구에서 위험한 경로로 풀릴 여지가 있다. 사용자 입력을 파일명으로 사용할 경우 정규화/필터링 정책을 별도로 두는 편이 안전하다. (프로젝트 정책에 맞춰 검토 필요)
결론
여러 파일 다운로드를 ZIP 1개로 합치면, 저장 흐름이 단순해지고 파일 관리도 쉬워진다.
핵심은 Blob/Object URL/a 다운로드 패턴을 유지한 채, JSZip으로 “중간 산출물”을 ZIP Blob으로 만드는 것이다. (MDN: Blob)
참고(공식 문서)
generateAsync() 문서. (JSZip: generateAsync)Blob. (MDN: Blob)URL.createObjectURL() / URL.revokeObjectURL(), blob URL. (MDN: URL.createObjectURL)HTMLAnchorElement.download. (MDN: a.download)HTMLCanvasElement.toBlob(). (MDN: canvas.toBlob)'use client'. (Next.js Docs)