ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,79 @@
+"use client";
+
+import { useRef, useState } from "react";
+import CameraIcon from "@/assets/images/icons/camera.svg";
+import FilledDeleteIcon from "@/assets/images/icons/filled_delete.svg";
+import { useSimpleImageUpload } from "@/hooks/useSimpleImageUpload";
+
+export default function ReviewImageInput({
+  onImageSelect,
+}: {
+  onImageSelect: (image: File | null) => void;
+}) {
+  const [isHovered, setIsHovered] = useState(false);
+  const inputRef = useRef<HTMLInputElement | null>(null);
+  const {
+    previewUrl,
+    handleImageUpload: handleImagePreview,
+    handleImageDelete,
+  } = useSimpleImageUpload();
+
+  const handleCameraclick = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    if (previewUrl) {
+      handleImageDelete();
+      onImageSelect(null);
+    } else {
+      inputRef.current?.click();
+    }
+  };
+
+  const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0] || null;
+    handleImagePreview(e);
+    onImageSelect(file);
+  };
+
+  return (
+    <div>
+      <label className='ml-2 text-body-2-normal font-medium text-gray-300'>
+        모임 관련 사진이 있나요?
+      </label>
+      <div
+        className='mt-3 flex h-[140px] cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-gray-800 bg-gray-900 *:text-gray-500'
+        onClick={handleCameraclick}
+        onMouseEnter={() => setIsHovered(true)}
+        onMouseLeave={() => setIsHovered(false)}
+      >
+        {previewUrl ? (
+          <div className='relative h-full w-full'>
+            <img
+              className='h-full w-full rounded-xl object-cover'
+              src={previewUrl}
+              alt='리뷰 이미지'
+            />
+            {isHovered && (
+              <div className='absolute inset-0 flex items-center justify-center rounded-xl bg-black/50'>
+                <FilledDeleteIcon className='size-8 text-gray-800' />
+              </div>
+            )}
+          </div>
+        ) : (
+          <>
+            <CameraIcon className='size-8' />
+            <p className='text-label-normal font-medium'>
+              이미지를 추가해주세요
+            </p>
+          </>
+        )}
+        <input
+          ref={inputRef}
+          className='hidden'
+          type='file'
+          accept='image/*'
+          onChange={handleImageUpload}
+        />
+      </div>
+    </div>
+  );
+}

리뷰: useUploadImage를 활용해도 되지 않나요?.?
@@ -0,0 +1,104 @@
+"use client";
+
+import { FormProvider, useForm } from "react-hook-form";
+import SolidButton from "@/components/common/buttons/SolidButton";
+import CommonTextArea from "@/components/common/inputs/TextArea";
+import RatingInput from "@/components/create-reaview/RatingInput";
+import ReviewImageInput from "@/components/create-reaview/ReviewImageInput";
+import useCookie from "@/hooks/auths/useTokenState";
+import useReviewMutations from "@/hooks/review/useReviewMutation";
+
+interface ReviewFormData {
+  rating: number;
+  content: string;
+  image: File | null;
+  meetupId: number;
+}
+
+export default function CreateReviewForm({ meetupId }: { meetupId: string }) {
+  const token = useCookie("accessToken");
+  const { createReviewMutation } = useReviewMutations();
+  const methods = useForm<ReviewFormData>({
+    defaultValues: {
+      rating: -1,
+      content: "",
+      image: null,
+      meetupId: Number(meetupId),
+    },
+    mode: "onChange",
+  });
+
+  const { handleSubmit, setValue, watch } = methods;
+
+  const rating = watch("rating");
+  const content = watch("content");
+  const isFormValid = rating !== -1 && content.trim() !== "";
+
+  const handleRatingChange = (value: number) => {
+    setValue("rating", value);
+  };
+
+  const handleImageSelect = (image: File | null) => {
+    setValue("image", image);
+  };
+
+  const onSubmit = handleSubmit(async (data) => {
+    if (!token) return;
+
+    const formData = new FormData();
+
+    const requestData = {
+      meetupId: Number(data.meetupId),
+      rating: data.rating,
+      content: data.content,
+    };
+
+    formData.append(
+      "request",
+      new Blob([JSON.stringify(requestData)], { type: "application/json" }),
+    );
+
+    if (data.image) {
+      formData.append("image", data.image);
+    }
+
+    if (data.image === null) {
+      formData.append("image", new Blob(), "");
+    }
+
+    await createReviewMutation.mutateAsync({ formData, token });
+  });
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={onSubmit}>
+        <div className='flex flex-col gap-12'>
+          <RatingInput
+            value={methods.watch("rating")}
+            onChange={handleRatingChange}
+          />
+          <CommonTextArea
+            formClassName='h-40'
+            required={true}
+            name='content'
+            label='구체적인 경험을 알려주세요'
+            placeholder='모임의 장소, 환경, 진행, 구성 등 만족스러웠나요?'
+            maxLength={150}
+          />
+          <ReviewImageInput onImageSelect={handleImageSelect} />
+        </div>
+        <SolidButton
+          type='submit'
+          className='mt-10'
+          state={
+            createReviewMutation.isPending || !isFormValid || !token
+              ? "inactive"
+              : "activated"
+          }
+        >
+          {createReviewMutation.isPending ? "작성 중..." : "작성 완료"}
+        </SolidButton>
+      </form>
+    </FormProvider>
+  );
+}

