06 · Developer Guide · 레시피
다중 이미지 업로드 + 자동 압축Image Upload
새소식·주보·갤러리 게시물 작성 시 한 번에 여러 장의 사진을 업로드합니다.
드래그앤드롭 · 브라우저 압축 · 진행률 · 실패 재시도까지 한 컴포넌트에서 처리합니다.
언제 쓰는가
- 관리자 글쓰기 페이지에서 본문 사진·표지 사진 업로드
- 새가족 환영회·구역 모임 같은 행사 사진 일괄 업로드
- 모바일에서 카메라로 찍은 사진을 바로 올릴 때 (보통 8~12MB → 자동 1.5MB로)
① 드래그앤드롭 + 진행률 컴포넌트
components/preview/ImageUploader.tsxtsx
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import imageCompression from "browser-image-compression";
import { ImagePlus, X } from "lucide-react";
type Item = {
id: string;
file: File;
preview: string;
progress: number; // 0~100
url?: string;
error?: string;
};
export function ImageUploader({
onUploaded,
maxCount = 20,
maxSizeMB = 1.5,
}: {
onUploaded: (urls: string[]) => void;
maxCount?: number;
maxSizeMB?: number;
}) {
const [items, setItems] = useState<Item[]>([]);
const onDrop = useCallback(async (accepted: File[]) => {
const left = maxCount - items.length;
const next = accepted.slice(0, left).map((file) => ({
id: crypto.randomUUID(),
file,
preview: URL.createObjectURL(file),
progress: 0,
}));
setItems((cur) => [...cur, ...next]);
// 순차 업로드 (병렬은 모바일에서 메모리 폭주)
for (const item of next) {
await uploadOne(item);
}
}, [items.length, maxCount]);
const uploadOne = async (item: Item) => {
try {
const compressed = await imageCompression(item.file, {
maxSizeMB,
maxWidthOrHeight: 2400,
useWebWorker: true,
onProgress: (p) =>
setItems((cur) =>
cur.map((x) => (x.id === item.id ? { ...x, progress: p / 2 } : x)),
),
});
const url = await uploadToStorage(compressed, (p) =>
setItems((cur) =>
cur.map((x) => (x.id === item.id ? { ...x, progress: 50 + p / 2 } : x)),
),
);
setItems((cur) => {
const updated = cur.map((x) => (x.id === item.id ? { ...x, url, progress: 100 } : x));
const done = updated.filter((x) => x.url).map((x) => x.url!);
if (done.length === updated.length) onUploaded(done);
return updated;
});
} catch (e) {
setItems((cur) =>
cur.map((x) => (x.id === item.id ? { ...x, error: String(e) } : x)),
);
}
};
const remove = (id: string) =>
setItems((cur) => cur.filter((x) => x.id !== id));
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "image/*": [".jpg", ".jpeg", ".png", ".webp"] },
maxFiles: maxCount - items.length,
noClick: items.length >= maxCount,
});
return (
<div>
<div {...getRootProps()} className={`rounded-lg border-2 border-dashed p-8 text-center ${isDragActive ? "border-accent bg-blue-50" : "border-border"}`}>
<input {...getInputProps()} />
<ImagePlus size={28} className="mx-auto text-text-muted" />
<p className="mt-3 text-[14px]">
{isDragActive ? "여기에 놓으세요" : "사진을 여기로 끌어다 놓거나 클릭해 선택"}
</p>
<p className="mt-1 text-[11.5px] text-text-muted">
JPG · PNG · WEBP · 최대 {maxCount}장
</p>
</div>
{items.length > 0 && (
<ul className="mt-4 grid grid-cols-3 gap-3 sm:grid-cols-4">
{items.map((item) => (
<li key={item.id} className="relative aspect-square overflow-hidden rounded-md">
<img src={item.preview} alt="" className="h-full w-full object-cover" />
{item.progress < 100 && !item.error && (
<div className="absolute inset-x-0 bottom-0 h-1 bg-white/30">
<div className="h-full bg-accent" style={{ width: `${item.progress}%` }} />
</div>
)}
{item.error && (
<div className="absolute inset-0 grid place-items-center bg-red-500/80 text-white">
실패
</div>
)}
<button
onClick={() => remove(item.id)}
aria-label="삭제"
className="absolute right-1 top-1 rounded-full bg-black/60 p-1 text-white"
>
<X size={12} />
</button>
</li>
))}
</ul>
)}
</div>
);
}② 업로드 함수 + 진행률 (XHR 우회)
Supabase JS SDK는 업로드 진행률을 노출하지 않습니다. 진행률 표시가 필요하면 XMLHttpRequest로 직접 호출.
lib/storage/upload.tsts
// lib/storage/upload.ts — Supabase Storage 업로드 + 진행률
import { createClient } from "@/lib/supabase/client";
export async function uploadToStorage(
file: Blob,
onProgress: (percent: number) => void,
) {
const supabase = createClient();
const ext = file.type.split("/")[1] ?? "jpg";
const path = `uploads/${crypto.randomUUID()}.${ext}`;
// Supabase JS는 진행률을 직접 노출하지 않으므로 XHR을 우회
const { data: { session } } = await supabase.auth.getSession();
const url = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public-assets/${path}`;
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader("Authorization", `Bearer ${session?.access_token}`);
xhr.setRequestHeader("x-upsert", "false");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress((e.loaded / e.total) * 100);
};
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(xhr.statusText));
xhr.onerror = () => reject("network");
xhr.send(file);
});
return supabase.storage.from("public-assets").getPublicUrl(path).data.publicUrl;
}③ EXIF 자동 회전 + 개인정보 제거
ts
// EXIF 회전 정보 처리 — iPhone에서 옆으로 누운 사진 자동 정상화
//
// browser-image-compression v2.x는 기본적으로 EXIF orientation을
// 읽어 정상 방향으로 회전한 뒤 EXIF를 제거합니다.
// (개인정보 보호: GPS·촬영 시각도 함께 제거됨)
//
// 회전만 하고 EXIF는 보존하고 싶다면:
const compressed = await imageCompression(file, {
maxSizeMB: 1.5,
preserveExif: true, // GPS·촬영 시각 보존
fileType: "image/jpeg",
});