상품을 등록하는 폼은 지난번에 올렸던 회원가입 폼과 똑같이 구현했다.
상품 등록 폼 스키마를 설정해주고
const formSchema = z.object({
id: z.string(),
category: z.string(),
name: z.string(),
price: z.preprocess(Number, z.number()),
image: z.any(),
// ...(생략)
});
폼을 선언한다.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
id: product?.id || uuid,
category: product?.category || "",
name: product?.name || "",
price: product?.price || 0,
image: previewImages,
// ...(생략)
},
});
다른 부분은 다 비슷한데 신경써줘야 하는 부분이 있다.
바로, 이미지를 업로드하는 부분이다.
먼저 렌더링 되는 UI 요소들은 다음과 같다.

<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormControl>
{/* 이미지 미리보기 및 삭제 버튼 */}
<div className="flex">
{/* 이미지 미리보기 */}
<div className="flex gap-2">
{previewImages?.map((image, id) => (
<div key={id} className="w-20 h-20 relative">
<img
src={image}
decoding="async"
loading="lazy"
alt={`${image}-${id}`}
className="w-full h-full absolute"
/>
{/* 이미지 삭제 버튼 */}
<button
id="delete_image"
onClick={(e) => deleteImage(e, id)}
className="absolute right-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#000000"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
))}
</div>
{/* 파일 선택 input */}
<Label htmlFor="input-file">
<div className="flex justify-center items-center w-20 h-20 rounded border border-zinc-400 bg-zinc-100">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#828282"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{/* 파일 선택 input */}
<input
{...field}
type="file"
className="hidden"
multiple
id="input-file"
onChange={addImages}
onKeyDown={onKeyDown}
/>
</div>
</Label>
</div>
</FormControl>
</FormItem>
)}
/>
이제 이미지를 등록하는 input의 onChange로 들어가는 addImages 함수를 작성해보자.
위에 선언했던 폼에 들어가는 값들 중에 image 값을 보면, previewImages가 들어가는 것을 알 수 있다. 그렇다면 addImages 함수에서는 previewImages에 값(이미지)을 추가하는 로직이 작성되어야 한다.
const [previewImages, setPreviewImages] = useState<string[]>([]); // blob data
// 이미지를 추가하는 함수
function addImages(e: React.ChangeEvent<HTMLInputElement>) {
e.preventDefault();
let images = e.target.files;
let previewImageUrlList = [...previewImages];
if (images) {
for (let i = 0; i < images.length; i++) {
const previewImageUrl = URL.createObjectURL(images[i]);
previewImageUrlList.push(previewImageUrl);
}
setPreviewImages(previewImageUrlList);
}
}
// 이미지를 삭제하는 함수
function deleteImage(e: MouseEvent<HTMLElement>, id: number) {
e.preventDefault();
setPreviewImages(previewImages.filter((_, index) => index !== id));
}
previewImageUrlList 라는 배열을 생성하여 이전 previewImages에 들어있던 이미지들을 가져온 뒤, e.target.files를 통해 새로 선택된 FileList 형태의 파일(이미지) 배열을 가져와서 합친다.(input 태그의 multiple 옵션을 사용해 다중 이미지 선택이 가능하다.)
이렇게 합쳐진 파일 배열을 순회하면서 각 파일에 대한 URL을 생성하여 이 URL을 previewImageUrlList에 추가한다.
URL은 URL.createObjectURL() 함수를 통해 파일 객체를 URL로 변환하여 생성된다.
URL.createObjectURL는 Blob 객체를 url(현재 윈도우의 document에서만 접근 가능)로 만들때 사용된다. 이를 통해 image를 입력 받았을 때 화면에 보여줄 수 있는 임시 url을 생성할 수 있다.
(이미지를 삭제하는 함수는 설명 생략)
이미지 업로드 구현을 마치고 나서, 성능 최적화에 대한 부분도 고민하게 되었다. lighthouse를 돌려봤더니 맨 위에 뜨는 경고 메시지가 이미지 크기에 대한 부분이었기 때문이다.
lighthouse 처음 사용해봤다.
애초에 이미지 크기를 원본 크기대로 올릴 필요가 없기 때문에 이미지를 올리기 전에 이미지 리사이징을 진행했다.
import Resizer from "react-image-file-resizer";
react-image-file-resizer 라는 라이브러리를 사용했다.
export const resizeFile = (file: Blob): Promise<string> =>
new Promise((resolve) => {
Resizer.imageFileResizer(
file,
300, // 최소 넓이. 후에 800으로 변경함 (아래에서 설명)
300, // 최소 높이. 후에 800으로 변경함
"WEBP",
100,
0,
(uri) => {
resolve(URL.createObjectURL(uri as Blob));
},
"blob"
);
});
Blob 형태로 받은 파일의 적절한 사이즈와 형식을 설정해주고 반환값인 Blob을 URL.createObjectURL 메소드를 사용하여 URL로 변환한 다음 Promise를 통해 반환한다.
// 이미지를 추가하는 함수
async function addImages(e: ChangeEvent<HTMLInputElement>) {
e.preventDefault();
let images = e.target.files;
if (images) {
// 성능 최적화를 위한 이미지 리사이징
const resizedImages = await Promise.all<string>(Array.from(images).map(resizeFile));
// 기존 이미지와 새로 리사이징된 이미지를 합친 후 previewImages에 저장
setPreviewImages((prev) => [...prev, ...resizedImages]);
}
}
그리고 addImages에서 받은 이미지 파일들을 리사이징하여 previewImages에 저장한다. (Promise.all을 사용하여 동시에 수행되며, 모든 리사이징 작업이 완료될 때까지 기다린다.)
이미지 리사이징을 수행하고 나서 같은 이미지를 업로드해 본 결과 이미지의 크기가 93,802바이트에서 12,170바이트로 줄은 것을 확인할 수 있었다.
그리고 lighthouse를 돌려봤더니 성능 개선..은 잘 모르겠지만 추천에 뜨는 '이미지 크기 적절하게 설정하기'가 사라진 것을 확인할 수 있었다.
최소 가로와 최소 높이를 300으로 설정해 주었더니 이미지 화질이 현저히 깨지는 문제가 발생하였다. 당시 크기가 크지 않은 파일들로 테스트를 하면서 개발하였기 때문에 빠뜨렸던 부분이었다.
이미지의 실제 크기는 보여줘야 하는 크기보다 2배 정도 크면 된다고 한다.
따라서 이미지가 가장 크게 보여지는 상품 상세 (가로, 세로 400px) 보다 2배 큰 800으로 적용해주었다.
더 치명적인 사실을 여태껏 간과하고 있었다.
이후 진행했던 프로젝트에서 이미지를 등록하는 로직을 구현하는 과정을 구현하던 중, 이미지 압축 품질을 설정하면 이미지 크기를 훨씬 줄일 수 있다는 사실을 알게 되었다.
export const resizeFile = (file: Blob): Promise<string> =>
new Promise((resolve) => {
Resizer.imageFileResizer(
file,
800,
800,
"WEBP",
80, // 압축 품질 설정
0,
(uri) => {
resolve(URL.createObjectURL(uri as Blob));
},
"blob"
);
});
따라서 이전 코드의 압축 품질을 100 -> 80으로 줄였더니 408.88KB -> 42KB로 약 89.73%(9.74배) 줄은 것을 확인할 수 있었다. (image0과 image1은 모두 동일한 파일을 업로드 한 것)
압축 품질을 줄였지만 이미지를 올린 후 확인해보았더니, 눈으로 확인하였을 때 크게 이미지가 달라지는 부분이 없다는 점도 확인하였다.
심지어 고급 압축 기술을 지원하는 WEBP 형식을 사용하면서, 압축을 진행하지 않았다는 사실이 참.. 바보같다. 지금이라도 알게 되어서 다행이기도 하다.