리뷰: `createReviewMutation.isPending` 사용해서 중복 호출 방지하면 좋을것 같습니다!
@@ -0,0 +1,104 @@
+"use client";
+
+import { FormProvider, useForm } from "react-hook-form";
+import SolidButton from "@/components/common/buttons/SolidButton";
+import CommonTextArea from "@/components/common/inputs/TextArea";
+import RatingInput from "@/components/create-reaview/RatingInput";
+import ReviewImageInput from "@/components/create-reaview/ReviewImageInput";
+import useCookie from "@/hooks/auths/useTokenState";
+import useReviewMutations from "@/hooks/review/useReviewMutation";
+
+interface ReviewFormData {
+  rating: number;
+  content: string;
+  image: File | null;
+  meetupId: number;
+}
+
+export default function CreateReviewForm({ meetupId }: { meetupId: string }) {
+  const token = useCookie("accessToken");
+  const { createReviewMutation } = useReviewMutations();
+  const methods = useForm<ReviewFormData>({
+    defaultValues: {
+      rating: -1,
+      content: "",
+      image: null,
+      meetupId: Number(meetupId),
+    },
+    mode: "onChange",
+  });
+
+  const { handleSubmit, setValue, watch } = methods;
+
+  const rating = watch("rating");
+  const content = watch("content");
+  const isFormValid = rating !== -1 && content.trim() !== "";
+
+  const handleRatingChange = (value: number) => {
+    setValue("rating", value);
+  };
+
+  const handleImageSelect = (image: File | null) => {
+    setValue("image", image);
+  };
+
+  const onSubmit = handleSubmit(async (data) => {
+    if (!token) return;
+
+    const formData = new FormData();
+
+    const requestData = {
+      meetupId: Number(data.meetupId),
+      rating: data.rating,
+      content: data.content,
+    };
+
+    formData.append(
+      "request",
+      new Blob([JSON.stringify(requestData)], { type: "application/json" }),
+    );
+
+    if (data.image) {
+      formData.append("image", data.image);
+    }
+
+    if (data.image === null) {
+      formData.append("image", new Blob(), "");
+    }
+
+    await createReviewMutation.mutateAsync({ formData, token });
+  });
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={onSubmit}>
+        <div className='flex flex-col gap-12'>
+          <RatingInput
+            value={methods.watch("rating")}
+            onChange={handleRatingChange}
+          />
+          <CommonTextArea
+            formClassName='h-40'
+            required={true}
+            name='content'
+            label='구체적인 경험을 알려주세요'
+            placeholder='모임의 장소, 환경, 진행, 구성 등 만족스러웠나요?'
+            maxLength={150}
+          />
+          <ReviewImageInput onImageSelect={handleImageSelect} />
+        </div>
+        <SolidButton
+          type='submit'
+          className='mt-10'
+          state={
+            createReviewMutation.isPending || !isFormValid || !token
+              ? "inactive"
+              : "activated"
+          }
+        >
+          {createReviewMutation.isPending ? "작성 중..." : "작성 완료"}
+        </SolidButton>
+      </form>
+    </FormProvider>
+  );
+}

