๐Ÿ  | Multi Image Form(๋‹ค์ค‘ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ) & supabase storage

NewHaยท2024๋…„ 8์›” 19์ผ
post-thumbnail

๐Ÿ“

์‚ฌ์šฉ์ž ์š”๊ตฌ์‚ฌํ•ญ

  • ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์‹œ, ๋‹ค์ค‘์˜ ์ด๋ฏธ์ง€๋ฅผ ํ•œ๊บผ๋ฒˆ์— ์˜ฌ๋ฆด ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
    โ†’ ์‚ฌ์šฉ์ž๊ฐ€ ์˜ฌ๋ฆฐ ๋‹ค์ค‘ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ›์•„ supabase storage์— ์ €์žฅํ•˜๊ณ  ๊ฒŒ์‹œ๊ธ€ ๋ฐ์ดํ„ฐ ๋ฒ ์ด์Šค์—๋„ ์ €์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  • ์—…๋กœ๋“œ ์ค‘ ์˜ฌ๋ฆฐ ํŒŒ์ผ๋“ค์„ ์‚ฌ์šฉ์ž๊ฐ€ ๋ณด๊ณ  ์„ ํƒํ•  ์ˆ˜ ์žˆ๋„๋ก ๋ฏธ๋ฆฌ ๋ณด์—ฌ์ฃผ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  • ์—…๋กœ๋“œํ•œ ์ด๋ฏธ์ง€๋ฅผ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
    โ†’ storage์™€ form๊ฐ์ฒด, ๋ฏธ๋ฆฌ๋ณด๊ธฐ์—์„œ ๋ชจ๋‘ ์‚ญ์ œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  • ์˜ฌ๋ฆฐ ๋‹ค์ค‘ ์ด๋ฏธ์ง€ ์ค‘์—์„œ ๋Œ€ํ‘œ์‚ฌ์ง„์„ ์„ ํƒํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
    โ†’ ์ฒ˜์Œ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ์‹œ | ๋Œ€ํ‘œ์ด๋ฏธ์ง€ ์„ ํƒ ์ „์—๋Š” ์ฒซ๋ฒˆ์งธ ์‚ฌ์ง„์ด ๋Œ€ํ‘œ์ด๋ฏธ์ง€๋กœ ์ง€์ •๋ฉ๋‹ˆ๋‹ค.
    โ†’ ๋ผ๋””์˜ค ๋ฒ„ํŠผ์„ ํ†ตํ•ด ๋Œ€ํ‘œ์ด๋ฏธ์ง€๋ฅผ ์„ ํƒํ•˜๊ณ , ๋”ฐ๋กœ ๊ฒŒ์‹œ๊ธ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“Œ react, supabase ์™ธ์— 'react-hook-form'๋„ ์‚ฌ์šฉํ•ด form์„ ์ž‘์„ฑํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Supabase Storage

Supabase Storage ๋Š” ํŒŒ์ผ์„ ์ €์žฅํ•˜๊ณ  ๊ด€๋ฆฌํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. ํŒŒ์ผ์€ ๋ฐ์ดํ„ฐ ์šฉ๋Ÿ‰์ด ํฌ๊ธฐ ๋•Œ๋ฌธ์— ๋ฐ์ดํ„ฐ ๋ฒ ์ด์Šค ์™ธ๋ถ€์— ๋”ฐ๋กœ ์ €์žฅํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

  • ๋ชจ๋“  ์ข…๋ฅ˜์˜ ๋ฏธ๋””์–ด ํŒŒ์ผ ๋ฐ ํด๋”๋ฅผ ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฃผ๋กœ ์ด๋ฏธ์ง€, ๋™์˜์ƒ, ๋ฌธ์„œ์™€ ๊ฐ™์€ ํŒŒ์ผ์„ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ํšจ์œจ์ ์œผ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ค๋‹ˆ๋‹ค.

  • storage ๋‚ด๋ถ€์—์„œ ํ”„๋กœ์ ํŠธ์— ์ ํ•ฉํ•œ ํด๋”๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค์–ด ํŒŒ์ผ์„ ์ •๋ฆฌํ•ด์„œ ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  • ๋‹ค์–‘ํ•œ ๋ณด์•ˆ ๋ฐ ์•ก์„ธ์Šค ๊ทœ์น™์— ๋”ฐ๋ผ ๋ณ„๋„์˜ ๋ฒ„ํ‚ท์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

Create Storage Bucket (๋ฒ„ํ‚ท ๋งŒ๋“ค๊ธฐ)

