[최종 프로젝트 - React with typescript] Supabase(4)_Storage(이미지 여러장 넣고 미리보기)

Habin Lee·2024년 1월 17일
7

Supabase Storage

  • text값은 다 넣었으니 이제 이미지를 넣어볼 차례!
  • Supabase Storage의 간단한 구조를 살펴보자면, 웹 페이지에서 업로드한 사진이 storage에 들어가고 그 storage에 들어있는 사진의 url을 원하는 테이블에 지정한 컬럼으로 가져와서 저장한다.

1. Storage 버킷 만들기

  • 테이블에 url을 넣으려면 업로드된 사진을 넣어둘 버킷을 만들어줘야한다.
  • supabase storage에 들어가면 아래와 같은 화면이 뜬다.

  • New bucket을 눌러 이름을 정하고 Public bucket을 체크하여 새로운 버킷을 만들어준다.
    (Public bucket을 체크하면 누구나 승인 없이 모든 객체를 읽을 수 있도록 해주는데, 현재는 storage test를 위한 것으로 보안이 크게 필요하지 않기 때문에 체크해도 괜찮다.)
  • 추가로 업로드 크기 제한이나 허용되는 유형도 제한할 수 있으니 프로젝트에 필요한 항목을 체크하여 사용하면 된다. (나는 이미지 파일만 허용되도록 설정하였다.)

  • create folder를 눌러 원하는 폴더를 만들어준다. (필요없다면 굳이 만들지 않아도 된다.)

  • 이제 Policies를 눌러 몇 가지 설정만 만져주면 된다.

  • New policy를 누르고 For full customization 선택

  • 모두 허용한다는 이름을 대충 지어주고 모든 작업이 가능하도록 Allowed operation의 체크박스를 모두 체크해준 뒤 Target roles은 authenticated 선택하고 Review를 눌러준다.

  • 그럼 아래와 같은 창이 뜰텐데 Save policy를 눌러 저장을 해주자.

  • 그럼 이렇게 4가지 작업을 모두 허용된 상태에서 사용할 수 있다는 설정이 완료된 화면을 볼 수 있다.

  • 여기까지 왔다면 기본적인 storage 세팅은 끝!

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

(1) form 만들기

ProductsWriteForm.tsx

  • 일단 부모 컴포넌트에서 값을 넣어줄 기본 값이 빈 배열인 state를 하나 만들어준다.
  • 일반적으로 타입이 string일 url에 왜 any를 붙였냐고 물어본다면.. 처음에 url을 담은 state의 타입은 string[] || null 이었다. 아무리 해도 string 배열이 타입으로 들어가지 않아서 그렇게 진행했지만... image_url을 타입에 넣으니 'never[]' 형식은 'string' 형식에 할당할 수 없습니다. 라는.. 처음보는 타입이 나와서 찾다가 결국 찾지 못해 최대한 쓰지 않으려 했던 any 타입을 쓰게 되었다는... 슬픈 이야기...
  • Anyway😉 우리가 supabase 테이블에 넣을 데이터는 실제 파일 형식이 아닌 url이기 때문에 url state만 만들어주면 된다.
  • 그리고 만들어 준 state를 props로 자식 컴포넌트에 넘겨준다.
const [uploadedFileUrl, setUploadedFileUrl]: any = useState([]);

...