그리고 실제로 firebase에 해당 상품을 등록하는 코드는 다음과 같다.
이번에도 이미지 등록을 신경써줘야 한다.
다른 항목들처럼 setDoc을 통해 Firestore Database에 바로 등록하는 것이 아니라, Firebase Storage에 저장해야 한다.

Firebase Storage: 주로 파일(이미지, 비디오, 오디오 등)을 안전하게 저장하고 관리한다.
Firestore Database에는 크기 제한이 있어 이미지와 같은 이진 데이터를 문서에 직접 저장하기에 적합하지 않다. 또한, 이진 데이터를 문서에 포함하면 실시간 동기화 및 쿼리 성능에 영향을 줄 수 있다. 매번 문서를 가져올 때마다 이진 데이터를 함께 가져와야 하므로 네트워크 오버헤드가 발생할 수 있다.
따라서 실제 이미지는 Firebase storage에 저장하고, 해당 이미지에 대한 URL을 Firestore Database에 저장하는 것이 좋다.
이러한 이유로 이미지는 따로 저장을 해줘야 한다.
export async function fbAddProduct(product: ProductType) {
const storage = getStorage();
const productRef = doc(db, "products", product.id);
// Firebase database에 업로드
await setDoc(productRef, {
id: product.id,
category: product.category,
name: product.name,
// ...(생략)
});
const images: string[] = [];
// Firebase Storage에 이미지 업로드
// Promise.all 사용하여 모든 이미지의 업로드를 병렬로 처리
await Promise.all(
product.image.map(async (image: string, index: number) => {
const imageRef = ref(storage, `products/${product.id}/image${index}`);
const blob = await blobUriToBlob(image);
try {
const snapshot = await uploadBytes(imageRef, blob);
// Storage: 파일을 다운로드할 수 있는 downloadURL 제공
const downloadURL = await getDownloadURL(snapshot.ref);
images.push(downloadURL);
} catch (e) {
console.error(`${index}번째 이미지 업로드 실패:`, e);
}
})
);
// docs에 이미지 downloadURL 배열 업로드
await updateDoc(productRef, { image: images });
}
그리고 추가로 상품 등록 뿐만 아니라 등록한 상품을 수정하는 데에 있어서도 같은 폼을 사용해야 했기 때문에 이전에 저장되어 있던 이미지들을 데이터베이스에서 가져와야 하고, 미리보기로 보여줘야 한다.
useEffect(() => {
if (product) {
getPrevImages();
}
}, []);
// 이미 업로드되어 있던 상품의 이전 이미지들 가져오기
async function getPrevImages() {
if (product && product?.image) {
const result = await fbGetPrevImagesURL(product.id, product?.image);
setPreviewImages(result); // preview : blob
}
}
// firebase 통신
export async function fbGetPrevImagesURL(id: string, images: string[]): Promise<string[]> {
const storage = getStorage();
const prevImages: string[] = [];
for (let i = 0; i < images.length; i++) {
const imageRef = ref(storage, `products/${id}/image${i}`);
const result = await getBlob(imageRef);
const blobURL = URL.createObjectURL(result);
prevImages.push(blobURL);
}
return prevImages;
}