supabase dashboard๋ฅผ ํ†ตํ•ด ๋ฒ„ํ‚ท์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

  • upload๋œ ์‚ฌ์ง„์„ ๋„ฃ์–ด๋‘˜ ๋ฒ„ํ‚ท์„ ๋งŒ๋“ค์–ด ์ค๋‹ˆ๋‹ค. supabase์—์„œ storage ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ด New bucket์„ ๋ˆŒ๋Ÿฌ ์ƒˆ๋กœ์šด ๋ฒ„ํ‚ท์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

    • ์ด ๋•Œ 'Public bucket' ์„ ์ฒดํฌํ•˜๋ฉด ๋ˆ„๊ตฌ๋‚˜ ์Šน์ธ ์—†์ด ๋ชจ๋“  ๊ฐ์ฒด๋ฅผ ์ฝ์„ ์ˆ˜ ์žˆ๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ๊ฐœ๋ฐœ ์ค‘์—๋Š” ์ฒดํฌํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ์šฉ์ดํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
    • 'Allowed MIME types' ์— ์—…๋กœ๋“œ ํฌ๊ธฐ๋ฅผ ์ œํ•œํ•˜๊ฑฐ๋‚˜, ํ—ˆ์šฉํ•˜๋Š” ์œ ํ˜•์„ ์ œํ•œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฏธ์ง€ ํŒŒ์ผ๋งŒ ๋ฐ›์œผ๋ ค๋ฉด image/*๋ผ๊ณ  ์ ์–ด์„œ ์ œํ•œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • Policies ์—์„œ New policy ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฒ„ํ‚ท์˜ ๊ทœ์น™์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

    • For full customization ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ์„ค์ •์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
    • 'Policy name' ์„ ์ •ํ•˜๊ณ , 'Allowed operation' ์—์„œ ๋ชจ๋“  ์ž‘์—…์ด ๊ฐ€๋Šฅํ•˜๋„๋ก ๋ชจ๋‘ ์ฒดํฌํ•ด์ค€ ๋’ค 'Target roles' ์—์„œ authenticated๋ฅผ ์„ ํƒํ•ด ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๋งŒ ์‚ฌ์šฉ๊ฐ€๋Šฅํ•˜๋„๋ก ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.(ํ•ด๋‹น ํ”„๋กœ์ ํŠธ๋Š” ๊ฒŒ์‹œ๊ธ€์„ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๋งŒ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.)
    • Review > Save policy ๋ฅผ ๋ˆŒ๋Ÿฌ ์„ค์ •์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.

Bucket directory structure (๋ฒ„ํ‚ท ํด๋” ๊ตฌ์กฐ)

ํ”„๋กœ์ ํŠธ์— ๋”ฐ๋ผ ๋ฒ„ํ‚ท๋‚ด์— ์ด๋ฏธ์ง€๋ฅผ ์ €์žฅํ•  ํด๋” ๊ตฌ์กฐ๋ฅผ ์ •ํ•ด์ค๋‹ˆ๋‹ค.

โˆ’ images  ๐Ÿ‘ˆ๐Ÿป ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์‚ฌ์šฉํ•˜๋Š” imageํŒŒ์ผ๋“ค์„ ๋‹ด์„ ๋ฒ„ํ‚ท(์œ„์—์„œ ์ƒ์„ฑ)
   โŽฟ post  ๐Ÿ‘ˆ๐Ÿป ๊ฒŒ์‹œ๊ธ€ ๊ด€๋ จ ์ด๋ฏธ์ง€๋งŒ ๋ชจ์•„๋‘๋Š” ํด๋”
		โŽฟ {user_id}  ๐Ÿ‘ˆ๐Ÿป ์‚ฌ์šฉ์ž๋ณ„ ํด๋”
        	โŽฟ {post_id}  ๐Ÿ‘ˆ๐Ÿป ๊ฒŒ์‹œ๊ธ€ ๋ณ„ ํด๋”
            โŽฟ temporary  ๐Ÿ‘ˆ๐Ÿป ์ž„์‹œ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ €์žฅํ•˜๋Š” ํด๋”
  • ๊ฒŒ์‹œ๊ธ€์— ๊ด€๋ จ๋œ ์‚ฌ์ง„์„ ์ €์žฅํ•  ๊ฒƒ์ด๋ฏ€๋กœ post' ํด๋”๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

  • ๊ฒŒ์‹œ๊ธ€ ๋ฐ์ดํ„ฐ์—์„œ storage์˜ ์‚ฌ์ง„์„ ๊ฐ€์ ธ๋‹ค ๋ Œ๋”๋งํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌ์กฐ๋ฅผ ์งœ์•ผํ•ฉ๋‹ˆ๋‹ค.

    • ๋”ฐ๋ผ์„œ ๊ฒŒ์‹œ๊ธ€์„ ์šฐ์„  '์‚ฌ์šฉ์žID' ๋ณ„๋กœ ํด๋”๋ฅผ ๋‚˜๋ˆ•๋‹ˆ๋‹ค.
    • ์‚ฌ์šฉ์ž๊ฐ€ ์—ฌ๋Ÿฌ ๊ฒŒ์‹œ๊ธ€์„ ์˜ฌ๋ฆด ์ˆ˜๋„ ์žˆ์œผ๋ฏ€๋กœ, ์‚ฌ์šฉ์ž ํด๋” ๋‚ด์—์„œ '๊ฒŒ์‹œ๊ธ€ID'๋ณ„๋กœ ํด๋”๋ฅผ ๋‚˜๋ˆ„๊ธฐ๋กœ ํ•ฉ๋‹ˆ๋‹ค.
  • ์ž‘์„ฑ ์ค‘ ์—…๋กœ๋“œ๋˜๋Š” ์ด๋ฏธ์ง€๋ฅผ ์šฐ์„  'temporary'์— ์ €์žฅํ•˜๊ณ , ๊ฒŒ์‹œ๊ธ€์„ ์ƒ์„ฑํ–ˆ์„ ๋•Œ '๊ฒŒ์‹œ๊ธ€ID' ํด๋”๋กœ ์˜ฎ๊ธฐ๊ธฐ๋กœ ํ•ฉ๋‹ˆ๋‹ค.

    • ์ž‘์„ฑ ์ค‘์—๋Š” ์•„์ง '๊ฒŒ์‹œ๊ธ€ID'๊ฐ€ ์—†๊ธฐ ๋•Œ๋ฌธ์— ์ฒ˜์Œ๋ถ€ํ„ฐ '๊ฒŒ์‹œ๊ธ€ID'ํด๋”๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

Image Upload (์ด๋ฏธ์ง€ ์—…๋กœ๋“œ)

Multi Image Upload Form (๋‹ค์ค‘ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ํผ)

Form ์ž‘์„ฑ

  • ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ์—์„œ react-hook-form์œผ๋กœ form๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค๋ฉด์„œ ์ด๋ฏธ์ง€๋ฅผ ์ €์žฅํ•  ๋นˆ๋ฐฐ์—ด์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค.(react-hook-form์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ๋นˆ ๋ฐฐ์—ด state๋ฅผ ๋งŒ๋“ค์–ด ๋‚ด๋ ค์ค๋‹ˆ๋‹ค.)

  • ์ž์‹ ์ปดํฌ๋„ŒํŠธ์—์„œ wireFrame์— ๋งž์ถฐ form์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

    • ์ฒซ๋ฒˆ์งธ ๋ฐ•์Šค : ์–ธ์ œ๋“  ํด๋ฆญ์‹œ ํŒŒ์ผ์„ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ๋Š” file input ๋ฒ„ํŠผ ์ž…๋‹ˆ๋‹ค.
    • ๋‚˜๋จธ์ง€ ๋ฐ•์Šค : ์ด๋ฏธ์ง€๊ฐ€ ์—†๋‹ค๋ฉด ํด๋ฆญ์‹œ ํŒŒ์ผ์„ ์˜ฌ๋ฆฌ๋Š” ๋ฒ„ํŠผ์ด๊ณ , ์ด๋ฏธ์ง€๊ฐ€ ์žˆ๋‹ค๋ฉด ์ด๋ฏธ์ง€๋ฅผ 3๊ฐœ์”ฉ ๋ฏธ๋ฆฌ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.
const MultiImageForm = (form) => {
  // ๋ฐ›์€ ํŒŒ์ผ์„ ๋ณด์—ฌ์ฃผ๊ธฐ ์œ„ํ•ด ์ด๋ฏธ์ง€URL์„ ์ €์žฅํ•  ๋ฐฐ์—ด ์ƒํƒœ
  const [displayedImages, setDisplayedImages] = useState<string[]>([]);
  // react-hook-form์˜ form๊ฐ์ฒด์—์„œ ๋Œ€ํ‘œ์‚ฌ์ง„ & ์ด๋ฏธ์ง€ ๊ฐ’์„ ๊ฐ์ง€(state์™€
  const selectedRepresentativeImage = form.watch('representative_img');
  const uploadedImages = useWatch({
    control: form.control,
    name: 'post_img',
  });
  
  return (
    <div>
      {/* ๐Ÿ‘‡๐Ÿป ์šฐ์„  ์ด๋ฏธ์ง€๋ฅผ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ๋Š” input box๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค. */}
      <label htmlFor='post_img'>
        <CameraIcon />
        <input 
          type='file' 
          id='post_img' 
          name='post_img'
          accept=".jpg, .jpeg, .png"
          mutiple 
          hidden />
      </label>
    </div>
  )
}

Event Handler (์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ)

  • input์œผ๋กœ file์„ ์—…๋กœ๋“œ ๋ฐ›๋Š” ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.

  • ๋‹ค์ค‘ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ›์œผ๋ฏ€๋กœ File[]์„ ๋ฐ›๊ฒŒ๋ฉ๋‹ˆ๋‹ค. ๋ฐ›์€ ์ด๋ฏธ์ง€ ๋ฐฐ์—ด์„ ์ˆœํšŒํ•˜๋ฉฐ ๊ฐ ์ด๋ฏธ์ง€๋ฅผ ์ธ์ˆ˜๋กœ ํ•˜์—ฌ uploadImages() ํ•จ์ˆ˜(์ด๋ฏธ์ง€๋ฅผ storage์— ์ €์žฅํ•˜๊ณ  ์ƒํƒœ๋ฅผ ์ •์ œํ•˜๋Š” ํ•จ์ˆ˜)๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

