이미지 업로드 기능 만들기(Supabase Storage, 이미지 미리보기)

비얌·2024년 10월 19일
0
post-thumbnail

✨ 개요

Supabase(DB, Storage)와 NextJS로 이미지 업로드 기능을 만들어보았다.

구현한 기능은 다음과 같다.

  1. 이미지를 선택해서 업로드하고, 화면에 미리보기 이미지를 보여주기
  2. 이미지를 Supabase Storage에 저장하기
  3. 이미지가 저장되는 동안 화면에 저장할 이미지를 보여주기


👏 결과 미리보기

업로드 버튼을 클릭하여 리락쿠마 이미지를 선택하면 선택한 이미지가 화면에 보여진다. 그리고 인쇄 버튼을 누르면 이미지가 저장된다.

리락쿠마 이미지가 저장되는 몇초의 시간 동안 화면에는 업로드 중인 이미지를 임시로 보여준다. 그리고 실제로 업로드가 되면 업로드된 이미지로 바꾸어 보여준다.



🛫 과정

전체 코드

전체 코드는 아래와 같다. 하나씩 살펴보며 복기해보려고 한다.

'use client';

import Image from 'next/image';
import { supabase } from '@/supabase/supabaseClient';
import { v4 as uuid } from 'uuid';
import { useRef, useState } from 'react';
import { toPng } from 'html-to-image';