return (
  <ProductsImage uploadedFileUrl={uploadedFileUrl} setUploadedFileUrl={setUploadedFileUrl} />

ProductsImage.tsx

  • 자식 컴포넌트에서 props의 타입을 지정해주고 간단한 폼을 만들어준다.
    (props로 받아오는 state 타입은 string 배열로 받아도 오류가 없다..)
  • 타입이 file인 input은 못생겼으니 hidden 속성으로 가리고 라벨 htmlFor에 id를 연결하여 꾸며준다.
  • 우리는 사진을 여러장 올려야하기 때문에 속성에 multiple을 넣어준다.
interface Props {
  uploadedFileUrl: string[],
  setUploadedFileUrl: React.Dispatch<React.SetStateAction<string[]>>
}

const ProductsImage = ({uploadedFileUrl, setUploadedFileUrl}: Props) => {
	return (
    	<div>
          <div>
            <h2>상품이미지*</h2>
            <p>0/12</p>
          </div>
          <div>
            <label htmlFor='file'>
              <input type='file' id='file' name='file' multiple hidden /> +
            </label>
          </div>
        </div>
    )
}
  • 그럼 이렇게 적당한 폼을 볼 수 있다.(아직 css는 손대지 않아서 여전히 구림🙃)

(2) 웹페이지에 이미지 업로드하기

  • file 형식의 업로드를 위해 타입이 File[ ]인 state를 만들어준다.
    (이 File 타입은 공식으로 지원해주기 때문에 내가 따로 만들어주지 않아도 된다.)
  • 부모컴포넌트에서 state를 만들어 props까지 내려준 이유가 있다.
    • uploadedFileUrl state는 그 파일을 url로 바꿔 화면에 띄울 수 있게 저장
    • 지금 선언한 files state는 말 그대로 jpg나 png를 그대로 저장
import React, { useState } from 'react'

const [files, setFiles] = useState<File[]>([]);
  • 웹 페이지에서 파일을 올려주는 handleFiles 라는 이름의 함수를 만들어준다.
const handleFiles = async (e: React.ChangeEvent<HTMLInputElement>) => {
  const fileList = e.target.files;
  if (fileList) {
    const filesArray = Array.from(fileList);
    filesArray.forEach((file) => {
      handleAddImages(file);
    });
  }
};

코드 추가 설명
1. handleFiles 함수를 만들고 files의 내용을 뽑아내 fileList에 담아준다.
2. fileList가 있다면 Array.from 함수를 사용하여 배열로 만들어준다.
3. 배열을 forEach문으로 순회하여 각각의 파일(file)마다 handleAddImages함수에 넣어준다.

(3) 업로드한 이미지를 url로 만들어 storage에 넣어주기

  • forEach로 순회할 handleAddImages 함수를 만들어준다.
import { supabase } from '../../../api/supabase/supabaseClient';
import { v4 as uuid } from 'uuid';

const handleAddImages = async (file: File) => {
  try {
    const newFileName = uuid();
    const {data, error} = await supabase
    .storage
    .from('Image')
    .upload(`products/${newFileName}`, file)

    if(error) {
      console.log('파일이 업로드 되지 않습니다.', error);
      return;
    }
    const res = supabase.storage.from('Image').getPublicUrl(data.path);
    setFiles((prevFiles) => [file, ...prevFiles]);
    setUploadedFileUrl((prev:any) => [...prev, res.data.publicUrl]);
  } catch (error) {
    console.error('알 수 없는 문제가 발생하였습니다. 다시 시도하여 주십시오.', error);
  }
};

코드 추가 설명
1. file은 File 타입으로 지정해주고, try/catch문을 사용한다.
2. supabase storage에는 한글로 된 파일명은 업로드가 되지 않기 때문에 새로운 파일 이름을 주기 위해 newFileName를 선언하여 uuid를 할당시켜준다.
3. supabase storage upload 로직을 적어준 뒤, from에는 내가 설정한 버킷이름을 적고, 업로드할 파일 이름에 백틱을 이용하여 업로드되는 폴더명/uuid를 넣어준다.
4. res 라는 변수를 선언하여 supabase storage의 지정된 버킷에서 원하는 이미지(data)의 path 값을 가져와 할당시켜준다. (그 path값에서 다시 data의 publicUrl이 우리가 원하는 url 문자열)
5. 사진이 업로드 될 때마다 원본파일과 url 모두 기존에 있던 파일을 전개구문으로 펼쳐 새로운 파일을 붙여 새로운 문자열 배열 형태로 만들어준다.

(3-1) 같은 파일만 축적되는 오류

  • 처음에 썼던 코드를 보면 url은 전개구문을 쓰지 않고 이후 들어오는 값들을 그대로 받아오다보니 url은 가장 나중에 업로드된 사진으로 덧씌워져 같은 사진으로 파일 갯수만 계속 늘어났던 것이다.
  • 그 부분을 파일과 똑같이 바꿔줬더니 다른 사진으로 축적되는 것을 확인할 수 있었다.
const res = supabase.storage.from('Image').getPublicUrl(data.path);
    setFiles((prevFiles) => [...prevFiles, file]);
    setUploadedFileUrl(res.data.publicUrl);

(4) input onChange로 연결해주기

  • 이제 위에서 만든 handleFiles 함수를 input에 onChange로 연결해준다.
  return (
    <div>
      <div>
        <h2>상품이미지*</h2>
        <p>{uploadedFileUrl.length}/12</p>
      </div>
      <div>
        <label htmlFor='file'>
          <input type='file' id='file' name='file'
            onChange={handleFiles} multiple hidden />+
        </label>
      </div>
    </div>
};

(5) storage에 있는 이미지 url를 table에 넣어주기

  • 이제 자식 컴포넌트에서 넣어놓은 데이터를 부모데이터에서 supabase DB에 넣을 차례!

ProductsWriteForm.tsx

  • 다른 로직을 건드릴 필요는 전혀 없고 image_url의 타입을 지정해주고 테이블 컬럼의 이름과 똑같이 변수를 선언하여 url state를 할당해준 뒤, input값이 모두 들어있는 새로운 객체에 넣어주기만 하면 된다.
const image_url = uploadedFileUrl

  // input값이 모두 들어있는 새로운 객체 만들어서 supabase insert
  const entireProductsPosts = {...textRadioValue, category, agreement, image_url}

3. 업로드한 사진 미리보기

(1) map 메서드를 이용하여 사진 미리보기

  • 업로드한 사진 url이 담긴 배열 uploadedFileUrl을 map으로 돌려준다.
  • 나는 추가하는 박스를 뒤에 놓고 싶기 때문에 사진이 앞에서 차곡차곡 생길 수 있도록 input박스 앞에 map 함수를 돌려줬다.
const ProductsImage = ({uploadedFileUrl, setUploadedFileUrl}: Props) => {
  
  ...(중략)
  
  return (
    <div>
      <div>
        <h2>상품이미지*</h2>
        <p>0/12</p>
      </div>
      <div>
        {uploadedFileUrl.map((img:string, i:number) => 
             <div key={i}>
                <img src={img} alt={`${img}-${i}`} />
             </div>
          )}
        <label htmlFor='file'>
          <input type='file' id='file' name='file'
            onChange={handleFiles} multiple hidden />+
        </label>
      </div>
    </div>
  )
};

(2) div 박스에 사진 크기 맞추기

  • 처음 사진을 올리면 사진 크기대로 div 박스를 뚫고 나와 UI가 엉망이었다.
  • 이 때 objectFit style을 사용해주면 편하게 설정할 수 있다.

object-fit 속성

object-fit 속성은 img, video, object, svg 과 같은 요소의 지정된 너비와 높이를 지정하는 css 속성으로, 오브젝트의 비율을 유지한 채 일정한 크기로 재가공 하는 경우에 유용하다.

  • fill: 박스 크기에 맞춰 이미지 크기를 조절하며 박스를 가득 채움. (기본값)
    • 종횡비가 일치하지 않으면 이미지가 늘어나거나 줄어들음
  • contain: 가로세로 비율을 유지한 채로 사이즈가 조절
    • 이미지와 컨테이너 간의 비율이 맞지 않는 경우엔 빈공간이 생김
  • cover: 이미지의 종횡비를 유지하면서 박스를 가득 채움.
    • 종횡비가 일치하지 않으면 컨테이너 박스를 넘어간 이미지 객체는 잘림
  • none: 이미지 크기를 조절하지 않음
  • scale-down: none과 contain 중 이미지의 크기가 더 작아지는 값에 따름
    • 크기가 다양한 이미지를 목록으로 표시할 때, 아주 작은 이미지들이 늘어나 확대되면 보기 좋지 않아지는 문제를 피할 수 있는 장점
  • 여기서 나는 contain이 좋다고 생각했는데, 디자이너 미팅 후 cover로 하자고 해서 cover로 선택!
  • 가로세로 길이는 div 크기에 맞춰 100%로 정하고 objectPosition을 center로 해서 사진을 정 가운데에 위치하도록 하였다.
<div>
  {uploadedFileUrl.map((img:string, i:number) => 
                       <div key={i}>
                         <img src={img} alt={`${img}-${i}`}
                           style={{objectFit: 'cover',
                             objectPosition: 'center',
                               width: '100%', height: '100%'}}/>
                       </div>
                      )}
  <label htmlFor='file'>
    <input type='file' id='file' name='file'
      onChange={handleFiles} multiple hidden />+
  </label>
</div>
  • 사진을 3장을 올리면 이렇게 올라가 있는 것을 볼 수 있다.(contain)

  • 아래는 cover를 적용한 모습. 확실히 cover가 더 깔끔해 보인다.

(3) 사진은 최대 12장까지만 넣을 수 있도록 만들어주기

  • 파일의 배열 길이가 12를 초과하면 pop 메서드로 추가로 더 들어오는 사진은 삭제될 수 있도록 해준다.
  • 이때 주의할 점은 file과 url 둘 다 지워줘야 보이지 않는 데이터가 쌓이는 걸 막아줄 수 있다.
  • 처음에 url만 삭제해줬더니 files는 그대로 남아있어 files 데이터는 삭제되지 않고 축적이 되고 있어 급하게 같은 로직을 추가해줬다.
  • 대신 같은 코드가 중복되고 있어서 논리곱연산자(&&)를 사용하여 한줄로 합쳐줬다.
if (uploadedFileUrl.length > 12) uploadedFileUrl.pop();
if (files.length > 12) files.pop();

=

if (uploadedFileUrl.length > 12 && files.length > 12) uploadedFileUrl.pop() && files.pop();

(3-1) 원하지 않는 사진이 삭제되는 오류

  • 처음에는 파일과 url이 모두 새로 업로드 되는 파일이 뒤에 붙도록 만들었는데, 12개가 초과되는 순간 뒷장의 파일이 삭제되지 않고 처음에 있던 사진부터 삭제되어 뒤에 새로운 카드가 붙게 되는 것이 아닌가..!
const res = supabase.storage.from('Image').getPublicUrl(data.path);
    setFiles((prevFiles) => [...prevFiles, file]);
    setUploadedFileUrl((prev:any) => [...prev, res.data.publicUrl]);
  • 그래서 전개 구문을 둘 다 뒤로 옮겼더니, 삭제는 추가되는 부분이 잘 삭제되는데 업로드되는 파일이 앞으로 붙어서 UI가 마음에 들지 않아 현재와 같은 코드가 완성되었다.😂
const res = supabase.storage.from('Image').getPublicUrl(data.path);
    setFiles((prevFiles) => [file, ...prevFiles]);
    setUploadedFileUrl((prev:any) => [res.data.publicUrl, ...prev]);

(4) 제한된 사진 수를 넘어가면 파일업로드 버튼 사라지게 하기

  • 업로드 한 사진이 제한된 12장을 넘어가면 파일업로드 버튼을 사라지도록 만들기 위해 삼항연산자를 사용했다.
  • 업로드 된 파일 수(uploadedFileUrl.length)가 12 이상일 경우에 빈 태그를 표시하고 아닐 경우에는 input 박스가 보이도록 만들어준다.
const ProductsImage = ({uploadedFileUrl, setUploadedFileUrl}: Props) => {
  
  ...(중략)
  
  return (
    <div>
      <div>
        <h2>상품이미지*</h2>
        <p>0/12</p>
      </div>
      <div>
        {uploadedFileUrl.map((img:string, i:number) => 
             <div key={i}>
                <img src={img} alt={`${img}-${i}`} />
             </div>
          )}
        {uploadedFileUrl.length >= 12 ? <></> : 
          <label htmlFor='file'>
          	<input type='file' id='file' name='file'
              onChange={handleFiles} multiple hidden />+
          </label>}
        </div>
      </div>
    )
};
  • 이렇게 11장을 넣으면 input 박스가 보이고

  • 12장을 넣으면 imput 박스가 사라지는 것을 볼 수 있다.

(5) 업로드한 사진 수 체크하기

  • 업로드한 사진 수가 실시간으로 변경되면 UX가 좋을 것 같아서 간단하게 구현했다.
  • uploadedFileUrl나 files의 length를 넣어주면 바로 확인이 가능하다.
const ProductsImage = ({uploadedFileUrl, setUploadedFileUrl}: Props) => {
  
  ...(중략)
  
  return (
    <div>
      <div>
        <h2>상품이미지*</h2>
        <p>{uploadedFileUrl.length}/12</p>
      </div>
      <div>
        {uploadedFileUrl.map(
          (img:string, i:number) => 
             <div key={i}>
                <img src={img} alt={`${img}-${i}`} />
                <button> X </button>
             </div>
          )}
        {uploadedFileUrl.length >= 12 ? <></> : 
          <label htmlFor='file'>
          	<input type='file' id='file' name='file'
              onChange={handleFiles} multiple hidden />+
          </label>}
        </div>
      </div>
    )
};
  • 별거 아니지만 UX가 좋아진 것을 알 수 있다.

4. 업로드한 사진 삭제하기

  • 업로드 한 사진을 지우고 싶을 때가 있으니 사진 옆에 x 버튼을 둬서 누르면 사라지도록 해주자.

(1) 삭제 함수 만들기

  • handleDeleteImage 함수를 만들고 file과 url에 filter를 걸어 없애주면 된다.
const ProductsImage = ({uploadedFileUrl, setUploadedFileUrl}: Props) => {
  
  ...(중략)
  
  const handleDeleteImage = (id:any) => {
    setUploadedFileUrl(uploadedFileUrl.filter((_, index) => index !== id));
    setFiles(files.filter((_, index) => index !== id));
  };

코드 추가 설명
함수에 들어가는 id라는 인자는 변경 state인 setUploadedFileUrl 안에서 기존 파일 배열uploadedFileUrl의 인자index와 비교하여 다르다면 filter로 걸러낸다. (files도 동일)

(2) button에 onClick 연결하기

  • 화면에 보여지는 부분에 버튼을 만들어 onClick 함수로 연결해준다.
return (
  <div>
    <div>
      <h2>상품이미지*</h2>
      <p>{uploadedFileUrl.length}/12</p>
    </div>
    <div>
      {uploadedFileUrl.map(
        (img:string, i:number) => 
        <div key={i}>
          <img src={img} alt={`${img}-${i}`} />
          <button onClick={() => handleDeleteImage(i)}>X</button>
        </div>
      )}
      {uploadedFileUrl.length >= 12 ? <></> : 
        <label htmlFor='file'>
        <input type='file' id='file' name='file'
          onChange={handleFiles} multiple hidden />+
      </label>}
    </div>
  </div>
)
  • 짠! 이렇게 하면 비루하지만 사진 옆에 x 버튼이 생겼다.

  • 이제 삭제가 잘 되는지도 확인 해봐야지 😏 중간에 있는 바다 사진을 삭제했더니 바다 사진만 사라지고 input 박스가 다시 나타난 것을 볼 수 있다.

5. 제품리스트에 업로드한 사진 미리보기

  • 자 이제 제품리스트에 업로드 한 사진까지 화면에 뿌려보자.
  • supabase에서 데이터를 가져오는 건 이미 저번 게시글 supabse(2)에 있으니 참고하면 된다.

ProductsCard.tsx

  • 카드 모양을 정하는 컴포넌트로 가서 products 컬럼에서 image_url도 빼내온다.
const ProductsCard = ({product}: {product: ProductsPostType}) => {
  const { title, price, quality, image_url } = product
  
  ...
  
};
  • 업로드 된 사진 중 가장 먼저 올린 사진이 보이도록 배열에 [0]을 붙여주자.
<div>
  <div>
    <img src={image_url[0]}/>
  </div>
  <div>
    {[quality].map(condition => <li key={condition}>{condition}</li>)}
  </div>
  <h2>{title}</h2>
  <h3>{price}</h3>
</div>
  • 배열의 첫 번째 사진이 화면에 잘 나왔을 것 같지만.. 현실은 이렇게 하니 값이 null이 나올 수 있어서 인덱스 0을 사용할 수 없다는 오류가 떴다.
  • 그래서 삼항연산자를 사용하여 null 값이 아닐 때만 사진이 나오도록 했는데, 어차피 사진은 필수로 넣어야해서 null이 나올 수가 없을 것이다🤭
<div>
  <div>
    {image_url !== null ? <img src={image_url[0]}/> : <h1></h1>}
  </div>
  <div>
    {[quality].map(condition => <li key={condition}>{condition}</li>)}
  </div>
  <h2>{title}</h2>
  <h3>{price}</h3>
</div>
  • 드디어 사진이 있는 제품 리스트가 잘 나온다..! 감격😂

느낀 점

  • storage... 너무 어려웠지만 그래도 한 번 코드를 짜고 나니 어떻게 돌아가는지 이해하기가 쉬웠다. 왜 다른 값 들처럼 url을 바로 테이블에 넣지 않는 지 궁금했는데 storage에 한 번 거쳐서 나온 url은 용량이 적어 속도가 조금 향상된다는 튜터님의 말씀을 듣고 그제서야 궁금증이 풀리게 되었다!

참고

0개의 댓글