const handleFiles = async (e: React.ChangeEvent<HTMLInputElement>) => {
  // ๋ฐ›์•„์˜จ ์ด๋ฏธ์ง€ ํŒŒ์ผ๋“ค์„ fileList ๋ณ€์ˆ˜์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  const fileList = e.target.files;
  // ๋ฐ›์•„์˜จ ํŒŒ์ผ์ด ์žˆ์œผ๋ฉด
  if(fileList) {
    // ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐฐ์—ด๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
    const fileArray = Array.from(fileList);
    // ๋ฐฐ์—ด์„ ์ˆœํšŒํ•˜๋ฉฐ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ํ•จ์ˆ˜์— ๊ฐ ์ด๋ฏธ์ง€๋ฅผ ์ธ์ˆ˜๋กœ ํ•˜์—ฌ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
    fileArray.forEach(file => {
      uploadImages(file);
    });
  }
};

...
  // ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋ฅผ onChange๋กœ ์—ฐ๊ฒฐํ•ฉ๋‹ˆ๋‹ค.
  <input 
     type='file' 
     id='post_img' 
     name='post_img'
     ...
     onChange={handleFiles}
  />
...

storage upload (์Šคํ† ๋ฆฌ์ง€ ์—…๋กœ๋“œ)

  • uploadImages
    • ๋ฐ›์€ ์ด๋ฏธ์ง€๋ฅผ ์ธ์ž๋กœ ํ•˜์—ฌ uploadStorage() ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
    • form๊ฐ์ฒด์˜ post_img ๋ฐฐ์—ด์„ ์—…๋ฐ์ดํŠธ ํ•ฉ๋‹ˆ๋‹ค.
    • storage์— ์˜ฌ๋ฆฐ ์ด๋ฏธ์ง€์˜ URL์„ ๊ฐ€์ ธ์™€ renderImg ๋ฐฐ์—ด์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
      • storage์˜ ์ด๋ฏธ์ง€ URL์€ '๋ฒ„ํ‚ท๋ช…/ํด๋” ๊ฒฝ๋กœ/์ด๋ฏธ์ง€ ์ด๋ฆ„'์œผ๋กœ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.
  • uploadStorage : ์ด๋ฏธ์ง€ ํŒŒ์ผ์„ supabase storage์— ์—…๋กœ๋“œ ํ•˜๋Š” ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค.
