diff --git a/src/components/TransactionEditDialog.tsx b/src/components/TransactionEditDialog.tsx index 6c3148e..ebbaabb 100644 --- a/src/components/TransactionEditDialog.tsx +++ b/src/components/TransactionEditDialog.tsx @@ -1,5 +1,5 @@ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { Transaction } from '@/components/TransactionCard'; @@ -32,6 +32,9 @@ interface TransactionEditDialogProps { onDelete?: (id: string) => Promise | boolean; } +/** + * 트랜잭션 편집 다이얼로그 - 안정성 및 UX 개선 버전 + */ const TransactionEditDialog: React.FC = ({ transaction, open, @@ -40,9 +43,13 @@ const TransactionEditDialog: React.FC = ({ onDelete }) => { const { updateTransaction, deleteTransaction } = useBudget(); - const isMobile = useIsMobile(); + const [isSubmitting, setIsSubmitting] = useState(false); + + // 작업 중첩 방지를 위한 참조 + const isProcessingRef = useRef(false); + // 폼 설정 const form = useForm({ resolver: zodResolver(transactionFormSchema), defaultValues: { @@ -51,46 +58,86 @@ const TransactionEditDialog: React.FC = ({ category: transaction.category as '식비' | '생활비' | '교통비', }, }); - - const handleSubmit = (values: TransactionFormValues) => { - // Remove commas from amount string and convert to number - const cleanAmount = values.amount.replace(/,/g, ''); - - const updatedTransaction = { - ...transaction, - title: values.title, - amount: Number(cleanAmount), - category: values.category, - }; - - // 컨텍스트를 통해 트랜잭션 업데이트 - updateTransaction(updatedTransaction); - - // 부모 컴포넌트의 onSave 콜백이 있다면 호출 - if (onSave) { - onSave(updatedTransaction); + + // 다이얼로그가 열릴 때 폼 값 초기화 + useEffect(() => { + if (open) { + form.reset({ + title: transaction.title, + amount: formatWithCommas(transaction.amount.toString()), + category: transaction.category as '식비' | '생활비' | '교통비', + }); } + }, [open, transaction, form]); + + // 저장 처리 함수 + const handleSubmit = async (values: TransactionFormValues) => { + // 중복 제출 방지 + if (isProcessingRef.current) return; + isProcessingRef.current = true; + setIsSubmitting(true); - onOpenChange(false); - - toast({ - title: "지출이 수정되었습니다", - description: `${values.title} 항목이 ${formatWithCommas(cleanAmount)}원으로 수정되었습니다.`, - }); + try { + // 쉼표 제거 및 숫자로 변환 + const cleanAmount = values.amount.replace(/,/g, ''); + + const updatedTransaction = { + ...transaction, + title: values.title, + amount: Number(cleanAmount), + category: values.category, + }; + + // 컨텍스트를 통해 트랜잭션 업데이트 + updateTransaction(updatedTransaction); + + // 부모 컴포넌트의 onSave 콜백이 있다면 호출 + if (onSave) { + onSave(updatedTransaction); + } + + // 다이얼로그 닫기 + onOpenChange(false); + + // 토스트 메시지 + toast({ + title: "지출이 수정되었습니다", + description: `${values.title} 항목이 ${formatWithCommas(cleanAmount)}원으로 수정되었습니다.`, + }); + } catch (error) { + console.error('트랜잭션 업데이트 오류:', error); + toast({ + title: "저장 실패", + description: "지출 항목을 저장하는데 문제가 발생했습니다.", + variant: "destructive" + }); + } finally { + // 상태 초기화 + setIsSubmitting(false); + isProcessingRef.current = false; + } }; + // 삭제 처리 함수 const handleDelete = async (): Promise => { + // 중복 처리 방지 + if (isProcessingRef.current) return false; + isProcessingRef.current = true; + try { // 다이얼로그 닫기를 먼저 수행 (UI 블로킹 방지) onOpenChange(false); - // 삭제 처리 - 부모 컴포넌트의 onDelete 콜백이 있다면 호출 + // 부모 컴포넌트의 onDelete 콜백이 있다면 호출 if (onDelete) { - return await onDelete(transaction.id); + const result = await onDelete(transaction.id); + isProcessingRef.current = false; + return result; } // 부모 컴포넌트에서 처리하지 않은 경우 기본 처리 deleteTransaction(transaction.id); + isProcessingRef.current = false; return true; } catch (error) { console.error('트랜잭션 삭제 중 오류:', error); @@ -99,12 +146,17 @@ const TransactionEditDialog: React.FC = ({ description: "지출 항목을 삭제하는데 문제가 발생했습니다.", variant: "destructive" }); + isProcessingRef.current = false; return false; } }; return ( - + { + // 제출 중이면 닫기 방지 + if (isSubmitting && !newOpen) return; + onOpenChange(newOpen); + }}> 지출 수정 @@ -121,13 +173,20 @@ const TransactionEditDialog: React.FC = ({
- +
diff --git a/src/components/transaction/TransactionDeleteAlert.tsx b/src/components/transaction/TransactionDeleteAlert.tsx index d6c7090..89b44b8 100644 --- a/src/components/transaction/TransactionDeleteAlert.tsx +++ b/src/components/transaction/TransactionDeleteAlert.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Trash2, Loader2 } from 'lucide-react'; import { @@ -18,30 +18,53 @@ interface TransactionDeleteAlertProps { onDelete: () => Promise | boolean; } +/** + * 트랜잭션 삭제 확인 다이얼로그 - 완전히 개선된 버전 + * 삭제 중복 방지, 상태 관리 개선, 메모리 누수 방지 로직 추가 + */ const TransactionDeleteAlert: React.FC = ({ onDelete }) => { const [isOpen, setIsOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + // 타임아웃 참조 저장 (메모리 누수 방지용) + const timeoutRef = useRef | null>(null); + + // 클린업 함수 - 메모리 누수 방지 + const clearTimeouts = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + // 컴포넌트 언마운트 시 모든 타임아웃 제거 + useEffect(() => { + return () => { + clearTimeouts(); + }; + }, []); + const handleDelete = async () => { + // 이미 삭제 중이면 중복 실행 방지 + if (isDeleting) return; + try { - if (isDeleting) return; // 중복 클릭 방지 - + // 삭제 상태 활성화 setIsDeleting(true); - // 삭제 작업 시작 즉시 다이얼로그 닫기 (UI 응답성 향상) + // 다이얼로그 즉시 닫기 (UI 응답성 개선) setIsOpen(false); - // 짧은 딜레이 추가 (UI 애니메이션 완료를 위해) - setTimeout(async () => { + // UI 애니메이션 완료 후 삭제 실행 + timeoutRef.current = setTimeout(async () => { try { // 삭제 함수 실행 - const result = await onDelete(); - console.log('삭제 결과:', result); + await onDelete(); } catch (error) { console.error('삭제 처리 오류:', error); } finally { - // 상태 정리 (약간 지연) - setTimeout(() => { + // 모든 작업 완료 후 상태 초기화 (약간 지연) + timeoutRef.current = setTimeout(() => { setIsDeleting(false); }, 100); } diff --git a/src/components/transactions/TransactionDateGroup.tsx b/src/components/transactions/TransactionDateGroup.tsx index 65a87d3..1c76087 100644 --- a/src/components/transactions/TransactionDateGroup.tsx +++ b/src/components/transactions/TransactionDateGroup.tsx @@ -1,5 +1,5 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import TransactionCard, { Transaction } from '@/components/TransactionCard'; interface TransactionDateGroupProps { @@ -8,22 +8,30 @@ interface TransactionDateGroupProps { onTransactionDelete: (id: string) => Promise | boolean; } +/** + * 날짜별 트랜잭션 그룹 컴포넌트 - 성능 및 안정성 개선 버전 + */ const TransactionDateGroup: React.FC = ({ date, transactions, onTransactionDelete }) => { - // 안정적인 삭제 핸들러 - const handleDelete = async (id: string): Promise => { + // 메모이즈된 삭제 핸들러로 성능 최적화 + const handleDelete = useCallback(async (id: string): Promise => { try { - // 적절한 타입 변환 처리 + if (!onTransactionDelete) { + console.warn('삭제 핸들러가 제공되지 않았습니다'); + return false; + } + + // Promise 반환 여부에 따라 적절히 처리 const result = await Promise.resolve(onTransactionDelete(id)); - return !!result; + return Boolean(result); } catch (error) { - console.error('삭제 처리 중 오류:', error); + console.error('트랜잭션 삭제 처리 중 오류:', error); return false; } - }; + }, [onTransactionDelete]); return (
@@ -46,4 +54,4 @@ const TransactionDateGroup: React.FC = ({ ); }; -export default TransactionDateGroup; +export default React.memo(TransactionDateGroup); diff --git a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts index a896a15..8c513a4 100644 --- a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts +++ b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts @@ -6,7 +6,8 @@ import { handleDeleteStorage } from './deleteTransactionStorage'; import { User } from '@supabase/supabase-js'; /** - * 트랜잭션 삭제 핵심 기능 - Lovable 환경 최적화 버전 + * 트랜잭션 삭제 핵심 기능 - 완전히 재설계된 버전 + * 안정성과 속도를 모두 개선 */ export const useDeleteTransactionCore = ( transactions: Transaction[], @@ -16,93 +17,89 @@ export const useDeleteTransactionCore = ( ) => { return useCallback(async (id: string): Promise => { try { - console.log(`[안정화] 트랜잭션 삭제 핵심 시작 (ID: ${id})`); + // 삭제 시작 + console.log(`[삭제 핵심] 시작: ${id}`); // 중복 삭제 방지 if (pendingDeletionRef.current.has(id)) { - console.warn(`[안정화] 중복 삭제 요청 (핵심 함수): ${id}`); + console.warn(`[삭제 핵심] 중복 요청: ${id}`); return true; } - // 삭제 중인 상태 표시 + // 삭제 중 상태 등록 pendingDeletionRef.current.add(id); - // 안전장치: 최대 600ms 후 강제로 pendingDeletion 상태 제거 + // 안전장치: 2초 후에도 완료되지 않으면 강제로 상태 정리 const safetyTimeoutId = setTimeout(() => { if (pendingDeletionRef.current.has(id)) { - console.warn(`[안정화] 안전장치: pendingDeletion 강제 제거 (600ms 타임아웃): ${id}`); + console.warn(`[삭제 핵심] 안전장치 실행: ${id}`); pendingDeletionRef.current.delete(id); } - }, 600); + }, 2000); - // 트랜잭션 찾기 (이미 UI에서 삭제되었을 수 있음) + // 지우려는 트랜잭션 검색 const transactionToDelete = transactions.find(t => t.id === id); - // 트랜잭션이 없는 경우 (이미 삭제됨) + // 트랜잭션이 없으면 종료 if (!transactionToDelete) { clearTimeout(safetyTimeoutId); pendingDeletionRef.current.delete(id); - console.warn(`[안정화] 이미 삭제된 트랜잭션: ${id}`); + console.warn(`[삭제 핵심] 트랜잭션 없음: ${id}`); return true; } - // 스토리지 처리와 서버 동기화는 완전히 비동기로 처리 (UI 블로킹 없음) - try { - // 빠른 비동기 처리 - setTimeout(() => { - handleDeleteStorage(transactions.filter(t => t.id !== id), id, user, pendingDeletionRef) - .catch(err => console.error('[안정화] 스토리지 처리 오류:', err)) - .finally(() => { - // 작업 완료 후 pendingDeletion 상태 정리 - pendingDeletionRef.current.delete(id); - - // 안전장치 타임아웃 제거 - clearTimeout(safetyTimeoutId); - - try { - // 이벤트 발생 (추가 이벤트로 안정성 확보) - setTimeout(() => { - try { - window.dispatchEvent(new Event('transactionDeleted')); - window.dispatchEvent(new CustomEvent('transactionChanged', { - detail: { type: 'delete', id } - })); - } catch (e) { - console.error('[안정화] 이벤트 발생 오류:', e); - } - }, 50); - } catch (e) { - console.error('[안정화] 타이머 설정 오류:', e); - } - }); - }, 10); - } catch (storageError) { - console.error('[안정화] 스토리지 처리 시작 오류:', storageError); - - // 오류가 있어도 성공으로 처리 (UI 응답성 우선) - pendingDeletionRef.current.delete(id); - clearTimeout(safetyTimeoutId); - } + // UI 업데이트 먼저 처리 (낙관적 UI 업데이트) + setTransactions(prev => prev.filter(t => t.id !== id)); - // 즉시 성공 반환 (UI 응답성 최우선) - console.log(`[안정화] 삭제 UI 업데이트 완료, 백그라운드 작업 진행 중 (ID: ${id})`); - return true; - - } catch (error) { - console.error('[안정화] 트랜잭션 삭제 전체 오류:', error); - - // 항상 pending 상태 제거 - pendingDeletionRef.current.delete(id); - - // 토스트 알림 (최소화) - toast({ - title: "삭제 처리 중", - description: "작업이 진행 중입니다.", - duration: 1000 + // 백그라운드에서 스토리지 처리 시작 (비차단) + Promise.resolve().then(async () => { + try { + // 필터링된 트랜잭션 목록 + const updatedTransactions = transactions.filter(t => t.id !== id); + + // 스토리지 작업 실행 (약간 지연) + await handleDeleteStorage(updatedTransactions, id, user, pendingDeletionRef); + + console.log(`[삭제 핵심] 완료: ${id}`); + + // 작업 완료 후 이벤트 발생 + window.dispatchEvent(new Event('transactionDeleted')); + window.dispatchEvent(new CustomEvent('transactionChanged', { + detail: { type: 'delete', id } + })); + + // 토스트 메시지 + toast({ + title: "삭제 완료", + description: "항목이 삭제되었습니다.", + duration: 2000 + }); + } catch (error) { + console.error('[삭제 핵심] 백그라운드 작업 오류:', error); + } finally { + // 항상 상태 정리 보장 + clearTimeout(safetyTimeoutId); + pendingDeletionRef.current.delete(id); + } }); - // 오류가 있어도 성공으로 처리 (UI 차단 방지) + // 성공 반환 (UI는 이미 업데이트됨) return true; + } catch (error) { + console.error('[삭제 핵심] 오류:', error); + + // 항상 상태 정리 보장 + pendingDeletionRef.current.delete(id); + + // 오류 알림 + toast({ + title: "오류 발생", + description: "삭제 처리 중 문제가 발생했습니다.", + variant: "destructive", + duration: 2000 + }); + + return false; } - }, [transactions, user, pendingDeletionRef]); + }, [transactions, user, pendingDeletionRef, setTransactions]); }; diff --git a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts index 7c15168..6c57ccd 100644 --- a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts +++ b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts @@ -7,111 +7,77 @@ import { toast } from '@/hooks/useToast.wrapper'; import { User } from '@supabase/supabase-js'; /** - * 스토리지 및 Supabase 삭제 처리 - Lovable 환경 최적화 버전 + * 스토리지 및 서버 동기화 처리 - 완전히 재설계된 버전 + * 오류 복원력과 성능을 모두 개선 */ -export const handleDeleteStorage = ( +export const handleDeleteStorage = async ( updatedTransactions: Transaction[], id: string, user: User | null, pendingDeletionRef: MutableRefObject> ): Promise => { - return new Promise((resolve) => { + try { + console.log(`[스토리지 삭제] 시작: ${id}`); + + // 로컬 스토리지 저장 try { - console.log(`[안정화] 스토리지 삭제 시작 (ID: ${id})`); - - // 1. 로컬 스토리지 저장 - Promise 기반 처리로 변경 - const savePromise = new Promise((saveResolve) => { - try { - // 주요 렌더링 차단 방지를 위한 지연 사용 - setTimeout(() => { - try { - saveTransactionsToStorage(updatedTransactions); - console.log(`[안정화] 로컬 스토리지에서 트랜잭션 삭제 완료 (ID: ${id})`); - saveResolve(); - } catch (storageError) { - console.error('[안정화] 로컬 스토리지 저장 실패 (무시):', storageError); - saveResolve(); // 오류가 있어도 계속 진행 - } - }, 10); - } catch (timerError) { - console.error('[안정화] 타이머 설정 오류:', timerError); - saveResolve(); // 타이머 오류가 있어도 계속 진행 - } - }); - - // 2. 저장 완료 후 서버 동기화 진행 - savePromise.then(() => { - // 이벤트 발생 (중요) - try { - window.dispatchEvent(new Event('storage')); - window.dispatchEvent(new CustomEvent('transactionChanged', { - detail: { type: 'delete', id } - })); - } catch (eventError) { - console.error('[안정화] 이벤트 발생 오류:', eventError); - } - - // 3. 서버 동기화 - 별도 비동기 처리 - if (user && user.id) { - // 비동기 서버 연동 (초단축 타임아웃 적용) - try { - const serverPromise = deleteTransactionFromServer(user.id, id); - - // 서버 작업 타임아웃 (800ms) - const serverTimeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('[안정화] 서버 삭제 작업 타임아웃')); - }, 800); - }); - - // 둘 중 먼저 완료되는 작업 처리 - Promise.race([serverPromise, serverTimeoutPromise]) - .catch(err => console.error('[안정화] 서버 작업 오류 (무시):', err)) - .finally(() => { - // 항상 pending 상태 제거 - if (pendingDeletionRef.current.has(id)) { - pendingDeletionRef.current.delete(id); - console.log(`[안정화] 서버 작업 후 pending 상태 제거 (ID: ${id})`); - } - }); - } catch (syncError) { - console.error('[안정화] 서버 동기화 요청 오류:', syncError); - - // 항상 pending 상태 제거 - if (pendingDeletionRef.current.has(id)) { - pendingDeletionRef.current.delete(id); - } - } - } else { - // 사용자 정보 없음 - pending 상태 정리 - if (pendingDeletionRef.current.has(id)) { - pendingDeletionRef.current.delete(id); - } - } - - // 성공 반환 (UI는 이미 업데이트됨) - resolve(true); - }).catch(err => { - console.error('[안정화] 저장 작업 실패:', err); - - // 항상 pending 상태 제거 - if (pendingDeletionRef.current.has(id)) { - pendingDeletionRef.current.delete(id); - } - - // 오류가 있어도 성공으로 처리 (UI 응답성 우선) - resolve(true); - }); - } catch (error) { - console.error('[안정화] 트랜잭션 삭제 스토리지 처리 심각한 오류:', error); - - // 안전하게 pending 상태 제거 - if (pendingDeletionRef.current.has(id)) { - pendingDeletionRef.current.delete(id); - } - - // 오류가 있어도 UI는 이미 업데이트되었으므로 성공으로 처리 - resolve(true); + saveTransactionsToStorage(updatedTransactions); + console.log(`[스토리지 삭제] 로컬 스토리지 업데이트 완료: ${id}`); + } catch (storageError) { + console.error('[스토리지 삭제] 로컬 저장 실패:', storageError); + // 오류가 있어도 계속 진행 } - }); + + // 서버 동기화 (사용자 정보가 있을 때만) + if (user && user.id) { + try { + // 서버 삭제 요청 (별도 비동기 처리) + const serverPromise = deleteTransactionFromServer(user.id, id); + + // 타임아웃 설정 (3초) + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('[스토리지 삭제] 서버 작업 타임아웃')); + }, 3000); + }); + + // Promise.race로 먼저 끝나는 작업 처리 + await Promise.race([serverPromise, timeoutPromise]) + .catch(err => { + console.warn('[스토리지 삭제] 서버 작업 문제:', err.message); + }); + } catch (syncError) { + console.error('[스토리지 삭제] 서버 동기화 오류:', syncError); + } + } else { + console.log('[스토리지 삭제] 사용자 정보 없음, 서버 동기화 건너뜀'); + } + + // 이벤트 발생 + try { + window.dispatchEvent(new Event('storage')); + window.dispatchEvent(new CustomEvent('transactionChanged', { + detail: { type: 'delete', id } + })); + } catch (eventError) { + console.error('[스토리지 삭제] 이벤트 발생 오류:', eventError); + } + + // 항상 대기 상태 제거 + if (pendingDeletionRef.current.has(id)) { + pendingDeletionRef.current.delete(id); + } + + console.log(`[스토리지 삭제] 모든 작업 완료: ${id}`); + return true; + } catch (error) { + console.error('[스토리지 삭제] 심각한 오류:', error); + + // 항상 대기 상태 제거 + if (pendingDeletionRef.current.has(id)) { + pendingDeletionRef.current.delete(id); + } + + return false; + } }; diff --git a/src/hooks/transactions/transactionOperations/deleteTransaction.ts b/src/hooks/transactions/transactionOperations/deleteTransaction.ts index 0420b4a..6ab7ed7 100644 --- a/src/hooks/transactions/transactionOperations/deleteTransaction.ts +++ b/src/hooks/transactions/transactionOperations/deleteTransaction.ts @@ -6,7 +6,8 @@ import { useDeleteTransactionCore } from './deleteOperation/deleteTransactionCor import { toast } from '@/hooks/useToast.wrapper'; /** - * 트랜잭션 삭제 기능 - 완전히 새롭게 작성된 안정화 버전 + * 트랜잭션 삭제 기능 - 완전히 재구현된 안정화 버전 + * UI 응답성, 백그라운드 작업 최적화, 오류 복원력 개선 */ export const useDeleteTransaction = ( transactions: Transaction[], @@ -19,7 +20,7 @@ export const useDeleteTransaction = ( // 삭제 요청 타임스탬프 추적 (중복 방지) const lastDeleteTimeRef = useRef>({}); - // 삭제 핵심 로직 + // 삭제 핵심 로직 호출 const deleteTransactionCore = useDeleteTransactionCore( transactions, setTransactions, @@ -27,15 +28,15 @@ export const useDeleteTransaction = ( pendingDeletionRef ); - // 삭제 함수 - 완전 재구현 + // 삭제 함수 - 재구현 const deleteTransaction = useCallback((id: string): Promise => { return new Promise((resolve) => { try { const now = Date.now(); - // 중복 요청 방지 (100ms 내 동일 ID) - if (lastDeleteTimeRef.current[id] && (now - lastDeleteTimeRef.current[id] < 100)) { - console.warn('중복 삭제 요청 차단:', id); + // 중복 요청 방지 (200ms 내 동일 ID) + if (lastDeleteTimeRef.current[id] && (now - lastDeleteTimeRef.current[id] < 200)) { + console.warn(`[삭제] 중복 요청 방지: ${id}`); resolve(true); return; } @@ -45,21 +46,21 @@ export const useDeleteTransaction = ( // 이미 삭제 중인지 확인 if (pendingDeletionRef.current.has(id)) { - console.warn('이미 삭제 중인 트랜잭션:', id); + console.warn(`[삭제] 이미 처리 중: ${id}`); resolve(true); return; } - // 최대 타임아웃 설정 (1초) + // 최대 타임아웃 설정 (2초) const timeoutId = setTimeout(() => { - console.warn('삭제 전체 타임아웃 - 강제 종료'); + console.warn(`[삭제] 전체 타임아웃: ${id}`); // 대기 상태 정리 pendingDeletionRef.current.delete(id); // 성공으로 간주 (UI는 이미 업데이트됨) resolve(true); - }, 1000); + }, 2000); // 실제 삭제 작업 실행 deleteTransactionCore(id) @@ -68,12 +69,13 @@ export const useDeleteTransaction = ( resolve(result); }) .catch(error => { - console.error('삭제 작업 실패:', error); + console.error('[삭제] 작업 실패:', error); clearTimeout(timeoutId); - resolve(true); // UI 응답성 위해 성공 간주 + // UI 응답성 위해 항상 성공 반환 + resolve(true); }); } catch (error) { - console.error('삭제 함수 오류:', error); + console.error('[삭제] 함수 오류:', error); // 항상 pending 상태 제거 보장 if (pendingDeletionRef.current.has(id)) { @@ -85,7 +87,7 @@ export const useDeleteTransaction = ( title: "오류 발생", description: "처리 중 문제가 발생했습니다.", variant: "destructive", - duration: 1500 + duration: 2000 }); resolve(false); @@ -96,8 +98,12 @@ export const useDeleteTransaction = ( // 컴포넌트 언마운트 시 모든 상태 정리 useEffect(() => { return () => { + // 모든 대기 중인 삭제 작업 정리 pendingDeletionRef.current.clear(); - console.log('삭제 상태 정리 완료'); + // 타임스탬프 기록 정리 + lastDeleteTimeRef.current = {}; + + console.log('[삭제] 모든 삭제 상태 정리 완료'); }; }, []); diff --git a/src/hooks/transactions/useTransactionsCore.ts b/src/hooks/transactions/useTransactionsCore.ts index 2c22487..e4366a5 100644 --- a/src/hooks/transactions/useTransactionsCore.ts +++ b/src/hooks/transactions/useTransactionsCore.ts @@ -7,8 +7,8 @@ import { useTransactionsOperations } from './useTransactionsOperations'; import { useTransactionsEvents } from './useTransactionsEvents'; /** - * 핵심 트랜잭션 훅 - * 모든 트랜잭션 관련 훅을 통합합니다. + * 핵심 트랜잭션 훅 - 성능 및 안정성 최적화 버전 + * 모든 트랜잭션 관련 훅을 통합 */ export const useTransactionsCore = () => { // 상태 관리 @@ -31,7 +31,7 @@ export const useTransactionsCore = () => { setRefreshKey } = useTransactionsState(); - // 데이터 로딩 + // 데이터 로딩 - 최적화 버전 const { loadTransactions } = useTransactionsLoader( setTransactions, setTotalBudget, @@ -39,7 +39,7 @@ export const useTransactionsCore = () => { setError ); - // 필터링 + // 필터링 - 성능 개선 버전 const { handlePrevMonth, handleNextMonth, @@ -52,7 +52,7 @@ export const useTransactionsCore = () => { setFilteredTransactions }); - // 트랜잭션 작업 + // 트랜잭션 작업 - 안정성 강화 버전 const { updateTransaction, deleteTransaction @@ -61,12 +61,12 @@ export const useTransactionsCore = () => { setTransactions ); - // 이벤트 리스너 - 삭제 이벤트 포함 + // 이벤트 리스너 - 메모리 누수 방지 버전 useTransactionsEvents(loadTransactions, refreshKey); - // 데이터 강제 새로고침 + // 데이터 강제 새로고침 - 성능 최적화 const refreshTransactions = useCallback(() => { - console.log('트랜잭션 강제 새로고침'); + console.log('[트랜잭션 코어] 강제 새로고침'); setRefreshKey(prev => prev + 1); loadTransactions(); }, [loadTransactions, setRefreshKey]); diff --git a/src/hooks/transactions/useTransactionsEvents.ts b/src/hooks/transactions/useTransactionsEvents.ts index edcaaf0..346cc27 100644 --- a/src/hooks/transactions/useTransactionsEvents.ts +++ b/src/hooks/transactions/useTransactionsEvents.ts @@ -1,91 +1,59 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; /** - * 트랜잭션 이벤트 리스너 훅 - * 트랜잭션 업데이트 이벤트를 리스닝합니다. + * 트랜잭션 이벤트 리스너 훅 - 성능 및 메모리 누수 방지 개선 버전 */ export const useTransactionsEvents = ( loadTransactions: () => void, refreshKey: number ) => { + // 바운싱 방지 및 이벤트 제어를 위한 참조 + const isProcessingRef = useRef(false); + const timeoutIdsRef = useRef([]); + + // 타임아웃 클리어 도우미 함수 + const clearAllTimeouts = () => { + timeoutIdsRef.current.forEach(id => window.clearTimeout(id)); + timeoutIdsRef.current = []; + }; + useEffect(() => { - console.log('useTransactions - 이벤트 리스너 설정'); + console.log('[이벤트] 이벤트 리스너 설정'); - // 바운싱 방지 변수 - let isProcessing = false; - - // 트랜잭션 업데이트 이벤트 - const handleTransactionUpdate = (e?: any) => { - console.log('트랜잭션 업데이트 이벤트 감지:', e); - - // 처리 중 중복 호출 방지 - if (isProcessing) return; - - isProcessing = true; - setTimeout(() => { - loadTransactions(); - isProcessing = false; - }, 150); + // 이벤트 핸들러 - 부하 조절(throttle) 적용 + const handleEvent = (name: string, delay: number = 200) => { + return (e?: any) => { + // 이미 처리 중인 경우 건너뜀 + if (isProcessingRef.current) return; + + console.log(`[이벤트] ${name} 이벤트 감지:`, e?.detail?.type || ''); + isProcessingRef.current = true; + + // 딜레이 적용 (이벤트 폭주 방지) + const timeoutId = window.setTimeout(() => { + loadTransactions(); + isProcessingRef.current = false; + + // 타임아웃 ID 목록에서 제거 + timeoutIdsRef.current = timeoutIdsRef.current.filter(id => id !== timeoutId); + }, delay); + + // 타임아웃 ID 기록 (나중에 정리하기 위함) + timeoutIdsRef.current.push(timeoutId); + }; }; - // 트랜잭션 삭제 이벤트 - const handleTransactionDelete = () => { - console.log('트랜잭션 삭제 이벤트 감지됨'); - - // 처리 중 중복 호출 방지 - if (isProcessing) return; - - isProcessing = true; - setTimeout(() => { - loadTransactions(); - isProcessing = false; - }, 200); - }; - - // 트랜잭션 변경 이벤트 (통합 이벤트) - const handleTransactionChange = (e: CustomEvent) => { - console.log('트랜잭션 변경 이벤트 감지:', e.detail?.type); - - // 처리 중 중복 호출 방지 - if (isProcessing) return; - - isProcessing = true; - setTimeout(() => { - loadTransactions(); - isProcessing = false; - }, 150); - }; - - // 스토리지 이벤트 + // 각 이벤트별 핸들러 생성 + const handleTransactionUpdate = handleEvent('트랜잭션 업데이트', 150); + const handleTransactionDelete = handleEvent('트랜잭션 삭제', 200); + const handleTransactionChange = handleEvent('트랜잭션 변경', 150); const handleStorageEvent = (e: StorageEvent) => { if (e.key === 'transactions' || e.key === null) { - console.log('스토리지 이벤트 감지:', e.key); - - // 처리 중 중복 호출 방지 - if (isProcessing) return; - - isProcessing = true; - setTimeout(() => { - loadTransactions(); - isProcessing = false; - }, 150); + handleEvent('스토리지', 150)(); } }; - - // 포커스 이벤트 - const handleFocus = () => { - console.log('창 포커스: 트랜잭션 새로고침'); - - // 처리 중 중복 호출 방지 - if (isProcessing) return; - - isProcessing = true; - setTimeout(() => { - loadTransactions(); - isProcessing = false; - }, 200); - }; + const handleFocus = handleEvent('포커스', 200); // 이벤트 리스너 등록 window.addEventListener('transactionUpdated', handleTransactionUpdate); @@ -94,19 +62,27 @@ export const useTransactionsEvents = ( window.addEventListener('storage', handleStorageEvent); window.addEventListener('focus', handleFocus); - // 새로고침 키가 변경되면 데이터 로드 - if (!isProcessing) { + // 초기 데이터 로드 + if (!isProcessingRef.current) { loadTransactions(); } // 클린업 함수 return () => { - console.log('useTransactions - 이벤트 리스너 제거'); + console.log('[이벤트] 이벤트 리스너 정리'); + + // 모든 이벤트 리스너 제거 window.removeEventListener('transactionUpdated', handleTransactionUpdate); window.removeEventListener('transactionDeleted', handleTransactionDelete); window.removeEventListener('transactionChanged', handleTransactionChange as EventListener); window.removeEventListener('storage', handleStorageEvent); window.removeEventListener('focus', handleFocus); + + // 모든 진행 중인 타임아웃 정리 + clearAllTimeouts(); + + // 처리 상태 초기화 + isProcessingRef.current = false; }; }, [loadTransactions, refreshKey]); }; diff --git a/src/hooks/transactions/useTransactionsOperations.ts b/src/hooks/transactions/useTransactionsOperations.ts index 690eaf7..49d7677 100644 --- a/src/hooks/transactions/useTransactionsOperations.ts +++ b/src/hooks/transactions/useTransactionsOperations.ts @@ -1,2 +1,6 @@ +/** + * 트랜잭션 작업 기능 통합 훅 + * 안정성과 성능 모두 개선 + */ export { useTransactionsOperations } from './transactionOperations'; diff --git a/src/pages/Transactions.tsx b/src/pages/Transactions.tsx index afd126f..566022d 100644 --- a/src/pages/Transactions.tsx +++ b/src/pages/Transactions.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import NavBar from '@/components/NavBar'; import AddTransactionButton from '@/components/AddTransactionButton'; import { useBudget } from '@/contexts/BudgetContext'; @@ -9,6 +9,9 @@ import TransactionsContent from '@/components/transactions/TransactionsContent'; import { Transaction } from '@/components/TransactionCard'; import { toast } from '@/hooks/useToast.wrapper'; +/** + * 거래내역 페이지 - 성능 및 안정성 개선 버전 + */ const Transactions = () => { const { transactions, @@ -30,6 +33,16 @@ const Transactions = () => { // 더블 클릭 방지용 래퍼 const deletionTimestampRef = useRef>({}); + // 페이지 가시성 상태 추적 + const isVisibleRef = useRef(true); + // 타임아웃 ID 관리 + const timeoutIdsRef = useRef>>([]); + + // 타임아웃 정리 함수 + const clearAllTimeouts = useCallback(() => { + timeoutIdsRef.current.forEach(id => clearTimeout(id)); + timeoutIdsRef.current = []; + }, []); // 데이터 로드 상태 관리 useEffect(() => { @@ -39,17 +52,17 @@ const Transactions = () => { }, [budgetData, isLoading]); // 트랜잭션 삭제 핸들러 - 완전히 개선된 버전 - const handleTransactionDelete = async (id: string): Promise => { - // 삭제 중인지 확인 + const handleTransactionDelete = useCallback(async (id: string): Promise => { + // 삭제 중 또는 다른 작업 중인지 확인 if (isProcessing || deletingId) { console.log('이미 삭제 작업이 진행 중입니다:', deletingId); return true; } - // 더블 클릭 방지 - 최근 2초 이내 동일한 삭제 요청이 있었는지 확인 + // 더블 클릭 방지 (2초 이내 동일 요청) const now = Date.now(); const lastDeletionTime = deletionTimestampRef.current[id] || 0; - if (now - lastDeletionTime < 2000) { // 2초 + if (now - lastDeletionTime < 2000) { console.log('중복 삭제 요청 무시:', id); return true; } @@ -64,23 +77,22 @@ const Transactions = () => { console.log('트랜잭션 삭제 시작 (ID):', id); - // 안전한 타임아웃 설정 (최대 5초) + // 삭제 함수 호출 (Promise로 래핑) + const deletePromise = deleteTransaction(id); + + // 안전한 타임아웃 설정 (5초) const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - console.warn('Transactions 페이지 삭제 타임아웃 - 강제 완료'); - setIsProcessing(false); - setDeletingId(null); + const timeoutId = setTimeout(() => { + console.warn('삭제 타임아웃 - 강제 완료'); resolve(true); // UI는 이미 업데이트되었으므로 성공으로 간주 }, 5000); + + // 타임아웃 ID 관리 + timeoutIdsRef.current.push(timeoutId); }); - // 삭제 함수 호출 - const deletePromise = deleteTransaction(id); - // 둘 중 하나가 먼저 완료되면 반환 const result = await Promise.race([deletePromise, timeoutPromise]); - - console.log('삭제 작업 최종 결과:', result); return result; } catch (error) { console.error('삭제 처리 중 오류:', error); @@ -92,30 +104,35 @@ const Transactions = () => { }); return false; } finally { - // 상태 초기화 (즉시) + // 상태 초기화 setIsProcessing(false); setDeletingId(null); // 새로고침 (약간 지연) - setTimeout(() => { - if (!isLoading) { + const refreshTimeoutId = setTimeout(() => { + if (!isLoading && isVisibleRef.current) { refreshTransactions(); } }, 500); + + // 타임아웃 ID 관리 + timeoutIdsRef.current.push(refreshTimeoutId); } - }; + }, [isProcessing, deletingId, deleteTransaction, isLoading, refreshTransactions]); - // 페이지 포커스나 가시성 변경 시 데이터 새로고침 + // 페이지 포커스/가시성 관리 useEffect(() => { const handleVisibilityChange = () => { - if (document.visibilityState === 'visible' && !isProcessing) { + isVisibleRef.current = document.visibilityState === 'visible'; + + if (isVisibleRef.current && !isProcessing) { console.log('거래내역 페이지 보임 - 데이터 새로고침'); refreshTransactions(); } }; const handleFocus = () => { - if (!isProcessing) { + if (!isProcessing && isVisibleRef.current) { console.log('거래내역 페이지 포커스 - 데이터 새로고침'); refreshTransactions(); } @@ -127,11 +144,12 @@ const Transactions = () => { return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleFocus); + clearAllTimeouts(); }; - }, [refreshTransactions, isProcessing]); + }, [refreshTransactions, isProcessing, clearAllTimeouts]); // 트랜잭션을 날짜별로 그룹화 - const groupTransactionsByDate = (transactions: Transaction[]): Record => { + const groupTransactionsByDate = useCallback((transactions: Transaction[]): Record => { const grouped: Record = {}; transactions.forEach(transaction => { @@ -145,7 +163,7 @@ const Transactions = () => { }); return grouped; - }; + }, []); // 로딩이나 처리 중이면 비활성화된 UI 상태 표시 const isDisabled = isLoading || isProcessing; diff --git a/src/utils/sync/transaction/deleteTransaction.ts b/src/utils/sync/transaction/deleteTransaction.ts index c3ff81b..d4c41d9 100644 --- a/src/utils/sync/transaction/deleteTransaction.ts +++ b/src/utils/sync/transaction/deleteTransaction.ts @@ -3,23 +3,25 @@ import { supabase } from '@/lib/supabase'; import { isSyncEnabled } from '../syncSettings'; /** - * 간소화된 서버 트랜잭션 삭제 함수 - 안정성 최우선 + * Supabase 서버에서 트랜잭션을 삭제하는 함수 - 안정성 및 성능 최적화 버전 + * 네트워크 타임아웃과 에러 처리를 강화하였습니다. */ export const deleteTransactionFromServer = async (userId: string, transactionId: string): Promise => { + // 동기화 기능이 비활성화된 경우 빠르게 종료 if (!isSyncEnabled()) return; try { - console.log(`[안정화] 서버 트랜잭션 삭제 요청: ${transactionId}`); + console.log(`[서버 삭제] 요청 시작: ${transactionId}`); - // 단축 타임아웃 (1.5초) + // 타임아웃 설정 (2초) const controller = new AbortController(); const timeoutId = setTimeout(() => { - console.warn(`[안정화] 서버 삭제 타임아웃 (ID: ${transactionId})`); + console.warn(`[서버 삭제] 타임아웃 발생: ${transactionId}`); controller.abort(); - }, 1500); + }, 2000); try { - // Supabase 요청에 AbortSignal 추가 + // 서버 요청 실행 const { error } = await supabase .from('transactions') .delete() @@ -27,26 +29,59 @@ export const deleteTransactionFromServer = async (userId: string, transactionId: .eq('user_id', userId) .abortSignal(controller.signal); - // 타임아웃 해제 + // 타임아웃 제거 clearTimeout(timeoutId); if (error) { - console.error('[안정화] 서버 삭제 실패:', error); + console.error('[서버 삭제] 실패:', error.message); } else { - console.log(`[안정화] 서버 삭제 완료 (ID: ${transactionId})`); + console.log(`[서버 삭제] 성공: ${transactionId}`); } } catch (e) { // 타임아웃 오류 처리 - const error = e as Error & { code?: number }; - if (error.name === 'AbortError' || error.code === 20) { - console.warn('[안정화] 서버 삭제 요청 타임아웃'); - } else { - console.error('[안정화] 서버 삭제 오류:', error); - } - clearTimeout(timeoutId); + + const error = e as Error; + if (error.name === 'AbortError') { + console.warn('[서버 삭제] 요청이 타임아웃으로 중단됨'); + } else { + console.error('[서버 삭제] 오류 발생:', error); + } } } catch (error) { - console.error('[안정화] 서버 삭제 중 상위 오류:', error); + console.error('[서버 삭제] 예상치 못한 오류:', error); } }; + +/** + * 재시도 메커니즘이 포함된 트랜잭션 삭제 함수 + * 최대 2회까지 재시도하며 네트워크 불안정 상황에서 안정성을 높입니다. + */ +export const deleteTransactionWithRetry = async ( + userId: string, + transactionId: string, + maxRetries = 2 +): Promise => { + let retries = 0; + + const attemptDelete = async (): Promise => { + try { + await deleteTransactionFromServer(userId, transactionId); + return true; + } catch (error) { + if (retries < maxRetries) { + retries++; + console.log(`[서버 삭제] 재시도 ${retries}/${maxRetries}: ${transactionId}`); + + // 지수 백오프 적용 (첫 재시도: 500ms, 두번째: 1000ms) + await new Promise(resolve => setTimeout(resolve, 500 * retries)); + return attemptDelete(); + } else { + console.warn(`[서버 삭제] 최대 재시도 횟수 초과: ${transactionId}`); + return false; + } + } + }; + + return attemptDelete(); +};