리뷰: 작성된 내용들을 새로고침 했을 때도 사라지지 않게, localStorage에 저장하면 더 좋을 것 같습니다.
@@ -0,0 +1,104 @@
+"use client";
+
+import { FormProvider, useForm } from "react-hook-form";
+import SolidButton from "@/components/common/buttons/SolidButton";
+import CommonTextArea from "@/components/common/inputs/TextArea";
+import RatingInput from "@/components/create-reaview/RatingInput";
+import ReviewImageInput from "@/components/create-reaview/ReviewImageInput";
+import useCookie from "@/hooks/auths/useTokenState";
+import useReviewMutations from "@/hooks/review/useReviewMutation";
+
+interface ReviewFormData {
+  rating: number;
+  content: string;
+  image: File | null;
+  meetupId: number;
+}
+
+export default function CreateReviewForm({ meetupId }: { meetupId: string }) {
+  const token = useCookie("accessToken");
+  const { createReviewMutation } = useReviewMutations();
+  const methods = useForm<ReviewFormData>({
+    defaultValues: {
+      rating: -1,
+      content: "",
+      image: null,
+      meetupId: Number(meetupId),
+    },
+    mode: "onChange",
+  });
+
+  const { handleSubmit, setValue, watch } = methods;
+
+  const rating = watch("rating");
+  const content = watch("content");
+  const isFormValid = rating !== -1 && content.trim() !== "";
+
+  const handleRatingChange = (value: number) => {
+    setValue("rating", value);
+  };
+
+  const handleImageSelect = (image: File | null) => {
+    setValue("image", image);
+  };
+
+  const onSubmit = handleSubmit(async (data) => {
+    if (!token) return;
+
+    const formData = new FormData();
+
+    const requestData = {
+      meetupId: Number(data.meetupId),
+      rating: data.rating,
+      content: data.content,
+    };
+
+    formData.append(
+      "request",
+      new Blob([JSON.stringify(requestData)], { type: "application/json" }),
+    );
+
+    if (data.image) {
+      formData.append("image", data.image);
+    }
+
+    if (data.image === null) {
+      formData.append("image", new Blob(), "");
+    }
+
+    await createReviewMutation.mutateAsync({ formData, token });
+  });
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={onSubmit}>
+        <div className='flex flex-col gap-12'>
+          <RatingInput
+            value={methods.watch("rating")}
+            onChange={handleRatingChange}
+          />
+          <CommonTextArea
+            formClassName='h-40'
+            required={true}
+            name='content'
+            label='구체적인 경험을 알려주세요'
+            placeholder='모임의 장소, 환경, 진행, 구성 등 만족스러웠나요?'
+            maxLength={150}
+          />
+          <ReviewImageInput onImageSelect={handleImageSelect} />
+        </div>
+        <SolidButton
+          type='submit'
+          className='mt-10'
+          state={
+            createReviewMutation.isPending || !isFormValid || !token
+              ? "inactive"
+              : "activated"
+          }
+        >
+          {createReviewMutation.isPending ? "작성 중..." : "작성 완료"}
+        </SolidButton>
+      </form>
+    </FormProvider>
+  );
+}

