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",
});

설계 포인트

Need Help

도움이 필요하신가요?

주님의교회 PCL 디자인 시스템을 적용하시다가 막히는 부분이 있다면, 다음 경로로 직접 문의하실 수 있습니다.