Next.js + Supabase 데이터 삽입(+이미지 여러장 올리기)

이지·2024년 7월 19일
0

Project

목록 보기
3/9
post-thumbnail

상품을 등록하는 form을 구현할 계획이고 아래의 기능들도 모두 구현되어야 한다.

  • Storage를 사용해서 이미지(여러장) 업로드
  • 이미지 순서 변경 (drag&drop)
  • 이미지 미리보기

form

form 생성


위의 이미지처럼 form을 만들었다.

🖼️ 이미지 여러장 올리기

이미지를 업로드할 수 있는 input의 코드를 더 살펴보자.
ProductManagement

export default function ProductManagement() {
  const multipleImgRef = useRef<HTMLInputElement>(null);
  const [previewImgs, setPreviewImgs] = useState<string[]>(Array(ImageMaxCnt).fill("")); // ImageMaxCnt: 이미지를 올릴 수 있는 최대 장수를 변수로 설정해 외부에서 선언
  const [imgFiles, setImgFiles] = useState<File[]>([]); // 이미지 File 형식을 담은 배열
  ...
  
  const handleMultipleImgAddBtn = () => {
    multipleImgRef.current?.click();
  };
  
  const handleMultipleImgInput = () => {
  // 파일 입력 요소에서 선택된 파일들을 가져옴
  const files = multipleImgRef.current?.files;
  if (!files) return; // 파일이 선택되지 않았으면 함수 종료

  const filesArray = Array.from(files); // files는 객체이기 때문에 배열로 변환

  // 기존 이미지 파일 배열에 새로운 파일 배열을 추가하고, 최대 이미지 개수를 초과하지 않도록 자름
  setImgFiles((prevImgFiles) =>
    [...prevImgFiles, ...filesArray].slice(0, ImageMaxCnt)
  );

  // 기존 이미지 URL 리스트를 복사
  const updatePreviewImgs = [...previewImgs];

  // 새로운 파일에서 생성된 이미지 URL 배열 생성
  const imgUrls = filesArray.map((file) => URL.createObjectURL(file));

  for (const imgUrl of imgUrls) {
    // 빈 자리(빈 문자열)의 인덱스를 찾음
    const emptyIndex = updatePreviewImgs.indexOf("");
    if (emptyIndex !== -1) {
      // 빈 자리가 있으면 해당 자리에 이미지 URL을 삽입
      updatePreviewImgs[emptyIndex] = imgUrl;
    }
  }

  // 이미지 URL 리스트 상태를 업데이트
  setPreviewImgs(updatePreviewImgs);
};

	return(
		...
		<div>
		  <input
		    type="file"
		    id="image"
		    name="image"
		    accept=".jpg,.jpeg,.png,.gif"
		    ref={multipleImgRef}
		    onChange={handleMultipleImgInput}
		    multiple // ⏪️
		  />
		  {previewImgs.map((img, idx) => (
		    <div key={idx}>
		      <ImagePreview
		        src={img}
		        alt="미리보기"
		        idx={idx}
		        img={img}
		        onClick={handleMultipleImgAddBtn}
		      />
		    </div>
		  ))}
		</div>;
)
  • 여러장의 파일을 선택하기위해 input에 multiple을 추가한다.
  • 기존의 input의 디자인이 예쁘지 않기 때문에 보이지 않게 설정하고, 이미지를 추가할 수 있는 버튼을 따로 만들어 해당 버튼의 onClick={handleMultipleImgAddBtn}을 통해 input에 설정된 ref={multipleImgRef}이 클릭되도록 한다.
  • previewImgs는 이미지 미리보기를 위한 배열로 이미지 파일이 선택되면 onChange={handleMultipleImgInput}을 통해 imgs에 미리보기를 위한 url이 담긴다.
  • imgFiles는 File 형식의 이미지를 담은 배열로 Storage에 이미지를 업로드하려면 File 형식을 전달해줘야 하기 때문

🧼 이미지 삭제


이미지 미리보기가 생성되면 삭제 버튼이 활성화되는데 여기에 handleImgDelete를 설정해 이미지를 삭제한다.

const handleImgDelete = (id: number) => {
    setPreviewImgs([...previewImgs.filter((_, idx) => idx !== id), ""]);
    setImgFiles([...imgFiles.filter((_, idx) => idx !== id)]);
  };

🔄 이미지 순서 변경

예전에 드래그 앤 드랍 구현을 위해 react-beautiful-dnd를 사용해서 구현한 적이 있는데, 이번에는 라이브러리 없이 구현해보았다.

드래그 관련 이벤트

  • dragstart: 사용자가 요소나 텍스트 블록을 드래그하기 시작했을 때 발생
  • dragEnter: 드래그한 요소나 텍스트 블록을 적합한 드롭 대상 위에 올라갔을 때 발생
  • drop: 요소나 텍스트 블록을 적합한 드롭 대상에 드롭했을 때 발생
export default function ProductManagement() {
  ...
  // 드래그 중인 이미지 인덱스를 저장하기 위한 useRef
  const dragImgIdx = useRef<number | null>(null);
  const dragOverImgIdx = useRef<number | null>(null);

  // 드래그 시작 시 호출되는 함수
  const dragStart = (e: DragEvent, position: number) => {
    dragImgIdx.current = position;
  };

  // 드래그 중 다른 이미지 위로 들어왔을 때 호출되는 함수
  const dragEnter = (e: DragEvent, position: number) => {
    dragOverImgIdx.current = position;
  };

  // 드래그가 끝났을 때 호출되는 함수(이미지 미리보기 배열과 이미지 파일이 담긴 배열을 둘 다 업데이트)
  const drop = (e: DragEvent) => {
    // 현재 이미지 리스트와 파일 리스트 복사
    const newImgList = [...previewImgs];
    const newImgFileList = [...imgFiles];

    // 드래그된 이미지와 파일 값 저장
    const dragImgValue = newImgList[dragImgIdx.current!];
    const dragImgFileValue = newImgFileList[dragImgIdx.current!];

    // 드래그된 이미지를 리스트에서 제거
    newImgList.splice(dragImgIdx.current!, 1);
    // 드래그된 이미지를 새로운 위치에 삽입
    newImgList.splice(dragOverImgIdx.current!, 0, dragImgValue);

    // 드래그된 파일을 리스트에서 제거
    newImgFileList.splice(dragImgIdx.current!, 1);
    // 드래그된 파일을 새로운 위치에 삽입
    newImgFileList.splice(dragOverImgIdx.current!, 0, dragImgFileValue);

    // 드래그 인덱스를 초기화
    dragImgIdx.current = null;
    dragOverImgIdx.current = null;

    // 상태 업데이트
    setPreviewImgs(newImgList);
    setImgFiles(newImgFileList);
  };

  ...

  return (
    <div>
      <form onSubmit={handleSubmit}>
        ...
          {previewImgs.map((img, idx) => (
            <div key={idx}>
              <ImagePreview
                ...
                onDragStart={dragStart}
                onDragEnter={dragEnter}
                onDragEnd={drop}
              />
            </div>
          ))}
        </div>
      </form>
    </div>
  );
}

ImagePreview

export default function ImagePreview({
  ...
  onDragStart,
  onDragEnter,
  onDragEnd,
}: Props) {
  return (
    <>
      <button type="button"	onClick={onClick}>
        <Image src={ImageIcon} alt="이미지 추가" />
      </button>
      {img && (
        <>
          <Image
            ...
            draggable
            onDragStart={(e) => onDragStart(e, idx)}
            onDragEnter={(e) => onDragEnter(e, idx)}
            onDragEnd={onDragEnd}
            onDragOver={(e) => e.preventDefault()}
          />
          <button type="button" onClick={() => onDelete(idx)}>
            <Image src={DeleteIcon} alt="이미지 삭제" />
          </button>
        </>
      )}
    </>
  );
}

라이브러리 없이 구현하고 느낀점은 라이브러리를 썼을 때보다 간단하게 구현이 가능했지만 드래그앤드랍 시 애니메이션이 깔끔한 react-beautiful-dnd가 그리워졌다.. 일단은 이렇게 구현해놓고 나중에 라이브러리를 도입하거나 직접 구현할 예정이다.

나머지 input

이미지 외의 상품 이름, 가격 등은 onBlur(focus가 해제될 때)가 발생하면 productInfo 에 담기도록 해줬다.

const handleInput = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setProductInfo((prev) => ({ ...prev, [name]: value }));
  };

