상품을 등록하는 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>;
)
multiple을 추가한다.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가 그리워졌다.. 일단은 이렇게 구현해놓고 나중에 라이브러리를 도입하거나 직접 구현할 예정이다.
이미지 외의 상품 이름, 가격 등은 onBlur(focus가 해제될 때)가 발생하면 productInfo 에 담기도록 해줬다.
const handleInput = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setProductInfo((prev) => ({ ...prev, [name]: value }));
};
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;
};
handleSubmit 실행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을 활용하는 것을 추천한다! 참고