diff --git a/src/components/AddTransactionButton.tsx b/src/components/AddTransactionButton.tsx index cb27665..d96b8c8 100644 --- a/src/components/AddTransactionButton.tsx +++ b/src/components/AddTransactionButton.tsx @@ -10,6 +10,7 @@ import ExpenseForm, { ExpenseFormValues } from './expenses/ExpenseForm'; import { Transaction } from '@/contexts/budget/types'; import { normalizeDate } from '@/utils/sync/transaction/dateUtils'; import useNotifications from '@/hooks/useNotifications'; +import { checkNetworkStatus } from '@/utils/network/checker'; const AddTransactionButton = () => { const [showExpenseDialog, setShowExpenseDialog] = useState(false); @@ -54,66 +55,75 @@ const AddTransactionButton = () => { // BudgetContext를 통해 지출 추가 addTransaction(newExpense); - try { - const { data: { user } } = await supabase.auth.getUser(); - - if (isSyncEnabled() && user) { - // ISO 형식으로 날짜 변환 - const isoDate = normalizeDate(formattedDate); - - const { error } = await supabase.from('transactions').insert({ - user_id: user.id, - title: data.title, - amount: parseInt(numericAmount), - date: isoDate, // ISO 형식 사용 - category: data.category, - type: 'expense', - transaction_id: newExpense.id, - payment_method: data.paymentMethod // Supabase에 필드 추가 - }); - - if (error) throw error; - - // 지출 추가 후 자동 동기화 실행 - console.log('지출 추가 후 자동 동기화 시작'); - const syncResult = await trySyncAllData(user.id); - - if (syncResult.success) { - // 동기화 성공 시 마지막 동기화 시간 업데이트 - const currentTime = new Date().toISOString(); - console.log('자동 동기화 성공, 시간 업데이트:', currentTime); - setLastSyncTime(currentTime); - - // 동기화 성공 알림 추가 - addNotification( - '동기화 완료', - '방금 추가하신 지출 데이터가 클라우드에 동기화되었습니다.' - ); - } - } - } catch (error) { - console.error('Supabase에 지출 추가 실패:', error); - // 실패 시 알림 추가 - addNotification( - '동기화 실패', - '지출 데이터 동기화 중 문제가 발생했습니다. 나중에 다시 시도됩니다.' - ); - } - // 다이얼로그를 닫습니다 setShowExpenseDialog(false); - // 이벤트 발생 처리 - 단일 이벤트로 통합 - window.dispatchEvent(new CustomEvent('transactionChanged', { - detail: { type: 'add', transaction: newExpense } - })); - // 토스트는 한 번만 표시 (지연 제거하여 래퍼에서 처리되도록) toast({ title: "지출이 추가되었습니다", description: `${data.title} 항목이 ${formatWithCommas(numericAmount)}원으로 등록되었습니다.`, duration: 3000 }); + + // 네트워크 상태 확인 후 Supabase 동기화 시도 + const isOnline = await checkNetworkStatus(); + + if (isSyncEnabled() && isOnline) { + try { + const { data: { user } } = await supabase.auth.getUser(); + + if (user) { + // ISO 형식으로 날짜 변환 + const isoDate = normalizeDate(formattedDate); + + console.log('Supabase에 지출 추가 시도 중...'); + const { error } = await supabase.from('transactions').insert({ + user_id: user.id, + title: data.title, + amount: parseInt(numericAmount), + date: isoDate, // ISO 형식 사용 + category: data.category, + type: 'expense', + transaction_id: newExpense.id, + payment_method: data.paymentMethod // Supabase에 필드 추가 + }); + + if (error) { + console.error('Supabase 데이터 저장 오류:', error); + throw error; + } + + // 지출 추가 후 자동 동기화 실행 + console.log('지출 추가 후 자동 동기화 시작'); + const syncResult = await trySyncAllData(user.id); + + if (syncResult.success) { + // 동기화 성공 시 마지막 동기화 시간 업데이트 + const currentTime = new Date().toISOString(); + console.log('자동 동기화 성공, 시간 업데이트:', currentTime); + setLastSyncTime(currentTime); + + // 동기화 성공 알림 추가 + addNotification( + '동기화 완료', + '방금 추가하신 지출 데이터가 클라우드에 동기화되었습니다.' + ); + } + } + } catch (error) { + console.error('Supabase에 지출 추가 실패:', error); + // 실패해도 조용히 처리 (나중에 자동으로 재시도될 것임) + console.log('로컬 데이터는 저장됨, 다음 동기화에서 재시도할 예정'); + } + } else if (isSyncEnabled() && !isOnline) { + console.log('네트워크 연결이 없어 로컬에만 저장되었습니다. 다음 동기화에서 업로드될 예정입니다.'); + } + + // 이벤트 발생 처리 - 단일 이벤트로 통합 + window.dispatchEvent(new CustomEvent('transactionChanged', { + detail: { type: 'add', transaction: newExpense } + })); + } catch (error) { console.error('지출 추가 중 오류 발생:', error); toast({ diff --git a/src/hooks/sync/syncResultHandler.ts b/src/hooks/sync/syncResultHandler.ts index 073f1f2..71e70f2 100644 --- a/src/hooks/sync/syncResultHandler.ts +++ b/src/hooks/sync/syncResultHandler.ts @@ -12,9 +12,16 @@ export const setSyncNotificationAdder = (adder: (title: string, message: string) notificationAdder = adder; }; +// 동기화 실패 카운터 추가 +let syncFailureCount = 0; +const MAX_SYNC_FAILURE_NOTIFICATIONS = 2; // 최대 알림 횟수 + // 동기화 결과 처리 함수 export const handleSyncResult = (result: SyncResult) => { if (result.success) { + // 성공 시 실패 카운터 초기화 + syncFailureCount = 0; + let title = ''; let description = ''; @@ -56,19 +63,26 @@ export const handleSyncResult = (result: SyncResult) => { // 동기화 실패 console.error("동기화 실패 세부 결과:", result.details); - const title = "동기화 실패"; - const description = "데이터 동기화 중 문제가 발생했습니다. 다시 시도해주세요."; + // 실패 카운터 증가 및 최대 알림 횟수 제한 + syncFailureCount++; - // 토스트 표시 - toast({ - title, - description, - variant: "destructive" - }); - - // 알림 추가 (설정된 경우) - if (notificationAdder) { - notificationAdder(title, description); + if (syncFailureCount <= MAX_SYNC_FAILURE_NOTIFICATIONS) { + const title = "동기화 실패"; + const description = "데이터 동기화 중 문제가 발생했습니다. 나중에 다시 시도합니다."; + + // 토스트 표시 + toast({ + title, + description, + variant: "destructive" + }); + + // 알림 추가 (설정된 경우) + if (notificationAdder) { + notificationAdder(title, description); + } + } else { + console.log(`동기화 실패 알림 제한 (${syncFailureCount}회 실패)`); } return false; @@ -85,3 +99,8 @@ export const useSyncNotifications = () => { return () => setSyncNotificationAdder(null); }, [addNotification]); }; + +// 동기화 실패 카운터 초기화 함수 (로그인/로그아웃 시 사용) +export const resetSyncFailureCount = () => { + syncFailureCount = 0; +}; diff --git a/src/hooks/sync/useSyncToggle.ts b/src/hooks/sync/useSyncToggle.ts index a6f41a0..77d7ddc 100644 --- a/src/hooks/sync/useSyncToggle.ts +++ b/src/hooks/sync/useSyncToggle.ts @@ -9,6 +9,8 @@ import { setLastSyncTime } from '@/utils/syncUtils'; import useNotifications from '@/hooks/useNotifications'; +import { resetSyncFailureCount } from './syncResultHandler'; +import { checkNetworkStatus } from '@/utils/network/checker'; /** * 동기화 토글 기능을 위한 커스텀 훅 @@ -17,6 +19,7 @@ export const useSyncToggle = () => { const [enabled, setEnabled] = useState(isSyncEnabled()); const { user } = useAuth(); const { addNotification } = useNotifications(); + const [isRetrying, setIsRetrying] = useState(false); // 사용자 로그인 상태 변경 감지 useEffect(() => { @@ -31,6 +34,9 @@ export const useSyncToggle = () => { // 동기화 상태 업데이트 setEnabled(isSyncEnabled()); + + // 로그인/로그아웃 시 실패 카운터 초기화 + resetSyncFailureCount(); }; // 초기 호출 @@ -71,6 +77,22 @@ export const useSyncToggle = () => { return; } + // 네트워크 상태 확인 + const isOnline = await checkNetworkStatus(); + if (checked && !isOnline) { + toast({ + title: "네트워크 연결 필요", + description: "동기화를 위해 인터넷 연결이 필요합니다.", + variant: "destructive" + }); + + addNotification( + "네트워크 연결 필요", + "동기화를 위해 인터넷 연결이 필요합니다." + ); + return; + } + // 현재 로컬 데이터 백업 const budgetDataBackup = localStorage.getItem('budgetData'); const categoryBudgetsBackup = localStorage.getItem('categoryBudgets'); @@ -82,9 +104,13 @@ export const useSyncToggle = () => { transactions: transactionsBackup ? '있음' : '없음' }); + // 동기화 설정 변경 setEnabled(checked); setSyncEnabled(checked); + // 실패 카운터 초기화 + resetSyncFailureCount(); + // 동기화 활성화/비활성화 알림 추가 addNotification( checked ? "동기화 활성화" : "동기화 비활성화", @@ -143,15 +169,37 @@ export const useSyncToggle = () => { return { enabled, setEnabled, handleSyncToggle }; }; -// 실제 동기화 수행 함수 +// 실제 동기화 수행 함수 (최대 2회까지 자동 재시도) const performSync = async (userId: string) => { if (!userId) return; - try { - const result = await trySyncAllData(userId); - return result; - } catch (error) { - console.error('동기화 오류:', error); - throw error; + let attempts = 0; + const maxAttempts = 2; + + while (attempts < maxAttempts) { + try { + attempts++; + console.log(`동기화 시도 ${attempts}/${maxAttempts}`); + + // 네트워크 상태 확인 + const isOnline = await checkNetworkStatus(); + if (!isOnline) { + console.log('네트워크 연결 없음, 동기화 건너뜀'); + throw new Error('네트워크 연결 필요'); + } + + const result = await trySyncAllData(userId); + return result; + } catch (error) { + console.error(`동기화 시도 ${attempts} 실패:`, error); + + if (attempts < maxAttempts) { + // 재시도 전 잠시 대기 + await new Promise(resolve => setTimeout(resolve, 2000)); + console.log(`${attempts+1}번째 동기화 재시도 중...`); + } else { + throw error; + } + } } }; diff --git a/src/utils/network/checker.ts b/src/utils/network/checker.ts index 4baf877..1ed8ea4 100644 --- a/src/utils/network/checker.ts +++ b/src/utils/network/checker.ts @@ -1,3 +1,4 @@ + /** * 네트워크 연결 확인 유틸리티 */ @@ -12,39 +13,30 @@ export const checkNetworkStatus = async (): Promise => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 2000); - // 여러 엔드포인트로 확인하여 신뢰성 향상 - const endpoints = [ - 'https://www.google.com', - 'https://www.cloudflare.com', - 'https://www.apple.com' - ]; + // 실패 방지를 위해 작은 이미지 요청으로 변경 + const timestamp = new Date().getTime(); + const url = `https://www.google.com/favicon.ico?t=${timestamp}`; try { - // 각 엔드포인트에 대해 fetch 요청 시도 - const fetchPromises = endpoints.map(url => - fetch(url, { method: 'HEAD', signal: controller.signal }) - .then(res => { - if (res.ok) return true; - throw new Error(`Failed to fetch ${url}`); - }) - .catch(err => { - console.log(`[네트워크] ${url} 연결 실패:`, err.message); - return false; // 실패 시 false 반환 - }) - ); - - // 모든 요청을 병렬로 실행하고 결과 확인 - const results = await Promise.all(fetchPromises); - - // 하나라도 성공했으면 온라인으로 판단 - const isOnline = results.some(result => result === true); + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal, + cache: 'no-store' // 캐시 무시 + }); clearTimeout(timeoutId); - setNetworkStatus(isOnline ? 'online' : 'offline'); - return isOnline; + + if (response.ok) { + setNetworkStatus('online'); + return true; + } else { + console.log(`[네트워크] 상태 확인 실패: ${response.status}`); + setNetworkStatus('offline'); + return false; + } } catch (error) { clearTimeout(timeoutId); - console.error('[네트워크] 네트워크 확인 실패:', error); + console.error('[네트워크] 네트워크 연결 확인 실패:', error); setNetworkStatus('offline'); return false; }