Submit

Storage에 이미지 업로드하기

ProductManagement

import { v4 } from "uuid";
...
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const uploadedImgUrls = await handleUploadStorage(imgFiles);

    const error = await addProductAction({
      ...productInfo,
      image: uploadedImgUrls,
    });
    if (error) {
      console.error("상품 등록 실패", error);
      return;
    }

    console.log("상품 등록 성공!");
  };

const handleUploadStorage = async (files: File[]) => {
    const filefolder = v4(); // 고유의 파일명을 생성
    const uploadedImgUrls = [];

    // storage에 이미지 업로드
    for (let i = 0; i < files.length; i++) {
      const { data, error } = await supabase.storage
        .from("Image")
        .upload(`products/${filefolder}/${i}`, files[i]);

      if (error) {
        console.error("이미지 업로드에 실패했습니다.", error);
        return;
      }
      
      // storage에 담긴 이미지의 publicUrl 가져오기
      const res = await supabase.storage.from("Image").getPublicUrl(data.path);
      uploadedImgUrls.push(res.data.publicUrl);
    }

    return uploadedImgUrls;
  };
  1. form이 submit된 경우 handleSubmit 실행
  2. 이미지를 storage에 추가
  3. 해당 이미지의 publicUrl을 배열에 담음
  4. addProductAction에 입력된 상품 정보와 이미지를 넣어서 실행

데이터 삽입

export async function addProductAction(formData: Product) {
  const supabase = createClient();
  const image = formData.image;

  const { error } = await supabase
    .from("product")
    .insert({ ...formData, image });

  if (error) {
    return error;
  }
  redirect("/sellercenter/product");
}

+ 데이터베이스 접근 권한 설정

product에 접근하기 위해서 policy 설정이 필요한데, auth에 동일한 id가 있고, user_type이 SELLER인 경우에만 접근이 가능하도록 policy 설정을 해줬다.

product의 column에는 상품을 등록한 유저(판매자)의 id도 같이 저장되어야 하는데
seller_id 컬럼의 Data Type => uuid, Default Value => auth.uid()

Foreign Key는 아래처럼 설정해줬다.

이렇게 설정하고 상품등록을 진행하면 현재 로그인한 유저의 id가 자동으로 product 데이터베이스에 함께 담기게 된다.

로그인과 회원가입을 구현할 때 Server Action을 사용해서 formData를 좀 더 쉽게 관리할 수 있었기 때문에 상품등록에서도 Server Action을 사용하려고 했으나, 이미지가 여러 장일 때 폼에서 이미지를 삭제하는 과정에서 Server Action에서 받아온 formData의 이미지가 업데이트가 안되는 문제가 있어 Server Action을 사용하지 않았다.(Server Action을 활용하고싶어서 여러 방법을 시도해봤지만 실패했다 😞) 더 간단한 폼이라면 Server Action을 활용하는 것을 추천한다! 참고

참고

0개의 댓글