export default function Home() {
  const [isEditing, setIsEditing] = useState<boolean>(false);
  const [uploadedImageUrl, setUploadedImageUrl] = useState<string>('');
  const [text, setText] = useState<string>('');
  const [imageUuid, setImageUuid] = useState<string>('');
  const [uploadImage, setUploadImage] = useState<File | null>(null);
  const elementRef = useRef(null);

  const handleClickEdit = () => {
    setIsEditing((prev) => !prev);
  };

  const handleClickSave = async () => {
    // 텍스트, 이미지를 DB에 저장하기

    setIsEditing((prev) => !prev);
  };

  const handleSaveLocalImage = async (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    const newFileName = uuid();
    setImageUuid(newFileName);

    // 파일이 없으면 종료
    const file = e.target.files;
    if (!file || !file[0]) return;

    // 업로드 전에 브라우저 내부에서만 유효한 임시 URL 생성
    const localPreviewUrl = URL.createObjectURL(file[0]);
    setUploadedImageUrl(localPreviewUrl);

    // 실제로 supabase storage에 업로드되기 전에 이미지를 로컬에서 보여주기
    setUploadImage(file[0]);
  };

  const handleDelete = () => {
    setUploadedImageUrl('');
    setUploadImage(null);
    setText('');
  };

  const handleDownloadImage = async () => {
    // 리액트 컴포넌트를 이미지로 변환하여 다운로드
    if (elementRef.current) {
      // elementRef가 참조하는 DOM 요소가 존재하는지 확인
      toPng(elementRef.current, { cacheBust: false }) // 해당 요소를 PNG로 변환
        .then((dataUrl) => {
          // 변환이 완료되면, PNG 이미지의 Data URL을 반환
          const link = document.createElement('a'); // 다운로드를 위한 <a> 태그 생성
          link.download = '폴라로이드.png'; // 다운로드될 파일의 이름 설정
          link.href = dataUrl; // <a> 태그의 href 속성에 변환된 이미지의 Data URL을 설정
          link.click(); // 링크 클릭을 트리거하여 다운로드 시작
        })
        .catch((err) => {
          // 변환 과정에서 에러가 발생한 경우 처리
          console.log(err); // 에러를 콘솔에 출력
        });
    }

    // 이미지와 텍스트가 없으면 종료
    if (!text && !uploadImage) {
      setIsEditing(false);
      return;
    }

    // Supabase storage에 이미지 파일 업로드
    const { data, error } = await supabase.storage
      .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET as string)
      .upload(imageUuid, uploadImage as File, {
        headers: {
          'x-upsert': 'true', // optionally set upsert to true to overwrite existing files
        },
      });

    if (error) {
      console.error('Upload error:', error);
      return;
    }

    // 이미지가 업로드 완료되고 생성된 public URL을 가져와서 화면에 표시
    const res = supabase.storage
      .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET as string)
      .getPublicUrl(data.path);

    setUploadedImageUrl(res.data.publicUrl); // 실제 이미지 URL로 교체

    // Supabase DB에 이미지 URL과 텍스트 저장
    const { error: dbError } = await supabase
      .from('polaroid-data')
      .insert([{ image_url: res.data.publicUrl, description: text }]);

    if (dbError) {
      console.error('DB insert error:', dbError);
      return;
    }
  };

  return (
    <div className="h-dvh bg-yellow-50 p-6 pt-16 overflow-y-hidden">
      {!isEditing ? (
        <div className="flex justify-between px-6 py-4">
          <Image
            src="/print.png"
            alt="인쇄"
            height={30}
            width={30}
            className="cursor-pointer downloadButton"
            onClick={handleDownloadImage}
          />
          <Image
            src="/pen.png"
            alt="수정"
            height={30}
            width={30}
            className="cursor-pointer"
            onClick={handleClickEdit}
          />
        </div>
      ) : (
        <div className="flex justify-between px-6 py-4">
          <Image
            src="/trashcan.png"
            alt="삭제"
            height={30}
            width={30}
            className="cursor-pointer"
            onClick={handleDelete}
          />
          <Image
            src="/save.png"
            alt="저장"
            height={30}
            width={30}
            className="cursor-pointer"
            onClick={handleClickSave}
          />
        </div>
      )}
      {/* 프레임 시작 */}
      <div className="flex justify-center items-center relative">
        <div
          ref={elementRef}
          className="flex flex-col justify-center items-center relative"
        >
          <div className="relative">
            <Image src="/frame.jpg" alt="폴라로이드" height={516} width={324} />
          </div>
          {uploadedImageUrl ? (
            <div className="top-10 left-4 h-[382px] w-[288px] flex items-center justify-center absolute">
              <div className="inset-0">
                <div className="h-[382px] w-[288px] bg-white"></div>
                <Image
                  src={uploadedImageUrl}
                  alt="이미지"
                  fill
                  style={{ objectFit: 'contain' }}
                />
              </div>
            </div>
          ) : (
            <label
              htmlFor="file"
              className="absolute top-10 left-4 h-[382px] w-[288px] flex items-center justify-center cursor-pointer bg-white"
            >
              <input
                type="file"
                id="file"
                name="file"
                hidden
                onChange={handleSaveLocalImage}
                disabled={!isEditing}
              />
              <Image src="/upload.png" alt="업로드" height={40} width={40} />
            </label>
          )}
          <textarea
            className="relative -top-16 w-full px-4 z-50 bg-transparent resize-none"
            rows={2}
            placeholder="어떤 기념할 일이 있었나요?"
            disabled={!isEditing}
            value={text}
            onChange={(e) => setText(e.target.value)}
          />
        </div>
      </div>
    </div>
  );
}

1. 업로드 버튼, 미리보기 화면

이미지 업로드 버튼과 미리보기 화면 컴포넌트는 아래와 같다.

<label><label> 안에 있는 <input>을 보면, id가 file로 이어져있다. 따라서 input을 클릭하면 label도 클릭된다.

일반적인 input type="file"은 이렇게 투박하게 생겼다. 따라서 input에 hidden 속성을 주어(TailwindCSS 사용) 안보이게 하고, label로 UI를 디자인했다. 그리고 업로드 버튼 아이콘을 Image로 넣어줬다.

그리고 업로드된 이미지는 이미지가 업로드될 때 onChange 이벤트로 handleSaveLocalImage 함수가 실행된다.

실제로 이미지를 업로드하기 전에 이미지를 로컬에서 보여주기 위해 uploadImage에 로컬에서만 유효한 이미지 url을 만들어서(URL.createObjectURL) Image의 src로 넣어주면 이미지 미리보기를 할 수 있다.