리뷰: 해당 라인은 불필요하다고 생각합니다. 토큰이 없으면 어짜피 서버에서 검증 후 에러를 던져줍니다. 
UX적으로 API 요청 전 검사 후 리다이렉트가 필요하면 `mutatonFn`을 wrapping하는 방식을 사용하시거나
중앙집중식으로 관리하는 방식으로 구현하시길 바랍니다.
@@ -0,0 +1,104 @@
+"use client";
+
+import { FormProvider, useForm } from "react-hook-form";
+import SolidButton from "@/components/common/buttons/SolidButton";
+import CommonTextArea from "@/components/common/inputs/TextArea";
+import RatingInput from "@/components/create-reaview/RatingInput";
+import ReviewImageInput from "@/components/create-reaview/ReviewImageInput";
+import useCookie from "@/hooks/auths/useTokenState";
+import useReviewMutations from "@/hooks/review/useReviewMutation";
+
+interface ReviewFormData {
+  rating: number;
+  content: string;
+  image: File | null;
+  meetupId: number;
+}
+
+export default function CreateReviewForm({ meetupId }: { meetupId: string }) {
+  const token = useCookie("accessToken");
+  const { createReviewMutation } = useReviewMutations();
+  const methods = useForm<ReviewFormData>({
+    defaultValues: {
+      rating: -1,
+      content: "",
+      image: null,
+      meetupId: Number(meetupId),
+    },
+    mode: "onChange",
+  });
+
+  const { handleSubmit, setValue, watch } = methods;
+
+  const rating = watch("rating");
+  const content = watch("content");
+  const isFormValid = rating !== -1 && content.trim() !== "";
+
+  const handleRatingChange = (value: number) => {
+    setValue("rating", value);
+  };
+
+  const handleImageSelect = (image: File | null) => {
+    setValue("image", image);
+  };
+
+  const onSubmit = handleSubmit(async (data) => {
+    if (!token) return;
+
+    const formData = new FormData();
+
+    const requestData = {
+      meetupId: Number(data.meetupId),
+      rating: data.rating,
+      content: data.content,
+    };
+
+    formData.append(
+      "request",
+      new Blob([JSON.stringify(requestData)], { type: "application/json" }),
+    );
+
+    if (data.image) {
+      formData.append("image", data.image);
+    }
+
+    if (data.image === null) {
+      formData.append("image", new Blob(), "");
+    }
+
+    await createReviewMutation.mutateAsync({ formData, token });
+  });
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={onSubmit}>
+        <div className='flex flex-col gap-12'>
+          <RatingInput
+            value={methods.watch("rating")}
+            onChange={handleRatingChange}
+          />
+          <CommonTextArea
+            formClassName='h-40'
+            required={true}
+            name='content'
+            label='구체적인 경험을 알려주세요'
+            placeholder='모임의 장소, 환경, 진행, 구성 등 만족스러웠나요?'
+            maxLength={150}
+          />
+          <ReviewImageInput onImageSelect={handleImageSelect} />
+        </div>
+        <SolidButton
+          type='submit'
+          className='mt-10'
+          state={
+            createReviewMutation.isPending || !isFormValid || !token
+              ? "inactive"
+              : "activated"
+          }
+        >
+          {createReviewMutation.isPending ? "작성 중..." : "작성 완료"}
+        </SolidButton>
+      </form>
+    </FormProvider>
+  );
+}

