From d216daf2f4ab9818f4d3dc71b9539230bca1ffef Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 23 Mar 2025 10:22:23 +0000 Subject: [PATCH] Refactor code based on feedback Refactor the code based on the provided feedback. --- src/contexts/budget/BudgetContext.tsx | 387 +++++++++++++++++++++- src/hooks/useOptimizedDataSync.ts | 105 ++++++ src/pages/Index.tsx | 112 ++----- src/utils/eventEmitter.ts | 142 ++++++++ src/utils/performance/debounceThrottle.ts | 118 +++++++ src/utils/sync/syncOptimizer.ts | 99 ++++++ 6 files changed, 870 insertions(+), 93 deletions(-) create mode 100644 src/hooks/useOptimizedDataSync.ts create mode 100644 src/utils/eventEmitter.ts create mode 100644 src/utils/performance/debounceThrottle.ts create mode 100644 src/utils/sync/syncOptimizer.ts diff --git a/src/contexts/budget/BudgetContext.tsx b/src/contexts/budget/BudgetContext.tsx index 30ca899..1db5361 100644 --- a/src/contexts/budget/BudgetContext.tsx +++ b/src/contexts/budget/BudgetContext.tsx @@ -1,23 +1,380 @@ -import React from 'react'; -import { useBudgetState } from './useBudgetState'; -import { BudgetContext, BudgetContextType } from './useBudget'; -import { BudgetPeriod, Transaction } from './types'; +import React, { createContext, useState, useContext, useEffect, useCallback } from 'react'; +import { Transaction, BudgetData } from './types'; +import { + safelyLoadBudgetData, + calculateSpentAmounts, + DEFAULT_MONTHLY_BUDGET +} from './budgetUtils'; +import { + loadBudgetDataFromStorage, + saveBudgetDataToStorage +} from './storage'; +import { loadCategoryBudgetsFromStorage, saveCategoryBudgetsToStorage } from './storage'; +import { loadTransactionsFromStorage, saveTransactionsToStorage } from './storage'; +import { useCategoryBudgetState } from './hooks/useCategoryBudgetState'; +import { useBudgetDataEvents } from './hooks/useBudgetDataEvents'; +import { useBudgetDataLoad } from './hooks/useBudgetDataLoad'; +import { toast } from '@/hooks/useToast.wrapper'; +import { APP_EVENTS } from '@/utils/eventEmitter'; -// 컨텍스트 프로바이더 컴포넌트 -export const BudgetProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const budgetState = useBudgetState(); +// 컨텍스트 타입 정의 +interface BudgetContextType { + transactions: Transaction[]; + filteredTransactions: Transaction[]; + budgetData: BudgetData; + categoryBudgets: Record; + selectedTab: string; + isInitialized: boolean; + lastUpdateTime: number; + setSelectedTab: (tab: string) => void; + addTransaction: (transaction: Transaction) => void; + updateTransaction: (updatedTransaction: Transaction) => void; + deleteTransaction: (transactionId: string) => void; + handleBudgetGoalUpdate: (targetAmount: number) => void; + getCategorySpending: (category: string, transactions?: Transaction[]) => number; + updateCategoryBudgets: (newCategoryBudgets: Record) => void; + resetBudgetData: () => void; +} + +// 기본값으로 컨텍스트 생성 +const BudgetContext = createContext({ + transactions: [], + filteredTransactions: [], + budgetData: { + daily: { + targetAmount: 0, + spentAmount: 0, + remainingAmount: 0 + }, + weekly: { + targetAmount: 0, + spentAmount: 0, + remainingAmount: 0 + }, + monthly: { + targetAmount: 0, + spentAmount: 0, + remainingAmount: 0 + } + }, + categoryBudgets: {}, + selectedTab: 'monthly', + isInitialized: false, + lastUpdateTime: 0, + + // eslint-disable-next-line @typescript-eslint/no-empty-function + setSelectedTab: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + addTransaction: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + updateTransaction: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + deleteTransaction: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + handleBudgetGoalUpdate: () => {}, + getCategorySpending: () => 0, + // eslint-disable-next-line @typescript-eslint/no-empty-function + updateCategoryBudgets: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + resetBudgetData: () => {} +}); + +// 컨텍스트 사용 훅 +export const useBudget = () => useContext(BudgetContext); + +// 컨텍스트 제공자 컴포넌트 +export const BudgetProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // 상태 정의 + const [transactions, setTransactions] = useState([]); + const [filteredTransactions, setFilteredTransactions] = useState([]); + const [budgetData, setBudgetData] = useState(safelyLoadBudgetData()); + const [selectedTab, setSelectedTab] = useState('monthly'); + const [isInitialized, setIsInitialized] = useState(false); + const [lastUpdateTime, setLastUpdateTime] = useState(Date.now()); + + // 카테고리 예산 상태 관리 + const { + categoryBudgets, + updateCategoryBudgets + } = useCategoryBudgetState(); + + // 예산 데이터 로드 및 초기화 + const { loadBudgetData } = useBudgetDataLoad( + isInitialized, + setIsInitialized, + budgetData, + setBudgetData, + setLastUpdateTime + ); + + // 예산 데이터 이벤트 처리 + useBudgetDataEvents( + isInitialized, + transactions, + setBudgetData, + setLastUpdateTime + ); + + // 첫 마운트 시 데이터 로드 + useEffect(() => { + try { + console.log('BudgetContext 초기화 중...'); + + // 트랜잭션 데이터 로드 (성능 최적화: 마운트 시 한 번만) + const loadedTransactions = loadTransactionsFromStorage(); + setTransactions(loadedTransactions); + + // 필터링 없이 초기 데이터 설정 + setFilteredTransactions(loadedTransactions); + + // 카테고리 예산 및 예산 데이터는 해당 훅에서 처리 + + // 이벤트 리스너 설정 + const handleTransactionUpdate = () => { + console.log('트랜잭션 업데이트 이벤트 발생'); + const updatedTransactions = loadTransactionsFromStorage(); + setTransactions(updatedTransactions); + setFilteredTransactions(updatedTransactions); + }; + + // 예산이 업데이트될 때마다 콘솔 출력 + const handleBudgetUpdate = () => { + console.log('BudgetContext: 전역 예산 데이터 이벤트 감지, 현재 targetAmount=' + budgetData.monthly.targetAmount); + }; + + // 이벤트 리스너 등록 + window.addEventListener('transactionUpdated', handleTransactionUpdate); + window.addEventListener('budgetDataUpdated', handleBudgetUpdate); + window.addEventListener(APP_EVENTS.TRANSACTION_UPDATED, handleTransactionUpdate); + window.addEventListener(APP_EVENTS.DATA_UPDATED, handleTransactionUpdate); + + console.log('BudgetContext 초기화 완료'); + + return () => { + window.removeEventListener('transactionUpdated', handleTransactionUpdate); + window.removeEventListener('budgetDataUpdated', handleBudgetUpdate); + window.removeEventListener(APP_EVENTS.TRANSACTION_UPDATED, handleTransactionUpdate); + window.removeEventListener(APP_EVENTS.DATA_UPDATED, handleTransactionUpdate); + }; + } catch (error) { + console.error('BudgetContext 초기화 오류:', error); + } + }, []); + + // 현재 예산 데이터 업데이트 로깅 (디버깅용) + useEffect(() => { + console.log(`최신 예산 데이터: ${JSON.stringify(budgetData)} 마지막 업데이트: ${new Date(lastUpdateTime).toISOString()}`); + console.log(`예산 상태 업데이트: 트랜잭션 수: ${transactions.length} 카테고리 예산: ${JSON.stringify(categoryBudgets)} 예산 데이터: ${JSON.stringify(budgetData)}`); + }, [budgetData, transactions.length, categoryBudgets, lastUpdateTime]); + + // 예산 목표 업데이트 핸들러 + const handleBudgetGoalUpdate = useCallback((targetAmount: number) => { + console.log(`예산 목표 업데이트 요청: ${targetAmount}원`); + + try { + // 현재 예산 데이터 복사 + const updatedBudgetData = { ...budgetData }; + + // 월간 예산 설정 + updatedBudgetData.monthly.targetAmount = targetAmount; + updatedBudgetData.monthly.remainingAmount = targetAmount - updatedBudgetData.monthly.spentAmount; + + // 일일 예산 계산 (월간 ÷ 30) + const dailyBudget = Math.floor(targetAmount / 30); + updatedBudgetData.daily.targetAmount = dailyBudget; + updatedBudgetData.daily.remainingAmount = dailyBudget - updatedBudgetData.daily.spentAmount; + + // 주간 예산 계산 (월간 ÷ 4.3) + const weeklyBudget = Math.floor(targetAmount / 4.3); + updatedBudgetData.weekly.targetAmount = weeklyBudget; + updatedBudgetData.weekly.remainingAmount = weeklyBudget - updatedBudgetData.weekly.spentAmount; + + // 새로운 상태로 업데이트 + setBudgetData(updatedBudgetData); + + // 저장소에도 저장 + saveBudgetDataToStorage(updatedBudgetData); + + // 마지막 업데이트 시간 갱신 + setLastUpdateTime(Date.now()); + + console.log(`예산 목표 업데이트 완료: ${targetAmount}원`); + } catch (error) { + console.error('예산 목표 업데이트 중 오류:', error); + toast({ + title: "예산 업데이트 실패", + description: "예산 목표를 업데이트하는데 문제가 발생했습니다.", + variant: "destructive" + }); + } + }, [budgetData]); + + // 트랜잭션 추가 함수 + const addTransaction = useCallback((transaction: Transaction) => { + try { + const updatedTransactions = [transaction, ...transactions]; + setTransactions(updatedTransactions); + setFilteredTransactions(updatedTransactions); + + // 로컬 스토리지에 저장 + saveTransactionsToStorage(updatedTransactions); + + console.log(`트랜잭션 추가 완료: ${transaction.title}, ${transaction.amount}원`); + } catch (error) { + console.error('트랜잭션 추가 중 오류:', error); + toast({ + title: "지출 추가 실패", + description: "지출 내역을 추가하는데 문제가 발생했습니다.", + variant: "destructive" + }); + } + }, [transactions]); + + // 트랜잭션 업데이트 함수 + const updateTransaction = useCallback((updatedTransaction: Transaction) => { + try { + const updatedTransactions = transactions.map(transaction => + transaction.id === updatedTransaction.id ? updatedTransaction : transaction + ); + + setTransactions(updatedTransactions); + setFilteredTransactions(updatedTransactions); + + // 로컬 스토리지에 저장 + saveTransactionsToStorage(updatedTransactions); + + console.log(`트랜잭션 업데이트 완료: ${updatedTransaction.title}, ${updatedTransaction.amount}원`); + } catch (error) { + console.error('트랜잭션 업데이트 중 오류:', error); + toast({ + title: "지출 업데이트 실패", + description: "지출 내역을 업데이트하는데 문제가 발생했습니다.", + variant: "destructive" + }); + } + }, [transactions]); + + // 트랜잭션 삭제 함수 + const deleteTransaction = useCallback((transactionId: string) => { + try { + const updatedTransactions = transactions.filter( + transaction => transaction.id !== transactionId + ); + + setTransactions(updatedTransactions); + setFilteredTransactions(updatedTransactions); + + // 로컬 스토리지에 저장 + saveTransactionsToStorage(updatedTransactions); + + console.log(`트랜잭션 삭제 완료: ID ${transactionId}`); + } catch (error) { + console.error('트랜잭션 삭제 중 오류:', error); + toast({ + title: "지출 삭제 실패", + description: "지출 내역을 삭제하는데 문제가 발생했습니다.", + variant: "destructive" + }); + } + }, [transactions]); + + // 예산 데이터 초기화 함수 + const resetBudgetData = useCallback(() => { + try { + console.log('예산 데이터 초기화 시작'); + + // 기본 예산 데이터로 초기화 + const defaultBudgetData: BudgetData = { + daily: { + targetAmount: Math.floor(DEFAULT_MONTHLY_BUDGET / 30), + spentAmount: 0, + remainingAmount: Math.floor(DEFAULT_MONTHLY_BUDGET / 30) + }, + weekly: { + targetAmount: Math.floor(DEFAULT_MONTHLY_BUDGET / 4.3), + spentAmount: 0, + remainingAmount: Math.floor(DEFAULT_MONTHLY_BUDGET / 4.3) + }, + monthly: { + targetAmount: DEFAULT_MONTHLY_BUDGET, + spentAmount: 0, + remainingAmount: DEFAULT_MONTHLY_BUDGET + } + }; + + // 상태 업데이트 + setBudgetData(defaultBudgetData); + + // 저장소에 저장 + saveBudgetDataToStorage(defaultBudgetData); + + // 마지막 업데이트 시간 갱신 + setLastUpdateTime(Date.now()); + + console.log('예산 데이터 초기화 완료'); + toast({ + title: "예산 초기화 완료", + description: "모든 예산 데이터가 기본값으로 초기화되었습니다.", + }); + } catch (error) { + console.error('예산 데이터 초기화 중 오류:', error); + toast({ + title: "예산 초기화 실패", + description: "예산 데이터를 초기화하는데 문제가 발생했습니다.", + variant: "destructive" + }); + } + }, []); + + // 카테고리별 지출 금액 계산 함수 + const getCategorySpending = useCallback((category: string, txs?: Transaction[]): number => { + try { + const transactionsToUse = txs || transactions; + + // 특정 카테고리의 지출 항목만 필터링 + const categoryTransactions = transactionsToUse.filter( + t => t.category === category && t.type === 'expense' + ); + + // 지출 금액 합계 계산 + const totalSpent = categoryTransactions.reduce( + (sum, transaction) => sum + transaction.amount, + 0 + ); + + return totalSpent; + } catch (error) { + console.error(`카테고리 지출 계산 중 오류 (${category}):`, error); + return 0; + } + }, [transactions]); + + // 컨텍스트 값 정의 + const contextValue: BudgetContextType = { + transactions, + filteredTransactions, + budgetData, + categoryBudgets, + selectedTab, + isInitialized, + lastUpdateTime, + + setSelectedTab, + addTransaction, + updateTransaction, + deleteTransaction, + handleBudgetGoalUpdate, + getCategorySpending, + updateCategoryBudgets, + resetBudgetData + }; + + // 컨텍스트 제공 return ( - + {children} ); }; - -// useBudget 훅은 useBudget.ts 파일로 이동했습니다 -export { useBudget } from './useBudget'; -export type { BudgetContextType } from './useBudget'; - -// types.ts에서 타입들을 export type으로 내보냅니다 -export type { BudgetPeriod, Transaction } from './types'; diff --git a/src/hooks/useOptimizedDataSync.ts b/src/hooks/useOptimizedDataSync.ts new file mode 100644 index 0000000..379c460 --- /dev/null +++ b/src/hooks/useOptimizedDataSync.ts @@ -0,0 +1,105 @@ + +/** + * 최적화된 데이터 동기화 훅 + */ +import { useEffect, useState, useCallback } from 'react'; +import { useAuth } from '@/contexts/auth'; +import { optimizedSync, debouncedSync, throttledSync } from '@/utils/sync/syncOptimizer'; +import { isSyncEnabled } from '@/utils/syncUtils'; +import { emitEvent, APP_EVENTS } from '@/utils/eventEmitter'; + +export function useOptimizedDataSync() { + const { user } = useAuth(); + const [isSyncing, setIsSyncing] = useState(false); + const [lastSyncTime, setLastSyncTime] = useState( + localStorage.getItem('lastSyncTime') + ); + + // 동기화 상태 추적 + useEffect(() => { + // 동기화 완료 이벤트 리스너 + const handleSyncComplete = (e: CustomEvent) => { + setIsSyncing(false); + + // 성공 시 마지막 동기화 시간 업데이트 + if (e.detail?.success) { + const time = new Date().toISOString(); + setLastSyncTime(time); + localStorage.setItem('lastSyncTime', time); + } + }; + + // 동기화 시작 이벤트 리스너 + const handleSyncStart = () => { + setIsSyncing(true); + }; + + // 이벤트 리스너 등록 + window.addEventListener(APP_EVENTS.SYNC_STARTED, handleSyncStart as EventListener); + window.addEventListener(APP_EVENTS.SYNC_COMPLETED, handleSyncComplete as EventListener); + window.addEventListener(APP_EVENTS.SYNC_FAILED, () => setIsSyncing(false)); + + return () => { + // 이벤트 리스너 제거 + window.removeEventListener(APP_EVENTS.SYNC_STARTED, handleSyncStart as EventListener); + window.removeEventListener(APP_EVENTS.SYNC_COMPLETED, handleSyncComplete as EventListener); + window.removeEventListener(APP_EVENTS.SYNC_FAILED, () => setIsSyncing(false)); + }; + }, []); + + // 즉시 동기화 실행 함수 + const syncNow = useCallback(async () => { + if (!user || !isSyncEnabled() || isSyncing) return false; + + try { + // 동기화 시작 이벤트 발생 + emitEvent(APP_EVENTS.SYNC_STARTED); + + // 동기화 실행 + const result = await optimizedSync(user.id); + + // 동기화 완료 이벤트 발생 + emitEvent(APP_EVENTS.SYNC_COMPLETED, { success: result.success, data: result }); + + return result.success; + } catch (error) { + // 동기화 실패 이벤트 발생 + emitEvent(APP_EVENTS.SYNC_FAILED, { error }); + console.error('[동기화] 오류:', error); + return false; + } + }, [user, isSyncing]); + + // 자동 동기화 (페이지 로드 시) + useEffect(() => { + if (user && isSyncEnabled()) { + // 페이지 로드 시 한 번 실행 (스로틀 적용) + const syncOnLoad = async () => { + await throttledSync(user.id); + }; + + // 페이지가 완전히 로드된 후 동기화 실행 + const timer = setTimeout(syncOnLoad, 1000); + + return () => clearTimeout(timer); + } + }, [user]); + + // 디바운스된 동기화 함수 + const debouncedSyncNow = useCallback(() => { + if (!user || !isSyncEnabled()) return; + + // 동기화 시작 이벤트 발생 (UI 업데이트용) + emitEvent(APP_EVENTS.SYNC_STARTED); + + // 디바운스된 동기화 실행 + debouncedSync(user.id); + }, [user]); + + return { + isSyncing, + lastSyncTime, + syncNow, + debouncedSyncNow + }; +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 8f62b5f..fa6e417 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -11,6 +11,9 @@ import { useWelcomeDialog } from '@/hooks/useWelcomeDialog'; import { useDataInitialization } from '@/hooks/useDataInitialization'; import { useIsMobile } from '@/hooks/use-mobile'; import useNotifications from '@/hooks/useNotifications'; +import { setupPageVisibilityEvents, emitEvent, APP_EVENTS } from '@/utils/eventEmitter'; +import { useOptimizedDataSync } from '@/hooks/useOptimizedDataSync'; +import SafeAreaContainer from '@/components/SafeAreaContainer'; // 메인 컴포넌트 const Index = () => { @@ -30,6 +33,12 @@ const Index = () => { const { isInitialized } = useDataInitialization(resetBudgetData); const isMobile = useIsMobile(); const { addNotification } = useNotifications(); + const { syncNow } = useOptimizedDataSync(); + + // 페이지 가시성 이벤트 설정 (최적화) + useEffect(() => { + setupPageVisibilityEvents(); + }, []); // 초기화 후 환영 메시지 표시 상태 확인 useEffect(() => { @@ -59,98 +68,45 @@ const Index = () => { } }, [isInitialized, user, addNotification]); - // 페이지가 처음 로드될 때 데이터 로딩 확인 + // 페이지가 처음 로드될 때 데이터 로딩 최적화 useEffect(() => { - console.log('Index 페이지 마운트, 현재 데이터 상태:'); - console.log('트랜잭션:', transactions.length); - console.log('예산 데이터:', budgetData); + console.log('Index 페이지 마운트'); - // 페이지 마운트 시 데이터 동기화 이벤트 수동 발생 - try { - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - } catch (e) { - console.error('이벤트 발생 오류:', e); - } + // 페이지 로드 시 한 번에 모든 데이터 이벤트 발생 (통합 처리) + emitEvent(APP_EVENTS.PAGE_LOADED); - // 백업된 데이터 복구 확인 (메인 데이터가 없는 경우만) + // 백업된 데이터 확인 작업은 마운트 시 한 번만 수행 try { - if (!localStorage.getItem('budgetData')) { - const budgetBackup = localStorage.getItem('budgetData_backup'); - if (budgetBackup) { - console.log('예산 데이터 백업에서 복구'); - localStorage.setItem('budgetData', budgetBackup); - window.dispatchEvent(new Event('budgetDataUpdated')); - } + // 데이터 존재 여부 확인 및 백업 복구 (한 번만 실행) + if (!localStorage.getItem('budgetData') && localStorage.getItem('budgetData_backup')) { + localStorage.setItem('budgetData', localStorage.getItem('budgetData_backup')!); + emitEvent(APP_EVENTS.BUDGET_UPDATED); } - if (!localStorage.getItem('categoryBudgets')) { - const categoryBackup = localStorage.getItem('categoryBudgets_backup'); - if (categoryBackup) { - console.log('카테고리 예산 백업에서 복구'); - localStorage.setItem('categoryBudgets', categoryBackup); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - } + if (!localStorage.getItem('categoryBudgets') && localStorage.getItem('categoryBudgets_backup')) { + localStorage.setItem('categoryBudgets', localStorage.getItem('categoryBudgets_backup')!); + emitEvent(APP_EVENTS.CATEGORY_UPDATED); } - if (!localStorage.getItem('transactions')) { - const transactionBackup = localStorage.getItem('transactions_backup'); - if (transactionBackup) { - console.log('트랜잭션 백업에서 복구'); - localStorage.setItem('transactions', transactionBackup); - window.dispatchEvent(new Event('transactionUpdated')); - } + if (!localStorage.getItem('transactions') && localStorage.getItem('transactions_backup')) { + localStorage.setItem('transactions', localStorage.getItem('transactions_backup')!); + emitEvent(APP_EVENTS.TRANSACTION_UPDATED); + } + + // 로그인 상태면 동기화 수행 (지연 시작으로 초기 로딩 성능 개선) + if (user) { + setTimeout(() => { + syncNow(); + }, 1500); } } catch (error) { console.error('백업 복구 시도 중 오류:', error); } - }, [transactions.length, budgetData]); - - // 앱이 포커스를 얻었을 때 데이터를 새로고침 - useEffect(() => { - const handleFocus = () => { - console.log('창이 포커스를 얻음 - 데이터 새로고침'); - // 이벤트 발생시켜 데이터 새로고침 - try { - window.dispatchEvent(new Event('storage')); - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - } catch (e) { - console.error('이벤트 발생 오류:', e); - } - }; - - // 포커스 이벤트 - window.addEventListener('focus', handleFocus); - - // 가시성 변경 이벤트 (백그라운드에서 전경으로 돌아올 때) - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - console.log('페이지가 다시 보임 - 데이터 새로고침'); - handleFocus(); - } - }); - - // 정기적인 데이터 새로고침 (10초마다) - const refreshInterval = setInterval(() => { - if (document.visibilityState === 'visible') { - console.log('정기 새로고침 - 데이터 업데이트'); - handleFocus(); - } - }, 10000); - - return () => { - window.removeEventListener('focus', handleFocus); - document.removeEventListener('visibilitychange', () => {}); - clearInterval(refreshInterval); - }; }, []); return ( -
-
+ +
{ {/* 첫 사용자 안내 팝업 */} -
+
); }; diff --git a/src/utils/eventEmitter.ts b/src/utils/eventEmitter.ts new file mode 100644 index 0000000..26c65aa --- /dev/null +++ b/src/utils/eventEmitter.ts @@ -0,0 +1,142 @@ + +/** + * 이벤트 버스 최적화 - 통합된 이벤트 관리 시스템 + */ + +// 전역 이벤트 이름 정의 +export const APP_EVENTS = { + // 데이터 변경 이벤트 + DATA_UPDATED: 'app:data-updated', + TRANSACTION_UPDATED: 'app:transaction-updated', + BUDGET_UPDATED: 'app:budget-updated', + CATEGORY_UPDATED: 'app:category-updated', + + // 인증 이벤트 + AUTH_STATE_CHANGED: 'app:auth-state-changed', + USER_LOGGED_IN: 'app:user-logged-in', + USER_LOGGED_OUT: 'app:user-logged-out', + + // 동기화 이벤트 + SYNC_STARTED: 'app:sync-started', + SYNC_COMPLETED: 'app:sync-completed', + SYNC_FAILED: 'app:sync-failed', + + // UI 이벤트 + PAGE_LOADED: 'app:page-loaded', + TAB_CHANGED: 'app:tab-changed', +} + +// 최적화된 이벤트 발생 함수 (디바운스, 이벤트 통합 등) +let queuedEvents: Record = {}; +let eventQueueTimer: ReturnType | null = null; + +/** + * 이벤트 발생 - 디바운스 적용 (짧은 시간 내 동일 이벤트는 마지막 한 번만 발생) + */ +export function emitEvent(eventName: string, detail?: any): void { + // 이벤트 대기열에 저장 + queuedEvents[eventName] = { detail }; + + // 이미 타이머가 설정되어 있으면 기존 타이머 유지 + if (eventQueueTimer) return; + + // 타이머 설정 (10ms 후 대기열의 이벤트 모두 발생) + eventQueueTimer = setTimeout(() => { + // 대기열의 모든 이벤트 처리 + for (const [name, data] of Object.entries(queuedEvents)) { + try { + // 표준 이벤트 발생 + window.dispatchEvent(new CustomEvent(name, { + detail: data.detail, + bubbles: true, + cancelable: true, + })); + + // 하위 호환성 유지를 위한 기존 이벤트 매핑 + if (name === APP_EVENTS.TRANSACTION_UPDATED) { + window.dispatchEvent(new Event('transactionUpdated')); + } else if (name === APP_EVENTS.BUDGET_UPDATED) { + window.dispatchEvent(new Event('budgetDataUpdated')); + } else if (name === APP_EVENTS.CATEGORY_UPDATED) { + window.dispatchEvent(new Event('categoryBudgetsUpdated')); + } + + console.log(`[이벤트] 발생: ${name}`); + } catch (error) { + console.error(`[이벤트] 발생 오류 (${name}):`, error); + } + } + + // 대기열 및 타이머 초기화 + queuedEvents = {}; + eventQueueTimer = null; + }, 10); +} + +/** + * 여러 이벤트 한 번에 발생 (배치 이벤트) + */ +export function emitBatchEvents(events: { name: string; detail?: any }[]): void { + // 모든 이벤트를 대기열에 추가 + events.forEach(event => { + queuedEvents[event.name] = { detail: event.detail }; + }); + + // 기존 타이머 취소 + if (eventQueueTimer) { + clearTimeout(eventQueueTimer); + } + + // 새 타이머 설정 (즉시 실행) + eventQueueTimer = setTimeout(() => { + // 대기열의 모든 이벤트 처리 + for (const [name, data] of Object.entries(queuedEvents)) { + try { + // 표준 이벤트 발생 + window.dispatchEvent(new CustomEvent(name, { + detail: data.detail, + bubbles: true, + cancelable: true, + })); + + // 하위 호환성 유지를 위한 기존 이벤트 매핑 + if (name === APP_EVENTS.TRANSACTION_UPDATED) { + window.dispatchEvent(new Event('transactionUpdated')); + } else if (name === APP_EVENTS.BUDGET_UPDATED) { + window.dispatchEvent(new Event('budgetDataUpdated')); + } else if (name === APP_EVENTS.CATEGORY_UPDATED) { + window.dispatchEvent(new Event('categoryBudgetsUpdated')); + } + + console.log(`[이벤트] 배치 발생: ${name}`); + } catch (error) { + console.error(`[이벤트] 배치 발생 오류 (${name}):`, error); + } + } + + // 대기열 및 타이머 초기화 + queuedEvents = {}; + eventQueueTimer = null; + }, 0); +} + +/** + * 페이지가 포커스를 얻었을 때 전체 데이터 새로고침 이벤트 발생 + */ +export function setupPageVisibilityEvents(): void { + // 포커스 이벤트 + window.addEventListener('focus', () => { + console.log('[이벤트] 창이 포커스를 얻음'); + emitEvent(APP_EVENTS.PAGE_LOADED); + }); + + // 가시성 변경 이벤트 + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + console.log('[이벤트] 페이지가 다시 보임'); + emitEvent(APP_EVENTS.PAGE_LOADED); + } + }); + + console.log('[이벤트] 페이지 가시성 이벤트 설정 완료'); +} diff --git a/src/utils/performance/debounceThrottle.ts b/src/utils/performance/debounceThrottle.ts new file mode 100644 index 0000000..ba91773 --- /dev/null +++ b/src/utils/performance/debounceThrottle.ts @@ -0,0 +1,118 @@ + +/** + * 성능 최적화를 위한 유틸리티 함수 + */ + +// 디바운스: 여러 호출 중 마지막 호출만 실행 +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType | null = null; + + return function (...args: Parameters) { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout) clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// 스로틀: 일정 시간 내에 한 번만 실행 +export function throttle any>( + func: T, + limit: number +): (...args: Parameters) => void { + let inThrottle = false; + + return function (...args: Parameters) { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => { + inThrottle = false; + }, limit); + } + }; +} + +// 실행 중인 작업 추적 +const pendingOperations: Record = {}; + +// 중복 실행 방지 (한 번에 동일 작업 한 번만 실행) +export function preventDuplicateOperation Promise>( + operationKey: string, + func: T +): (...args: Parameters) => Promise | void> { + return async function (...args: Parameters): Promise | void> { + // 이미 실행 중인 작업이면 중단 + if (pendingOperations[operationKey]) { + console.log(`[성능] ${operationKey} 작업이 이미 실행 중입니다. 중복 요청 무시.`); + return; + } + + try { + // 작업 시작 플래그 설정 + pendingOperations[operationKey] = true; + console.log(`[성능] ${operationKey} 작업 시작`); + + // 작업 실행 + const result = await func(...args); + return result; + } finally { + // 작업 종료 후 플래그 해제 + pendingOperations[operationKey] = false; + console.log(`[성능] ${operationKey} 작업 완료`); + } + }; +} + +// 네트워크 요청 캐시 (메모리 캐시, 세션 내 유효) +const requestCache: Record = {}; + +// 캐시 설정 (TTL: 캐시 유효 시간(ms)) +export function withCache Promise>( + cacheKey: string, + func: T, + ttl: number = 60000 // 기본값 1분 +): (...args: Parameters) => Promise> { + return async function (...args: Parameters): Promise> { + const now = Date.now(); + + // 캐시 유효성 확인 + if ( + requestCache[cacheKey] && + now - requestCache[cacheKey].timestamp < ttl + ) { + console.log(`[성능] 캐시된 데이터 사용: ${cacheKey}`); + return requestCache[cacheKey].data; + } + + // 캐시 만료 또는 없음 - 새로 요청 + console.log(`[성능] 새 데이터 요청: ${cacheKey}`); + const result = await func(...args); + + // 결과 캐싱 + requestCache[cacheKey] = { + data: result, + timestamp: now + }; + + return result; + }; +} + +// 배치 상태 업데이트 관리 +export function batchedUpdates(updates: () => T): T { + // React의 unstable_batchedUpdates 사용 (가능한 경우) + if (typeof window !== 'undefined' && 'ReactDOM' in window) { + // @ts-ignore: ReactDOM이 전역에 존재할 수 있음 + return window.ReactDOM.unstable_batchedUpdates(updates); + } + + // 폴백: 기본 실행 + return updates(); +} diff --git a/src/utils/sync/syncOptimizer.ts b/src/utils/sync/syncOptimizer.ts new file mode 100644 index 0000000..0605b6e --- /dev/null +++ b/src/utils/sync/syncOptimizer.ts @@ -0,0 +1,99 @@ + +/** + * 동기화 최적화 유틸리티 + */ +import { debounce, throttle, preventDuplicateOperation } from '../performance/debounceThrottle'; +import { trySyncAllData } from '@/utils/syncUtils'; +import { toast } from '@/hooks/useToast.wrapper'; + +// 동기화 상태 플래그 +let isSyncRunning = false; +let lastSyncTime = 0; +const MIN_SYNC_INTERVAL = 30000; // 최소 동기화 간격 (30초) + +/** + * 최적화된 동기화 함수 - 중복 방지, 속도 제한 적용 + */ +export const optimizedSync = preventDuplicateOperation( + 'sync-all-data', + async (userId: string) => { + // 이미 동기화 중이면 중단 + if (isSyncRunning) { + console.log('[최적화] 이미 동기화 작업이 진행 중입니다.'); + return { success: false, reason: 'already-running' }; + } + + // 너무 빈번한 동기화 요청 방지 + const now = Date.now(); + if (now - lastSyncTime < MIN_SYNC_INTERVAL) { + console.log(`[최적화] 동기화 요청이 너무 빈번합니다. ${MIN_SYNC_INTERVAL / 1000}초 후 다시 시도하세요.`); + return { success: false, reason: 'too-frequent' }; + } + + try { + isSyncRunning = true; + console.log('[최적화] 동기화 시작...'); + + // 네트워크 상태 확인 + if (!navigator.onLine) { + console.log('[최적화] 오프라인 상태입니다. 동기화를 건너뜁니다.'); + return { success: false, reason: 'offline' }; + } + + // 실제 동기화 수행 + const result = await trySyncAllData(userId); + + // 마지막 동기화 시간 기록 + lastSyncTime = Date.now(); + + return result; + } catch (error) { + console.error('[최적화] 동기화 오류:', error); + + // 중요 오류만 사용자에게 표시 + toast({ + title: "동기화 오류", + description: "데이터 동기화 중 문제가 발생했습니다. 나중에 다시 시도해주세요.", + variant: "destructive", + }); + + return { success: false, reason: 'error', error }; + } finally { + isSyncRunning = false; + } + } +); + +/** + * 디바운스된 동기화 함수 (빠르게 여러 번 호출해도 마지막 한 번만 실행) + */ +export const debouncedSync = debounce( + async (userId: string) => { + console.log('[최적화] 디바운스된 동기화 실행'); + return optimizedSync(userId); + }, + 2000 // 2초 대기 +); + +/** + * 스로틀된 동기화 함수 (일정 시간 내 한 번만 실행) + */ +export const throttledSync = throttle( + async (userId: string) => { + console.log('[최적화] 스로틀된 동기화 실행'); + return optimizedSync(userId); + }, + 10000 // 10초마다 최대 한 번 +); + +/** + * 자동 동기화 시도 (에러 무시) + */ +export const attemptBackgroundSync = async (userId: string) => { + try { + return await optimizedSync(userId); + } catch (error) { + console.error('[최적화] 백그라운드 동기화 오류 (무시됨):', error); + return { success: false, reason: 'background-error' }; + } +};