const handleSaveLocalImage = async (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    const newFileName = uuid();
    setImageUuid(newFileName);

    // 파일이 없으면 종료
    const file = e.target.files;
    if (!file || !file[0]) return;

    // 업로드 전에 브라우저 내부에서만 유효한 임시 URL 생성
    const localPreviewUrl = URL.createObjectURL(file[0]);
    setUploadedImageUrl(localPreviewUrl);

    // 실제로 supabase storage에 업로드되기 전에 이미지를 로컬에서 보여주기
    setUploadImage(file[0]);
  };

  const handleDelete = () => {
    setUploadedImageUrl('');
    setUploadImage(null);
    setText('');
  };

{uploadedImageUrl ? (
  <div className="top-10 left-4 h-[382px] w-[288px] flex items-center justify-center absolute">
    <div className="inset-0">
      <div className="h-[382px] w-[288px] bg-white"></div>
      <Image
        src={uploadedImageUrl}
        alt="이미지"
        fill
        style={{ objectFit: 'contain' }}
        />
    </div>
  </div>
) : (
  <label
    htmlFor="file"
    className="absolute top-10 left-4 h-[382px] w-[288px] flex items-center justify-center cursor-pointer bg-white"
    >
    <input
      type="file"
      id="file"
      name="file"
      hidden
      onChange={handleSaveLocalImage}
      disabled={!isEditing}
      />
    <Image src="/upload.png" alt="업로드" height={40} width={40} />
  </label>
)}

2. 이미지를 Supabase Storage에 저장하기

인쇄 버튼을 누르면 이미지가 Supabase Storage에 저장되는 동시에 다운로드 받아진다.

이미지를 저장할 때, 위에서 만들어놓은 imageUuid를 인자로 넣어준다. 왜인지 업로드하는 파일명으로 저장하면 저장이 잘 안될 때가 있어서 고유한 uuid를 만들어서 넣었다.

이미지가 업로드되는데 몇초가 걸려서, 그동안은 업로드할 이미지를 임시로 화면에 보여준다.

 const handleDownloadImage = async () => {
   // 이미지 다운로드 코드 생략...

   // Supabase storage에 이미지 파일 업로드
   const { data, error } = await supabase.storage
   .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET as string)
   .upload(imageUuid, uploadImage as File, {
     headers: {
       'x-upsert': 'true', // 동일한 파일을 올릴 수 있도록 설정
     },
   });

   if (error) {
     console.error('Upload error:', error);
     return;
   }

   // 이미지가 업로드 완료되고 생성된 public URL을 가져와서 화면에 표시
   const res = supabase.storage
   .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET as string)
   .getPublicUrl(data.path);

   setUploadedImageUrl(res.data.publicUrl); // 실제 이미지 URL로 교체

   // Supabase DB에 이미지 URL과 텍스트 저장
   const { error: dbError } = await supabase
   .from('polaroid-data')
   .insert([{ image_url: res.data.publicUrl, description: text }]);

   if (dbError) {
     console.error('DB insert error:', dbError);
     return;
   }
 };
  
<Image
  src="/print.png"
  alt="인쇄"
  height={30}
  width={30}
  className="cursor-pointer downloadButton"
  onClick={handleDownloadImage}
  />


✨ 결과

이미지 업로드하기 기능이 완성되었다!



🐹 회고

이번 기회로 Supabase Storage를 처음 써보게 되었다. 이미지를 업로드하면 그 이미지의 url을 하나하나 만들어서 반환해준다는 것이 신기했다.

그리고 이미지를 업로드하는데 이렇게 몇초간의 시간이 걸릴줄은 몰랐다. 이미지를 업로드했을 때 빈 화면이나 로딩 화면을 표시하기 싫어서 미리 이미지를 보여준 건데, 문제가 생길 수 있는 부분같다. 예를 들어 이미지를 업로드하는 동안 문제가 생겨서 실제로는 업로드가 안됐다든가.. 하지만 사실 지금 DB의 의미가 없어서😂

예전에 저장한 데이터를 보는 기능이 아직 없고 그저 이미지 다운로드 기능만 있어서 당장은 문제가 될 것 같지는 않다. 만약 예전에 저장한 이미지를 보는 기능을 만들게 된다면 이부분을 다시 생각해보자!

profile
🐹강화하고 싶은 기억을 기록하고 공유하자🐹
post-custom-banner

0개의 댓글