Repository: llddang/todayeat
Pull request #73: Feature/#68 register image
Author: login: llddang
Labels: name: ✨ Feature
description: 기능 개발
name: 🎨 Design
description: 마크업 & CSS 스타일링
Created: 2025-04-10T13:51:56Z | Merged: 2025-04-11T10:47:12Z

## #️⃣ 연관된 이슈

- close #68

<br>

## 📝 작업 내용

- 식단 기록 페이지에서 처음 이미지를 등록하는 컴포넌트 구현

<br>

## 🖼 스크린샷

|등록 안 했을 경우|한개의 이미지만 등록한 경우|3개 이미지를 등록한 경우|
|----|----|----|
|![스크린샷 2025-04-10 오후 10 44 30](https://github.com/user-attachments/assets/e5c4b7a9-487e-493f-85b7-8f21e765ee5a)|![스크린샷 2025-04-10 오후 10 44 37](https://github.com/user-attachments/assets/f4dcdf7a-86a9-4ed2-b48a-5bd6120b56f3)|![스크린샷 2025-04-10 오후 10 44 47](https://github.com/user-attachments/assets/7b0db652-8d1a-4d6c-be06-632366ffbb19)|


<br>

## 💬 리뷰 요구사항

- 리뷰 예상 시간 :`15분`
- `MealPostAddImagePreviewGallery` 코드의 로직에서 문제는 없습니다.
- 하지만 strictMode에서 useEffect가 2번 동작하면서 blob이 삭제되면서, 초기에 사진이 안 뜨는 경우가 있습니다.
- 로직 상에 문제는 없고, strictMode를 우회하는 방법보다 로직상에 문제가 없으니 주석을 남겨놓는 것을 추천받았습니다!

<details>
<summary>테스트 코드</summary>

```tsx
'use client';

import MealPostAddImage from '@/components/_meal/_post/meal-post-add-image';
import { useState } from 'react';

const HomePage = () => {
  const [imageFiles, setImageFiles] = useState<File[]>([]);

  return (
    <div className="px-4 py-8">
      <MealPostAddImage imageFiles={imageFiles} setImageFiles={setImageFiles} />
    </div>
  );
};

export default HomePage;
```

</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **신규 기능**
	- 식사 이미지 업로드 기능 추가: 사용자는 업로드 버튼을 통해 최대 3장의 이미지를 선택할 수 있으며, 파일 크기 제한과 중복 체크가 적용됩니다.
	- 업로드된 이미지는 미리보기 갤러리로 표시되며, 각 이미지 옆에 삭제 버튼이 제공됩니다.
	- 업로드 인터페이스는 빈 이미지 슬롯과 안내 일러스트를 포함하여 사용자 경험을 개선하였습니다.
	- 새로운 상수 추가: 최대 파일 크기 및 최대 이미지 수를 정의하는 상수가 추가되었습니다.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Linked issue #68: [Feature] 사진 등록하는 컴포넌트 구현
# ✨ Feature

### 1. 어떤 기능인가요?

- 식단 기록의 첫 화면에서 사진을 등록할 수 있는 컴포넌트를 구현할 예정입니다.
<img width="778" alt="Image" src="https://github.com/user-attachments/assets/3cb905e9-f685-4667-9ee1-24cfe69e5910" />

<br>

### 2. 작업 상세 내용

- [x] 버튼 클릭 시 사진을 업로드할 수 있도록 수정
- [x] 미리보기 사진 등록/삭제 구현

<br>

### 3. 참고할만한 자료 (선택)


Diff:
diff --git a/public/icons/close_line.svg b/public/icons/close_line.svg
new file mode 100644
index 00000000..00a90327
--- /dev/null
+++ b/public/icons/close_line.svg
@@ -0,0 +1 @@
+<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/><path fill='#2F2F2FFF' d='m12 13.414 5.657 5.657a1 1 0 0 0 1.414-1.414L13.414 12l5.657-5.657a1 1 0 0 0-1.414-1.414L12 10.586 6.343 4.929A1 1 0 0 0 4.93 6.343L10.586 12l-5.657 5.657a1 1 0 1 0 1.414 1.414z'/></g></svg>
\ No newline at end of file
diff --git a/public/icons/pic_line.svg b/public/icons/pic_line.svg
new file mode 100644
index 00000000..5c721fd0
--- /dev/null
+++ b/public/icons/pic_line.svg
@@ -0,0 +1 @@
+<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g fill='none'><path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/><path fill='#CECECEFF' d='M20 3a2 2 0 0 1 1.995 1.85L22 5v14a2 2 0 0 1-1.85 1.995L20 21H4a2 2 0 0 1-1.995-1.85L2 19V5a2 2 0 0 1 1.85-1.995L4 3zM9.879 12.05l-5.657 5.657a1.002 1.002 0 0 1-.222.168V19h16v-1.125a1 1 0 0 1-.222-.168L16.95 14.88l-.707.707.207.207a1 1 0 0 1-1.415 1.414L9.88 12.05ZM20 5H4v10.1l4.995-4.994a1.25 1.25 0 0 1 1.666-.091l.101.09 4.066 4.067 1.238-1.238a1.25 1.25 0 0 1 1.666-.091l.102.091L20 15.101zm-4.5 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3'/></g></svg>
\ No newline at end of file
diff --git a/public/illustrations/image-upload.svg b/public/illustrations/image-upload.svg
new file mode 100644
index 00000000..23eadffb
--- /dev/null
+++ b/public/illustrations/image-upload.svg
@@ -0,0 +1,14 @@
+<svg width="512" height="480" viewBox="0 0 512 480" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M406.999 300C415.4 300 421.999 293.399 421.999 285V266.1L346.999 105L217.47 137.382C223.489 128.034 226.999 116.923 226.999 105C226.999 71.909 200.075 45 166.999 45C133.923 45 106.999 71.909 106.999 105C106.999 127.448 119.397 147.039 137.693 157.327L106.999 165L2.99805 360.3C8.99905 377.699 25.498 390 44.999 390H316.999C325.4 390 331.999 383.399 331.999 375C331.999 333.6 365.599 300 406.999 300ZM458.093 353.892L429.177 325.093C423.71 318.273 414.937 315.088 406.999 315.088C397.715 315.088 389.105 319.642 385.905 323.892L356.198 353.614C343.686 365.437 344.372 385.246 355.641 395.831C361.149 401.66 368.942 405 376.999 405C376.999 421.816 390.183 435 406.999 435C423.815 435 436.999 421.816 436.999 405C445.349 405 453.259 401.543 457.8 396.387C470.123 384.741 469.89 365.037 458.093 353.892Z" fill="url(#paint0_linear_593_5443)"/>
+<path d="M328.715 149.626L422 266.1V45C422 20.147 401.853 0 377 0H45C20.147 0 0 20.147 0 45V345C0 350.4 0.901 355.499 2.999 360.3L125.289 209.665C131.291 202.149 142.715 202.144 148.723 209.655L191.287 262.859C197.292 270.365 208.708 270.365 214.713 262.859L305.293 149.633C311.296 142.129 322.708 142.126 328.715 149.626ZM167 150C142.099 150 122 129.899 122 105C122 80.099 142.099 60 167 60C191.901 60 212 80.099 212 105C212 129.899 191.901 150 167 150ZM407 270C349.1 270 302 317.1 302 375C302 432.9 349.1 480 407 480C464.9 480 512 432.9 512 375C512 317.1 464.9 270 407 270ZM447.499 385.499C442.103 391.499 431.899 391.501 426.5 385.499L421.999 381.299V405C421.999 413.399 415.4 420 406.999 420C398.598 420 391.999 413.399 391.999 405V381.299L387.498 385.499C381.798 391.499 372.2 391.499 366.499 385.499C360.499 379.799 360.499 370.199 366.499 364.499L396.499 334.499C400.963 328.917 412.63 328.412 417.498 334.499L447.498 364.499C453.5 370.199 453.5 379.799 447.499 385.499Z" fill="url(#paint1_linear_593_5443)"/>
+<defs>
+<linearGradient id="paint0_linear_593_5443" x1="234.995" y1="435" x2="234.995" y2="45" gradientUnits="userSpaceOnUse">
+<stop stop-color="#FFD2D6"/>
+<stop offset="1" stop-color="#FFEDEF"/>
+</linearGradient>
+<linearGradient id="paint1_linear_593_5443" x1="256" y1="480" x2="256" y2="0" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F95943"/>
+<stop offset="1" stop-color="#FFD2D6"/>
+</linearGradient>
+</defs>
+</svg>
diff --git a/src/components/_meal/_post/meal-post-add-image-button.tsx b/src/components/_meal/_post/meal-post-add-image-button.tsx
new file mode 100644
index 00000000..93e008f5
--- /dev/null
+++ b/src/components/_meal/_post/meal-post-add-image-button.tsx
@@ -0,0 +1,54 @@
+import { ChangeEvent, useRef } from 'react';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  handleImageFilesChange: (files: File[]) => void;
+};
+const MealPostAddImageButton = ({ imageFiles, handleImageFilesChange }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+
+    const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
+    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+
+    const hasDuplicatedImage = fileArray.filter((newFile) =>
+      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+    );
+    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+
+    handleImageFilesChange([...imageFiles, ...fileArray]);
+  };
+
+  return (
+    <>
+      <label htmlFor="meal-image" className="sr-only">
+        음식 이미지
+      </label>
+      <input
+        ref={inputFileRef}
+        name="meal-image"
+        type="file"
+        multiple
+        accept="image/*"
+        onChange={handleInputFileChange}
+        className="hidden"
+      />
+      <button
+        onClick={() => inputFileRef.current?.click()}
+        className="before:bg-image-upload-illustration relative flex w-full flex-col items-center gap-4 rounded-2xl bg-white px-4 py-7 before:h-10 before:w-10 before:bg-contain before:bg-no-repeat before:content-['']"
+      >
+        <span className="text-sm leading-[140%] tracking-snug text-gray-600">사진은 최대 3개까지 등록 가능합니다</span>
+      </button>
+    </>
+  );
+};
+export default MealPostAddImageButton;
diff --git a/src/components/_meal/_post/meal-post-add-image-empty-box.tsx b/src/components/_meal/_post/meal-post-add-image-empty-box.tsx
new file mode 100644
index 00000000..d6dd3ad4
--- /dev/null
+++ b/src/components/_meal/_post/meal-post-add-image-empty-box.tsx
@@ -0,0 +1,11 @@
+import Image from 'next/image';
+import PIC_LINE from '@/../public/icons/pic_line.svg';
+
+const MealPostAddImageEmptyBox = () => {
+  return (
+    <li className="flex aspect-square w-full items-center justify-center rounded-2xl bg-white/50">
+      <Image src={PIC_LINE} alt="비어있는 이미지 슬롯" />
+    </li>
+  );
+};
+export default MealPostAddImageEmptyBox;
diff --git a/src/components/_meal/_post/meal-post-add-image-preview-box.tsx b/src/components/_meal/_post/meal-post-add-image-preview-box.tsx
new file mode 100644
index 00000000..00d4e671
--- /dev/null
+++ b/src/components/_meal/_post/meal-post-add-image-preview-box.tsx
@@ -0,0 +1,23 @@
+import Image from 'next/image';
+import CLOSE_LINE from '@/../public/icons/close_line.svg';
+
+type MealPostAddImagePreviewBoxProps = {
+  imageUrl: string;
+  name: string;
+  fileId: string;
+  onRemove: (fileId: string) => void;
+};
+const MealPostAddImagePreviewBox = ({ imageUrl, name, fileId, onRemove }: MealPostAddImagePreviewBoxProps) => {
+  return (
+    <div className="relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-2xl">
+      <Image src={imageUrl} alt={`${name} 이미지`} width="100" height="100" className="h-full w-full object-cover" />
+      <button
+        onClick={() => onRemove(fileId)}
+        className="absolute right-[0.37rem] top-[0.37rem] rounded-full bg-white/80 p-1.5 backdrop-blur-[10px]"
+      >
+        <Image src={CLOSE_LINE} alt="닫기 이모지" className="h-3 w-3" />
+      </button>
+    </div>
+  );
+};
+export default MealPostAddImagePreviewBox;
diff --git a/src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx b/src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx
new file mode 100644
index 00000000..dc6999c6
--- /dev/null
+++ b/src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx
@@ -0,0 +1,48 @@
+import { useEffect } from 'react';
+import MealPostAddImageEmptyBox from '@/components/_meal/_post/meal-post-add-image-empty-box';
+import MealPostAddImagePreviewBox from '@/components/_meal/_post/meal-post-add-image-preview-box';
+import { MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+import { cleanupBlobUrl } from '@/lib/utils/cleanup-blob-url.util';
+import { getFileId } from '@/lib/utils/file.util';
+
+type MealPostAddImagePreviewGalleryProps = {
+  imageFiles: File[];
+  handleImageFilesChange: (files: File[]) => void;
+};
+
+const MealPostAddImagePreviewGallery = ({
+  imageFiles,
+  handleImageFilesChange
+}: MealPostAddImagePreviewGalleryProps) => {
+  const previewImages = imageFiles.map((file) => ({ imageUrl: URL.createObjectURL(file), fileId: getFileId(file) }));
+  const emptyImageCount = MAX_MEAL_IMAGE_COUNT - imageFiles.length;
+  const emptyImages = Array(emptyImageCount).fill(1);
+
+  const handleRemoveImage = (fileId: string) => {
+    handleImageFilesChange(imageFiles.filter((file) => getFileId(file) !== fileId));
+  };
+
+  // WARN: react strict mode에서 제대로 동작하지 않을 수 있음.
+  useEffect(() => {
+    return () => {
+      previewImages.forEach(({ imageUrl }) => cleanupBlobUrl(imageUrl));
+    };
+  }, [previewImages]);
+
+  return (
+    <ul className="flex w-full gap-3">
+      {previewImages.map((preview, index) => (
+        <MealPostAddImagePreviewBox
+          key={preview.fileId}
+          name={`음식 ${index}`}
+          {...preview}
+          onRemove={handleRemoveImage}
+        />
+      ))}
+      {emptyImages.map((_, index) => (
+        <MealPostAddImageEmptyBox key={index} />
+      ))}
+    </ul>
+  );
+};
+export default MealPostAddImagePreviewGallery;
diff --git a/src/components/_meal/_post/meal-post-add-image.tsx b/src/components/_meal/_post/meal-post-add-image.tsx
new file mode 100644
index 00000000..31caa369
--- /dev/null
+++ b/src/components/_meal/_post/meal-post-add-image.tsx
@@ -0,0 +1,26 @@
+import { useState } from 'react';
+import MealPostAddImageButton from '@/components/_meal/_post/meal-post-add-image-button';
+import MealPostAddImagePreviewGallery from '@/components/_meal/_post/meal-post-add-image-preview-gallery';
+
+type MealPostAddImageProps = {
+  initialImages?: File[];
+  onImagesChange?: (files: File[]) => void;
+};
+const MealPostAddImage = ({ initialImages = [], onImagesChange }: MealPostAddImageProps) => {
+  const [imageFiles, setImageFiles] = useState<File[]>(initialImages);
+
+  const handleImageFilesChange = (newFiles: File[]) => {
+    setImageFiles(newFiles);
+    if (onImagesChange) onImagesChange(newFiles);
+  };
+
+  return (
+    <div className="w-full rounded-3xl bg-white/50 p-4 backdrop-blur-[50px] [&>*:not(:last-child)]:mb-3">
+      <MealPostAddImageButton imageFiles={imageFiles} handleImageFilesChange={handleImageFilesChange} />
+      {imageFiles.length !== 0 && (
+        <MealPostAddImagePreviewGallery imageFiles={imageFiles} handleImageFilesChange={handleImageFilesChange} />
+      )}
+    </div>
+  );
+};
+export default MealPostAddImage;
diff --git a/src/constants/common.constant.ts b/src/constants/common.constant.ts
index ae3e223b..a1e7adf3 100644
--- a/src/constants/common.constant.ts
+++ b/src/constants/common.constant.ts
@@ -1,6 +1,9 @@
 // 퍼널의 URL 쿼리 파라미터 키값
 export const FUNNEL_QUERY_PARAM = 'step';
 
+export const KB = 1024;
+export const MB = 1024 * KB;
+
 const MACRO_NAME_MAP: Record<string, string> = {
   CARBOHYDRATE: '탄수화물',
   PROTEIN: '단백질',
diff --git a/src/constants/meal.constant.ts b/src/constants/meal.constant.ts
index 5bb16a68..2b509f00 100644
--- a/src/constants/meal.constant.ts
+++ b/src/constants/meal.constant.ts
@@ -4,6 +4,7 @@ import BREAKFAST from '@/../public/illustrations/meal-category-breakfast.svg';
 import LUNCH from '@/../public/illustrations/meal-category-lunch.svg';
 import DINNER from '@/../public/illustrations/meal-category-dinner.svg';
 import SNACK from '@/../public/illustrations/meal-category-snack.svg';
+import { MB } from '@/constants/common.constant';
 
 export const MEAL_CATEGORY_OPTIONS: { [key in MealCategoryType]: { name: string; icon: StaticImageData } } = {
   BREAKFAST: { name: '아침', icon: BREAKFAST },
@@ -11,3 +12,6 @@ export const MEAL_CATEGORY_OPTIONS: { [key in MealCategoryType]: { name: string;
   DINNER: { name: '저녁', icon: DINNER },
   SNACK: { name: '간식', icon: SNACK }
 } as const;
+
+export const MAX_FILE_SIZE = 10 * MB;
+export const MAX_MEAL_IMAGE_COUNT = 3;
diff --git a/src/lib/apis/storage.api.ts b/src/lib/apis/storage.api.ts
index b24bde86..aef443ee 100644
--- a/src/lib/apis/storage.api.ts
+++ b/src/lib/apis/storage.api.ts
@@ -1,7 +1,7 @@
 'use server';
 
 import { getServerClient } from '@/lib/utils/supabase/server.util';
-import { sanitizeFilename } from '@/lib/utils/filename.util';
+import { sanitizeFilename } from '@/lib/utils/file.util';
 import { categoriesError, ErrorResponse } from '@/types/error.type';
 import { SupabaseBucketValue } from '@/types/supabase-bucket.type';
 
diff --git a/src/lib/utils/filename.util.ts b/src/lib/utils/file.util.ts
similarity index 75%
rename from src/lib/utils/filename.util.ts
rename to src/lib/utils/file.util.ts
index 5190d568..388e3b2e 100644
--- a/src/lib/utils/filename.util.ts
+++ b/src/lib/utils/file.util.ts
@@ -73,3 +73,27 @@ export function sanitizeFilename(filename: string, replacement: string = '_'): s
 
   return sanitized;
 }
+
+/**
+ * 파일의 고유 식별자(ID)를 생성하는 함수
+ *
+ * 파일의 내재적 속성(이름, 크기, 마지막 수정 시간, MIME 타입)을 조합하여
+ * 해시 알고리즘을 적용해 고유한 식별자를 생성합니다.
+ * 이 식별자는 같은 파일에 대해 항상 동일하게 유지됩니다.
+ *
+ * @param {File} file - 식별자를 생성할 File 객체
+ * @returns {string} 형식: '[해시값]-[파일크기]-[파일명]' 형태의 고유 식별자
+ *
+ */
+export const getFileId = (file: File): string => {
+  const fileInfo = [file.name, file.size.toString(), file.lastModified.toString(), file.type].join('|');
+
+  let hash = 0;
+  for (let i = 0; i < fileInfo.length; i++) {
+    const char = fileInfo.charCodeAt(i);
+    hash = (hash << 5) - hash + char;
+    hash = hash & hash;
+  }
+
+  return `${hash.toString(16)}-${file.size}-${file.name.replace(/\s+/g, '_')}`;
+};
diff --git a/tailwind.config.ts b/tailwind.config.ts
index eacc429f..959756fe 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -142,7 +142,8 @@ const config: Config = {
       },
       backgroundImage: {
         'info-icon': "url('/icons/information_line.svg')",
-        'sparkle-illustration': "url('/illustration/cta-banner-sparkle.svg')"
+        'sparkle-illustration': "url('/illustration/cta-banner-sparkle.svg')",
+        'image-upload-illustration': "url('/illustrations/image-upload.svg')"
       },
       letterSpacing: {
         snug: '-0.0175rem'


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>🧹 Nitpick comments (5)</summary><blockquote>

<details>
<summary>src/constants/meal.constant.ts (1)</summary><blockquote>

`15-16`: **상수 정의가 적절합니다.**

이미지 업로드를 위한 제한사항을 상수로 정의한 것은 좋은 접근 방식입니다. 파일 크기 제한(10MB)과 최대 이미지 개수(3개)는 적절한 값으로 보입니다.


한 가지 고려사항으로, 사용자가 제한 사항을 초과할 경우 보여줄 오류 메시지도 상수로 정의하면 더 일관된 사용자 경험을 제공할 수 있을 것입니다.

</blockquote></details>
<details>
<summary>src/lib/utils/file.util.ts (1)</summary><blockquote>

`77-99`: **파일 ID 생성 함수가 잘 구현되었습니다.**

파일의 고유 식별자를 생성하는 함수가 잘 구현되었습니다. 파일의 여러 속성을 조합하여 중복 파일을 식별하는 방식이 효과적입니다.


다만, 해시 알고리즘에서 충돌이 발생할 가능성이 있으며, 특수 문자 처리에 대한 보완이 필요할 수 있습니다.


```diff
export const getFileId = (file: File): string => {
  const fileInfo = [file.name, file.size.toString(), file.lastModified.toString(), file.type].join('|');

  let hash = 0;
  for (let i = 0; i < fileInfo.length; i++) {
    const char = fileInfo.charCodeAt(i);
    hash = (hash << 5) - hash + char;
    hash = hash & hash;
  }

-  return `${hash.toString(16)}-${file.size}-${file.name.replace(/\s+/g, '_')}`;
+  // 모든 특수 문자를 언더스코어로 대체하여 안전한 ID 생성
+  const safeName = file.name.replace(/[^a-zA-Z0-9]/g, '_');
+  return `${hash.toString(16)}-${file.size}-${safeName}`;
};
```

</blockquote></details>
<details>
<summary>src/components/_meal/_post/meal-post-add-image-preview-box.tsx (1)</summary><blockquote>

`1-23`: **미리보기 상자 컴포넌트가 적절히 구현되었습니다.**

이미지 미리보기와 삭제 버튼이 포함된 컴포넌트가 잘 구현되었습니다. Next.js의 Image 컴포넌트를 활용하여 최적화된 이미지 렌더링을 제공하고 있습니다. Tailwind CSS를 사용한 스타일링도 명확하게 되어 있습니다.

한 가지 개선할 점은 삭제 버튼의 접근성입니다. 스크린 리더 사용자를 위해 더 명확한 레이블을 제공하면 좋을 것 같습니다.



```diff
- <button
-   onClick={() => onRemove(fileId)}
-   className="absolute right-[0.37rem] top-[0.37rem] rounded-full bg-white/80 p-1.5 backdrop-blur-[10px]"
- >
-   <Image src={CLOSE_LINE} alt="닫기 이모지" className="h-3 w-3" />
- </button>
+ <button
+   onClick={() => onRemove(fileId)}
+   className="absolute right-[0.37rem] top-[0.37rem] rounded-full bg-white/80 p-1.5 backdrop-blur-[10px]"
+   aria-label={`${name} 이미지 삭제`}
+ >
+   <Image src={CLOSE_LINE} alt="닫기 아이콘" className="h-3 w-3" />
+ </button>
```

</blockquote></details>
<details>
<summary>src/components/_meal/_post/meal-post-add-image-button.tsx (2)</summary><blockquote>

`14-31`: **파일 유효성 검사가 잘 구현되어 있지만 사용자 경험을 개선할 수 있습니다.**

파일 개수, 크기, 중복 검사 등 필요한 유효성 검사가 잘 구현되어 있습니다. 그러나 브라우저 기본 alert 대신 더 사용자 친화적인 알림 메커니즘을 사용하면 좋을 것 같습니다.


```diff
- if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+ if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) {
+   // 토스트 메시지나 인라인 에러 메시지를 표시하는 상태 변수 활용
+   // 예: setErrorMessage('3장까지 첨부 가능합니다.');
+   return;
+ }

- const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
- if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+ const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
+ if (hasOverSizeFile) {
+   // 토스트 메시지나 인라인 에러 메시지를 표시하는 상태 변수 활용
+   // 예: setErrorMessage('10MB 이하의 파일을 첨부해주세요.');
+   return;
+ }

- const hasDuplicatedImage = fileArray.filter((newFile) =>
-   imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
- );
- if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+ const hasDuplicatedImage = fileArray.filter((newFile) =>
+   imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+ );
+ if (hasDuplicatedImage.length > 0) {
+   // 토스트 메시지나 인라인 에러 메시지를 표시하는 상태 변수 활용
+   // 예: setErrorMessage('동일한 이미지를 업로드할 수 없습니다.');
+   return;
+ }
```

알림 메시지를 위한 상태와 UI 컴포넌트를 추가하는 것을 고려해보세요. 필요한 경우 프로젝트에서 사용 중인 UI 라이브러리의 토스트 또는 알림 컴포넌트를 활용할 수 있습니다.

---

`33-55`: **UI 구현이 잘 되어있으며, 접근성 측면에서 개선 가능합니다.**

숨겨진 파일 입력과 버튼을 통한 이미지 업로드 UI가 잘 구현되어 있습니다. 최대 이미지 개수에 도달했을 때 일러스트 투명도를 조정하여 시각적 피드백을 제공하는 것도 좋은 접근입니다.

다만, 사용자에게 제공되는 메시지가 고정된 '사진은 최대 3개까지 등록 가능합니다.'라는 문구만 있어, 현재 몇 장이 업로드 되었는지 알기 어렵습니다. 현재 업로드된 이미지 수를 표시하면 더 명확한 정보를 제공할 수 있을 것입니다.



```diff
- <span className="tracking-snug text-sm leading-[140%] text-gray-600">사진은 최대 3개까지 등록 가능합니다.</span>
+ <span className="tracking-snug text-sm leading-[140%] text-gray-600">
+   사진은 최대 3개까지 등록 가능합니다. ({imageFiles.length}/{MAX_MEAL_IMAGE_COUNT})
+ </span>
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between a7c923ea5101817808607d61bfdfb56e0c0bf8e0 and e7e8a524a6a5f84e2393747e8cf76edda5083e9a.

</details>

<details>
<summary>⛔ Files ignored due to path filters (3)</summary>

* `public/icons/close_line.svg` is excluded by `!**/*.svg`
* `public/icons/pic_line.svg` is excluded by `!**/*.svg`
* `public/illustrations/image-upload.svg` is excluded by `!**/*.svg`

</details>

<details>
<summary>📒 Files selected for processing (8)</summary>

* `src/components/_meal/_post/meal-post-add-image-button.tsx` (1 hunks)
* `src/components/_meal/_post/meal-post-add-image-empty-box.tsx` (1 hunks)
* `src/components/_meal/_post/meal-post-add-image-preview-box.tsx` (1 hunks)
* `src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx` (1 hunks)
* `src/components/_meal/_post/meal-post-add-image.tsx` (1 hunks)
* `src/constants/meal.constant.ts` (1 hunks)
* `src/lib/apis/storage.api.ts` (1 hunks)
* `src/lib/utils/file.util.ts` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (2)</summary>

<details>
<summary>src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx (3)</summary><blockquote>

<details>
<summary>src/lib/utils/file.util.ts (1)</summary>

* `getFileId` (88-99)

</details>
<details>
<summary>src/constants/meal.constant.ts (1)</summary>

* `MAX_MEAL_IMAGE_COUNT` (16-16)

</details>
<details>
<summary>src/lib/utils/cleanup-blob-url.util.ts (1)</summary>

* `cleanupBlobUrl` (8-12)

</details>

</blockquote></details>
<details>
<summary>src/components/_meal/_post/meal-post-add-image-button.tsx (2)</summary><blockquote>

<details>
<summary>src/constants/meal.constant.ts (2)</summary>

* `MAX_MEAL_IMAGE_COUNT` (16-16)
* `MAX_FILE_SIZE` (15-15)

</details>
<details>
<summary>src/lib/utils/file.util.ts (1)</summary>

* `getFileId` (88-99)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>src/lib/apis/storage.api.ts (1)</summary>

`4-4`: **임포트 경로 업데이트가 올바르게 처리되었습니다.**

파일 유틸리티 함수의 임포트 경로가 올바르게 업데이트되었습니다. 이 변경은 코드 구조를 개선하고 관련 기능을 `file.util.ts` 파일로 통합하는 좋은 방식입니다.

</details>
<details>
<summary>src/components/_meal/_post/meal-post-add-image-empty-box.tsx (1)</summary>

`4-10`: **컴포넌트가 간결하게 구현되었습니다.**

빈 이미지 슬롯을 위한 컴포넌트가 효과적으로 구현되었습니다. 컴포넌트의 역할이 명확하고 스타일링이 적절합니다.

</details>
<details>
<summary>src/components/_meal/_post/meal-post-add-image.tsx (1)</summary>

`1-19`: **적절한 구성으로 잘 구현되었습니다.**

컴포넌트가 깔끔하게 구성되어 있으며, 이미지 파일 업로드 버튼과 이미지 미리보기 갤러리를 적절히 조합했습니다. 조건부 렌더링을 통해 이미지가 있을 때만 미리보기 갤러리를 표시하는 로직도 잘 구현되었습니다.

</details>
<details>
<summary>src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx (2)</summary>

`13-21`: **이미지 갤러리 기능이 잘 구현되었습니다.**

빈 슬롯 계산 및 이미지 삭제 기능이 적절히 구현되어 있습니다. 각 이미지 파일에 대해 고유 ID를 생성하여 삭제 기능을 구현한 방식이 좋습니다.

---

`29-43`: **갤러리 렌더링 구현이 적절합니다.**

미리보기 이미지와 빈 슬롯을 적절히 렌더링하고 있습니다. `key` 속성을 올바르게 사용하여 React의 재조정 과정을 최적화했습니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-10T13:55:23Z
user: login: jiyxxn
body: 이다은 화이팅!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-11T01:54:26Z
user: login: JaeSang1998
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-11T02:00:33Z
user: login: llddang
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T02:30:34Z
user: login: llddang
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T02:35:36Z
user: login: JaeSang1998
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-11T02:38:07Z
user: login: llddang
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T02:39:09Z
user: login: jiyxxn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T02:47:32Z
user: login: arendt9797
body: 수고하셨습니다!
strict 모드 때매 고생하셨네요
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-11T05:53:17Z
user: login: llddang
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T06:14:16Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>src/components/_meal/_post/meal-post-add-image-button.tsx (3)</summary><blockquote>

`18-26`: **에러 메시지 표시 방식 개선이 필요합니다.**

현재 파일 유효성 검사 실패 시 네이티브 `alert()` 함수를 사용하고 있습니다. 사용자 경험 향상을 위해 커스텀 토스트나 알림 컴포넌트를 사용하는 것이 좋을 것 같습니다. 

또한, 에러 메시지가 코드에 하드코딩되어 있는데, 향후 다국어 지원을 위해 상수로 분리하거나 i18n 라이브러리를 적용하는 것을 고려해보세요.


```diff
-    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) {
+      // TODO: 커스텀 토스트 컴포넌트로 변경
+      return alert(`${MAX_MEAL_IMAGE_COUNT}장까지 첨부 가능합니다.`);
+    }

-    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+    if (hasOverSizeFile) {
+      // TODO: 커스텀 토스트 컴포넌트로 변경
+      return alert(`${MAX_FILE_SIZE / (1024 * 1024)}MB 이하의 파일을 첨부해주세요.`);
+    }

-    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+    if (hasDuplicatedImage.length > 0) {
+      // TODO: 커스텀 토스트 컴포넌트로 변경
+      return alert('동일한 이미지를 업로드할 수 없습니다.');
+    }
```

---

`12-29`: **파일 유효성 검사 로직이 잘 구현되었습니다.**

파일 개수 제한, 파일 크기 검증, 중복 파일 확인 등 필요한 유효성 검사가 모두 잘 구현되어 있습니다. 특히 `getFileId` 유틸리티 함수를 활용한 중복 파일 확인 로직이 효과적입니다.

다만 유효성 검사 로직을 별도의 함수로 분리하면 코드 가독성과 테스트 용이성이 더 향상될 수 있을 것 같습니다.



```diff
+  const validateFileCount = (newFiles: File[]): boolean => {
+    if (newFiles.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) {
+      alert('3장까지 첨부 가능합니다.');
+      return false;
+    }
+    return true;
+  };
+
+  const validateFileSize = (newFiles: File[]): boolean => {
+    const hasOverSizeFile = newFiles.some((file) => file.size >= MAX_FILE_SIZE);
+    if (hasOverSizeFile) {
+      alert('10MB 이하의 파일을 첨부해주세요.');
+      return false;
+    }
+    return true;
+  };
+
+  const validateDuplicateFiles = (newFiles: File[]): boolean => {
+    const hasDuplicatedImage = newFiles.filter((newFile) =>
+      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+    );
+    if (hasDuplicatedImage.length > 0) {
+      alert('동일한 이미지를 업로드할 수 없습니다.');
+      return false;
+    }
+    return true;
+  };

   const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
     const files = e.target.files;
     if (!files) return;
     const fileArray = Array.from(files);
     e.target.value = '';
 
-    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
-
-    const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
-    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
-
-    const hasDuplicatedImage = fileArray.filter((newFile) =>
-      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
-    );
-    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+    if (!validateFileCount(fileArray) || !validateFileSize(fileArray) || !validateDuplicateFiles(fileArray)) {
+      return;
+    }
 
     handleImageFilesChange([...imageFiles, ...fileArray]);
   };
```

---

`45-50`: **버튼 텍스트에 MAX_MEAL_IMAGE_COUNT 상수 사용 권장**

버튼에 표시되는 메시지에서 최대 이미지 개수를 하드코딩된 "3개"로 표시하고 있습니다. 코드 일관성과 유지보수를 위해 `MAX_MEAL_IMAGE_COUNT` 상수를 활용하는 것이 좋을 것 같습니다.


```diff
-        <span className="text-sm leading-[140%] tracking-snug text-gray-600">사진은 최대 3개까지 등록 가능합니다</span>
+        <span className="text-sm leading-[140%] tracking-snug text-gray-600">사진은 최대 {MAX_MEAL_IMAGE_COUNT}개까지 등록 가능합니다</span>
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 92aa51fbac234e4c16d6e4efc6af7f9f988a6fac and 40b23ea998e7c875150de445a97b9475e19ef694.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `src/components/_meal/_post/meal-post-add-image-button.tsx` (1 hunks)
* `src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx` (1 hunks)
* `src/components/_meal/_post/meal-post-add-image.tsx` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/components/_meal/_post/meal-post-add-image-button.tsx (2)</summary><blockquote>

<details>
<summary>src/constants/meal.constant.ts (2)</summary>

* `MAX_MEAL_IMAGE_COUNT` (17-17)
* `MAX_FILE_SIZE` (16-16)

</details>
<details>
<summary>src/lib/utils/file.util.ts (1)</summary>

* `getFileId` (88-99)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>src/components/_meal/_post/meal-post-add-image.tsx (3)</summary>

`9-10`: **컴포넌트 속성 및 상태 관리가 잘 구현되었습니다.**

컴포넌트가 내부적으로 상태를 관리하면서도 콜백 메커니즘을 통해 부모 컴포넌트와 소통하는 방식이 적절하게 구현되었습니다. `initialImages` 기본값을 빈 배열로 설정한 것도 좋은 방식입니다.

---

`12-15`: **이벤트 핸들러가 명확하게 구현되었습니다.**

`handleImageFilesChange` 함수가 내부 상태를 업데이트하고 선택적으로 부모 컴포넌트의 콜백을 호출하는 방식이 잘 구현되어 있습니다. `onImagesChange` 존재 여부를 확인한 후 호출하는 안전한 방식도 좋습니다.

---

`17-24`: **UI 렌더링 로직이 깔끔합니다.**

조건부 렌더링을 사용하여 이미지가 있을 때만 `MealPostAddImagePreviewGallery` 컴포넌트를 표시하는 구현이 효율적입니다. 또한, Tailwind CSS를 활용한 스타일링이 명확하게 정의되어 있습니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-11T08:15:41Z
user: login: woohyuckk
body: 고생하셨습니다! 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-11T09:15:37Z

Review comments:
diff_hunk: @@ -0,0 +1,58 @@
+import Image from 'next/image';
+import { ChangeEvent, Dispatch, SetStateAction, useRef } from 'react';
+import IMAGE_UPLOAD from '@/../public/illustrations/image-upload.svg';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImageButton = ({ imageFiles, setImageFiles }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+
+    const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
+    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+
+    const hasDuplicatedImage = fileArray.filter((newFile) =>
+      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+    );
+    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+
+    setImageFiles((prev) => [...prev, ...fileArray]);
+  };
+
+  return (
+    <>
+      <label className="sr-only">음식 이미지</label>
path: src/components/_meal/_post/meal-post-add-image-button.tsx
user: login: jiyxxn
body: `htmlFor` 속성이 없어서 인풋이랑 연결이 안됐을 것 같아요!
created_at: 2025-04-11T01:28:43Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,19 @@
+import { Dispatch, SetStateAction } from 'react';
+import MealPostAddImageButton from '@/components/_meal/_post/meal-post-add-image-button';
+import MealPostAddImagePreviewGallery from '@/components/_meal/_post/meal-post-add-image-preview-gallery';
+
+type MealPostAddImageProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImage = ({ setImageFiles, imageFiles }: MealPostAddImageProps) => {
+  return (
+    <div className="w-full space-y-3 rounded-3xl bg-white/50 p-4 backdrop-blur-[50px]">
path: src/components/_meal/_post/meal-post-add-image.tsx
user: login: jiyxxn
body: ![image](https://github.com/user-attachments/assets/2315fdf2-e905-4aed-bd93-ffe611638e34)

`space` 속성 때문에 버튼 위쪽으로 마진이 잡혀서 시안보다 윗단 간격이 넓어진 것 같아요!

![image](https://github.com/user-attachments/assets/fc479134-990a-405f-89eb-aeec5a0c7d1d)

created_at: 2025-04-11T01:38:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,58 @@
+import Image from 'next/image';
+import { ChangeEvent, Dispatch, SetStateAction, useRef } from 'react';
+import IMAGE_UPLOAD from '@/../public/illustrations/image-upload.svg';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImageButton = ({ imageFiles, setImageFiles }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+
+    const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
+    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+
+    const hasDuplicatedImage = fileArray.filter((newFile) =>
+      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+    );
+    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+
+    setImageFiles((prev) => [...prev, ...fileArray]);
+  };
+
+  return (
+    <>
+      <label className="sr-only">음식 이미지</label>
+      <input
+        ref={inputFileRef}
+        type="file"
+        multiple
+        accept="image/*"
+        onChange={handleInputFileChange}
+        className="hidden"
+      />
+      <button
+        onClick={() => inputFileRef.current?.click()}
+        className="flex w-full flex-col items-center gap-4 rounded-2xl bg-white px-4 py-7"
+      >
+        <Image
+          src={IMAGE_UPLOAD}
+          alt="이미지 업로드 일러스트"
+          className={`h-10 w-10 ${imageFiles.length === MAX_MEAL_IMAGE_COUNT && 'opacity-30 mix-blend-luminosity'}`}
+        />
path: src/components/_meal/_post/meal-post-add-image-button.tsx
user: login: jiyxxn
body: 이 일러스트는 꾸밈요소라서 이미지 태그로 들어가지 않고 배경이미지로 처리하는 편이 좋을 것 같은데요! 만약 이미지 태그를 사용하신다고 하면 넥스트 이미지보다는 일반 `img` 태그가 좀 더 낫지 않을까 싶습니다! 최적화할정도의 중요한 이미지가 아니라고 생각합니다!
created_at: 2025-04-11T01:40:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,58 @@
+import Image from 'next/image';
+import { ChangeEvent, Dispatch, SetStateAction, useRef } from 'react';
+import IMAGE_UPLOAD from '@/../public/illustrations/image-upload.svg';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImageButton = ({ imageFiles, setImageFiles }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+
+    const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
+    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+
+    const hasDuplicatedImage = fileArray.filter((newFile) =>
+      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+    );
+    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+
+    setImageFiles((prev) => [...prev, ...fileArray]);
+  };
+
+  return (
+    <>
+      <label className="sr-only">음식 이미지</label>
+      <input
+        ref={inputFileRef}
+        type="file"
+        multiple
+        accept="image/*"
+        onChange={handleInputFileChange}
+        className="hidden"
+      />
+      <button
+        onClick={() => inputFileRef.current?.click()}
+        className="flex w-full flex-col items-center gap-4 rounded-2xl bg-white px-4 py-7"
+      >
+        <Image
+          src={IMAGE_UPLOAD}
+          alt="이미지 업로드 일러스트"
+          className={`h-10 w-10 ${imageFiles.length === MAX_MEAL_IMAGE_COUNT && 'opacity-30 mix-blend-luminosity'}`}
+        />
path: src/components/_meal/_post/meal-post-add-image-button.tsx
user: login: jiyxxn
body: ![image](https://github.com/user-attachments/assets/9f6cb142-864b-4845-99a5-2b6648b91c45)

그리고 시안보다 아이콘이 채도가 좀 더 높은 것 같아요!
created_at: 2025-04-11T01:43:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,58 @@
+import Image from 'next/image';
+import { ChangeEvent, Dispatch, SetStateAction, useRef } from 'react';
+import IMAGE_UPLOAD from '@/../public/illustrations/image-upload.svg';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImageButton = ({ imageFiles, setImageFiles }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+
+    const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
+    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+
+    const hasDuplicatedImage = fileArray.filter((newFile) =>
+      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+    );
+    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+
+    setImageFiles((prev) => [...prev, ...fileArray]);
+  };
+
+  return (
+    <>
+      <label className="sr-only">음식 이미지</label>
+      <input
+        ref={inputFileRef}
+        type="file"
+        multiple
+        accept="image/*"
+        onChange={handleInputFileChange}
+        className="hidden"
+      />
+      <button
+        onClick={() => inputFileRef.current?.click()}
+        className="flex w-full flex-col items-center gap-4 rounded-2xl bg-white px-4 py-7"
+      >
+        <Image
+          src={IMAGE_UPLOAD}
+          alt="이미지 업로드 일러스트"
+          className={`h-10 w-10 ${imageFiles.length === MAX_MEAL_IMAGE_COUNT && 'opacity-30 mix-blend-luminosity'}`}
+        />
+        <span className="tracking-snug text-sm leading-[140%] text-gray-600">사진은 최대 3개까지 등록 가능합니다.</span>
path: src/components/_meal/_post/meal-post-add-image-button.tsx
user: login: jiyxxn
body: 문장 끝에 . 빼주셔야 할 것 같습니다!
created_at: 2025-04-11T01:44:43Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,11 @@
+import Image from 'next/image';
+import PIC_LINE from '@/../public/icons/pic_line.svg';
+
+const MealPostAddImageEmptyBox = () => {
+  return (
+    <li className="flex aspect-square w-full items-center justify-center rounded-2xl bg-white/50">
+      <Image src={PIC_LINE} alt="사진 아이콘" />
path: src/components/_meal/_post/meal-post-add-image-empty-box.tsx
user: login: jiyxxn
body: 이 부분은 아이콘이 없을 경우 '비어있는 상태'라는 의미가 전달되지 않기 때문에 이미지 태그로 들어가는 방향은 맞다고 생각합니다! 다만 최적화는 불필요할 것 같아 `img` 태그로 사용하고, `alt`를 '비어있는 이미지 슬롯' 혹은 '이미지 추가 가능' 과 같은 설명을 넣어주면 스크린 리더를 사용하시는 분들께도 해당 요소의 목적이 잘 전달될 수 있을 것 같아요. 어떠실까요?
created_at: 2025-04-11T01:49:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,45 @@
+import { Dispatch, SetStateAction, useEffect } from 'react';
+import MealPostAddImageEmptyBox from '@/components/_meal/_post/meal-post-add-image-empty-box';
+import MealPostAddImagePreviewBox from '@/components/_meal/_post/meal-post-add-image-preview-box';
+import { MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+import { cleanupBlobUrl } from '@/lib/utils/cleanup-blob-url.util';
+import { getFileId } from '@/lib/utils/file.util';
+
+type MealPostAddImagePreviewGalleryProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+
+const MealPostAddImagePreviewGallery = ({ imageFiles, setImageFiles }: MealPostAddImagePreviewGalleryProps) => {
+  const previewImages = imageFiles.map((file) => ({ imageUrl: URL.createObjectURL(file), fileId: getFileId(file) }));
+  const emptyImageCount = MAX_MEAL_IMAGE_COUNT - imageFiles.length;
+  const emptyImages = Array(emptyImageCount).fill(1);
+
+  const handleRemoveImage = (fileId: string) => {
+    setImageFiles((prev) => prev.filter((file) => getFileId(file) !== fileId));
+  };
+
+  // WARN: react strict mode에서 제대로 동작하지 않을 수 있음.
+  useEffect(() => {
+    return () => {
+      previewImages.forEach(({ imageUrl }) => cleanupBlobUrl(imageUrl));
+    };
+  }, [previewImages]);
+
+  return (
+    <ul className="flex w-full justify-between gap-3">
path: src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx
user: login: jiyxxn
body: `justify-between`이 없어도 동일한 UI로 구현될 것 같아요!
created_at: 2025-04-11T01:52:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,58 @@
+import Image from 'next/image';
+import { ChangeEvent, Dispatch, SetStateAction, useRef } from 'react';
+import IMAGE_UPLOAD from '@/../public/illustrations/image-upload.svg';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImageButton = ({ imageFiles, setImageFiles }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
path: src/components/_meal/_post/meal-post-add-image-button.tsx
user: login: JaeSang1998
body: 아직 별도의 toast aleart 는 없나요?
created_at: 2025-04-11T01:53:54Z
author_association: NONE
diff_hunk: @@ -11,3 +11,6 @@ export const MEAL_CATEGORY_OPTIONS: { [key in MealCategoryType]: { name: string;
   DINNER: { name: '저녁', icon: DINNER },
   SNACK: { name: '간식', icon: SNACK }
 } as const;
+
+export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes
path: src/constants/meal.constant.ts
user: login: JaeSang1998
body: 이런 값을 쓸때 
const MB = 1024 * 1024 처럼 표현해줘도 좋아요.
좀 더 용량을 많이쓰게 되면

const KB = 1024 
const MB = 1024 * KB

이런느낌으로 먼저 만들어두고 단위별로 써도 좋구요!
created_at: 2025-04-11T01:56:35Z
author_association: NONE
diff_hunk: @@ -0,0 +1,19 @@
+import { Dispatch, SetStateAction } from 'react';
+import MealPostAddImageButton from '@/components/_meal/_post/meal-post-add-image-button';
+import MealPostAddImagePreviewGallery from '@/components/_meal/_post/meal-post-add-image-preview-gallery';
+
+type MealPostAddImageProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImage = ({ setImageFiles, imageFiles }: MealPostAddImageProps) => {
path: src/components/_meal/_post/meal-post-add-image.tsx
user: login: JaeSang1998
body: 컴포넌트 자체에서 images 를 가지게 하지 않고 외부에서 state 와 image file 을 주입하는 이유가 있을까요?
created_at: 2025-04-11T02:00:18Z
author_association: NONE
diff_hunk: @@ -0,0 +1,58 @@
+import Image from 'next/image';
+import { ChangeEvent, Dispatch, SetStateAction, useRef } from 'react';
+import IMAGE_UPLOAD from '@/../public/illustrations/image-upload.svg';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImageButton = ({ imageFiles, setImageFiles }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
path: src/components/_meal/_post/meal-post-add-image-button.tsx
user: login: llddang
body: 넹 아직 사용하기로 결정한 토스트가 없습니다!
created_at: 2025-04-11T02:30:34Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,19 @@
+import { Dispatch, SetStateAction } from 'react';
+import MealPostAddImageButton from '@/components/_meal/_post/meal-post-add-image-button';
+import MealPostAddImagePreviewGallery from '@/components/_meal/_post/meal-post-add-image-preview-gallery';
+
+type MealPostAddImageProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImage = ({ setImageFiles, imageFiles }: MealPostAddImageProps) => {
path: src/components/_meal/_post/meal-post-add-image.tsx
user: login: llddang
body: ![image](https://github.com/user-attachments/assets/1964e7c9-894a-41da-950b-82de5ec7293e)

사진 올리리고 분석하기를 클릭했을 때, images의 처리가 필요하다고 생각되어 부모에게 받도록 했습니다!
인자로 받지 않는다면 context를 활용하여 해결할 수 있을 것 같긴합니다!

이럴때는 주로 context로 관리하나요?
created_at: 2025-04-11T02:35:36Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,19 @@
+import { Dispatch, SetStateAction } from 'react';
+import MealPostAddImageButton from '@/components/_meal/_post/meal-post-add-image-button';
+import MealPostAddImagePreviewGallery from '@/components/_meal/_post/meal-post-add-image-preview-gallery';
+
+type MealPostAddImageProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImage = ({ setImageFiles, imageFiles }: MealPostAddImageProps) => {
path: src/components/_meal/_post/meal-post-add-image.tsx
user: login: JaeSang1998
body: 이런 Image Uploader 컴포넌트들은 여러군데에서 쓰이기 마련이라 보통 컴포넌트가 이미지에 대한 책임을 가지고 있고 외부에서는 업로드된 이미지를 콜백을 이용해 반환받는 형태로 많이 쓰여요.

그런 부분이 고려가 되어있는건가 해서 여쭤봤습니다.
근데 지금은 여기서만 쓰이는 컴포넌트라 큰 문제는 없어보입니다~

created_at: 2025-04-11T02:38:07Z
author_association: NONE
diff_hunk: @@ -0,0 +1,11 @@
+import Image from 'next/image';
+import PIC_LINE from '@/../public/icons/pic_line.svg';
+
+const MealPostAddImageEmptyBox = () => {
+  return (
+    <li className="flex aspect-square w-full items-center justify-center rounded-2xl bg-white/50">
+      <Image src={PIC_LINE} alt="사진 아이콘" />
path: src/components/_meal/_post/meal-post-add-image-empty-box.tsx
user: login: llddang
body: 여기서는 "비어있는 이미지" 라고 사용자한테 알려줘야하는 요소임으로 가상 요소로 관리하지 않고 보여주는 걸까요?
created_at: 2025-04-11T02:39:09Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,11 @@
+import Image from 'next/image';
+import PIC_LINE from '@/../public/icons/pic_line.svg';
+
+const MealPostAddImageEmptyBox = () => {
+  return (
+    <li className="flex aspect-square w-full items-center justify-center rounded-2xl bg-white/50">
+      <Image src={PIC_LINE} alt="사진 아이콘" />
path: src/components/_meal/_post/meal-post-add-image-empty-box.tsx
user: login: jiyxxn
body: 네! 보통 배경이미지나 가상요소로 들어가는 경우는 텍스트로 이미 해당 요소에 대한 설명이 충분한 경우에 들어가게 되는데요, 이 요소는 비어있는 영역이라는 설명이 따로 텍스트로 들어가지 않기 때문에 이미지 자체가 그 의미를 내포하게 됩니다!
따라서 스크린 리더에서 인식할 수 있도록 이미지 태그로 넣어주는 편입니다!

이미지가 없어도 의미전달이 되는가? 여부로 생각해보시면 이해가 쉬울 거에요!
created_at: 2025-04-11T02:47:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,19 @@
+import { Dispatch, SetStateAction } from 'react';
+import MealPostAddImageButton from '@/components/_meal/_post/meal-post-add-image-button';
+import MealPostAddImagePreviewGallery from '@/components/_meal/_post/meal-post-add-image-preview-gallery';
+
+type MealPostAddImageProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImage = ({ setImageFiles, imageFiles }: MealPostAddImageProps) => {
path: src/components/_meal/_post/meal-post-add-image.tsx
user: login: llddang
body: 오옹 보통 콜백형식으로 반환하는 군요!

변경해보겠습니다!
created_at: 2025-04-11T06:14:16Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,54 @@
+import { ChangeEvent, Dispatch, SetStateAction, useRef } from 'react';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  setImageFiles: Dispatch<SetStateAction<File[]>>;
+};
+const MealPostAddImageButton = ({ imageFiles, setImageFiles }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+
+    const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
+    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+
+    const hasDuplicatedImage = fileArray.filter((newFile) =>
+      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+    );
+    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+
+    setImageFiles((prev) => [...prev, ...fileArray]);
+  };
+
+  return (
+    <>
+      <label htmlFor="meal-image" className="sr-only">
+        음식 이미지
+      </label>
+      <input
+        ref={inputFileRef}
+        name="meal-image"
+        type="file"
+        multiple
+        accept="image/*"
+        onChange={handleInputFileChange}
+        className="hidden"
+      />
path: src/components/_meal/_post/meal-post-add-image-button.tsx
user: login: woohyuckk
body: 자꾸 까먹게되는 sr-only옵션 input의 label이 없을때는 label을 선언할 생각을 잘안하게되는데 다시 한번 더 상기하게 되네요! 
created_at: 2025-04-11T08:13:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,54 @@
+import { ChangeEvent, useRef } from 'react';
+import { getFileId } from '@/lib/utils/file.util';
+import { MAX_FILE_SIZE, MAX_MEAL_IMAGE_COUNT } from '@/constants/meal.constant';
+
+type MealPostAddImageButtonProps = {
+  imageFiles: File[];
+  handleImageFilesChange: (files: File[]) => void;
+};
+const MealPostAddImageButton = ({ imageFiles, handleImageFilesChange }: MealPostAddImageButtonProps) => {
+  const inputFileRef = useRef<HTMLInputElement>(null);
+
+  const handleInputFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (!files) return;
+    const fileArray = Array.from(files);
+    e.target.value = '';
+
+    if (fileArray.length + imageFiles.length > MAX_MEAL_IMAGE_COUNT) return alert('3장까지 첨부 가능합니다.');
+
+    const hasOverSizeFile = fileArray.some((file) => file.size >= MAX_FILE_SIZE);
+    if (hasOverSizeFile) return alert('10MB 이하의 파일을 첨부해주세요.');
+
+    const hasDuplicatedImage = fileArray.filter((newFile) =>
+      imageFiles.some((existingFile) => getFileId(existingFile) === getFileId(newFile))
+    );
+    if (hasDuplicatedImage.length > 0) return alert('동일한 이미지를 업로드할 수 없습니다.');
+
+    handleImageFilesChange([...imageFiles, ...fileArray]);
+  };
+
+  return (
+    <>
+      <label htmlFor="meal-image" className="sr-only">
+        음식 이미지
+      </label>
+      <input
+        ref={inputFileRef}
+        name="meal-image"
+        type="file"
+        multiple
+        accept="image/*"
+        onChange={handleInputFileChange}
+        className="hidden"
+      />
path: src/components/_meal/_post/meal-post-add-image-button.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**접근성을 위한 input 요소의 id 속성 추가가 필요합니다.**

현재 label의 `htmlFor` 속성은 "meal-image"를 가리키고 있지만, 연결된 input 요소에는 `id` 속성이 없고 `name` 속성만 있습니다. 접근성 향상을 위해 input 요소에 일치하는 `id` 속성을 추가해주세요.


```diff
      <label htmlFor="meal-image" className="sr-only">
        음식 이미지
      </label>
      <input
        ref={inputFileRef}
        name="meal-image"
+       id="meal-image"
        type="file"
        multiple
        accept="image/*"
        onChange={handleInputFileChange}
        className="hidden"
      />
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
      <label htmlFor="meal-image" className="sr-only">
        음식 이미지
      </label>
      <input
        ref={inputFileRef}
        name="meal-image"
        id="meal-image"
        type="file"
        multiple
        accept="image/*"
        onChange={handleInputFileChange}
        className="hidden"
      />
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-11T08:15:40Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-10T13:52:02Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough
이 PR은 식단 포스트 내 이미지 업로드 기능 구현을 위해 새로운 컴포넌트와 상수를 추가합니다. 주요 변경 사항으로는 이미지 업로드와 미리보기를 위한 버튼, 갤러리, 프리뷰 박스, 빈 슬롯 컴포넌트들이 포함되며, 파일 검증(최대 용량, 개수, 중복 체크) 로직이 구현되었습니다. 또한, 최대 파일 크기와 이미지 업로드 가능 개수를 제한하는 상수가 추가되었고, 파일의 고유 ID를 생성하는 유틸리티 함수가 도입되었습니다. 단순 import 경로 수정도 함께 반영되었습니다.

## Changes

| 파일(s) | 변경 요약 |
|---------|-----------|
| `src/components/_meal/_post/meal-post-add-image-button.tsx`<br>`src/components/_meal/_post/meal-post-add-image-empty-box.tsx`<br>`src/components/_meal/_post/meal-post-add-image-preview-box.tsx`<br>`src/components/_meal/_post/meal-post-add-image-preview-gallery.tsx`<br>`src/components/_meal/_post/meal-post-add-image.tsx` | 식단 포스트 이미지 업로드, 미리보기, 삭제 기능을 위한 새로운 React 컴포넌트들 추가 |
| `src/constants/meal.constant.ts` | 최대 파일 크기(10MB)와 이미지 업로드 개수(3)를 정의하는 상수 추가 |
| `src/lib/apis/storage.api.ts` | `sanitizeFilename` 함수의 import 경로를 `@/lib/utils/file.util`로 수정 |
| `src/lib/utils/file.util.ts` | 파일의 고유 ID 생성을 위한 `getFileId` 함수 추가 |
| `src/constants/common.constant.ts` | KB 및 MB 상수 추가 |
| `tailwind.config.ts` | 새로운 배경 이미지 설정 추가 |

## Sequence Diagram(s)
```mermaid
sequenceDiagram
  participant U as 사용자
  participant A as MealPostAddImage
  participant B as MealPostAddImageButton
  participant I as 파일 입력
  participant G as MealPostAddImagePreviewGallery

  U->>B: "이미지 추가" 버튼 클릭
  B->>I: 파일 선택 다이얼로그 열기
  I-->>B: 선택된 파일 반환
  B->>B: 파일 검증 (개수, 용량, 중복 확인)
  B->>A: 유효한 파일 상태 업데이트
  A->>G: 이미지 리스트 전달하여 렌더링
  G->>G: 미리보기 이미지 및 삭제 기능 처리
```

## Assessment against linked issues

| Objective                                         | Addressed | Explanation                                     |
|---------------------------------------------------|-----------|-------------------------------------------------|
| 사진 업로드 기능 구현 (버튼 클릭 및 파일 선택) (#68)    | ✅        |                                                 |
| 미리보기 사진 등록 및 삭제 기능 구현 (#68)             | ✅        |                                                 |

## Suggested reviewers
- woohyuckk
- jiyxxn

## Poem
> 난 작은 토끼, 코를 킁킁  
> 새로운 변화에 발 맞춰 뛰고  
> 이미지가 춤추며 화면을 장식해  
> 업로드부터 미리보기, 모두 완벽해 😊  
> 오늘도 즐겁게 달려가네, 히피!  
> (\_/)<br> ( •_•) <br> / >🍎

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡💬 Agentic Chat (Pro Plan, General Availability)</summary>
> 
> - We're introducing multi-step agentic chat in review comments and issue comments, within and outside of PR's. This feature enhances review and issue discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments and add commits to existing pull requests.
> 
> </details>

<!-- announcements_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=llddang/todayeat&utm_content=73):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: vercel[bot]
created_at: 2025-04-10T13:52:11Z
body: [vc]: #EhSC80jVw18GaExaIaRKh94E6tDp0sX6DZziFot5ucE=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJqaXdvb2RhLWRldiIsInJvb3REaXJlY3RvcnkiOm51bGwsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2xsZGRhbmdzLXByb2plY3RzL2ppd29vZGEtZGV2LzhlcFZmeWgzRDNDZHNVQWhwVEFVMWl3dFpFaEwiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsInByZXZpZXdVcmwiOiJqaXdvb2RhLWRldi1naXQtZmVhdHVyZS02OC1yZWdpc3Rlci1pbWFnZS1sbGRkYW5ncy1wcm9qZWN0cy52ZXJjZWwuYXBwIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **jiwooda-dev** | ⬜️ Ignored ([Inspect](https://vercel.com/llddangs-projects/jiwooda-dev/8epVfyh3D3CdsUAhpTAU1iwtZEhL)) | [Visit Preview](https://jiwooda-dev-git-feature-68-register-image-llddangs-projects.vercel.app) |  | Apr 11, 2025 8:11am |
</details>


author_association: NONE