리뷰: 여기서 throw 된 에러는 어디서 어떻게 핸들링 될까요?
@@ -0,0 +1,104 @@
+"use client";
+
+import { FormProvider, useForm } from "react-hook-form";
+import SolidButton from "@/components/common/buttons/SolidButton";
+import CommonTextArea from "@/components/common/inputs/TextArea";
+import RatingInput from "@/components/create-reaview/RatingInput";
+import ReviewImageInput from "@/components/create-reaview/ReviewImageInput";
+import useCookie from "@/hooks/auths/useTokenState";
+import useReviewMutations from "@/hooks/review/useReviewMutation";
+
+interface ReviewFormData {
+  rating: number;
+  content: string;
+  image: File | null;
+  meetupId: number;
+}
+
+export default function CreateReviewForm({ meetupId }: { meetupId: string }) {
+  const token = useCookie("accessToken");
+  const { createReviewMutation } = useReviewMutations();
+  const methods = useForm<ReviewFormData>({
+    defaultValues: {
+      rating: -1,
+      content: "",
+      image: null,
+      meetupId: Number(meetupId),
+    },
+    mode: "onChange",
+  });
+
+  const { handleSubmit, setValue, watch } = methods;
+
+  const rating = watch("rating");
+  const content = watch("content");
+  const isFormValid = rating !== -1 && content.trim() !== "";
+
+  const handleRatingChange = (value: number) => {
+    setValue("rating", value);
+  };
+
+  const handleImageSelect = (image: File | null) => {
+    setValue("image", image);
+  };
+
+  const onSubmit = handleSubmit(async (data) => {
+    if (!token) return;
+
+    const formData = new FormData();
+
+    const requestData = {
+      meetupId: Number(data.meetupId),
+      rating: data.rating,
+      content: data.content,
+    };
+
+    formData.append(
+      "request",
+      new Blob([JSON.stringify(requestData)], { type: "application/json" }),
+    );
+
+    if (data.image) {
+      formData.append("image", data.image);
+    }
+
+    if (data.image === null) {
+      formData.append("image", new Blob(), "");
+    }
+
+    await createReviewMutation.mutateAsync({ formData, token });
+  });
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={onSubmit}>
+        <div className='flex flex-col gap-12'>
+          <RatingInput
+            value={methods.watch("rating")}
+            onChange={handleRatingChange}
+          />
+          <CommonTextArea
+            formClassName='h-40'
+            required={true}
+            name='content'
+            label='구체적인 경험을 알려주세요'
+            placeholder='모임의 장소, 환경, 진행, 구성 등 만족스러웠나요?'
+            maxLength={150}
+          />
+          <ReviewImageInput onImageSelect={handleImageSelect} />
+        </div>
+        <SolidButton
+          type='submit'
+          className='mt-10'
+          state={
+            createReviewMutation.isPending || !isFormValid || !token
+              ? "inactive"
+              : "activated"
+          }
+        >
+          {createReviewMutation.isPending ? "작성 중..." : "작성 완료"}
+        </SolidButton>
+      </form>
+    </FormProvider>
+  );
+}

리뷰: `Blob` 인스턴스로 만드는 이유가 궁금합니다.
@@ -0,0 +1,79 @@
+"use client";
+
+import { useRef, useState } from "react";
+import CameraIcon from "@/assets/images/icons/camera.svg";
+import FilledDeleteIcon from "@/assets/images/icons/filled_delete.svg";
+import { useSimpleImageUpload } from "@/hooks/useSimpleImageUpload";
+
+export default function ReviewImageInput({
+  onImageSelect,
+}: {
+  onImageSelect: (image: File | null) => void;
+}) {
+  const [isHovered, setIsHovered] = useState(false);
+  const inputRef = useRef<HTMLInputElement | null>(null);
+  const {
+    previewUrl,
+    handleImageUpload: handleImagePreview,
+    handleImageDelete,
+  } = useSimpleImageUpload();
+
+  const handleCameraclick = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    if (previewUrl) {
+      handleImageDelete();
+      onImageSelect(null);
+    } else {
+      inputRef.current?.click();
+    }
+  };
+
+  const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0] || null;
+    handleImagePreview(e);
+    onImageSelect(file);
+  };
+
+  return (
+    <div>
+      <label className='ml-2 text-body-2-normal font-medium text-gray-300'>
+        모임 관련 사진이 있나요?
+      </label>
+      <div
+        className='mt-3 flex h-[140px] cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-gray-800 bg-gray-900 *:text-gray-500'
+        onClick={handleCameraclick}
+        onMouseEnter={() => setIsHovered(true)}
+        onMouseLeave={() => setIsHovered(false)}
+      >
+        {previewUrl ? (
+          <div className='relative h-full w-full'>
+            <img
+              className='h-full w-full rounded-xl object-cover'
+              src={previewUrl}
+              alt='리뷰 이미지'
+            />
+            {isHovered && (
+              <div className='absolute inset-0 flex items-center justify-center rounded-xl bg-black/50'>
+                <FilledDeleteIcon className='size-8 text-gray-800' />
+              </div>
+            )}
+          </div>
+        ) : (
+          <>
+            <CameraIcon className='size-8' />
+            <p className='text-label-normal font-medium'>
+              이미지를 추가해주세요
+            </p>
+          </>
+        )}
+        <input
+          ref={inputRef}
+          className='hidden'
+          type='file'
+          accept='image/*'
+          onChange={handleImageUpload}
+        />
+      </div>
+    </div>
+  );
+}

