Repository: MoimService/Moim-FE
Pull request #57: Feat/refactor/like optimistic update/DEVING-67
Author: login: lee1nna
Labels: name: ✨ feat
name: 🐛 fix
name: ♻️ refactor
Created: 2025-03-07T00:48:25Z | Merged: 2025-03-11T08:12:45Z

## 📝 주요 작업 내용

- 좋아요 낙관적 업데이트 적용
- 좋아요/좋아요 취소 토스트 바 적용
- 닉네임 길어질 경우 프로필 이미지 깨짐 현상 수정
- 카드 그리드 형식으로 수정
- 헤더 드롭다운 메뉴 오픈 시 우측 여백 생기는 현상 수정

## 📺 스크린샷

- 낙관적 업데이트 적용 전
![optimistic 전](https://github.com/user-attachments/assets/bed9f019-5601-4b86-81ea-efad3c679cdf)

- 낙관적 업데이트 적용 후
![optimistic 후](https://github.com/user-attachments/assets/1326746f-a4ff-44fc-9364-fd17145957de)

- 카드 그리드 적용
![image](https://github.com/user-attachments/assets/b3bfb3f1-aff3-4cfd-9804-311da595b7d6)


## 🔗 참고 사항

ex) 의논할 점, 리뷰어가 알 필요가 있는 추가 정보나 문서, 참고 링크를 포함.

## 💬 리뷰 요구사항
- 좋아요 관련 로직을 useLikeHandler 훅으로 별도로 뺐는데, 이 쪽 로직이 조금 복잡해서
더 좋은 방향 있으면 말씀해주시면 감사하겠습니다!

## 📃 관련 이슈

ex) [DEVING-67]


[DEVING-67]: https://moimservice.atlassian.net/browse/DEVING-67?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/src/app/meeting/_features/CardWrapper.tsx b/src/app/meeting/_features/CardWrapper.tsx
index c8fc61c..393c0d5 100644
--- a/src/app/meeting/_features/CardWrapper.tsx
+++ b/src/app/meeting/_features/CardWrapper.tsx
@@ -25,7 +25,7 @@ const CardWrapper = ({ meetingId }: { meetingId: number }) => {
         <VerticalCard
           meetingId={meetingId}
           category={translateCategoryNameToKor(categoryStr)}
-          className="h-fit w-full"
+          className="h-[650px] w-full"
           thumbnailHeight={252}
           thumbnailWidth={343}
           key={meeting.meetingId}
diff --git a/src/app/meeting/_features/MeetingExtraInfo.tsx b/src/app/meeting/_features/MeetingExtraInfo.tsx
index 827f3d3..1752c42 100644
--- a/src/app/meeting/_features/MeetingExtraInfo.tsx
+++ b/src/app/meeting/_features/MeetingExtraInfo.tsx
@@ -39,8 +39,8 @@ const MeetingExtraInfo = ({
           <div
             className={`${isMobile ? 'typo-button2' : 'typo-head2'} mt-1 flex items-center text-Cgray700`}
           >
-            <Profile className="mr-2 h-10 w-10" />
-            <span className="text-ellipsi truncate">{name}</span>
+            <Profile className="mr-2 h-10 w-10 flex-shrink-0" />
+            <span className="truncate text-ellipsis">{name}</span>
           </div>
         </div>
         <div className="flex flex-col">
diff --git a/src/app/meeting/_features/MeetingList.tsx b/src/app/meeting/_features/MeetingList.tsx
index d72747b..1be1229 100644
--- a/src/app/meeting/_features/MeetingList.tsx
+++ b/src/app/meeting/_features/MeetingList.tsx
@@ -46,6 +46,9 @@ const MeetingList = () => {
     },
   );
 
+  const allMeetings: SearchMeeting[] =
+    data?.pages.flatMap((page) => page.content) || [];
+
   const lastMeetingRef = useInfiniteScroll({
     fetchNextPage,
     isFetchingNextPage,
@@ -140,40 +143,35 @@ const MeetingList = () => {
       {/* 모임 리스트 웹뷰 */}
       {breakpoint === 'desktop' && (
         <div className="flex-col sm:hidden lg:flex">
-          {data?.pages.map((page, pageIndex) => (
-            <div key={pageIndex}>
-              {page.content.map((meeting: SearchMeeting) => {
-                return (
-                  <HorizonCard
-                    onClick={handleMoveDetailPage}
-                    key={meeting.meetingId}
-                    meetingId={meeting.meetingId}
-                    category={translateCategoryNameToKor(categoryStr)}
-                    title={meeting.meetingTitle}
-                    thumbnailUrl={meeting.thumbnail}
-                    location={meeting.location}
-                    isLike={meeting.isLike}
-                    total={meeting.maxMember}
-                    value={meeting.memberCount}
-                    searchQuery={searchQuery}
-                    likesCount={meeting.likesCount}
-                    skills={meeting.meetingSkillArray}
-                  >
-                    <MeetingExtraInfo
-                      lastMeetingRef={
-                        page.nextCursor === meeting.meetingId
-                          ? lastMeetingRef
-                          : null
-                      }
-                      name={meeting.name}
-                      startDate={meeting.startDate}
-                      meetingId={meeting.meetingId}
-                      variant="desktop"
-                    />
-                  </HorizonCard>
-                );
-              })}
-            </div>
+          {allMeetings.map((meeting) => (
+            <HorizonCard
+              onClick={handleMoveDetailPage}
+              key={meeting.meetingId}
+              meetingId={meeting.meetingId}
+              category={translateCategoryNameToKor(categoryStr)}
+              title={meeting.meetingTitle}
+              thumbnailUrl={meeting.thumbnail}
+              location={meeting.location}
+              isLike={meeting.isLike}
+              total={meeting.maxMember}
+              value={meeting.memberCount}
+              searchQuery={searchQuery}
+              likesCount={meeting.likesCount}
+              skills={meeting.meetingSkillArray}
+            >
+              <MeetingExtraInfo
+                lastMeetingRef={
+                  meeting.meetingId ===
+                  allMeetings[allMeetings.length - 1].meetingId
+                    ? lastMeetingRef
+                    : null
+                }
+                name={meeting.name}
+                startDate={meeting.startDate}
+                meetingId={meeting.meetingId}
+                variant="desktop"
+              />
+            </HorizonCard>
           ))}
         </div>
       )}
@@ -181,87 +179,77 @@ const MeetingList = () => {
       {/* 모임 리스트 테블릿뷰 */}
       {breakpoint === 'tablet' && (
         <div className="hidden flex-col md:flex lg:hidden">
-          {data?.pages.map((page, pageIndex) => (
-            <div key={pageIndex}>
-              {page.content.map((meeting: SearchMeeting) => {
-                return (
-                  <HorizonCard
-                    onClick={handleMoveDetailPage}
-                    className="items-center"
-                    key={meeting.meetingId}
-                    meetingId={meeting.meetingId}
-                    category={translateCategoryNameToKor(categoryStr)}
-                    title={meeting.meetingTitle}
-                    thumbnailUrl={meeting.thumbnail}
-                    thumbnailHeight={160}
-                    thumbnailWidth={160}
-                    location={meeting.location}
-                    isLike={meeting.isLike}
-                    likesCount={meeting.likesCount}
-                    total={meeting.maxMember}
-                    value={meeting.memberCount}
-                    searchQuery={searchQuery}
-                    skills={meeting.meetingSkillArray}
-                  >
-                    <MeetingExtraInfo
-                      lastMeetingRef={
-                        page.nextCursor === meeting.meetingId
-                          ? lastMeetingRef
-                          : null
-                      }
-                      name={meeting.name}
-                      startDate={meeting.startDate}
-                      meetingId={meeting.meetingId}
-                      variant="tablet"
-                    />
-                  </HorizonCard>
-                );
-              })}
-            </div>
+          {allMeetings.map((meeting) => (
+            <HorizonCard
+              onClick={handleMoveDetailPage}
+              className="items-center"
+              key={meeting.meetingId}
+              meetingId={meeting.meetingId}
+              category={translateCategoryNameToKor(categoryStr)}
+              title={meeting.meetingTitle}
+              thumbnailUrl={meeting.thumbnail}
+              thumbnailHeight={160}
+              thumbnailWidth={160}
+              location={meeting.location}
+              isLike={meeting.isLike}
+              likesCount={meeting.likesCount}
+              total={meeting.maxMember}
+              value={meeting.memberCount}
+              searchQuery={searchQuery}
+              skills={meeting.meetingSkillArray}
+            >
+              <MeetingExtraInfo
+                lastMeetingRef={
+                  meeting.meetingId ===
+                  allMeetings[allMeetings.length - 1].meetingId
+                    ? lastMeetingRef
+                    : null
+                }
+                name={meeting.name}
+                startDate={meeting.startDate}
+                meetingId={meeting.meetingId}
+                variant="tablet"
+              />
+            </HorizonCard>
           ))}
         </div>
       )}
 
       {/* 모임 리스트 모바일뷰 */}
       {breakpoint === 'mobile' && (
-        <div className="flex flex-col items-center md:hidden lg:hidden">
-          {data?.pages.map((page, pageIndex) => (
-            <div key={pageIndex}>
-              {page.content.map((meeting: SearchMeeting) => {
-                return (
-                  <VerticalCard
-                    onClick={handleMoveDetailPage}
-                    className="h-[380px]"
-                    thumbnailHeight={160}
-                    thumbnailWidth={311}
-                    category={translateCategoryNameToKor(categoryStr)}
-                    key={meeting.meetingId}
-                    meetingId={meeting.meetingId}
-                    title={meeting.meetingTitle}
-                    thumbnailUrl={meeting.thumbnail}
-                    location={meeting.location}
-                    isLike={meeting.isLike}
-                    likesCount={meeting.likesCount}
-                    total={meeting.maxMember}
-                    value={meeting.memberCount}
-                    searchQuery={searchQuery}
-                    skills={meeting.meetingSkillArray}
-                  >
-                    <MeetingExtraInfo
-                      lastMeetingRef={
-                        page.nextCursor === meeting.meetingId
-                          ? lastMeetingRef
-                          : null
-                      }
-                      name={meeting.name}
-                      startDate={meeting.startDate}
-                      meetingId={meeting.meetingId}
-                      variant="mobile"
-                    />
-                  </VerticalCard>
-                );
-              })}
-            </div>
+        <div className="flex w-full flex-wrap items-center justify-center md:hidden lg:hidden">
+          {allMeetings.map((meeting) => (
+            <VerticalCard
+              onClick={handleMoveDetailPage}
+              className="h-[380px]"
+              thumbnailHeight={160}
+              thumbnailWidth={311}
+              category={translateCategoryNameToKor(categoryStr)}
+              key={meeting.meetingId}
+              meetingId={meeting.meetingId}
+              title={meeting.meetingTitle}
+              thumbnailUrl={meeting.thumbnail}
+              location={meeting.location}
+              isLike={meeting.isLike}
+              likesCount={meeting.likesCount}
+              total={meeting.maxMember}
+              value={meeting.memberCount}
+              searchQuery={searchQuery}
+              skills={meeting.meetingSkillArray}
+            >
+              <MeetingExtraInfo
+                lastMeetingRef={
+                  meeting.meetingId ===
+                  allMeetings[allMeetings.length - 1].meetingId
+                    ? lastMeetingRef
+                    : null
+                }
+                name={meeting.name}
+                startDate={meeting.startDate}
+                meetingId={meeting.meetingId}
+                variant="mobile"
+              />
+            </VerticalCard>
           ))}
         </div>
       )}
diff --git a/src/components/common/Dropdown.tsx b/src/components/common/Dropdown.tsx
index ad94081..c981fea 100644
--- a/src/components/common/Dropdown.tsx
+++ b/src/components/common/Dropdown.tsx
@@ -125,7 +125,7 @@ const Dropdown = ({
     !className?.includes('w-[460px]');
 
   return (
-    <DropdownMenuPrimitive.Root onOpenChange={setIsOpen}>
+    <DropdownMenuPrimitive.Root onOpenChange={setIsOpen} modal={false}>
       <DropdownMenuPrimitive.Trigger
         ref={triggerRef}
         className={cn(
diff --git a/src/components/ui/HorizonCard.tsx b/src/components/ui/HorizonCard.tsx
index 15453d0..f595da1 100644
--- a/src/components/ui/HorizonCard.tsx
+++ b/src/components/ui/HorizonCard.tsx
@@ -1,21 +1,11 @@
-import {
-  useCancelLikeMeeting,
-  useLikeMeeting,
-} from '@/hooks/mutations/useMeetingMutation';
-import {
-  MEETING_QUERY_KEYS,
-  meetingKeys,
-} from '@/hooks/queries/useMeetingQueries';
-import { getAccessToken } from '@/lib/serverActions';
+import useLikeHandler from '@/hooks/common/useLikeHandler';
 import { getIconComponent } from '@/util/getIconDetail';
-import { useQueryClient } from '@tanstack/react-query';
 import { Heart, Map } from 'lucide-react';
 import Image from 'next/image';
 import { useParams, useRouter } from 'next/navigation';
 import { useState } from 'react';
 import { IMeetingSearchCondition } from 'types/meeting';
 
-import { useToast } from '../common/ToastContext';
 import { Button } from './Button';
 import { Progress } from './Progress';
 import Modal from './modal/Modal';
@@ -35,6 +25,7 @@ interface HorizonCardProps {
   thumbnailHeight?: number;
   onClick?: (id: number) => void;
   searchQuery?: IMeetingSearchCondition;
+  showLikeButton?: boolean;
   isLike?: boolean;
   likesCount?: number;
   skills?: string[];
@@ -55,41 +46,20 @@ const HorizonCard = ({
   value,
   total = 100,
   searchQuery,
+  showLikeButton = true,
   likesCount,
   skills,
 }: HorizonCardProps) => {
-  const { showToast } = useToast();
-  const queryClient = useQueryClient();
-  const { mutate: likeMutation } = useLikeMeeting(meetingId, {
-    onSuccess: () => {
-      invalidateMeetingQuery();
-    },
-    onError: () => {
-      showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 });
-    },
+  const { toggleLike } = useLikeHandler({
+    meetingId,
+    category,
+    searchQuery,
+    onAuthRequired: () => setIsLoginModalOpen(true),
   });
 
-  const { mutate: cancellikeMutation } = useCancelLikeMeeting(meetingId, {
-    onSuccess: () => {
-      invalidateMeetingQuery();
-    },
-    onError: () => {
-      showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 });
-    },
-  });
-
-  const invalidateMeetingQuery = () => {
-    if (category && searchQuery) {
-      queryClient.invalidateQueries({
-        queryKey: MEETING_QUERY_KEYS.meetings(category, searchQuery),
-      });
-    }
-    queryClient.invalidateQueries({
-      queryKey: MEETING_QUERY_KEYS.topMeetings(category),
-    });
-    queryClient.invalidateQueries({
-      queryKey: meetingKeys.detailInfo(meetingId),
-    });
+  const handleLikeButton = (e: React.MouseEvent<HTMLButtonElement>) => {
+    e.stopPropagation();
+    toggleLike(isLike);
   };
 
   // TODO: 리팩토링 예정
@@ -100,25 +70,6 @@ const HorizonCard = ({
     if (onClick && !id) onClick(meetingId);
   };
 
-  const handleLikeButton = async (e: React.MouseEvent<HTMLButtonElement>) => {
-    e.stopPropagation();
-    const token = await getAccessToken();
-
-    // 토큰 없으면 로그인 안내 팝업 노출
-    if (!token) {
-      setIsLoginModalOpen(true);
-      return;
-    }
-
-    if (!isLike) {
-      likeMutation();
-    }
-
-    if (isLike) {
-      cancellikeMutation();
-    }
-  };
-
   const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
   const router = useRouter();
 
@@ -145,23 +96,24 @@ const HorizonCard = ({
       >
         <p className="text-center text-white">로그인이 필요한 서비스 입니다.</p>
       </Modal>
-      <Button
-        className="absolute right-4 top-4 h-auto w-auto"
-        variant="text"
-        size="sm"
-        onClick={(e) => handleLikeButton(e)}
-        icon={
-          <Heart
-            style={{ width: '24px', height: '24px' }}
-            className={`${isLike ? 'fill-main' : 'stroke-Cgray500'} h-4 w-4 md:h-6 md:w-6`}
-          />
-        }
-      >
-        <span className="typo-caption2 absolute top-7 text-Cgray500">
-          {likesCount}
-        </span>
-      </Button>
-
+      {showLikeButton && (
+        <Button
+          className="absolute right-4 top-4 h-auto w-auto"
+          variant="text"
+          size="sm"
+          onClick={(e) => handleLikeButton(e)}
+          icon={
+            <Heart
+              style={{ width: '24px', height: '24px' }}
+              className={`${isLike ? 'fill-main' : 'stroke-Cgray500'} h-4 w-4 md:h-6 md:w-6`}
+            />
+          }
+        >
+          <span className="typo-caption2 absolute top-7 text-Cgray500">
+            {likesCount}
+          </span>
+        </Button>
+      )}
       <div
         className="relative flex-shrink-0"
         style={{ height: `${thumbnailHeight}px`, width: `${thumbnailWidth}px` }}
diff --git a/src/components/ui/VerticalCard.tsx b/src/components/ui/VerticalCard.tsx
index 03db759..382c37d 100644
--- a/src/components/ui/VerticalCard.tsx
+++ b/src/components/ui/VerticalCard.tsx
@@ -1,21 +1,11 @@
-import {
-  useCancelLikeMeeting,
-  useLikeMeeting,
-} from '@/hooks/mutations/useMeetingMutation';
-import {
-  MEETING_QUERY_KEYS,
-  meetingKeys,
-} from '@/hooks/queries/useMeetingQueries';
-import { getAccessToken } from '@/lib/serverActions';
+import useLikeHandler from '@/hooks/common/useLikeHandler';
 import { getIconComponent } from '@/util/getIconDetail';
-import { useQueryClient } from '@tanstack/react-query';
 import { Heart, Map } from 'lucide-react';
 import Image from 'next/image';
 import { useRouter } from 'next/navigation';
 import { useState } from 'react';
 import { IMeetingSearchCondition } from 'types/meeting';
 
-import { useToast } from '../common/ToastContext';
 import { Button } from './Button';
 import { Progress } from './Progress';
 import Modal from './modal/Modal';
@@ -33,6 +23,7 @@ interface VerticalCardProps {
   location: string;
   onClick?: (id: number) => void;
   isLike?: boolean;
+  showLikeButton?: boolean;
   searchQuery?: IMeetingSearchCondition;
   value: number;
   total: number;
@@ -52,69 +43,27 @@ const VerticalCard = ({
   location,
   onClick,
   isLike = false,
-  likesCount,
+  showLikeButton = true,
   searchQuery,
   value,
   total = 100,
   skills,
 }: VerticalCardProps) => {
-  const { showToast } = useToast();
-  const queryClient = useQueryClient();
-  const { mutate: likeMutation } = useLikeMeeting(meetingId, {
-    onSuccess: () => {
-      invalidateMeetingQuery();
-    },
-    onError: () => {
-      showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 });
-    },
-  });
-
-  const { mutate: cancellikeMutation } = useCancelLikeMeeting(meetingId, {
-    onSuccess: () => {
-      invalidateMeetingQuery();
-    },
-    onError: () => {
-      showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 });
-    },
-  });
-
-  // TODO: 리팩토링 예정
-  const invalidateMeetingQuery = () => {
-    if (category && searchQuery) {
-      queryClient.invalidateQueries({
-        queryKey: MEETING_QUERY_KEYS.meetings(category, searchQuery),
-      });
-    }
-    queryClient.invalidateQueries({
-      queryKey: MEETING_QUERY_KEYS.topMeetings(category),
-    });
-    queryClient.invalidateQueries({
-      queryKey: meetingKeys.detailInfo(meetingId),
-    });
-  };
-
   const handleClickCard = () => {
     if (isLoginModalOpen) return;
     if (onClick) onClick(meetingId);
   };
 
-  const handleLikeButton = async (e: React.MouseEvent<HTMLButtonElement>) => {
-    e.stopPropagation();
-    const token = await getAccessToken();
-
-    // 토큰 없으면 로그인 안내 팝업 노출
-    if (!token) {
-      setIsLoginModalOpen(true);
-      return;
-    }
-
-    if (!isLike) {
-      likeMutation();
-    }
+  const { toggleLike } = useLikeHandler({
+    meetingId,
+    category,
+    searchQuery,
+    onAuthRequired: () => setIsLoginModalOpen(true),
+  });
 
-    if (isLike) {
-      cancellikeMutation();
-    }
+  const handleLikeButton = (e: React.MouseEvent<HTMLButtonElement>) => {
+    e.stopPropagation();
+    toggleLike(isLike);
   };
 
   const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
@@ -129,7 +78,7 @@ const VerticalCard = ({
 
   return (
     <div
-      className={`h-auto w-[335px] cursor-pointer bg-BG p-4 ${className}`}
+      className={`flex h-[490px] w-[335px] cursor-pointer flex-col justify-between bg-BG p-4 ${className}`}
       role="presentation"
       onClick={handleClickCard}
     >
@@ -151,7 +100,7 @@ const VerticalCard = ({
           <div className="h-full w-full animate-pulse rounded-[20px] bg-Cgray200"></div>
         )}
         <Image
-          className="rounded-[20px] object-cover"
+          className="flex-shrink-0 rounded-[20px] object-cover"
           src={thumbnail ? thumbnail : '/thumbnail.jpg'}
           alt="card_thumbnail"
           fill
@@ -164,9 +113,9 @@ const VerticalCard = ({
           <span className="max-w-[270px] overflow-hidden truncate text-ellipsis whitespace-nowrap">
             {title}
           </span>
-          <div className="flex flex-col items-center">
+          {showLikeButton && (
             <Button
-              className="right-0 h-auto w-auto"
+              className="relative h-auto w-auto"
               variant="text"
               size="sm"
               onClick={(e) => handleLikeButton(e)}
@@ -177,8 +126,7 @@ const VerticalCard = ({
                 />
               }
             ></Button>
-            <div className="typo-caption2 text-Cgray500">{likesCount}</div>
-          </div>
+          )}
         </div>
         <div className="relative flex items-center gap-1 truncate text-Cgray500">
           <Map size={20} strokeWidth={1} />
diff --git a/src/hooks/common/useLikeHandler.ts b/src/hooks/common/useLikeHandler.ts
new file mode 100644
index 0000000..56285ce
--- /dev/null
+++ b/src/hooks/common/useLikeHandler.ts
@@ -0,0 +1,248 @@
+import { useToast } from '@/components/common/ToastContext';
+import {
+  useCancelLikeMeeting,
+  useLikeMeeting,
+} from '@/hooks/mutations/useMeetingMutation';
+import {
+  MEETING_QUERY_KEYS,
+  meetingKeys,
+} from '@/hooks/queries/useMeetingQueries';
+import { getAccessToken } from '@/lib/serverActions';
+import { InfiniteData, QueryKey, useQueryClient } from '@tanstack/react-query';
+import { useParams } from 'next/navigation';
+import {
+  IMeetingSearchCondition,
+  Paginated,
+  SearchMeeting,
+  TopMeeting,
+} from 'types/meeting';
+
+interface UseLikeHandlerProps {
+  meetingId: number;
+  category: string;
+  searchQuery?: IMeetingSearchCondition;
+  onAuthRequired?: () => void;
+}
+
+const useLikeHandler = ({
+  meetingId,
+  category,
+  searchQuery,
+  onAuthRequired,
+}: UseLikeHandlerProps) => {
+  const queryClient = useQueryClient();
+
+  // 배열 캐시 업데이트
+  const updateCacheArray = <T extends TopMeeting>(
+    queryKey: QueryKey,
+    meetingId: number,
+    isLike: boolean,
+    likeCount: number,
+  ) => {
+    queryClient.setQueryData<T[]>(queryKey, (oldData) => {
+      if (!oldData) return oldData;
+      return oldData.map((meeting) =>
+        meeting.meetingId === meetingId
+          ? { ...meeting, isLike, likesCount: meeting.likesCount + likeCount }
+          : meeting,
+      );
+    });
+  };
+
+  // 객체 캐시 업데이트
+  const updateCacheObject = <T extends SearchMeeting>(
+    queryKey: QueryKey,
+    isLike: boolean,
+    likeCount: number,
+  ) => {
+    queryClient.setQueryData<T>(queryKey, (oldData) => {
+      if (!oldData) return oldData;
+      return { ...oldData, isLike, likesCount: oldData.likesCount + likeCount };
+    });
+  };
+
+  // 무한스크롤 배열 캐시 업데이트
+  const updateInfiniteMeetingCache = <T extends SearchMeeting>(
+    queryKey: QueryKey,
+    meetingId: number,
+    isLike: boolean,
+    likeCount: number,
+  ) => {
+    queryClient.setQueryData<InfiniteData<Paginated<T>>>(
+      queryKey,
+      (oldData) => {
+        if (!oldData) return oldData;
+        return {
+          ...oldData,
+          pages: oldData.pages.map((page) => ({
+            ...page,
+            content: page.content.map((item) =>
+              item.meetingId === meetingId
+                ? { ...item, isLike, likesCount: item.likesCount + likeCount }
+                : item,
+            ),
+          })),
+        };
+      },
+    );
+  };
+
+  const { showToast } = useToast();
+  const { id } = useParams();
+
+  const { mutate: likeMutation } = useLikeMeeting(meetingId, {
+    onMutate: () => {
+      // 모임 목록
+      if (category && searchQuery) {
+        updateInfiniteMeetingCache<SearchMeeting>(
+          MEETING_QUERY_KEYS.meetings(category, searchQuery),
+          meetingId,
+          true,
+          1,
+        );
+        return;
+      }
+
+      // 추천모임
+      if (category && !id) {
+        updateCacheArray(
+          MEETING_QUERY_KEYS.topMeetings(category),
+          meetingId,
+          true,
+          1,
+        );
+        return;
+      }
+
+      // 상세모임
+      if (id) {
+        updateCacheObject(meetingKeys.detailInfo(meetingId), true, 1);
+        return;
+      }
+    },
+    onSuccess: () => {
+      showToast('찜한 모임에 추가되었습니다!', 'success', { duration: 2000 });
+    },
+    onError: () => {
+      // TODO: 내가 만든 모임 좋아요 할 경우 toast 문구 변경
+      showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 });
+
+      // 모임 목록
+      if (category && searchQuery) {
+        updateInfiniteMeetingCache<SearchMeeting>(
+          MEETING_QUERY_KEYS.meetings(category, searchQuery),
+          meetingId,
+          false,
+          -1,
+        );
+        return;
+      }
+
+      // 추천모임
+      if (category && !id) {
+        updateCacheArray(
+          MEETING_QUERY_KEYS.topMeetings(category),
+          meetingId,
+          false,
+          -1,
+        );
+        return;
+      }
+
+      // 상세모임
+      if (id) {
+        updateCacheObject(meetingKeys.detailInfo(meetingId), false, -1);
+        return;
+      }
+    },
+  });
+
+  const { mutate: cancelLikeMutation } = useCancelLikeMeeting(meetingId, {
+    onMutate: () => {
+      // 모임 목록
+      if (category && searchQuery) {
+        updateInfiniteMeetingCache<SearchMeeting>(
+          MEETING_QUERY_KEYS.meetings(category, searchQuery),
+          meetingId,
+          false,
+          -1,
+        );
+        return;
+      }
+
+      // 추천모임
+      if (category && !id) {
+        updateCacheArray(
+          MEETING_QUERY_KEYS.topMeetings(category),
+          meetingId,
+          false,
+          -1,
+        );
+        return;
+      }
+
+      // 상세모임
+      if (id) {
+        updateCacheObject(meetingKeys.detailInfo(meetingId), false, -1);
+        return;
+      }
+    },
+    onSuccess: () => {
+      showToast('찜한 모임에서 삭제되었습니다!', 'success', { duration: 2000 });
+    },
+    onError: () => {
+      // 오류 처리
+      showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 });
+
+      // 모임 목록
+      if (category && searchQuery) {
+        updateInfiniteMeetingCache<SearchMeeting>(
+          MEETING_QUERY_KEYS.meetings(category, searchQuery),
+          meetingId,
+          true,
+          1,
+        );
+        return;
+      }
+
+      // 추천모임
+      if (category && !id) {
+        updateCacheArray(
+          MEETING_QUERY_KEYS.topMeetings(category),
+          meetingId,
+          true,
+          1,
+        );
+        return;
+      }
+
+      // 상세모임
+      if (id) {
+        updateCacheObject(meetingKeys.detailInfo(meetingId), true, 1);
+        return;
+      }
+    },
+  });
+
+  // 좋아요 토글 함수
+  const toggleLike = async (isLiked: boolean) => {
+    const token = await getAccessToken();
+    if (!token) {
+      // 로그인 상태가 아니라면, onAuthRequired 콜백 실행
+      if (onAuthRequired) {
+        onAuthRequired();
+      }
+      return;
+    }
+
+    if (!isLiked) {
+      likeMutation();
+    } else {
+      cancelLikeMutation();
+    }
+  };
+
+  return { toggleLike };
+};
+
+export default useLikeHandler;
diff --git a/src/hooks/common/useMediaQuery.ts b/src/hooks/common/useMediaQuery.ts
index df4a74e..94fc963 100644
--- a/src/hooks/common/useMediaQuery.ts
+++ b/src/hooks/common/useMediaQuery.ts
@@ -9,9 +9,9 @@ const useMediaQuery = () => {
   useEffect(() => {
     const handleResize = () => {
       const width = window.innerWidth;
-      if (width > 376 && width <= 745) {
+      if (width > 376 && width < 745) {
         setBreakpoint('mobile');
-      } else if (width > 745 && width <= 1020) {
+      } else if (width >= 745 && width <= 1020) {
         setBreakpoint('tablet');
       } else if (width > 1020) {
         setBreakpoint('desktop');
diff --git a/src/hooks/queries/useMeetingQueries.ts b/src/hooks/queries/useMeetingQueries.ts
index eb40a62..2d22d42 100644
--- a/src/hooks/queries/useMeetingQueries.ts
+++ b/src/hooks/queries/useMeetingQueries.ts
@@ -7,6 +7,7 @@ import {
 } from 'service/api/meeting';
 import type { CategoryTitle, IMeetingSearchCondition } from 'types/meeting';
 
+// 검색 쿼리 객체 기술 스킬 정렬
 const getSortedSearchQuery = (
   searchQueryObj: IMeetingSearchCondition,
 ): IMeetingSearchCondition => ({
@@ -14,6 +15,7 @@ const getSortedSearchQuery = (
   skillArray: [...searchQueryObj.skillArray].sort(),
 });
 
+// 모임 쿼리키
 const MEETING_QUERY_KEYS = {
   topMeetings: (category: string) => ['topMeetings', category] as const,
   meetings: (category: string, searchQueryObj: IMeetingSearchCondition) => {
@@ -33,6 +35,8 @@ const MEETING_QUERY_KEYS = {
       id,
     ] as const,
 };
+
+// 추천 모임
 const useTopMeetings = (category: CategoryTitle, options = {}) => {
   return useQuery({
     queryKey: MEETING_QUERY_KEYS.topMeetings(category),
@@ -41,6 +45,7 @@ const useTopMeetings = (category: CategoryTitle, options = {}) => {
   });
 };
 
+// 모임 리스트
 const useInfiniteSearchMeetings = (
   category: CategoryTitle,
   searchQueryObj: IMeetingSearchCondition,
diff --git a/src/service/api/meeting.ts b/src/service/api/meeting.ts
index 65ae7f1..34cff72 100644
--- a/src/service/api/meeting.ts
+++ b/src/service/api/meeting.ts
@@ -14,12 +14,9 @@ const getTopMeetings = async (
 ): Promise<TopMeeting[]> => {
   const token = await getAccessToken();
 
-  const res = await (token ? authAPI : basicAPI).get(
-    `${process.env.NEXT_PUBLIC_API_URL}/api/v1/meetings/top`,
-    {
-      params: { categoryTitle },
-    },
-  );
+  const res = await (token ? authAPI : basicAPI).get(`/api/v1/meetings/top`, {
+    params: { categoryTitle },
+  });
 
   return res.data.data;
 };
@@ -33,7 +30,7 @@ const getMeetings = async (
   const token = await getAccessToken();
 
   const res = await (token ? authAPI : basicAPI).post(
-    `${process.env.NEXT_PUBLIC_API_URL}/api/v1/meetings/search?categoryTitle=${category}`,
+    `/api/v1/meetings/search?categoryTitle=${category}`,
     newSearchQueryObj,
   );
 


Reviews:
user: login: dbswl701
body: 고생하셨습니다!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T02:20:31Z
user: login: Lee-Dong-Seok
body: 고생하셨습니다!👍😊
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T04:45:49Z
user: login: lee1nna
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T08:08:54Z
user: login: lee1nna
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T08:09:41Z

Review comments:
diff_hunk: @@ -46,6 +46,9 @@ const MeetingList = () => {
     },
   );
 
+  const allMeetings: SearchMeeting[] =
+    data?.pages.flatMap((page) => page.content) || [];
+
path: src/app/meeting/_features/MeetingList.tsx
user: login: dbswl701
body: flatMap 좋습니다! 하나 배워갑니다!
created_at: 2025-03-11T01:30:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,12 +14,9 @@ const getTopMeetings = async (
 ): Promise<TopMeeting[]> => {
   const token = await getAccessToken();
 
-  const res = await (token ? authAPI : basicAPI).get(
-    `${process.env.NEXT_PUBLIC_API_URL}/api/v1/meetings/top`,
-    {
-      params: { categoryTitle },
-    },
-  );
+  const res = await (token ? authAPI : basicAPI).get(`/api/v1/meetings/top`, {
+    params: { categoryTitle },
+  });
path: src/service/api/meeting.ts
user: login: dbswl701
body: authAPI로 통일해주시면 될 것 같아요!
created_at: 2025-03-11T02:16:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,7 +125,7 @@ const Dropdown = ({
     !className?.includes('w-[460px]');
 
   return (
-    <DropdownMenuPrimitive.Root onOpenChange={setIsOpen}>
+    <DropdownMenuPrimitive.Root onOpenChange={setIsOpen} modal={false}>
path: src/components/common/Dropdown.tsx
user: login: dbswl701
body: modal 프로퍼티를 설정하신 이유가 궁금합니다!
created_at: 2025-03-11T02:17:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,7 +125,7 @@ const Dropdown = ({
     !className?.includes('w-[460px]');
 
   return (
-    <DropdownMenuPrimitive.Root onOpenChange={setIsOpen}>
+    <DropdownMenuPrimitive.Root onOpenChange={setIsOpen} modal={false}>
path: src/components/common/Dropdown.tsx
user: login: lee1nna
body: 드롭다운이 modal true가 default인데 이 설정때문에 gnb에서 프로필 클릭시 우측에 스크롤 여백이 나왔었는데,
modal 설정을 false로 해줘야지 해당 이슈가 안생겨서 추가해두었습니당
created_at: 2025-03-11T08:08:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,12 +14,9 @@ const getTopMeetings = async (
 ): Promise<TopMeeting[]> => {
   const token = await getAccessToken();
 
-  const res = await (token ? authAPI : basicAPI).get(
-    `${process.env.NEXT_PUBLIC_API_URL}/api/v1/meetings/top`,
-    {
-      params: { categoryTitle },
-    },
-  );
+  const res = await (token ? authAPI : basicAPI).get(`/api/v1/meetings/top`, {
+    params: { categoryTitle },
+  });
path: src/service/api/meeting.ts
user: login: lee1nna
body: 이후 다른 브랜치에서 통일하도록 하겠습니다!
created_at: 2025-03-11T08:09:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-03-07T00:48:33Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are limited to specific labels.
> 
> <details>
> <summary>:label: Labels to auto review (1)</summary>
> 
> * AI Review
> 
> </details>
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

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

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=MoimService/Moim-FE&utm_content=57):

- 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 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: netlify[bot]
created_at: 2025-03-07T00:48:39Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *deving-together* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 3d85dfabe19c2b2c199d16ddcc88c7d3eca7874d |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/deving-together/deploys/67cea4a2320ef900086add29 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-57--deving-together.netlify.app](https://deploy-preview-57--deving-together.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTU3LS1kZXZpbmctdG9nZXRoZXIubmV0bGlmeS5hcHAifQ.ZrJfCWKE2pokvppoYt9wlnA4U7tmycNxscnGXc-FsaQ)<br /><br />_Use your smartphone camera to open QR code link._</details> |
|<span aria-hidden="true">![Lighthouse](https://app.netlify.com/public/external-usage/git-comments/lighthouse-sm.png)</span><br />Lighthouse | 1 paths audited<br />**Performance**: 47<br />**Accessibility**: 95<br />**Best Practices**: 83<br />**SEO**: 100<br />**PWA**: -<br />[View the detailed breakdown and full score reports](https://app.netlify.com/sites/deving-together/deploys/67cea4a2320ef900086add29) |
---
<!-- [deving-together Preview](https://deploy-preview-57--deving-together.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/deving-together/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-07T00:49:41Z
body: ## [Codecov](https://app.codecov.io/gh/MoimService/Moim-FE/pull/57?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=MoimService) Report
All modified and coverable lines are covered by tests :white_check_mark:

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-07T00:50:01Z
body: ## CI Status Report

### 검사 결과
- Lint: ✅ success
- Format: ✅ success
- Type Check: ✅ success
- Tests: ✅ success
- Build: ✅ success

✅ 모든 검사가 통과되었습니다.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-10T08:38:19Z
body: ## CI Status Report

### 검사 결과
- Lint: ✅ success
- Format: ✅ success
- Type Check: ✅ success
- Tests: ✅ success
- Build: ✅ success

✅ 모든 검사가 통과되었습니다.
author_association: NONE