From 8d7054de028340a8ae46b791c5bfee8462cb63a0 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:04:08 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=83=81=ED=83=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EA=B8=B0=EB=B0=98=20=EC=95=A1=EC=85=98=EC=9D=98=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=8B=A4=ED=96=89=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 18 +++ src/components/common/Post/PostFooter.tsx | 100 ++++++++++----- src/components/feed/Profile.tsx | 52 +++++--- src/components/feed/UserProfileItem.tsx | 51 +++++--- .../memory/RecordItem/PollRecord.tsx | 118 +++++++++--------- src/hooks/useDebouncedCallback.ts | 33 +++++ src/hooks/usePreventDoubleClick.ts | 22 ++++ src/main.tsx | 2 +- src/pages/searchBook/SearchBook.tsx | 61 ++++----- 10 files changed, 306 insertions(+), 152 deletions(-) create mode 100644 src/hooks/useDebouncedCallback.ts create mode 100644 src/hooks/usePreventDoubleClick.ts diff --git a/package.json b/package.json index da28e12c..5240acc6 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@emotion/css": "^11.13.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@tanstack/react-query": "^5.90.21", "@types/react-datepicker": "^7.0.0", "axios": "^1.11.0", "react": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fae23ebd..0d47d142 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@emotion/styled': specifier: ^11.14.0 version: 11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.1.0) '@types/react-datepicker': specifier: ^7.0.0 version: 7.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -597,6 +600,14 @@ packages: cpu: [x64] os: [win32] + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + '@types/axios@0.14.4': resolution: {integrity: sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==} deprecated: This is a stub types definition. axios provides its own type definitions, so you do not need this installed. @@ -2172,6 +2183,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.2': optional: true + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.1.0)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.1.0 + '@types/axios@0.14.4': dependencies: axios: 1.11.0 diff --git a/src/components/common/Post/PostFooter.tsx b/src/components/common/Post/PostFooter.tsx index 1677c40e..2d998ae3 100644 --- a/src/components/common/Post/PostFooter.tsx +++ b/src/components/common/Post/PostFooter.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import like from '../../../assets/feed/like.svg'; import activeLike from '../../../assets/feed/activeLike.svg'; import comment from '../../../assets/feed/comment.svg'; @@ -7,6 +7,7 @@ import activeSave from '../../../assets/feed/activeSave.svg'; import lockIcon from '../../../assets/feed/lockIcon.svg'; import { postSaveFeed } from '@/api/feeds/postSave'; import { postFeedLike } from '@/api/feeds/postFeedLike'; +import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; import { Container } from './PostFooter.styled'; interface PostFooterProps { @@ -36,39 +37,75 @@ const PostFooter = ({ const [likeCount, setLikeCount] = useState(initialLikeCount); const [saved, setSaved] = useState(isSaved); - const handleLike = async () => { - try { - const response = await postFeedLike(feedId, !liked); + const likedRef = useRef(isLiked); + const savedRef = useRef(isSaved); + const { isLoading: isLikeLoading, run: runLike } = usePreventDoubleClick(); + const { isLoading: isSaveLoading, run: runSave } = usePreventDoubleClick(); - if (response.isSuccess) { - setLiked(response.data.isLiked); - setLikeCount(prev => (response.data.isLiked ? prev + 1 : prev - 1)); - console.log('좋아요 상태 변경 성공:', response.data.isLiked); - } else { - console.error('좋아요 상태 변경 실패:', response.message); + useEffect(() => { + setLiked(isLiked); + likedRef.current = isLiked; + }, [isLiked]); + + useEffect(() => { + setSaved(isSaved); + savedRef.current = isSaved; + }, [isSaved]); + + const handleLike = () => { + runLike(async () => { + const nextLiked = !likedRef.current; + likedRef.current = nextLiked; + setLiked(nextLiked); + setLikeCount(prev => (nextLiked ? prev + 1 : prev - 1)); + + await new Promise(resolve => setTimeout(resolve, 300)); + + try { + const response = await postFeedLike(feedId, nextLiked); + if (!response.isSuccess && likedRef.current === nextLiked) { + const rollbackState = !nextLiked; + likedRef.current = rollbackState; + setLiked(rollbackState); + setLikeCount(prev => (nextLiked ? prev - 1 : prev + 1)); + } + } catch { + if (likedRef.current === nextLiked) { + const rollbackState = !nextLiked; + likedRef.current = rollbackState; + setLiked(rollbackState); + setLikeCount(prev => (nextLiked ? prev - 1 : prev + 1)); + } } - } catch (error) { - console.error('좋아요 API 호출 실패:', error); - } + }); }; - const handleSave = async () => { - try { - const response = await postSaveFeed(feedId, !saved); + const handleSave = () => { + runSave(async () => { + const nextSaved = !savedRef.current; + savedRef.current = nextSaved; + setSaved(nextSaved); + onSaveToggle?.(feedId, nextSaved); - if (response.isSuccess) { - const newSaveState = response.data?.isSaved ?? !saved; - setSaved(newSaveState); - console.log('저장 상태 변경 성공:', newSaveState); - if (onSaveToggle) { - onSaveToggle(feedId, newSaveState); + await new Promise(resolve => setTimeout(resolve, 300)); + + try { + const response = await postSaveFeed(feedId, nextSaved); + if (!response.isSuccess && savedRef.current === nextSaved) { + const rollbackState = !nextSaved; + savedRef.current = rollbackState; + setSaved(rollbackState); + onSaveToggle?.(feedId, rollbackState); + } + } catch { + if (savedRef.current === nextSaved) { + const rollbackState = !nextSaved; + savedRef.current = rollbackState; + setSaved(rollbackState); + onSaveToggle?.(feedId, rollbackState); } - } else { - console.error('저장 상태 변경 실패:', response.message); } - } catch (error) { - console.error('저장 API 호출 실패:', error); - } + }); }; const handleComment = () => { @@ -80,7 +117,7 @@ const PostFooter = ({
- +
{likeCount}
@@ -96,7 +133,12 @@ const PostFooter = ({ 비공개 ) ) : ( - 저장 + 저장 )}
diff --git a/src/components/feed/Profile.tsx b/src/components/feed/Profile.tsx index 2321e07e..82151f63 100644 --- a/src/components/feed/Profile.tsx +++ b/src/components/feed/Profile.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import MyFollower from './MyFollower'; import { postFollow } from '@/api/users/postFollow'; import { Container, UserProfile } from './Profile.styled'; import { usePopupStore } from '@/stores/popupStore'; +import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; export interface ProfileProps { showFollowButton?: boolean; @@ -30,34 +31,45 @@ const Profile = ({ isMyFeed, }: ProfileProps) => { const [followed, setFollowed] = useState(isFollowing); + const followedRef = useRef(!!isFollowing); const { openPopup } = usePopupStore(); + const { isLoading: isFollowLoading, run: runFollow } = usePreventDoubleClick(); useEffect(() => { setFollowed(isFollowing); + followedRef.current = !!isFollowing; }, [isFollowing]); - const toggleFollow = async () => { - if (!userId) { - return; - } + const toggleFollow = () => { + if (!userId) return; + runFollow(async () => { + const nextFollowed = !followedRef.current; + followedRef.current = nextFollowed; + setFollowed(nextFollowed); - try { - const response = await postFollow(userId, !followed); + await new Promise(resolve => setTimeout(resolve, 300)); - setFollowed(response.data.isFollowing); + try { + const response = await postFollow(userId, nextFollowed); + if (followedRef.current !== nextFollowed) return; - const message = response.data.isFollowing - ? `${nickname}님을 띱 했어요.` - : `${nickname}님을 띱 취소했어요.`; + if (response.data.isFollowing !== nextFollowed) { + followedRef.current = response.data.isFollowing; + setFollowed(response.data.isFollowing); + } - openPopup('snackbar', { - message, - variant: 'top', - onClose: () => {}, - }); - } catch (error) { - console.error('띱하기 실패:', error); - } + openPopup('snackbar', { + message: response.data.isFollowing ? `${nickname}님을 띱 했어요.` : `${nickname}님을 띱 취소했어요.`, + variant: 'top', + onClose: () => {}, + }); + } catch { + if (followedRef.current !== nextFollowed) return; + const rollbackState = !nextFollowed; + followedRef.current = rollbackState; + setFollowed(rollbackState); + } + }); }; return ( @@ -73,7 +85,7 @@ const Profile = ({
{showFollowButton && !isMyFeed && ( -
+
{followed ? '띱 취소' : '띱 하기'}
)} diff --git a/src/components/feed/UserProfileItem.tsx b/src/components/feed/UserProfileItem.tsx index 187baf74..9b434af4 100644 --- a/src/components/feed/UserProfileItem.tsx +++ b/src/components/feed/UserProfileItem.tsx @@ -1,10 +1,11 @@ import { useNavigate } from 'react-router-dom'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import rightArrow from '../../assets/feed/rightArrow.svg'; import type { UserProfileItemProps } from '@/types/user'; import { postFollow } from '@/api/users/postFollow'; import { Wrapper, UserProfile } from './UserProfileItem.styled'; import { usePopupStore } from '@/stores/popupStore'; +import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; const UserProfileItem = ({ profileImageUrl, @@ -19,8 +20,10 @@ const UserProfileItem = ({ isMyself, }: UserProfileItemProps) => { const navigate = useNavigate(); - const [followed, setFollowed] = useState(isFollowing); + const [followed, setFollowed] = useState(!!isFollowing); + const followedRef = useRef(!!isFollowing); const { openPopup } = usePopupStore(); + const { isLoading: isFollowLoading, run: runFollow } = usePreventDoubleClick(); const handleProfileClick = () => { if (isMyself) { @@ -30,25 +33,37 @@ const UserProfileItem = ({ } }; - const toggleFollow = async (e: React.MouseEvent) => { + const toggleFollow = (e: React.MouseEvent) => { e.stopPropagation(); + if (!userId) return; + runFollow(async () => { + const nextFollowed = !followedRef.current; + followedRef.current = nextFollowed; + setFollowed(nextFollowed); - try { - const response = await postFollow(userId, !followed); - setFollowed(response.data.isFollowing); + await new Promise(resolve => setTimeout(resolve, 300)); - const message = response.data.isFollowing - ? `${nickname}님을 띱 했어요.` - : `${nickname}님을 띱 취소했어요.`; + try { + const response = await postFollow(userId, nextFollowed); + if (followedRef.current !== nextFollowed) return; - openPopup('snackbar', { - message, - variant: 'top', - onClose: () => {}, - }); - } catch (error) { - console.error('팔로우/언팔로우 실패:', error); - } + if (response.data.isFollowing !== nextFollowed) { + followedRef.current = response.data.isFollowing; + setFollowed(response.data.isFollowing); + } + + openPopup('snackbar', { + message: response.data.isFollowing ? `${nickname}님을 띱 했어요.` : `${nickname}님을 띱 취소했어요.`, + variant: 'top', + onClose: () => {}, + }); + } catch { + if (followedRef.current !== nextFollowed) return; + const rollbackState = !nextFollowed; + followedRef.current = rollbackState; + setFollowed(rollbackState); + } + }); }; return ( @@ -64,7 +79,7 @@ const UserProfileItem = ({
{type === 'followlist' && ( -
+
{followed ? '띱 취소' : '띱 하기'}
)} diff --git a/src/components/memory/RecordItem/PollRecord.tsx b/src/components/memory/RecordItem/PollRecord.tsx index 43791ea4..9e9bbebc 100644 --- a/src/components/memory/RecordItem/PollRecord.tsx +++ b/src/components/memory/RecordItem/PollRecord.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom'; import type { PollOption } from '../../../types/memory'; import { postVote } from '@/api/record/postVote'; import { usePopupActions } from '@/hooks/usePopupActions'; +import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; import { PollSection, PollQuestion, @@ -33,7 +34,8 @@ const PollRecord = ({ }: PollRecordProps) => { const [animate, setAnimate] = useState(false); const [currentOptions, setCurrentOptions] = useState(pollOptions); - const [isVoting, setIsVoting] = useState(false); + const optionsRef = useRef(pollOptions); + const { isLoading: isVoting, run: runVote } = usePreventDoubleClick(); const pollRef = useRef(null); const { roomId } = useParams<{ roomId: string }>(); const { openSnackbar } = usePopupActions(); @@ -69,81 +71,85 @@ const PollRecord = ({ useEffect(() => { setCurrentOptions(pollOptions); + optionsRef.current = pollOptions; }, [pollOptions]); - const handleOptionClick = async (e: React.MouseEvent, option: PollOption) => { + const handleOptionClick = (e: React.MouseEvent, option: PollOption) => { e.stopPropagation(); if (isVoting || !roomId || shouldBlur) return; - setIsVoting(true); - - try { - const voteData = { - voteItemId: option.voteItemId, - type: !option.isVoted, - }; - - const response = await postVote(parseInt(roomId), postId, voteData); - - if (response.isSuccess) { - const updatedOptions = currentOptions.map(opt => { - const updatedItem = response.data.voteItems.find( - (item: PollOption) => item.voteItemId === opt.voteItemId, - ); - if (updatedItem) { - return { - ...opt, - percentage: updatedItem.percentage, - count: updatedItem.count, - isVoted: updatedItem.isVoted, - isHighest: - updatedItem.count === - Math.max(...response.data.voteItems.map((item: PollOption) => item.count)), - }; - } - return opt; + runVote(async () => { + const previousOptions = optionsRef.current; + const latest = optionsRef.current.find(item => item.voteItemId === option.voteItemId); + if (!latest) return; + + const nextVoted = !latest.isVoted; + const optimisticOptions = optionsRef.current.map(item => { + if (item.voteItemId !== option.voteItemId) return item; + return { + ...item, + isVoted: nextVoted, + count: item.count + (nextVoted ? 1 : -1), + }; + }); + + const maxCount = Math.max(...optimisticOptions.map(item => item.count)); + const normalizedOptions = optimisticOptions.map(item => ({ + ...item, + isHighest: item.count === maxCount, + })); + + optionsRef.current = normalizedOptions; + setCurrentOptions(normalizedOptions); + + await new Promise(resolve => setTimeout(resolve, 300)); + + try { + const response = await postVote(parseInt(roomId, 10), postId, { + voteItemId: option.voteItemId, + type: nextVoted, }); + const target = optionsRef.current.find(item => item.voteItemId === option.voteItemId); + if (!target || target.isVoted !== nextVoted) return; + + if (!response.isSuccess) { + optionsRef.current = previousOptions; + setCurrentOptions(previousOptions); + openSnackbar({ + message: response.message || '투표 처리 중 오류가 발생했습니다.', + variant: 'top', + onClose: () => {}, + }); + return; + } + const serverMaxCount = Math.max(...response.data.voteItems.map((item: PollOption) => item.count)); + const updatedOptions = response.data.voteItems.map((item: PollOption) => ({ + ...item, + isHighest: item.count === serverMaxCount, + })); + optionsRef.current = updatedOptions; setCurrentOptions(updatedOptions); onVoteUpdate?.(updatedOptions); - const actionText = voteData.type ? '투표했습니다' : '투표를 취소했습니다'; openSnackbar({ - message: actionText, + message: nextVoted ? '투표했습니다' : '투표를 취소했습니다', variant: 'top', onClose: () => {}, }); - } else { - let errorMessage = '투표 처리 중 오류가 발생했습니다.'; - - if (response.code === 120001) { - errorMessage = '이미 투표한 투표항목입니다.'; - } else if (response.code === 120002) { - errorMessage = '투표하지 않은 투표항목은 취소할 수 없습니다.'; - } else if (response.code === 140011) { - errorMessage = '방 접근 권한이 없습니다.'; - } else if (response.code === 120000) { - errorMessage = '투표는 존재하지만 투표항목이 비어있습니다.'; - } else if (response.message) { - errorMessage = response.message; + } catch { + const target = optionsRef.current.find(item => item.voteItemId === option.voteItemId); + if (target && target.isVoted === nextVoted) { + optionsRef.current = previousOptions; + setCurrentOptions(previousOptions); } - openSnackbar({ - message: errorMessage, + message: '네트워크 오류가 발생했습니다. 다시 시도해주세요.', variant: 'top', onClose: () => {}, }); } - } catch (error) { - console.error('투표 API 호출 실패:', error); - openSnackbar({ - message: '네트워크 오류가 발생했습니다. 다시 시도해주세요.', - variant: 'top', - onClose: () => {}, - }); - } finally { - setIsVoting(false); - } + }); }; const hasVotes = currentOptions.some(option => option.count > 0); diff --git a/src/hooks/useDebouncedCallback.ts b/src/hooks/useDebouncedCallback.ts new file mode 100644 index 00000000..225396bb --- /dev/null +++ b/src/hooks/useDebouncedCallback.ts @@ -0,0 +1,33 @@ +import { useEffect, useMemo, useRef } from 'react'; + +export const useDebouncedCallback = void>( + callback: T, + delay: number, +) => { + const callbackRef = useRef(callback); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return useMemo(() => { + return (...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }; + }, [delay]); +}; diff --git a/src/hooks/usePreventDoubleClick.ts b/src/hooks/usePreventDoubleClick.ts new file mode 100644 index 00000000..6949969d --- /dev/null +++ b/src/hooks/usePreventDoubleClick.ts @@ -0,0 +1,22 @@ +import { useCallback, useRef, useState } from 'react'; + +export const usePreventDoubleClick = () => { + const lockRef = useRef(false); + const [isLoading, setIsLoading] = useState(false); + + const run = useCallback(async (asyncFn: () => Promise) => { + if (lockRef.current) return; + + lockRef.current = true; + setIsLoading(true); + + try { + await asyncFn(); + } finally { + lockRef.current = false; + setIsLoading(false); + } + }, []); + + return { isLoading, run }; +}; diff --git a/src/main.tsx b/src/main.tsx index a5e70585..8cda91d4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,4 +5,4 @@ import { initGA } from './shared/lib/analytics/ga'; initGA(); -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render(); \ No newline at end of file diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index f538efa5..8a573674 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -40,6 +40,7 @@ import FeedPost from '@/components/feed/FeedPost'; import { getFeedsByIsbn, type FeedItem, type FeedSort } from '@/api/feeds/getFeedsByIsbn'; import { usePopupStore } from '@/stores/popupStore'; import LoadingSpinner from '@/components/common/LoadingSpinner'; +import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; const FILTER = ['최신순', '인기순'] as const; const toFeedSort = (f: (typeof FILTER)[number]): FeedSort => (f === '최신순' ? 'latest' : 'like'); @@ -54,7 +55,8 @@ const SearchBook = () => { const [bookDetail, setBookDetail] = useState(null); const [recruitingRoomsData, setRecruitingRoomsData] = useState(null); const [isSaved, setIsSaved] = useState(false); - const [isSaving, setIsSaving] = useState(false); + const isSavedRef = useRef(false); + const { isLoading: isSaveLoading, run: runSave } = usePreventDoubleClick(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -88,17 +90,15 @@ const SearchBook = () => { if (bookResponse.isSuccess) { setBookDetail(bookResponse.data); setIsSaved(bookResponse.data.isSaved); + isSavedRef.current = bookResponse.data.isSaved; } else { setError(bookResponse.message); } if (recruitingResponse.isSuccess) { setRecruitingRoomsData(recruitingResponse.data); - } else { - console.error('모집중인 모임방 조회 실패:', recruitingResponse.message); } - } catch (err) { - console.error('데이터 조회 오류:', err); + } catch { setError('정보를 불러오는데 실패했습니다.'); } finally { setIsLoading(false); @@ -121,11 +121,9 @@ const SearchBook = () => { setFeeds(res.data.feeds); setNextCursor(res.data.nextCursor); setIsLast(res.data.isLast); - } else { - console.error('피드 조회 실패:', res.message); } - } catch (e) { - console.error('피드 조회 오류:', e); + } catch { + // no-op } finally { setIsLoadingFeeds(false); } @@ -144,11 +142,9 @@ const SearchBook = () => { setFeeds(prev => [...prev, ...res.data.feeds]); setNextCursor(res.data.nextCursor); setIsLast(res.data.isLast); - } else { - console.error('피드 추가 로드 실패:', res.message); } - } catch (e) { - console.error('피드 추가 로드 오류:', e); + } catch { + // no-op } finally { setIsLoadingMore(false); } @@ -209,21 +205,30 @@ const SearchBook = () => { } }; - const handleSaveButton = async () => { - if (!isbn || isSaving) return; - try { - setIsSaving(true); - const response = await postSaveBook(isbn, !isSaved); - if (response.isSuccess) { - setIsSaved(response.data.isSaved); - } else { - console.error('북마크 실패:', response.message); + const handleSaveButton = () => { + if (!isbn) return; + runSave(async () => { + const nextSaved = !isSavedRef.current; + isSavedRef.current = nextSaved; + setIsSaved(nextSaved); + + await new Promise(resolve => setTimeout(resolve, 300)); + + try { + const response = await postSaveBook(isbn, nextSaved); + if (!response.isSuccess && isSavedRef.current === nextSaved) { + const rollback = !nextSaved; + isSavedRef.current = rollback; + setIsSaved(rollback); + } + } catch { + if (isSavedRef.current === nextSaved) { + const rollback = !nextSaved; + isSavedRef.current = rollback; + setIsSaved(rollback); + } } - } catch (error) { - console.error('북마크 중 오류 발생:', error); - } finally { - setIsSaving(false); - } + }); }; useEffect(() => { @@ -281,7 +286,7 @@ const SearchBook = () => { 피드에 글쓰기 더하기 아이콘 - + 저장 버튼 From aa60cc243275386cca10c395dee107b266ab2b7b Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sat, 21 Feb 2026 00:10:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=83=81=ED=83=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EA=B8=B0=EB=B0=98=20=EC=95=A1=EC=85=98=EC=9D=98=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=8B=A4=ED=96=89=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EB=8F=84=EC=9E=85=20(=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/Post/Reply.tsx | 40 +++++++++++++++++------- src/components/common/Post/SubReply.tsx | 41 +++++++++++++++++-------- 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/components/common/Post/Reply.tsx b/src/components/common/Post/Reply.tsx index aecc66c1..b1b68c5d 100644 --- a/src/components/common/Post/Reply.tsx +++ b/src/components/common/Post/Reply.tsx @@ -5,6 +5,7 @@ import like from '../../../assets/feed/like.svg'; import activeLike from '../../../assets/feed/activeLike.svg'; import { useReplyActions } from '@/hooks/useReplyActions'; import { usePopupActions } from '@/hooks/usePopupActions'; +import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; import { postLike } from '@/api/comments/postLike'; import { deleteComment } from '@/api/comments/deleteComment'; import { DeletedContainer, Container, ReplySection } from './Reply.styled'; @@ -32,27 +33,43 @@ const Reply = ({ const [liked, setLiked] = useState(isLike); const [likeCount, setLikeCount] = useState(initialLikeCount); const containerRef = useRef(null); + const { isLoading: isLikeLoading, run: runLike } = usePreventDoubleClick(); const { startReply } = useReplyActions(); const { openMoreMenu, closePopup, openSnackbar } = usePopupActions(); - const handleLike = async () => { - try { - const response = await postLike(commentId, !liked); + const handleLike = () => { + runLike(async () => { + const previousLiked = liked; + const previousLikeCount = likeCount; + const nextLiked = !liked; - if (response.isSuccess) { - setLiked(response.data.isLiked); - setLikeCount(prev => (response.data.isLiked ? prev + 1 : prev - 1)); - } else { + setLiked(nextLiked); + setLikeCount(prev => (nextLiked ? prev + 1 : prev - 1)); + + await new Promise(resolve => setTimeout(resolve, 300)); + + try { + const response = await postLike(commentId, nextLiked); + if (!response.isSuccess) { + setLiked(previousLiked); + setLikeCount(previousLikeCount); + openSnackbar({ + message: response.message || '좋아요 처리 중 오류가 발생했습니다.', + variant: 'top', + onClose: () => {}, + }); + } + } catch { + setLiked(previousLiked); + setLikeCount(previousLikeCount); openSnackbar({ - message: response.message || '좋아요 처리 중 오류가 발생했습니다.', + message: '좋아요 처리 중 오류가 발생했습니다.', variant: 'top', onClose: () => {}, }); } - } catch (error) { - console.error('좋아요 상태 변경 실패:', error); - } + }); }; const handleReplyClick = () => { @@ -163,6 +180,7 @@ const Reply = ({ handleLike(); }} alt="좋아요" + style={{ opacity: isLikeLoading ? 0.6 : 1 }} />
{likeCount}
diff --git a/src/components/common/Post/SubReply.tsx b/src/components/common/Post/SubReply.tsx index 88e0a007..290be35c 100644 --- a/src/components/common/Post/SubReply.tsx +++ b/src/components/common/Post/SubReply.tsx @@ -6,6 +6,7 @@ import activeLike from '../../../assets/feed/activeLike.svg'; import replyIcon from '../../../assets/feed/replyIcon.svg'; import { useReplyActions } from '@/hooks/useReplyActions'; import { usePopupActions } from '@/hooks/usePopupActions'; +import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; import { postLike } from '@/api/comments/postLike'; import { deleteComment } from '@/api/comments/deleteComment'; import { DeletedContainer, Container, ReplyIcon, Content, ReplySection } from './SubReply.styled'; @@ -34,6 +35,7 @@ const SubReply = ({ const [liked, setLiked] = useState(isLike); const [currentLikeCount, setCurrentLikeCount] = useState(likeCount); const containerRef = useRef(null); + const { isLoading: isLikeLoading, run: runLike } = usePreventDoubleClick(); const { startReply } = useReplyActions(); const { openMoreMenu, closePopup, openSnackbar } = usePopupActions(); @@ -42,24 +44,38 @@ const SubReply = ({ startReply(creatorNickname, commentId); }; - const handleLike = async () => { - try { - const response = await postLike(commentId, !liked); + const handleLike = () => { + runLike(async () => { + const previousLiked = liked; + const previousLikeCount = currentLikeCount; + const nextLiked = !liked; - if (response.isSuccess) { - setLiked(response.data.isLiked); - setCurrentLikeCount(prev => (response.data.isLiked ? prev + 1 : prev - 1)); - } else { - console.error('좋아요 상태 변경 실패:', response.message); + setLiked(nextLiked); + setCurrentLikeCount(prev => (nextLiked ? prev + 1 : prev - 1)); + + await new Promise(resolve => setTimeout(resolve, 300)); + + try { + const response = await postLike(commentId, nextLiked); + if (!response.isSuccess) { + setLiked(previousLiked); + setCurrentLikeCount(previousLikeCount); + openSnackbar({ + message: response.message || '좋아요 처리 중 오류가 발생했습니다.', + variant: 'top', + onClose: () => {}, + }); + } + } catch { + setLiked(previousLiked); + setCurrentLikeCount(previousLikeCount); openSnackbar({ - message: response.message || '좋아요 처리 중 오류가 발생했습니다.', + message: '좋아요 처리 중 오류가 발생했습니다.', variant: 'top', onClose: () => {}, }); } - } catch (error) { - console.error('좋아요 상태 변경 실패:', error); - } + }); }; const handleDelete = async () => { @@ -170,6 +186,7 @@ const SubReply = ({ handleLike(); }} alt="좋아요" + style={{ opacity: isLikeLoading ? 0.6 : 1 }} />
{currentLikeCount}