diff --git a/src/components/RecentTransactionsSection.tsx b/src/components/RecentTransactionsSection.tsx index f5c3093..b9b7718 100644 --- a/src/components/RecentTransactionsSection.tsx +++ b/src/components/RecentTransactionsSection.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { Transaction } from './TransactionCard'; import TransactionEditDialog from './TransactionEditDialog'; import { ChevronRight } from 'lucide-react'; @@ -7,6 +7,7 @@ import { useBudget } from '@/contexts/BudgetContext'; import { Link } from 'react-router-dom'; import { categoryIcons } from '@/constants/categoryIcons'; import TransactionIcon from './transaction/TransactionIcon'; +import { toast } from '@/hooks/useToast.wrapper'; interface RecentTransactionsSectionProps { transactions: Transaction[]; @@ -19,41 +20,87 @@ const RecentTransactionsSection: React.FC = ({ }) => { const [selectedTransaction, setSelectedTransaction] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); - const { - updateTransaction, - deleteTransaction - } = useBudget(); + const [isDeleting, setIsDeleting] = useState(false); + const { updateTransaction, deleteTransaction } = useBudget(); const handleTransactionClick = (transaction: Transaction) => { setSelectedTransaction(transaction); setIsDialogOpen(true); }; - const handleUpdateTransaction = (updatedTransaction: Transaction) => { + const handleUpdateTransaction = useCallback((updatedTransaction: Transaction) => { if (onUpdateTransaction) { onUpdateTransaction(updatedTransaction); } // 직접 컨텍스트를 통해 업데이트 updateTransaction(updatedTransaction); - }; + }, [onUpdateTransaction, updateTransaction]); - // 타입 불일치 해결: boolean 또는 Promise 반환하도록 수정 - const handleDeleteTransaction = async (id: string): Promise => { + // 안정적인 삭제 처리 함수 + const handleDeleteTransaction = useCallback(async (id: string): Promise => { try { - // 직접 컨텍스트를 통해 삭제 - deleteTransaction(id); - return true; // 삭제 성공 시 true 반환 + // 이미 삭제 중인 경우 중복 요청 방지 + if (isDeleting) { + console.warn('이미 삭제 작업이 진행 중입니다'); + return true; + } + + setIsDeleting(true); + + // 먼저 다이얼로그 닫기 (UI 응답성 확보) + setIsDialogOpen(false); + + // 3초 타임아웃 설정 (UI 멈춤 방지) + const timeoutPromise = new Promise(resolve => { + const timeout = setTimeout(() => { + console.warn('삭제 타임아웃 - 강제 완료'); + setIsDeleting(false); + resolve(true); + }, 3000); + + return () => clearTimeout(timeout); + }); + + // 실제 삭제 요청 + const deletePromise = (async () => { + try { + deleteTransaction(id); + return true; + } catch (error) { + console.error('삭제 요청 실패:', error); + return false; + } + })(); + + // 둘 중 먼저 완료되는 것으로 처리 + await Promise.race([deletePromise, timeoutPromise]); + + setIsDeleting(false); + return true; } catch (error) { console.error('트랜잭션 삭제 중 오류:', error); - return false; // 삭제 실패 시 false 반환 + + // 항상 상태 정리 + setIsDeleting(false); + setIsDialogOpen(false); + + toast({ + title: "삭제 실패", + description: "처리 중 오류가 발생했습니다.", + variant: "destructive", + duration: 1500 + }); + + return false; } - }; + }, [deleteTransaction, isDeleting]); const formatCurrency = (amount: number) => { return amount.toLocaleString('ko-KR') + '원'; }; - return
+ return ( +

최근 지출

@@ -61,25 +108,42 @@ const RecentTransactionsSection: React.FC = ({
- {transactions.length > 0 ? transactions.map(transaction =>
handleTransactionClick(transaction)} className="flex justify-between py-3 cursor-pointer hover:bg-gray-50 px-[5px]"> -
- -
-

{transaction.title}

-

{transaction.date}

-
+ {transactions.length > 0 ? transactions.map(transaction => ( +
handleTransactionClick(transaction)} + className="flex justify-between py-3 cursor-pointer hover:bg-gray-50 px-[5px]" + > +
+ +
+

{transaction.title}

+

{transaction.date}

-
-

-{formatCurrency(transaction.amount)}

-

{transaction.category}

-
-
) :
+
+
+

-{formatCurrency(transaction.amount)}

+

{transaction.category}

+
+
+ )) : ( +
지출 내역이 없습니다 -
} +
+ )}
- {selectedTransaction && } -
; + {selectedTransaction && ( + + )} +
+ ); }; export default RecentTransactionsSection; diff --git a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts index 09a0db9..7108ff8 100644 --- a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts +++ b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts @@ -5,7 +5,7 @@ import { toast } from '@/hooks/useToast.wrapper'; import { handleDeleteStorage } from './deleteTransactionStorage'; /** - * 트랜잭션 삭제 핵심 기능 - 심각한 버그 수정 + * 트랜잭션 삭제 핵심 기능 - 완전 리팩토링 버전 */ export const useDeleteTransactionCore = ( transactions: Transaction[], @@ -13,114 +13,78 @@ export const useDeleteTransactionCore = ( user: any, pendingDeletionRef: MutableRefObject> ) => { - // 트랜잭션 삭제 - 안정성 개선 버전 + // 트랜잭션 삭제 - 안정성 최고 개선 버전 return useCallback(async (id: string): Promise => { - return new Promise(async (resolve) => { - try { - console.log('트랜잭션 삭제 작업 시작 - ID:', id); - - // 이미 삭제 중인지 확인 - if (pendingDeletionRef.current.has(id)) { - console.warn('이미 삭제 중인 트랜잭션:', id); - // 이미 진행 중이면 true 반환하고 종료 - resolve(true); - return; - } - - // pendingDeletion에 추가 - pendingDeletionRef.current.add(id); - - // 전달된 ID로 트랜잭션 찾기 - const transactionToDelete = transactions.find(t => t.id === id); - - // 트랜잭션이 존재하는지 확인 - if (!transactionToDelete) { - console.warn('삭제할 트랜잭션이 없음:', id); - - // 토스트 메시지 - toast({ - title: "삭제 실패", - description: "해당 항목을 찾을 수 없습니다.", - variant: "destructive", - duration: 1500 - }); - - // 삭제 중 표시 제거 - pendingDeletionRef.current.delete(id); - resolve(false); - return; - } - - // 1. UI 업데이트 단계 (트랜잭션 목록에서 제거) - try { - const updatedTransactions = transactions.filter(t => t.id !== id); - setTransactions(updatedTransactions); - - // 삭제 성공 토스트 - toast({ - title: "삭제 완료", - description: "지출 항목이 삭제되었습니다.", - duration: 1500 - }); - - // 2. 스토리지 업데이트 단계 - try { - // 스토리지 처리 (Promise 반환) - const storageResult = await handleDeleteStorage( - updatedTransactions, - id, - user, - pendingDeletionRef - ); - - // 이벤트 발생 - window.dispatchEvent(new Event('transactionDeleted')); - - console.log('삭제 작업 완료:', id); - resolve(true); - } catch (storageError) { - console.error('스토리지 작업 오류:', storageError); - - // 스토리지 오류가 있어도 UI는 이미 업데이트됨 - if (pendingDeletionRef.current.has(id)) { - pendingDeletionRef.current.delete(id); - } - resolve(true); - } - } catch (uiError) { - console.error('UI 업데이트 단계 오류:', uiError); - - // 삭제 중 표시 제거 - if (pendingDeletionRef.current.has(id)) { - pendingDeletionRef.current.delete(id); - } - - toast({ - title: "삭제 실패", - description: "지출 항목을 삭제하는 중 오류가 발생했습니다.", - variant: "destructive", - duration: 1500 - }); - - resolve(false); - } - } catch (error) { - console.error('트랜잭션 삭제 전체 오류:', error); - - // 삭제 중 표시 제거 - if (pendingDeletionRef.current.has(id)) { - pendingDeletionRef.current.delete(id); - } + try { + console.log('트랜잭션 삭제 작업 시작 - ID:', id); + + // 이미 삭제 중인지 확인 (중복 삭제 방지) + if (pendingDeletionRef.current.has(id)) { + console.warn('이미 삭제 중인 트랜잭션:', id); + return true; // 이미 진행 중이므로 성공으로 간주 + } + + // 삭제 진행 표시 추가 + pendingDeletionRef.current.add(id); + + // 트랜잭션 찾기 + const transactionIndex = transactions.findIndex(t => t.id === id); + + // 트랜잭션이 존재하는지 확인 + if (transactionIndex === -1) { + pendingDeletionRef.current.delete(id); + console.warn('삭제할 트랜잭션을 찾을 수 없음:', id); toast({ title: "삭제 실패", - description: "지출 삭제 중 오류가 발생했습니다.", - duration: 1500, - variant: "destructive" + description: "항목을 찾을 수 없습니다.", + variant: "destructive", + duration: 1500 }); - resolve(false); + return false; } - }); + + // 1. 즉시 UI 상태 업데이트 (사용자 경험 향상) + const updatedTransactions = transactions.filter(t => t.id !== id); + setTransactions(updatedTransactions); + + // 삭제 토스트 알림 + toast({ + title: "삭제 완료", + description: "지출 항목이 삭제되었습니다.", + duration: 1500 + }); + + // 2. 스토리지 처리 (비동기, 실패해도 UI는 이미 업데이트됨) + try { + await handleDeleteStorage(updatedTransactions, id, user, pendingDeletionRef); + } catch (storageError) { + console.error('스토리지 처리 오류:', storageError); + // 실패해도 UI는 이미 업데이트됨 (pendingDeletion은 handleDeleteStorage에서 처리됨) + } + + // 이벤트 발생 (다른 부분에 통지) + window.dispatchEvent(new Event('transactionDeleted')); + + console.log('삭제 작업 최종 완료:', id); + return true; + } catch (error) { + console.error('트랜잭션 삭제 전체 오류:', error); + + // 오류 발생 시에도 항상 pending 상태 제거 + if (pendingDeletionRef.current.has(id)) { + pendingDeletionRef.current.delete(id); + } + + toast({ + title: "삭제 실패", + description: "지출 삭제 중 오류가 발생했습니다.", + duration: 1500, + variant: "destructive" + }); + + return false; + } }, [transactions, setTransactions, user, pendingDeletionRef]); }; diff --git a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts index 9b0a33a..659dac8 100644 --- a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts +++ b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts @@ -6,7 +6,7 @@ import { deleteTransactionFromSupabase } from '../../supabaseUtils'; import { toast } from '@/hooks/useToast.wrapper'; /** - * 스토리지 및 Supabase 삭제 처리 - 안정성 개선 버전 + * 스토리지 및 Supabase 삭제 처리 - 완전히 개선된 버전 */ export const handleDeleteStorage = ( updatedTransactions: Transaction[], @@ -15,84 +15,60 @@ export const handleDeleteStorage = ( pendingDeletionRef: MutableRefObject> ): Promise => { return new Promise((resolve) => { + // 연속 호출 방지 (이미 완료된 요청일 경우 처리하지 않음) + if (!pendingDeletionRef.current.has(id)) { + console.warn('삭제 요청이 이미 완료되었습니다. ID:', id); + resolve(true); + return; + } + try { - // 로컬 스토리지 업데이트 (동기 처리) + // 로컬 스토리지 동기 처리 (즉시 처리) try { saveTransactionsToStorage(updatedTransactions); console.log('로컬 스토리지 저장 완료 (ID: ' + id + ')'); } catch (storageError) { console.error('로컬 스토리지 저장 실패:', storageError); - // 오류가 있어도 계속 진행 } - // Supabase 업데이트 (비동기 처리) + // Supabase 처리 (비동기) if (user) { - let isCompleted = false; - - // 10초 타임아웃 설정 + // 최대 3초 타임아웃 (UI 블로킹 방지) const timeoutId = setTimeout(() => { - if (!isCompleted) { - console.warn('Supabase 삭제 작업 타임아웃:', id); - if (pendingDeletionRef.current) { - pendingDeletionRef.current.delete(id); - } - if (!isCompleted) { - isCompleted = true; - resolve(true); // UI 업데이트는 이미 완료되었으므로 성공으로 처리 - } - } - }, 5000); + console.log('Supabase 삭제 타임아웃 - UI 정상화 처리'); + pendingDeletionRef.current.delete(id); + resolve(true); + }, 3000); + // Supabase 호출 시도 try { - // Supabase 호출 (삭제) deleteTransactionFromSupabase(user, id) .then(() => { console.log('Supabase 삭제 완료:', id); + clearTimeout(timeoutId); // 타임아웃 제거 + pendingDeletionRef.current.delete(id); + resolve(true); }) .catch(error => { console.error('Supabase 삭제 오류:', error); - }) - .finally(() => { - clearTimeout(timeoutId); - - if (pendingDeletionRef.current) { - pendingDeletionRef.current.delete(id); - } - - if (!isCompleted) { - isCompleted = true; - resolve(true); - } + clearTimeout(timeoutId); // 타임아웃 제거 + pendingDeletionRef.current.delete(id); + resolve(true); // UI는 이미 업데이트되어 있으므로 true 반환 }); } catch (e) { - console.error('Supabase 작업 오류:', e); + console.error('Supabase 작업 시작 실패:', e); clearTimeout(timeoutId); - - if (pendingDeletionRef.current) { - pendingDeletionRef.current.delete(id); - } - - if (!isCompleted) { - isCompleted = true; - resolve(true); - } + pendingDeletionRef.current.delete(id); + resolve(true); } } else { - // 로그인 안한 사용자는 바로 완료 처리 - if (pendingDeletionRef.current) { - pendingDeletionRef.current.delete(id); - } + // 로그인하지 않은 경우 바로 완료 처리 + pendingDeletionRef.current.delete(id); resolve(true); } - } catch (storageError) { - console.error('스토리지 작업 중 오류:', storageError); - - // 작업 완료 표시 (오류 발생해도 필수) - if (pendingDeletionRef.current) { - pendingDeletionRef.current.delete(id); - } - - // 실패해도 UI는 업데이트 완료된 상태로 간주 + } catch (error) { + console.error('스토리지 작업 중 일반 오류:', error); + pendingDeletionRef.current.delete(id); // 항상 pending 상태 제거 resolve(false); } }); diff --git a/src/hooks/transactions/transactionOperations/deleteTransaction.ts b/src/hooks/transactions/transactionOperations/deleteTransaction.ts index caa8686..96056a2 100644 --- a/src/hooks/transactions/transactionOperations/deleteTransaction.ts +++ b/src/hooks/transactions/transactionOperations/deleteTransaction.ts @@ -6,19 +6,22 @@ import { useDeleteTransactionCore } from './deleteOperation/deleteTransactionCor import { toast } from '@/hooks/useToast.wrapper'; /** - * 트랜잭션 삭제 기능 - 완전 개선 버전 + * 트랜잭션 삭제 기능 - 완전 리팩토링 버전 */ export const useDeleteTransaction = ( transactions: Transaction[], setTransactions: React.Dispatch> ) => { - // 삭제 중인 트랜잭션 ID 추적 + // 삭제 중인 트랜잭션 ID 추적 (Set으로 중복 방지) const pendingDeletionRef = useRef>(new Set()); const { user } = useAuth(); - // 타임아웃 관리 - const timeoutRef = useRef>({}); - + // 각 삭제 작업의 타임아웃 관리 + const timeoutsRef = useRef>({}); + + // 마지막 삭제 요청 시간 추적 (급발진 방지) + const lastDeleteTimeRef = useRef(0); + // 삭제 핵심 로직 const deleteTransactionCore = useDeleteTransactionCore( transactions, @@ -27,80 +30,68 @@ export const useDeleteTransaction = ( pendingDeletionRef ); - // 타임아웃 정리 함수 + // 모든 타임아웃 정리 const clearAllTimeouts = useCallback(() => { - Object.values(timeoutRef.current).forEach(timeout => { + Object.values(timeoutsRef.current).forEach(timeout => { clearTimeout(timeout); }); - timeoutRef.current = {}; + timeoutsRef.current = {}; }, []); - // 모든 대기 중 삭제 작업 정리 - const clearAllPendingDeletions = useCallback(() => { - pendingDeletionRef.current.clear(); - }, []); - - // 완전히 개선된 삭제 함수 + // 삭제 함수 (완전 안정화) const deleteTransaction = useCallback(async (id: string): Promise => { try { + // 급발진 방지 (300ms 내 연속 호출 차단) + const now = Date.now(); + if (now - lastDeleteTimeRef.current < 300) { + console.warn('삭제 요청이 너무 빠릅니다. 무시합니다.'); + return true; + } + lastDeleteTimeRef.current = now; + // 이미 삭제 중인지 확인 if (pendingDeletionRef.current.has(id)) { - console.log('이미 삭제 중인 트랜잭션:', id); - return true; // 이미 진행 중이면 true 반환 + console.warn('이미 삭제 중인 트랜잭션:', id); + return true; } - // 30초 이상 된 진행 중 삭제 정리 - const now = Date.now(); - const pendingTimeCheck = now - 30000; // 30초 전 - - if (pendingDeletionRef.current.size > 0) { - clearAllPendingDeletions(); - clearAllTimeouts(); - } - - // 삭제 작업 시간 제한 (3초) - const deletePromise = deleteTransactionCore(id); - - // 타임아웃 설정 (최대 3초) - const timeoutPromise = new Promise((resolve) => { - timeoutRef.current[id] = setTimeout(() => { - console.warn('삭제 작업 시간 초과. 강제 완료:', id); - - // 작업 중 표시 제거 + // 강제 타임아웃 3초 설정 (UI 멈춤 방지) + const timeoutPromise = new Promise(resolve => { + timeoutsRef.current[id] = setTimeout(() => { + console.warn('삭제 타임아웃 발생 - 강제 완료:', id); if (pendingDeletionRef.current.has(id)) { pendingDeletionRef.current.delete(id); } - - // 타임아웃 제거 - delete timeoutRef.current[id]; - - // UI는 이미 업데이트되었으므로 성공으로 간주 + delete timeoutsRef.current[id]; resolve(true); }, 3000); }); - // Promise.race를 사용하여 둘 중 하나가 먼저 완료되면 처리 + // 실제 삭제 작업 호출 + const deletePromise = deleteTransactionCore(id); + + // 둘 중 먼저 완료되는 것으로 처리 const result = await Promise.race([deletePromise, timeoutPromise]); - // 성공적으로 완료되면 타임아웃 취소 - if (timeoutRef.current[id]) { - clearTimeout(timeoutRef.current[id]); - delete timeoutRef.current[id]; + // 타임아웃 제거 + if (timeoutsRef.current[id]) { + clearTimeout(timeoutsRef.current[id]); + delete timeoutsRef.current[id]; } return result; } catch (error) { - console.error('트랜잭션 삭제 오류:', error); + console.error('트랜잭션 삭제 최상위 오류:', error); - // 작업 중 표시 제거 + // 항상 pending 상태 제거 보장 if (pendingDeletionRef.current.has(id)) { pendingDeletionRef.current.delete(id); } // 타임아웃 제거 - if (timeoutRef.current[id]) { - clearTimeout(timeoutRef.current[id]); - delete timeoutRef.current[id]; + if (timeoutsRef.current[id]) { + clearTimeout(timeoutsRef.current[id]); + delete timeoutsRef.current[id]; } toast({ @@ -112,15 +103,15 @@ export const useDeleteTransaction = ( return false; } - }, [deleteTransactionCore, clearAllPendingDeletions, clearAllTimeouts]); + }, [deleteTransactionCore]); // 컴포넌트 언마운트 시 모든 타임아웃 정리 useEffect(() => { return () => { clearAllTimeouts(); - clearAllPendingDeletions(); + pendingDeletionRef.current.clear(); }; - }, [clearAllTimeouts, clearAllPendingDeletions]); + }, [clearAllTimeouts]); return deleteTransaction; };