const uploadStorage = async(file:File, fileName: string) => {
  // images ๋ฒ„ํ‚ท ๋‚ด post > userId ํด๋” ๋‚ด์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  // temporaryํด๋”์— ๋ฐ›์€ ํŒŒ์ผ๋ช…์œผ๋กœ file์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  const { error } = await supabase.storage
  	.from(`images/post/${userId}`)
  	.upload(`temporary/${fileName}`, file);
  
  if (error) alert('์ด๋ฏธ์ง€ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
}

const uploadImages = async (file:file) => {
  // ์ด๋ฏธ์ง€ ํŒŒ์ผ์„ uuid()ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ์ƒˆ๋กœ ์ด๋ฆ„์„ ์ง“์Šต๋‹ˆ๋‹ค.
  const newFileName = uuid();
  // file๊ณผ ์ƒˆ์ด๋ฆ„์„ ์ธ์ž๋กœ upload ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
  await uploadStorage(file, newFileName);
  
  // form๊ฐ์ฒด์˜ post_img ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธ ํ•ฉ๋‹ˆ๋‹ค.
  const updatedImages = [...form.getValues('post_img'), newFileName];
  form.setValue('post_img', updatedImages);
  
  // temporary์— ์ €์žฅ๋œ ์ƒˆ๋กœ์šด ์ด๋ฏธ์ง€์˜ url์„ ๊ฐ€์ ธ์™€ renderImg ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค.
  const newFileUrl = `${POST_STORAGE_URL}/${userId}/temporary/${newFileName}`;
  setDisplayedImages(prev => [...prev, newFileUrl]);
}

Preview Images (์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ)

map ๋ฉ”์†Œ๋“œ๋กœ <img> ๋ Œ๋”๋ง

  • ์ด๋ฏธ์ง€๊ฐ€ ์—†๊ฑฐ๋‚˜ 3๊ฐœ ์ดํ•˜๋ผ๋ฉด ๋‚จ์€ ์˜์—ญ์€ <label> ๋ฐ•์Šค๋กœ ์ฑ„์›๋‹ˆ๋‹ค.

  • ์ด๋ฏธ์ง€๊ฐ€ ์žˆ๋‹ค๋ฉด map ๋ฉ”์†Œ๋“œ๋กœ <img>๋กœ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

    • ์ด ๋•Œ, ๋ฐ•์Šค ํฌ๊ธฐ๋Œ€๋กœ ์‚ฌ์ง„์ด ๋ณด์ด๊ฒŒ ํ•˜๋ ค๋ฉด css๋กœ 'object-fit'์†์„ฑ๏ผŠ์„ ์„ค์ •ํ•ด์ฃผ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  return (
    ...
      {/* ๐Ÿ‘‡๐Ÿป ์ด๋ฏธ์ง€๊ฐ€ ์žˆ๋‹ค๋ฉด ๋ Œ๋”๋งํ•˜๊ณ  */}
      {displayedImages.map((imgSrc, index) => (
		  <div key={uuid()}>
              <Img src={imgSrc} />
          </div>
      ))}
      {/* ๐Ÿ‘‡๐Ÿป ์ด๋ฏธ์ง€๊ฐ€ 3๊ฐœ๋ณด๋‹ค ์ ๋‹ค๋ฉด ๋‚จ๋Š” ์นธ์€ label๋กœ ์ฑ„์šด๋‹ค. */}
      {uploadedImages.length < 3 &&
            Array.from(3 - uploadedImages.length).map(_ => (
              <label
                key={uuid()}
                htmlFor="post_img"
              />
       ))}
    ...
  )

๐Ÿ“

object-filt

img, video ๋“ฑ์˜ ์š”์†Œ์— ๋น„์œจ์„ ์œ ์ง€ํ•œ ์ฑ„ ํฌ๊ธฐ๋ฅผ ์กฐ์ •ํ•˜๋Š” ์†์„ฑ์ž…๋‹ˆ๋‹ค.

  • fill(default): ๋ฐ•์Šค ํฌ๊ธฐ์— ๋งž์ถฐ ์กฐ์ ˆํ•ด ๋ฐ•์Šค๋ฅผ ์ฑ„์šฐ๋Š” ์†์„ฑ๊ฐ’์ž…๋‹ˆ๋‹ค. (๋น„์œจ์ด ๋ฐ•์Šค์™€ ๋‹ค๋ฅด๋ฉด ์ด๋ฏธ์ง€๊ฐ€ ๋Š˜์–ด๋‚˜๊ฑฐ๋‚˜ ์ค„์–ด๋“  ์ฑ„๋กœ ์ฑ„์›Œ์ง‘๋‹ˆ๋‹ค.)
  • contain: ์ด๋ฏธ์ง€ ๋น„์œจ์„ ์œ ์ง€ํ•œ ์ฑ„ ๋ฐ•์Šค๋ฅผ ์ฑ„์šฐ๋Š” ์†์„ฑ๊ฐ’์ž…๋‹ˆ๋‹ค. (๋น„์œจ์ด ๋‹ค๋ฅด๋ฉด ๋ฐ•์Šค์— ๋นˆ๊ณต๊ฐ„์ด ์ƒ๊น๋‹ˆ๋‹ค.)
  • cover: ์ด๋ฏธ์ง€์˜ ๋น„์œจ์„ ์œ ์ง€ํ•˜๋ฉด์„œ ๋ฐ•์Šค๋ฅผ ์ฑ„์šฐ๋Š” ์†์„ฑ๊ฐ’์ž…๋‹ˆ๋‹ค. (๋น„์œจ์ด ๋‹ค๋ฅด๋ฉด ๋ฐ•์Šคํฌ๊ธฐ์— ๋งž์ถฐ์ง€๋„๋ก ํ™•๋Œ€๋˜์–ด ๋‚จ๋Š” ์ด๋ฏธ์ง€ ๋ถ€๋ถ„์ด ์ž˜๋ฆฝ๋‹ˆ๋‹ค.)
  • none : ์ด๋ฏธ์ง€ํฌ๊ธฐ๋ฅผ ์กฐ์ ˆํ•˜์ง€ ์•Š์€ ์ฑ„, ์›๋ณธ ํฌ๊ธฐ๋Œ€๋กœ ์˜ฌ๋ผ๊ฐ‘๋‹ˆ๋‹ค.

๐Ÿ‘‰๐Ÿป cover ์†์„ฑ๊ฐ’์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. ์ด ๋•Œ objectPosition: 'center'๋กœ ํ•˜์—ฌ ์‚ฌ์ง„์„ ๊ฐ€์šด๋ฐ ์œ„์น˜ํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด๋ฏธ์ง€๋ฅผ ์„ธ๊ฐœ์”ฉ ๋ณด์ด๊ฒŒ ํ•˜๊ธฐ

  • currentPageIndex > 0
    : ์ด๋ฏธ์ง€ ํŽ˜์ด์ง€๊ฐ€ ์ฒซ๋ฒˆ์งธ๊ฐ€ ์•„๋‹ ๋•Œ๋งŒ(์ด์ „ ๋ฒ„ํŠผ์ด ํ•„์š”ํ•œ ๋•Œ) ์ด์ „ ๋ฒ„ํŠผ์ด ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.

  • totalImageCount > 3
    & currentPageIndex < Math.ceil(totalImageCount / 3) - 1
    : ์ด๋ฏธ์ง€๊ฐ€ 3๊ฐœ ์ด์ƒ์ด๊ณ  ํ˜„์žฌ ํŽ˜์ด์ง€๊ฐ€ ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€๊ฐ€ ์•„๋‹ ๋•Œ๋งŒ(๋‹ค์Œ ๋ฒ„ํŠผ์ด ํ•„์š”ํ•œ ๋•Œ) ๋‹ค์Œ ๋ฒ„ํŠผ์ด ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.

  • ์ด๋ฏธ์ง€๋ฅผ 3๊ฐœ์”ฉ ๋‚˜๋ˆ„์–ด ๋ณด์—ฌ์ฃผ๊ธฐ ์œ„ํ•ด ์ „์ฒด ์ด๋ฏธ์ง€ ๋ฐฐ์—ด์—์„œ 3๊ฐœ์”ฉ ์ž˜๋ผ๋‚ด์–ด map ๋ฉ”์†Œ๋“œ๋กœ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

    • currentPageIndex * 3 : slice๋ฅผ ์‹œ์ž‘ํ•  ์ธ๋ฑ์Šค๋กœ, ํ˜„์žฌ ํŽ˜์ด์ง€์— 3์„ ๊ณฑํ•˜๋ฉด ์ด์ „๊นŒ์ง€ ๋ณด์—ฌ์ค€ ์‚ฌ์ง„์˜ ๊ฐฏ์ˆ˜๊ฐ€ ๋˜์–ด ์ด์ „์— ๋ณด์—ฌ์ค€ ์‚ฌ์ง„ ๋‹ค์Œ๋ถ€ํ„ฐ sliceํ•ฉ๋‹ˆ๋‹ค.
    • (currentPageIndex + 1) * 3 : slice๋ฅผ ๋๋‚ผ ์ธ๋ฑ์Šค๋กœ, ๋‹ค์Œ ํŽ˜์ด์ง€์— ๋ณด์ผ ๋งˆ์ง€๋ง‰ 3๋ฒˆ์งธ ์‚ฌ์ง„๊นŒ์ง€ ์ž๋ฅด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
// 3๊ฐœ์”ฉ ๋ณด์ด๊ฒŒ ํ•  ๊ฒƒ์ด๋ฏ€๋กœ ์ด๋ฏธ์ง€ ์ˆซ์ž๋ฅผ ์ƒ์ˆ˜๋กœ ์ €์žฅํ•˜๊ณ  ํŽ˜์ด์ง€๋ฅผ state๋กœ ๋งŒ๋“ญ๋‹ˆ๋‹ค.
const IMAGE_PER_PAGE = 3
const [currentPageIndex, setCurrentPageIndex] = useState(0);
// ์ด๋ฏธ์ง€์˜ ์ด ๊ธธ์ด๋Š” ์ž์ฃผ ์“ฐ์ด๋ฏ€๋กœ ๋”ฐ๋กœ ์„ ์–ธํ•ด ๋‘ก๋‹ˆ๋‹ค.
const totalImageCount = uploadedImages?.length || 0;

// ๋‹ค์Œ 3๊ฐœ์˜ ์ด๋ฏธ์ง€ ํŽ˜์ด์ง€๋กœ ๋„˜์–ด๊ฐ€๋„๋ก ํ•˜๋Š” next ๋ฒ„ํŠผ์˜ ์ด๋ฒคํŠธํ•ธ๋“ค๋Ÿฌ
const handleNextImages = () => {
  // ํ˜„์žฌํŽ˜์ด์ง€๊ฐ€ ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€๊ฐ€ ์•„๋‹ˆ๋ผ๋ฉด ๋‹ค์Œ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.
  if(currentPageIndex < Math.ceil(totalImageCount / IMAGE_PER_PAGE) - 1 {
     setCurrentPageIndex(currentPageIndex + 1);
  }
};

// ์ด์ „ 3๊ฐœ์˜ ์ด๋ฏธ์ง€ ํŽ˜์ด์ง€๋กœ ๋„˜์–ด๊ฐ€๋„๋ก ํ•˜๋Š” prev ๋ฒ„ํŠผ์˜ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ
const handlePrevImage = () => {
  // ํ˜„์žฌ ํŽ˜์ด์ง€๊ฐ€ ์ฒซ๋ฒˆ์งธ ํŽ˜์ด์ง€๊ฐ€ ์•„๋‹ˆ๋ผ๋ฉด ์ด์ „ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.
  if(currentPageIndex > 0) {
    setCurrentPageIndex(currenPageIndex - 1);
  }
};

return (
  ...
  {/* ํ˜„์žฌ ์ด๋ฏธ์ง€ ํŽ˜์ด์ง€๊ฐ€ 0๋ณด๋‹ค ํด๋•Œ๋งŒ '์ด์ „ ๋ฒ„ํŠผ'์ด ๋‚˜์˜ค๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค */}
  {currentPageIndex > 0 && (
  	<button onClick={handlePrevImage}>โฌ…๏ธ</button>
  )}
  {/* ์ด๋ฏธ์ง€๋ฅผ 3๊ฐœ์”ฉ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. */}
  {displayedImages
    .slice(currentPageIndex * IMAGE_PER_PAGE, (currentPageIndex + 1) * IMAGE_PER_PAGE)
    .map((imgSrc) => (
    	<img src={imgSrc} />
    ))
  }
  {/* ์ด๋ฏธ์ง€๊ฐ€ 3๊ฐœ ์ด์ƒ์ด์–ด์„ธ ํŽ˜์ด์ง€๊ฐ€ ํ•„์š”ํ•  ๋•Œ '๋‹ค์Œ ๋ฒ„ํŠผ'์ด ๋‚˜์˜ค๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.*/}
  {totalImageCount > IMAGE_PER_PAGE &&
          currentPageIndex <
            Math.ceil(totalImageCount / IMAGE_PER_PAGE) - 1 && (
            <button onClick={handleNextImage}>โžก๏ธ</button>
   )}
  )

Remove button (์ด๋ฏธ์ง€ ์‚ญ์ œ ๋ฒ„ํŠผ)

  • ์ด๋ฏธ์ง€ ์‚ญ์ œ ๋ฒ„ํŠผ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ๋ฒ„ํŠผ์„ ํด๋ฆญํ–ˆ์„ ๋•Œ deleteImage()ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด์„œ ํด๋ฆญํ•œ ์ด๋ฏธ์ง€์˜ src์ฃผ์†Œ๋ฅผ ๊ฐ™์ด ๋„˜๊ฒจ์ค๋‹ˆ๋‹ค.

  • ๋„˜๊ฒจ๋ฐ›์€ ์ด๋ฏธ์ง€์˜ URL์€ ์—…๋กœ๋“œ์‹œ ๊ตฌ์กฐ์— ๋”ฐ๋ผ images/post/[userId]/temporary/[imgName]์œผ๋กœ ๋˜์–ด์žˆ์Šต๋‹ˆ๋‹ค.

  • ํ•ด๋‹น URL์— ๋”ฐ๋ผ storage์—์„œ ์‚ญ์ œํ•ด์ค๋‹ˆ๋‹ค. 'bucket'์—์„œ 'ํ•ด๋‹น ํŒŒ์ผ'์„ ์‚ญ์ œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ .from({bucket๋ช…})๊ณผ .remove([{์ด๋ฏธ์ง€ ๊ฒฝ๋กœ}/{์ด๋ฏธ์ง€ ์ด๋ฆ„}])์œผ๋กœ ์ ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

  • form๊ฐ์ฒด์—๋Š” imgName๋งŒ ์ €์žฅ์ค‘์ด๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ imgName๋งŒ ์ถ”์ถœํ•˜์—ฌ filter๋กœ ๋ฐฐ์—ด์—์„œ ์‚ญ์ œํ•ด ์—…๋ฐ์ดํŠธ ํ•ฉ๋‹ˆ๋‹ค.

  • displayImages๋ฐฐ์—ด์—์„œ๋„ ์‚ญ์ œํ•ด ๋ฏธ๋ฆฌ๋ณด๊ธฐ์—์„œ๋„ ์‚ญ์ œํ•ด ์ค๋‹ˆ๋‹ค.

// ์ด๋ฏธ์ง€ ์‚ญ์ œ ๋ฒ„ํŠผ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ
const deleteImage = async(imgSrc) => {
  // imgName ์„ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.
  const imgName = imgSrc.split('/').slice(-1)[0];
  
  // storage bucket์—์„œ ์‚ญ์ œํ•ด์ค๋‹ˆ๋‹ค.
  const { error } = await supabase.storage
  	.from('images')
  	.remove([`post/${userId}/temporary/${imgName}`]);
  if (error) alert('์ด๋ฏธ์ง€ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
  
  // form๊ฐ์ฒด์—์„œ ์‚ญ์ œํ•˜๊ณ  ์—…๋ฐ์ดํŠธ ํ•ฉ๋‹ˆ๋‹ค.
  const images = form.watch('post_img').filter(img => img !== imgName);
  form.setValue('post_img', images);
  
  // ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋กœ ๋ณด์—ฌ์ง€๋Š” ์ด๋ฏธ์ง€ ๋ฐฐ์—ด์—์„œ๋„ ์‚ญ์ œํ•ด์ค๋‹ˆ๋‹ค.
  setDisplayedImages(prev => prev.filter(imgUrl => !imgUrl.includes(imgName)));
}
...
return (
  ...
  {displayImages.slice(...)
	.map((imgSrc) => (
  	  <div>
        {/* ๐Ÿ‘‡๐Ÿป ์‚ญ์ œ ๋ฒ„ํŠผ ์ถ”๊ฐ€ */}
    	<button onClick={() => deleteImage(imgSrc)}>โŽ</button>
    	<img src={imgSrc} />
      </div>
  	)
  }
  ...
)

๐Ÿšจ


์ด๋ฏธ์ง€๋ฅผ ์‚ญ์ œํ•˜์—ฌ 3์˜ ๋ฐฐ์ˆ˜๋กœ ๋‚จ์œผ๋ฉด input ๋ฒ„ํŠผ ์™ธ์— ์•„๋ฌด๊ฒƒ๋„ ์—†์–ด์„œ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ˜ผ๋ž€์„ ์•ผ๊ธฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฏธ์ง€๋ฅผ ์‚ญ์ œํ•˜์—ฌ 3์˜ ๋ฐฐ์ˆ˜๋กœ ๋‚จ์œผ๋ฉด ์ด์ „ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋„๋ก ๋กœ์ง์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.

  • totalImageCount % 3 === 0 : ์ด๋ฏธ์ง€ ๊ฐœ์ˆ˜๊ฐ€ 3์˜ ๋ฐฐ์ˆ˜๋กœ ๋‚จ์•˜๊ณ 
  • currentPageIndex > 0 : ํ˜„์žฌ ํŽ˜์ด์ง€๊ฐ€ ๋„˜์–ด๊ฐ€ ์žˆ๋Š” ์ƒํƒœ๋ผ๋ฉด
  • setCurrentPageIndex(currentPageIndex - 1) : ์ด์ „ ํŽ˜์ด์ง€๋กœ ์ด๋™๋ฉ๋‹ˆ๋‹ค.
const deleteImage = async (imgSrc: string) => {
  ...
  if (totalImageCount % 3 === 0 && currentPageIndex > 0) {
        setCurrentPageIndex(currentPageIndex - 1);
  }
  ...
}

Select representative image (๋Œ€ํ‘œ์ด๋ฏธ์ง€ ์„ ํƒ)

๋ผ๋””์˜ค ๋ฒ„ํŠผ ์ถ”๊ฐ€

  • setRepresentativeImage(imgSrc)

    • ๋ผ๋””์˜ค ๋ฒ„ํŠผ ํด๋ฆญ์‹œ ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜๋กœ, ํด๋ฆญ๋œ ์ด๋ฏธ์ง€์˜ src์ฃผ์†Œ๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.
    • ์ด๋ฏธ์ง€ ์ด๋ฆ„์„ ์ถ”์ถœํ•ด form๊ฐ์ฒด์˜ ๋Œ€ํ‘œ์‚ฌ์ง„ ๊ฐ’์œผ๋กœ ํ•ด๋‹น ์ด๋ฏธ์ง€์˜ ์ด๋ฆ„์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • ๋ผ๋””์˜ค ๋ฒ„ํŠผ์ด ์ฒดํฌ๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ(checked)๋Š” ํ•ด๋‹น ์ด๋ฏธ์ง€ URL์— ๋Œ€ํ‘œ์‚ฌ์ง„ ๊ฐ’(์ด๋ฏธ์ง€ ์ด๋ฆ„)์ด ํฌํ•จ๋˜์–ด ์žˆ๋Š”์ง€ ์—ฌ๋ถ€๋กœ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.

    • imgSrc.includes(selectedRepresentaiveImage)
  • ๋ผ๋””์˜ค ๋ฒ„ํŠผ๊ณผ '๋Œ€ํ‘œ์‚ฌ์ง„'๊ธ€์”จ์˜ ์œ„์น˜๋ฅผ css๋กœ ์กฐ์ •ํ•ด ์ค๋‹ˆ๋‹ค.

// form ๊ฐ์ฒด์—์„œ ๋Œ€ํ‘œ์‚ฌ์ง„ ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”์ถœํ•ด ๋ณ€์ˆ˜์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
const selectedRepresentativeImage = form.watch('representative_img');

// ๋ผ๋””์˜ค๋ฒ„ํŠผ ์„ ํƒ์‹œ ๋Œ€ํ‘œ์‚ฌ์ง„์œผ๋กœ ์„ค์ •ํ•˜๋Š” ํ•จ์ˆ˜
const setRepresentativeImage = (imgSrc) => {
  // ์„ ํƒ๋œ ์ด๋ฏธ์ง€์˜ ์ด๋ฆ„์„ ์ถ”์ถœํ•ด ๋Œ€ํ‘œ์‚ฌ์ง„ ๊ฐ’์œผ๋กœ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค.
  const imgName = imgSrc.split('/').slice(-1)[0];
  form.setValue('representative_img', imgName);
};

return (
  ...
  {displayedImages.slice(...)
	.map((imgSrc, index) => (
  	  <div>
    	<button onClick={() => deleteImage(imgSrc)}>โŽ</button>
    	<label htmlFor={`img_${index}`}>
          <input 
            type="radio" 
            id={`img_${index}`} 
            checked={imgSrc.includes(selectedRepresentativeImage)}
            onChange{()=>setRepresentativeImage(imgSrc)}
          />
          <img src={imgSrc} />
          {/* ํ•ด๋‹น ์ด๋ฏธ์ง€ URL์— ๋Œ€ํ‘œ์‚ฌ์ง„์˜ ์ด๋ฆ„์ด ์žˆ๋‹ค๋ฉด '๋Œ€ํ‘œ์‚ฌ์ง„'์ด๋ผ๋Š” ๊ธ€์ž๋ฅผ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.*/}
          {imgSrc.includes(seletecRepresentativeImage) && (
              <p>๋Œ€ํ‘œ์‚ฌ์ง„</p>
          )}
        </label>
      </div>
   ))}
   ...
 )

๐Ÿšจ

๋ชจ๋“  ๊ฒฝ์šฐ์—์„œ ์ €์žฅ๋  ๋•Œ 'post_img'์™€ 'representative_img'๋ฅผ ๋‚˜๋ˆ„์–ด ์ €์žฅํ•˜๋ ค๊ณ  ํ•˜๋‹ˆ, totalImageCount๋ฅผ ๊ณ„์‚ฐํ•˜๊ณ  ์ถ”๊ฐ€, ์‚ญ์ œํ•˜๋Š” ๋กœ์ง์ด ๋ณต์žกํ•ด์กŒ์Šต๋‹ˆ๋‹ค. (๋„ˆ๋ฌด ๋งŽ์ด ๋ณ€๊ฒฝํ•˜๊ณ  ์‚ญ์ œํ•˜์—ฌ ์ด ๋•Œ์˜ ์ฝ”๋“œ๋ฅผ ์ถ”์ ํ•  ์ˆ˜ ์—†์–ด์กŒ์Šต๋‹ˆ๋‹ค๐Ÿ˜ญ)
๐Ÿ‘‰๐Ÿป ๋”ฐ๋ผ์„œ representative_img๋ฅผ ๋”ฐ๋กœ ์ €์žฅํ•˜๋˜, post_img์—๋Š” representative_img๋ฅผ ํฌํ•จํ•ด ์ €์žฅํ•˜๊ณ  ๋‚˜์ค‘์— ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—post_img๋ฅผ ๋„˜๊ธธ ๋•Œ representative_img๋ฅผ ์ œ์™ธํ•˜๊ณ  ๋„˜๊ธฐ๋Š” ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ–ˆ์Šต๋‹ˆ๋‹ค.

const onSubmitPost = (formData) => {
  const postImgExcludeRep = formData.post_img.filter(
      imgName => imgName !== formData.representative_img,
    );
  const Data = {
    post_img: postImgExcludeRep, 
    representative_img: formData.representative_img,
    ...
  }
}

์ฒซ๋ฒˆ์งธ ์‚ฌ์ง„์„ ๋Œ€ํ‘œ์‚ฌ์ง„ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์„ค์ •ํ•˜๊ธฐ

  • ์—…๋กœ๋“œ ๋˜๋Š” ์ฒซ๋ฒˆ์งธ ์ด๋ฏธ์ง€๋ฅผ ์šฐ์„  ๋Œ€ํ‘œ์‚ฌ์ง„์˜ ๊ฐ’์œผ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.
  • ๋ผ๋””์˜ค ๋ฒ„ํŠผ์„ ํด๋ฆญํ–ˆ์„ ๋•Œ ๋Œ€ํ‘œ์‚ฌ์ง„์˜ ๊ฐ’๊ณผ ํ‘œ์‹œ๊ฐ€ ๋ณ€๊ฒฝ๋ฉ๋‹ˆ๋‹ค.

๐Ÿ“Œ ๋‹ค์Œ์€ ์„ค์ •์„ ํ•˜๋ฉฐ ๊ฒช์€ ์—๋Ÿฌ๋“ค์ž…๋‹ˆ๋‹ค. ๋›ฐ์–ด ๋„˜์–ด ์ฝ์œผ์…”๋„ ๋ฉ๋‹ˆ๋‹ค!

๐Ÿšจ

form๊ฐ์ฒด์˜ default๊ฐ’์œผ๋กœ post_img[0]์œผ๋กœ ์„ค์ •ํ•˜๋ ค ํ–ˆ์œผ๋‚˜, ๊ฐ์ฒด๊ฐ€ ๋งŒ๋“ค์–ด ์ง€๊ธฐ ์ „์—๋Š” 'post_img'๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์•„ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿšจ

๋Œ€ํ‘œ์‚ฌ์ง„์„ state๋กœ ๋ณ€๊ฒฝํ•ด ์ดˆ๊ธฐ๊ฐ’์œผ๋กœ post_img[0]์œผ๋กœ ์„ค์ •ํ•˜๊ณ  ๋ผ๋””์˜ค๋ฒ„ํŠผ ํ•ธ๋“ค๋Ÿฌ์—์„œ setํ•จ์ˆ˜๋กœ ๋ณ€๊ฒฝํ•˜๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.

// ๋Œ€ํ‘œ์‚ฌ์ง„ state
const [selectedRepresentativeImage, setSelectedRepresentativeImage] =
    useState(form.watch('house_img')[0]);
// ๋ผ๋””์˜ค๋ฒ„ํŠผ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ
const setRepresentativeImage = (imgSrc: string) => {
  const imgName = imgSrc.split('/').slice(-1)[0];
  setSelectedRepresentativeImage(imgName);
};

๐Ÿ‘‰๐Ÿป state๊ฐ€ ์ƒ์„ฑ๋  ๋•Œ๋งŒ ์ดˆ๊ธฐ๊ฐ’์ด ์„ค์ •์ด ๋˜๋ฏ€๋กœ, ํŽ˜์ด์ง€ ๋ Œ๋”๋ง ํ›„์— ์ด๋ฏธ์ง€๊ฐ€ ์ฒ˜์Œ ์—…๋กœ๋“œ๋˜๋Š” ์‹œ์ ์— ์„ค์ •๋˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ์ฒซ๋ฒˆ์งธ ์ด๋ฏธ์ง€๊ฐ€ ๋Œ€ํ‘œ์‚ฌ์ง„์œผ๋กœ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

๐Ÿšจ

์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ํ•จ์ˆ˜์— ์ด๋ฏธ์ง€ ๋ฐฐ์—ด์˜ ๊ธธ์ด๊ฐ€ 0์ผ ๋•Œ, ์ฆ‰ ์ฒซ๋ฒˆ์งธ ์‚ฌ์ง„์ด๋ผ๋ฉด 'post_img'๊ฐ€ ์•„๋‹Œ 'representative_img'๊ฐ’์œผ๋กœ ์ €์žฅํ•˜๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.

const uploadImages = async (file: File) => {
	const newFileName = uuid();
    await uploadStorage(file, newFileName);
    if (totalImageCount === 0) {
      form.setValue('representative_img', newFileName);
      setRepresentativeImage(newFileName);
    } else {
      const updatedImages = [...form.getValues('post_img'), newFileName];
      form.setValue('post_img', updatedImages);
    }
}

๐Ÿ‘‰๐Ÿป totalImageCount๋Š” 'post_img'๋งŒ ๊ณ„์‚ฐํ•˜๋ฏ€๋กœ ๊ณ„์‚ฐ์— ํฌํ•จ๋˜์ง€ ์•Š์•„ ์•„๋ž˜ ์‚ฌ์ง„์ฒ˜๋Ÿผ ์—…๋กœ๋“œ์‹œ๋งˆ๋‹ค ๋ฐ•์Šค๊ฐ€ ํ•˜๋‚˜์”ฉ ๋Š˜์–ด๋‚ฌ๊ณ  ํŽ˜์ด์ง€ ๊ณ„์‚ฐ์ด ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ์—…๋กœ๋“œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ•˜์—ฌ ๋Œ€ํ‘œ์‚ฌ์ง„์ด ์ฒซ๋ฒˆ์งธ๊ฐ€ ์•„๋‹Œ ๋ ์ด๋ฏธ์ง€์— ํ‘œ์‹œ๊ฐ€ ์ƒ๊ฒจ๋‚ฌ์Šต๋‹ˆ๋‹ค.

๐Ÿ‘‰๐Ÿป ๋˜ํ•œ state๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด์„œ ์‚ญ์ œํ•  ๋•Œ ๋Œ€ํ‘œ์‚ฌ์ง„์ผ ๊ฒฝ์šฐ๋ฅผ ๊ณ ๋ คํ•ด ๋กœ์ง์„ ์ถ”๊ฐ€ํ•ด์•ผํ–ˆ๊ณ , ๋Œ€ํ‘œ์‚ฌ์ง„์œผ๋กœ ์ง€์ •๋œ ์‚ฌ์ง„์ด ์‚ญ์ œ๋˜์—ˆ์„ ๋•Œ ๋‹ค์‹œ ์ฒซ ์ด๋ฏธ์ง€๊ฐ€ ๋Œ€ํ‘œ์‚ฌ์ง„์œผ๋กœ ์ง€์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.


  • state๊ฐ€ ์•„๋‹Œ ๊ธฐ์กด์ฒ˜๋Ÿผ form๊ฐ์ฒด์˜ ๊ฐ’์„ ํ™œ์šฉํ•˜๊ณ , ์ฒ˜์Œ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ์‹œ ์ฒซ๋ฒˆ์งธ ์‚ฌ์ง„์„ ๋Œ€ํ‘œ์‚ฌ์ง„์œผ๋กœ ์ง€์ •๋„๋ก useEffect๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ํ–ˆ์Šต๋‹ˆ๋‹ค.

  • ๋Œ€ํ‘œ์‚ฌ์ง„์œผ๋กœ ์ง€์ •๋œ ์ด๋ฏธ์ง€ ์‚ญ์ œ์‹œ ๋‹ค์‹œ ์ฒซ๋ฒˆ์งธ ์‚ฌ์ง„์ด ๋Œ€ํ‘œ์‚ฌ์ง„์œผ๋กœ ์ง€์ •๋˜๋„๋ก deleteImage()ํ•จ์ˆ˜์— ๋กœ์ง์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.

useEffect(() => {
  if (!selectedRepresentativeImage && totalImageCount > 0) {
    form.setValue('representative_img', uploadedImages[0]);
  }
}, [totalImageCount]);

const deleteImage = async (imgSrc) => {
  const imgName = imgSrc.split('/').slice(-1)[0];
  ...
  // ๐ŸŒŸ ๋Œ€ํ‘œ์‚ฌ์ง„์œผ๋กœ ์ง€์ •๋œ ์ด๋ฏธ์ง€๊ฐ€ ์‚ญ์ œ๋˜๋Š” ๊ฒฝ์šฐ, ๋‹ค์‹œ useEffect๊ฐ€ ๋™์ž‘ํ•˜๋„๋ก ๋นˆ๊ฐ’์œผ๋กœ ๋ฐ”๊ฟ”์ค๋‹ˆ๋‹ค.
  if (imgName === selectedRepresentativeImage) {
    form.setValue('representative_img', '');
  }
  ...
};

Organize storage (์Šคํ† ๋ฆฌ์ง€ ์ •๋ฆฌ)

๐Ÿšจ

์ด๋ฏธ์ง€ ์—…๋กœ๋“œ์‹œ์— storage์— ์˜ฌ๋ฆฌ๊ฒŒ ๋˜๋ฉด์„œ ์‚ฌ์ง„์„ ์—…๋กœ๋“œํ•˜๊ณ  ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์„ ์™„๋ฃŒํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ(์ž‘์„ฑ ์ค‘ ํŽ˜์ด์ง€ ์ด๋™ ๋“ฑ)์—๋„ temporaryํด๋”์— ์ด๋ฏธ์ง€๊ฐ€ ์ €์žฅ๋˜์–ด ์žˆ๊ฒŒ ๋˜๋ฉด์„œ ์ด๋ฏธ์ง€ ๊ฒ€์ฆ ๋กœ์ง์ด ํ•„์š”ํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

  • ํผ ์ œ์ถœ์‹œ์— 'temporary' ํด๋”์— ์ €์žฅ๋œ ์ด๋ฏธ์ง€๋“ค ์ค‘ 'post_img'๋ฐฐ์—ด์— ์žˆ๋Š” ์ด๋ฏธ์ง€๋งŒ 'postID' ํด๋”๋กœ ์˜ฎ๊ธฐ๊ณ  'temporary'ํด๋”์— ๋‚จ์€ ์ด๋ฏธ์ง€๋Š” ๋ชจ๋‘ ์‚ญ์ œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
const moveImageStorage = async (postId) => {
  formData.post_img.forEach(imgName => {
    const { error } = await supabase.storage
      .from('images')
      .move(`${post_id}/${imgName}`, `temporary/${imgName}`);
    
    if (error) alert('์ด๋ฏธ์ง€ ์ด๋™์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
  };
  // ์ด๋™์ด ๋๋‚˜๋ฉด temporary ํด๋”๋ฅผ ๋น„์šฐ๋Š” ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
  removeStorageFile();
};

๐Ÿšจ

์ด๋™ ํ›„ ๋‚จ์€ ์ด๋ฏธ์ง€๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•  ๋•Œ, 'Empty a bucket' ๋กœ์ง์„ ์‚ฌ์šฉํ•˜๋ฉด bucket์„ ํ†ต์งธ๋กœ ๋น„์šฐ๊ฒŒ ๋˜์–ด 'images' ๋ฒ„ํ‚ท ์ „์ฒด๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ('Delete a bucket'๋กœ์ง ๋˜ํ•œ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ bucket์„ ์‚ญ์ œํ•˜๊ฒŒ๋˜์–ด ์ ํ•ฉํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.)

๐Ÿšจ

'Delete files in a bucket'๋กœ์ง์„ ์‚ฌ์šฉํ•ด 'temporary'ํด๋” ๋‚ด ๋ชจ๋“  ์‚ฌ์ง„์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ–ˆ์ง€๋งŒ ํ•ด๋‹น ๋กœ์ง์€ ํ•˜๋‚˜์˜ ํŒŒ์ผ๋งŒ ์‚ญ์ œ๊ฐ€ ๊ฐ€๋Šฅํ•ด ํด๋”๋ช…์ด๋‚˜ .remove([*])๋กœ ์ „์ฒด ํŒŒ์ผ์„ ์ง€์ •ํ•˜๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ๋Š” ๋™์ž‘ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

  • ์ด๋™ ํ›„ 'temporary'์— ๋‚จ์€ ์ด๋ฏธ์ง€ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์™€ ๋ฐ˜๋ณต๋ฌธ์œผ๋กœ ์ˆœํšŒํ•˜๋ฉฐ ๊ฐ ํŒŒ์ผ์„ ์‚ญ์ œํ•ด์ฃผ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
const removeStorageFile = async () => {
  // temporary ํด๋” ๋‚ด์˜ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ฆฌ์ŠคํŠธ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.
  const { data: removeImageList, error: getError} = await supabase.storage
    .from('images')
    .list(`post/${userId}/temporary`, {
      limit: 1000,
      offset: 0
    });
  
  if ( getError ) alert('์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
  
  // ๊ฐ€์ ธ์˜จ ์ด๋ฏธ์ง€ ํŒŒ์ผ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋Œ๋ฉด์„œ ํ•˜๋‚˜ํ•˜๋‚˜ ์‚ญ์ œํ•ด์ค๋‹ˆ๋‹ค.
  removeImageList.forEach(imgObj => {
    const { error } = await supabase.storage
      .from('images')
      .remove([`post/${userId}/temporary/${imgObj.name}`]);
    
    if ( error ) alert('์ด๋ฏธ์ง€ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
  };
}
  • submit ํ•จ์ˆ˜์—์„œ ์ž‘์„ฑํ•œ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
    • moveImageStorage() ๋กœ ์ด๋ฏธ์ง€ ์ด๋™์„ ํ•˜๊ณ ๋‚˜๋ฉด ํ•ด๋‹น ํ•จ์ˆ˜ ๋‚ด๋ถ€์—์„œ removeStorageFile() ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ด ์—ฐ์‡„์ ์œผ๋กœ temporary ํด๋”๋ฅผ ๋น„์šฐ๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.
const onSubmitPost = async (formData) => {
  // ๊ฒŒ์‹œ๊ธ€์„ ์—…๋กœ๋“œํ•˜๋ฉฐ ์„ฑ๊ณต์‹œ ๋ฐ์ดํ„ฐ์™€ post์˜ id๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.
  const { data: insertedData, error } = await supabase
        .from('post')
        .insert(formData)
        .select('id');
  if (error) alert('๊ฒŒ์‹œ๊ธ€ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
  
  // ์œ„์—์„œ ์—…๋กœ๋“œ์— ์„ฑ๊ณตํ•œ ๋ฐ์ดํ„ฐ์—์„œ id๋ฅผ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.
  const postId = insertedData[0].id;
  // ์ด๋ฏธ์ง€ ์ด๋™ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
  moveImageStorage(postId);
}

๐Ÿ”—

ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ์— ๋Œ€ํ•œ ๋ฆฌํŒฉํ† ๋ง ๊ฒŒ์‹œ๊ธ€์ด ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ‘€ Reference

profile
๋ฐฑ ๋ฒˆ์„ ๋ณด๋ฉด ํ•œ ๊ฐ€์ง€๋Š” ์•ˆ๋‹ค ๐Ÿ‘€

1๊ฐœ์˜ ๋Œ“๊ธ€

comment-user-thumbnail
2025๋…„ 6์›” 27์ผ

์•ˆ๋…•ํ•˜์„ธ์š”! supabase storage๋ฅผ ์ด์šฉํ•˜์—ฌ ํ”ผ๋“œ ๊ธฐ๋Šฅ์„ ๊ฐœ๋ฐœํ•˜๋˜ ์ค‘ ์—ฌ๋Ÿฌ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ๊ด€๋ จ ๋‚ด์šฉ ์ž˜ ์ฐธ๊ณ ํ•˜์˜€์Šต๋‹ˆ๋‹ค! ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค!
๊ทธ๋ฆฌ๊ณ  ํ•œ ๊ฐ€์ง€ ์—ฌ์ญค๋ณด๊ณ  ์‹ถ์€ ๊ฒŒ ์žˆ์Šต๋‹ˆ๋‹ค!
ํ˜„์žฌ ์ด๋ฏธ์ง€ ํŒŒ์ผ ์„ ํƒ -> ํ”„๋ฆฌ๋ทฐ๋กœ ํ™œ์šฉ๋  ์ด๋ฏธ์ง€๋“ค์„ supabase storage bucket/userid/temp ํŒŒ์ผ์— ์—…๋กœ๋“œ -> ๋ฐฑ์—”๋“œ๋ฅผ ํ™œ์šฉํ•ด ํ”ผ๋“œ ์—…๋กœ๋“œ(ํ”ผ๋“œ ํ…Œ์ด๋ธ”์— ํ”ผ๋“œ insert) -> ์‘๋‹ต์œผ๋กœ feed_id ๋ฐ›์•„์˜ด -> bucket/userid/feed_id๋กœ temp์— ์žˆ๋˜ ์ด๋ฏธ์ง€๋ฅผ ์ด๋™ํ•จ์œผ๋กœ ์ดํ•ด๊ฐ€ ๋ฉ๋‹ˆ๋‹ค!
์งˆ๋ฌธํ•˜๊ณ  ์‹ถ์€ ๊ฒƒ์€ ์ €๋Š” ๋ฐฑ์—”๋“œ๋กœ ํ”ผ๋“œ๋ฅผ ์—…๋กœ๋“œํ•  ๋•Œ ์ด๋ฏธ์ง€๋“ค์˜ url์„ ํ•จ๊ป˜ ์ €์žฅํ•ด๋‘์—ˆ๋Š”๋ฐ์š”. ํ”ผ๋“œ๋ฅผ ์—…๋กœ๋“œํ•  ์‹œ์—๋Š” ์ด๋ฏธ์ง€ url์ด temp์— ํŒŒ์ผ์ด ์žˆ์„ ๋•Œ์˜ ๋งํฌ๋กœ ์ €์žฅ์ด ๋˜์–ด ์ด๋ฏธ์ง€๋ฅผ ํ›„์— feed_id ํด๋”๋กœ ์˜ฎ๊ธฐ๊ฒŒ ๋์„ ๋•Œ ํ…Œ์ด๋ธ”์— ์ €์žฅ๋œ ์ด๋ฏธ์ง€ ๋งํฌ์™€ ๋ถˆ์ผ์น˜ํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ์ƒ๊น๋‹ˆ๋‹ค.
๊ฒŒ์‹œ๊ธ€์„ ์—…๋กœ๋“œํ•˜์‹ค ๋•Œ ์ด๋ฏธ์ง€ ๋งํฌ๋ฅผ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ•˜์…จ๋‚˜์š”? ์ €๋Š” temp ํด๋”๋ฅผ ๊ฒฝ์œ ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์ œ์™ธํ•˜๋Š” ๊ฒƒ์œผ๋กœ ํ•ด๊ฒฐํ•˜์˜€์Šต๋‹ˆ๋‹ค.

1๋…„ ์ „์˜ ๊ธ€์— ๊ฐ‘์ž๊ธฐ ๋Œ“๊ธ€ ๋‚จ๊ฒจ ์ฃ„์†กํ•ฉ๋‹ˆ๋‹ค!

๋‹ต๊ธ€ ๋‹ฌ๊ธฐ