diff --git a/src/utils/network/checker.ts b/src/utils/network/checker.ts new file mode 100644 index 0000000..4baf877 --- /dev/null +++ b/src/utils/network/checker.ts @@ -0,0 +1,56 @@ +/** + * 네트워크 연결 확인 유틸리티 + */ +import { setNetworkStatus } from './status'; + +/** + * 현재 네트워크 상태 확인 (개선 버전) + */ +export const checkNetworkStatus = async (): Promise => { + try { + // 더 빠른 타임아웃 설정 (2초) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + + // 여러 엔드포인트로 확인하여 신뢰성 향상 + const endpoints = [ + 'https://www.google.com', + 'https://www.cloudflare.com', + 'https://www.apple.com' + ]; + + 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); + + clearTimeout(timeoutId); + setNetworkStatus(isOnline ? 'online' : 'offline'); + return isOnline; + } catch (error) { + clearTimeout(timeoutId); + console.error('[네트워크] 네트워크 확인 실패:', error); + setNetworkStatus('offline'); + return false; + } + } catch (error) { + console.error('[네트워크] 연결 확인 중 예상치 못한 오류:', error); + setNetworkStatus('offline'); + return false; + } +}; diff --git a/src/utils/network/index.ts b/src/utils/network/index.ts new file mode 100644 index 0000000..706b695 --- /dev/null +++ b/src/utils/network/index.ts @@ -0,0 +1,42 @@ +/** + * 네트워크 유틸리티 모듈 + * + * 네트워크 상태 관리, 오류 처리, 재시도 로직 등을 제공합니다. + */ + +// 타입 내보내기 +export * from './types'; + +// 네트워크 상태 관리 +export { + setNetworkStatus, + getNetworkStatus, + onNetworkStatusChange +} from './status'; + +// 네트워크 연결 확인 +export { + checkNetworkStatus +} from './checker'; + +// 네트워크 모니터링 +export { + startNetworkMonitoring, + stopNetworkMonitoring, + attemptReconnect +} from './monitor'; + +// 동기화 작업 큐 관리 +export { + addToSyncQueue, + getSyncQueue, + removeFromSyncQueue, + updateSyncQueueItem, + processPendingSyncQueue +} from './queue'; + +// 재시도 로직 +export { + withRetry, + withTimeout +} from './retry'; diff --git a/src/utils/network/monitor.ts b/src/utils/network/monitor.ts new file mode 100644 index 0000000..6c6d3e7 --- /dev/null +++ b/src/utils/network/monitor.ts @@ -0,0 +1,181 @@ +/** + * 네트워크 모니터링 유틸리티 + */ +import { NetworkEventHandlers, ExtendedWindow } from './types'; +import { setNetworkStatus, getNetworkStatus } from './status'; +import { checkNetworkStatus } from './checker'; +import { processPendingSyncQueue } from './queue'; + +// 네트워크 모니터링 상태 +let isMonitoring = false; +let networkCheckInterval: ReturnType | null = null; + +// 네트워크 재연결 시도 설정 +const RECONNECT_INTERVAL = 5000; // 5초마다 재연결 시도 +const MAX_RECONNECT_ATTEMPTS = 10; // 최대 재연결 시도 횟수 +let reconnectAttempts = 0; +let reconnectTimer: ReturnType | null = null; + +/** + * 네트워크 재연결 시도 + */ +export const attemptReconnect = async (): Promise => { + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + console.log(`[네트워크] 최대 재연결 시도 횟수(${MAX_RECONNECT_ATTEMPTS}회) 도달`); + return false; + } + + reconnectAttempts++; + console.log(`[네트워크] 재연결 시도 ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + + // 재연결 상태로 변경 + setNetworkStatus('reconnecting'); + + try { + const isOnline = await checkNetworkStatus(); + + if (isOnline) { + console.log('[네트워크] 재연결 성공'); + setNetworkStatus('online'); + reconnectAttempts = 0; + + // 대기 중인 동기화 작업 처리 + processPendingSyncQueue().catch(err => { + console.error('[네트워크] 재연결 후 동기화 큐 처리 중 오류:', err); + }); + + return true; + } else { + console.log('[네트워크] 재연결 실패, 재시도 예정'); + setNetworkStatus('offline'); + + // 다음 재연결 시도 예약 + reconnectTimer = setTimeout(attemptReconnect, RECONNECT_INTERVAL); + return false; + } + } catch (error) { + console.error('[네트워크] 재연결 시도 중 오류:', error); + setNetworkStatus('offline'); + + // 다음 재연결 시도 예약 + reconnectTimer = setTimeout(attemptReconnect, RECONNECT_INTERVAL); + return false; + } +}; + +/** + * 네트워크 상태 모니터링 시작 + */ +export const startNetworkMonitoring = (): void => { + if (isMonitoring) { + return; + } + + isMonitoring = true; + + // 초기 네트워크 상태를 온라인으로 설정 (실제 확인 전) + setNetworkStatus('online'); + + // 초기 네트워크 상태 확인 + checkNetworkStatus().then(isOnline => { + if (!isOnline) { + // 오프라인 상태면 재연결 시도 시작 + reconnectTimer = setTimeout(attemptReconnect, RECONNECT_INTERVAL); + } + }); + + // 브라우저 온라인/오프라인 이벤트 리스너 + const handleOnline = () => { + console.log('[네트워크] 브라우저 온라인 이벤트 감지'); + setNetworkStatus('online'); + reconnectAttempts = 0; + + // 대기 중인 동기화 작업 처리 + processPendingSyncQueue().catch(err => { + console.error('[네트워크] 온라인 전환 후 동기화 큐 처리 중 오류:', err); + }); + }; + + const handleOffline = () => { + console.log('[네트워크] 브라우저 오프라인 이벤트 감지'); + setNetworkStatus('offline'); + + // 재연결 시도 시작 + reconnectTimer = setTimeout(attemptReconnect, RECONNECT_INTERVAL); + }; + + // 이벤트 리스너 등록 및 참조 저장 + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + // 전역 변수에 이벤트 핸들러 참조 저장 + (window as ExtendedWindow).__networkHandlers = { + handleOnline, + handleOffline + }; + + // 정기적인 네트워크 상태 확인 (30초마다) + networkCheckInterval = setInterval(async () => { + try { + const isOnline = await checkNetworkStatus(); + const currentStatus = getNetworkStatus(); + + if (isOnline && currentStatus !== 'online') { + // 오프라인에서 온라인으로 변경 + setNetworkStatus('online'); + reconnectAttempts = 0; + + // 대기 중인 동기화 작업 처리 + processPendingSyncQueue().catch(err => { + console.error('[네트워크] 정기 확인 후 동기화 큐 처리 중 오류:', err); + }); + } else if (!isOnline && currentStatus === 'online') { + // 온라인에서 오프라인으로 변경 + setNetworkStatus('offline'); + + // 재연결 시도 시작 + if (!reconnectTimer) { + reconnectTimer = setTimeout(attemptReconnect, RECONNECT_INTERVAL); + } + } + } catch (error) { + console.error('[네트워크] 정기 네트워크 상태 확인 중 오류:', error); + } + }, 30000); + + console.log('[네트워크] 네트워크 모니터링 시작'); +}; + +/** + * 네트워크 상태 모니터링 중지 + */ +export const stopNetworkMonitoring = (): void => { + if (!isMonitoring) { + return; + } + + isMonitoring = false; + + // 브라우저 이벤트 리스너 제거 + const handlers = (window as ExtendedWindow).__networkHandlers; + if (handlers) { + window.removeEventListener('online', handlers.handleOnline); + window.removeEventListener('offline', handlers.handleOffline); + delete (window as ExtendedWindow).__networkHandlers; + } + + // 인터벌 및 타이머 정리 + if (networkCheckInterval) { + clearInterval(networkCheckInterval); + networkCheckInterval = null; + } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + reconnectAttempts = 0; + + console.log('[네트워크] 네트워크 모니터링 중지'); +}; diff --git a/src/utils/network/queue.ts b/src/utils/network/queue.ts new file mode 100644 index 0000000..e5e3f72 --- /dev/null +++ b/src/utils/network/queue.ts @@ -0,0 +1,138 @@ +/** + * 동기화 작업 큐 관리 유틸리티 + */ +import { SyncQueueItem } from './types'; +import { getNetworkStatus } from './status'; + +// 동기화 작업 큐 관리 +const SYNC_QUEUE_KEY = 'sync_queue'; + +/** + * 동기화 작업을 큐에 추가 + */ +export const addToSyncQueue = (operation: { + type: 'upload' | 'download' | 'delete'; + entityType: 'transaction' | 'budget' | 'categoryBudget'; + data: Record; + timestamp?: number; + retries?: number; +}): void => { + // 타임스탬프 및 재시도 횟수 추가 + const enhancedOperation = { + ...operation, + timestamp: operation.timestamp || Date.now(), + retries: operation.retries || 0 + }; + + // 로컬 스토리지에서 기존 큐 가져오기 + const queueString = localStorage.getItem(SYNC_QUEUE_KEY) || '[]'; + let queue: typeof enhancedOperation[] = []; + + try { + queue = JSON.parse(queueString); + } catch (error) { + console.error('[네트워크] 동기화 큐 파싱 오류:', error); + queue = []; + } + + // 큐에 작업 추가 + queue.push(enhancedOperation); + + // 큐 저장 + localStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(queue)); + + console.log(`[네트워크] 동기화 큐에 작업 추가: ${operation.type} ${operation.entityType}`); +}; + +/** + * 동기화 큐 가져오기 + */ +export const getSyncQueue = (): SyncQueueItem[] => { + try { + const queueString = localStorage.getItem(SYNC_QUEUE_KEY) || '[]'; + return JSON.parse(queueString); + } catch (error) { + console.error('[네트워크] 동기화 큐 조회 실패:', error); + return []; + } +}; + +/** + * 동기화 큐에서 항목 제거 + */ +export const removeFromSyncQueue = (itemId: string): void => { + try { + const queue = getSyncQueue(); + const updatedQueue = queue.filter(item => item.id !== itemId); + localStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(updatedQueue)); + } catch (error) { + console.error('[네트워크] 동기화 큐 항목 제거 실패:', error); + } +}; + +/** + * 동기화 큐 항목 업데이트 + */ +export const updateSyncQueueItem = (itemId: string, updates: Partial): void => { + try { + const queue = getSyncQueue(); + const updatedQueue = queue.map(item => + item.id === itemId ? { ...item, ...updates } : item + ); + localStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(updatedQueue)); + } catch (error) { + console.error('[네트워크] 동기화 큐 항목 업데이트 실패:', error); + } +}; + +/** + * 보류 중인 동기화 작업 처리 + */ +export const processPendingSyncQueue = async (): Promise => { + // 현재 네트워크 상태 확인 + const networkStatus = getNetworkStatus(); + if (networkStatus !== 'online') { + console.log('[네트워크] 오프라인 상태에서 동기화 큐 처리 불가'); + return; + } + + // 큐 가져오기 + const queue = getSyncQueue(); + if (queue.length === 0) { + console.log('[네트워크] 처리할 동기화 작업 없음'); + return; + } + + console.log(`[네트워크] 동기화 큐 처리 시작 (${queue.length}개 항목)`); + + // 작업 처리 + for (const item of queue) { + try { + console.log(`[네트워크] 동기화 작업 처리 중: ${item.type} ${item.entityType}`); + + // TODO: 실제 동기화 작업 처리 로직 구현 + // 예: 서버에 데이터 업로드, 다운로드 등 + + // 성공 시 큐에서 제거 + removeFromSyncQueue(item.id); + console.log(`[네트워크] 동기화 작업 성공: ${item.type} ${item.entityType}`); + } catch (error) { + console.error(`[네트워크] 동기화 작업 실패: ${item.type} ${item.entityType}`, error); + + // 재시도 횟수 증가 + const retryCount = (item.retryCount || 0) + 1; + + if (retryCount <= 3) { + // 재시도 횟수가 3회 이하면 업데이트 + updateSyncQueueItem(item.id, { retryCount }); + console.log(`[네트워크] 동기화 작업 재시도 예정 (${retryCount}/3): ${item.type} ${item.entityType}`); + } else { + // 재시도 횟수 초과 시 제거 + removeFromSyncQueue(item.id); + console.log(`[네트워크] 동기화 작업 최대 재시도 횟수 초과로 제거: ${item.type} ${item.entityType}`); + } + } + } + + console.log('[네트워크] 동기화 큐 처리 완료'); +}; diff --git a/src/utils/network/retry.ts b/src/utils/network/retry.ts new file mode 100644 index 0000000..91b78be --- /dev/null +++ b/src/utils/network/retry.ts @@ -0,0 +1,71 @@ +/** + * 네트워크 요청 재시도 유틸리티 + */ +import { RetryOptions } from './types'; + +// 네트워크 재시도 설정 +const MAX_RETRY_COUNT = 3; +const INITIAL_RETRY_DELAY = 1000; // 1초 + +/** + * 네트워크 요청 래퍼 함수 (재시도 로직 포함) + */ +export const withRetry = async ( + fn: () => Promise, + options: RetryOptions = {} +): Promise => { + const { + maxRetries = MAX_RETRY_COUNT, + retryDelay = INITIAL_RETRY_DELAY, + onRetry = () => {}, + shouldRetry = () => true + } = options; + + let lastError: Error | unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + // 함수 실행 + return await fn(); + } catch (error) { + lastError = error; + + // 마지막 시도였으면 에러 발생 + if (attempt === maxRetries) { + break; + } + + // 재시도 여부 확인 + if (!shouldRetry(error)) { + break; + } + + // 재시도 콜백 호출 + onRetry(attempt + 1, error); + + // 지수 백오프 (exponential backoff) 적용 + const delay = retryDelay * Math.pow(2, attempt); + console.log(`[네트워크] ${attempt + 1}번째 재시도 ${delay}ms 후 실행`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw lastError; +}; + +/** + * 네트워크 요청 타임아웃 래퍼 함수 + */ +export const withTimeout = async ( + promise: Promise, + timeoutMs: number = 10000 +): Promise => { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`요청 시간 초과 (${timeoutMs}ms)`)); + }, timeoutMs); + }) + ]); +}; diff --git a/src/utils/network/status.ts b/src/utils/network/status.ts new file mode 100644 index 0000000..f8a0ab7 --- /dev/null +++ b/src/utils/network/status.ts @@ -0,0 +1,43 @@ +/** + * 네트워크 상태 관리 유틸리티 + */ +import { NetworkStatus } from './types'; + +// 메모리에 네트워크 상태 저장 +let networkStatus: NetworkStatus | null = null; + +/** + * 네트워크 상태 저장 (메모리 기반) + */ +export const setNetworkStatus = (status: NetworkStatus): void => { + // 메모리에 상태 저장 + networkStatus = status; + + // 상태 변경 이벤트 발생 + window.dispatchEvent(new CustomEvent('networkStatusChange', { detail: status })); + console.log(`[네트워크] 상태 변경: ${status}`); +}; + +/** + * 네트워크 상태 가져오기 (메모리 기반) + */ +export const getNetworkStatus = (): NetworkStatus => { + return networkStatus || 'online'; +}; + +/** + * 네트워크 상태 변경 이벤트 리스너 등록 + */ +export const onNetworkStatusChange = (callback: (status: NetworkStatus) => void): () => void => { + const handler = (event: Event) => { + const customEvent = event as CustomEvent; + callback(customEvent.detail); + }; + + window.addEventListener('networkStatusChange', handler); + + // 구독 취소 함수 반환 + return () => { + window.removeEventListener('networkStatusChange', handler); + }; +}; diff --git a/src/utils/network/types.ts b/src/utils/network/types.ts new file mode 100644 index 0000000..11f7da5 --- /dev/null +++ b/src/utils/network/types.ts @@ -0,0 +1,35 @@ +/** + * 네트워크 관련 타입 정의 + */ + +// 네트워크 상태 타입 +export type NetworkStatus = 'online' | 'offline' | 'reconnecting'; + +// 동기화 작업 큐 아이템 타입 +export interface SyncQueueItem { + id: string; + type: 'upload' | 'download' | 'delete'; + entityType: 'transaction' | 'budget' | 'categoryBudget'; + data: Record; + timestamp: number; + retryCount: number; +} + +// 네트워크 이벤트 핸들러 인터페이스 +export interface NetworkEventHandlers { + handleOnline: () => void; + handleOffline: () => void; +} + +// 확장된 Window 인터페이스 +export interface ExtendedWindow extends Window { + __networkHandlers?: NetworkEventHandlers; +} + +// 재시도 옵션 인터페이스 +export interface RetryOptions { + maxRetries?: number; + retryDelay?: number; + onRetry?: (attempt: number, error: Error | unknown) => void; + shouldRetry?: (error: Error | unknown) => boolean; +} diff --git a/src/utils/networkUtils.ts b/src/utils/networkUtils.ts index 5e450fb..6cbe0d5 100644 --- a/src/utils/networkUtils.ts +++ b/src/utils/networkUtils.ts @@ -62,19 +62,37 @@ export const checkNetworkStatus = async (): Promise => { 'https://www.apple.com' ]; - // 첫 번째 성공 응답 시 온라인으로 판단 - const isOnline = await Promise.any( - endpoints.map(url => + try { + // 각 엔드포인트에 대해 fetch 요청 시도 + const fetchPromises = endpoints.map(url => fetch(url, { method: 'HEAD', signal: controller.signal }) - .then(res => res.ok) - ) - ); - - clearTimeout(timeoutId); - setNetworkStatus(isOnline ? 'online' : 'offline'); - return isOnline; + .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); + + clearTimeout(timeoutId); + setNetworkStatus(isOnline ? 'online' : 'offline'); + return isOnline; + } catch (error) { + clearTimeout(timeoutId); + console.error('[네트워크] 네트워크 확인 실패:', error); + setNetworkStatus('offline'); + return false; + } } catch (error) { - console.error('[네트워크] 연결 확인 실패:', error); + console.error('[네트워크] 연결 확인 중 예상치 못한 오류:', error); setNetworkStatus('offline'); return false; } @@ -100,7 +118,7 @@ export const getNetworkStatus = (): NetworkStatus => { }; /** - * 네트워크 상태 모니터링 시작 + * 네트워크 상태 모니터링 시작 함수 개선 */ export const startNetworkMonitoring = (): void => { if (isMonitoring) { @@ -109,10 +127,11 @@ export const startNetworkMonitoring = (): void => { isMonitoring = true; + // 초기 네트워크 상태를 온라인으로 설정 (실제 확인 전) + setNetworkStatus('online'); + // 초기 네트워크 상태 확인 checkNetworkStatus().then(isOnline => { - setNetworkStatus(isOnline ? 'online' : 'offline'); - if (!isOnline) { // 오프라인 상태면 재연결 시도 시작 reconnectTimer = setTimeout(attemptReconnect, RECONNECT_INTERVAL);