리뷰: `useSimpleImageUpload`는 이전에 IndexedDB 이슈가 있었을 때 급하게 대체 구현이 필요해서 만들어둔 훅입니다.
이후 IndexedDB 이슈가 해결되었지만 유저페이지를 비롯한 기타 작업에 아직 실제 적용은 해보지 않은 상태라 일단 `useSimpleImageUpload`로 구현했습니다!

추후 리팩토링 단계에서 IndexedDB를 적용하면서 이 부분도 `useUploadImage`로 교체하도록 하겠습니당
@@ -0,0 +1,104 @@
+"use client";
+
+import { FormProvider, useForm } from "react-hook-form";
+import SolidButton from "@/components/common/buttons/SolidButton";
+import CommonTextArea from "@/components/common/inputs/TextArea";
+import RatingInput from "@/components/create-reaview/RatingInput";
+import ReviewImageInput from "@/components/create-reaview/ReviewImageInput";
+import useCookie from "@/hooks/auths/useTokenState";
+import useReviewMutations from "@/hooks/review/useReviewMutation";
+
+interface ReviewFormData {
+  rating: number;
+  content: string;
+  image: File | null;
+  meetupId: number;
+}
+
+export default function CreateReviewForm({ meetupId }: { meetupId: string }) {
+  const token = useCookie("accessToken");
+  const { createReviewMutation } = useReviewMutations();
+  const methods = useForm<ReviewFormData>({
+    defaultValues: {
+      rating: -1,
+      content: "",
+      image: null,
+      meetupId: Number(meetupId),
+    },
+    mode: "onChange",
+  });
+
+  const { handleSubmit, setValue, watch } = methods;
+
+  const rating = watch("rating");
+  const content = watch("content");
+  const isFormValid = rating !== -1 && content.trim() !== "";
+
+  const handleRatingChange = (value: number) => {
+    setValue("rating", value);
+  };
+
+  const handleImageSelect = (image: File | null) => {
+    setValue("image", image);
+  };
+
+  const onSubmit = handleSubmit(async (data) => {
+    if (!token) return;
+
+    const formData = new FormData();
+
+    const requestData = {
+      meetupId: Number(data.meetupId),
+      rating: data.rating,
+      content: data.content,
+    };
+
+    formData.append(
+      "request",
+      new Blob([JSON.stringify(requestData)], { type: "application/json" }),
+    );
+
+    if (data.image) {
+      formData.append("image", data.image);
+    }
+
+    if (data.image === null) {
+      formData.append("image", new Blob(), "");
+    }
+
+    await createReviewMutation.mutateAsync({ formData, token });
+  });
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={onSubmit}>
+        <div className='flex flex-col gap-12'>
+          <RatingInput
+            value={methods.watch("rating")}
+            onChange={handleRatingChange}
+          />
+          <CommonTextArea
+            formClassName='h-40'
+            required={true}
+            name='content'
+            label='구체적인 경험을 알려주세요'
+            placeholder='모임의 장소, 환경, 진행, 구성 등 만족스러웠나요?'
+            maxLength={150}
+          />
+          <ReviewImageInput onImageSelect={handleImageSelect} />
+        </div>
+        <SolidButton
+          type='submit'
+          className='mt-10'
+          state={
+            createReviewMutation.isPending || !isFormValid || !token
+              ? "inactive"
+              : "activated"
+          }
+        >
+          {createReviewMutation.isPending ? "작성 중..." : "작성 완료"}
+        </SolidButton>
+      </form>
+    </FormProvider>
+  );
+}

리뷰: 넵 리팩토링 때 반영해보겠습니다