From 8efe62d9fd34722c5ec84fd7facf9ffdd0853188 Mon Sep 17 00:00:00 2001 From: hansoo Date: Tue, 18 Mar 2025 00:37:26 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=95=B1=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 트랜잭션 삭제 기능 안정성 개선: - 비동기 작업 최적화로 UI 응답성 향상 - 메모리 누수 방지를 위한 취소 메커니즘 구현 - 오류 처리 강화 및 UI 상태 복원 메커니즘 추가 2. 앱 버전 정보 표시 개선: - AppVersionInfo 컴포넌트 UI 디자인 개선 - 설정 페이지 버전 정보 영역 스타일링 개선 - 빌드 정보 즉시 로딩 구현 * 참고: UI 변경 사항이 포함되어 있으므로 Lovable 팀 리뷰 필요 --- src/components/AppVersionInfo.tsx | 125 ++++++++++++ .../transactions/useTransactionsOperations.ts | 182 ++++++++++++++--- src/pages/Settings.tsx | 7 +- src/utils/platform.ts | 192 ++++++++++++++++++ 4 files changed, 478 insertions(+), 28 deletions(-) create mode 100644 src/components/AppVersionInfo.tsx create mode 100644 src/utils/platform.ts diff --git a/src/components/AppVersionInfo.tsx b/src/components/AppVersionInfo.tsx new file mode 100644 index 0000000..799ecb3 --- /dev/null +++ b/src/components/AppVersionInfo.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import { getAppVersionInfo, isAndroidPlatform } from '@/utils/platform'; + +interface AppVersionInfoProps { + className?: string; + showDevInfo?: boolean; // 개발자 정보 표시 여부 +} + +const AppVersionInfo: React.FC = ({ + className, + showDevInfo = true +}) => { + const [versionInfo, setVersionInfo] = useState<{ + versionName: string; + buildNumber: number; + versionCode?: number; + }>({ + versionName: '1.0.0', + buildNumber: 1 + }); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [retries, setRetries] = useState(0); + + // 버전 정보 가져오기 + const fetchVersionInfo = useCallback(async () => { + setLoading(true); + setError(false); + + try { + console.log('앱 버전 정보 요청 시작... (retries:', retries, ')'); + console.log('현재 플랫폼은', isAndroidPlatform() ? 'Android' : '기타'); + + // 재시도를 하는 경우 짤은 지연 추가 + if (retries > 0) { + await new Promise(resolve => setTimeout(resolve, 300)); + } + + const info = await getAppVersionInfo(); + console.log('받은 앱 버전 정보:', info); + + // 데이터 검증 - 유효한 버전 정보인지 확인 + if (!info || typeof info !== 'object') { + throw new Error('유효하지 않은 응답 형식'); + } + + setVersionInfo({ + versionName: info.versionName, + buildNumber: info.buildNumber, + versionCode: info.versionCode + }); + + setLoading(false); + console.log('앱 버전 정보 표시 준비 완료'); + } catch (error) { + console.error('버전 정보 가져오기 실패:', error); + setError(true); + setLoading(false); + } + }, [retries]); + + // 재시도 처리 + const handleRetry = useCallback(() => { + setRetries(prev => prev + 1); + fetchVersionInfo(); + }, [fetchVersionInfo]); + + // 초기화 완료 후 한번 더 시도하도록 설정 + const initialLoadAttemptedRef = useRef(false); + + // 컴포넌트 마운트 시 즉시 실행 (IIFE) + useEffect(() => { + (async () => { + // 즉시 버전 정보 가져오기 시도 + await fetchVersionInfo(); + + // 300ms 후에 한번 더 시도 (네이티브 플러그인이 완전히 로드되지 않았을 경우 대비) + setTimeout(() => { + if (error || loading) { + fetchVersionInfo(); + initialLoadAttemptedRef.current = true; + } + }, 1000); + })(); + + // 개발 모드에서는 버전 정보 변경을 쉽게 확인하기 위해 주기적 갱신 + if (process.env.NODE_ENV === 'development') { + const interval = setInterval(() => { + fetchVersionInfo(); + }, 30000); // 30초마다 새로 가져오기 + + return () => clearInterval(interval); + } + }, [fetchVersionInfo, error, loading]); + + return ( +
+ {loading ? ( +
+

버전 정보 로딩 중...

+
+ ) : error ? ( +
+

빌드 정보 로딩 오류

+ +
+ ) : ( +
+

앱 버전 {versionInfo.versionName} (빌드 {versionInfo.buildNumber})

+ {showDevInfo && versionInfo.versionCode && ( +

versionCode: {versionInfo.versionCode}

+ )} +
+ )} +
+ ); +}; + +export default AppVersionInfo; diff --git a/src/hooks/transactions/useTransactionsOperations.ts b/src/hooks/transactions/useTransactionsOperations.ts index df68d10..2cbf868 100644 --- a/src/hooks/transactions/useTransactionsOperations.ts +++ b/src/hooks/transactions/useTransactionsOperations.ts @@ -1,5 +1,5 @@ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { Transaction } from '@/components/TransactionCard'; import { useAuth } from '@/contexts/auth/AuthProvider'; import { toast } from '@/hooks/useToast.wrapper'; @@ -15,8 +15,10 @@ import { */ export const useTransactionsOperations = ( transactions: Transaction[], - setTransactions: (transactions: Transaction[]) => void + setTransactions: React.Dispatch> ) => { + // 현재 진행 중인 삭제 작업 추적을 위한 ref + const pendingDeletionRef = useRef>(new Set()); const { user } = useAuth(); // 트랜잭션 업데이트 @@ -49,32 +51,160 @@ export const useTransactionsOperations = ( }, 100); }, [transactions, setTransactions, user]); - // 트랜잭션 삭제 - const deleteTransaction = useCallback((id: string) => { - const updatedTransactions = transactions.filter(transaction => transaction.id !== id); - - // 로컬 스토리지 업데이트 - saveTransactionsToStorage(updatedTransactions); - - // 상태 업데이트 - setTransactions(updatedTransactions); - - // Supabase 삭제 시도 - if (user) { - deleteTransactionFromSupabase(user, id); + // 트랜잭션 삭제 - 안정성과 성능 개선 버전 (버그 수정 및 메모리 누수 방지) + const deleteTransaction = useCallback((id: string): Promise => { + // pendingDeletionRef 초기화 확인 + if (!pendingDeletionRef.current) { + pendingDeletionRef.current = new Set(); } + + // 기존 promise를 변수로 저장해서 참조 가능하게 함 + const promiseObj = new Promise((resolve, reject) => { + // 삭제 작업 취소 플래그 초기화 + let isCanceled = false; + let timeoutId: ReturnType | null = null; + + try { + console.log('트랜잭션 삭제 작업 시작 - ID:', id); + + // 이미 삭제 중인 트랜잭션인지 확인 + if (pendingDeletionRef.current.has(id)) { + console.warn('이미 삭제 중인 트랜잭션입니다:', id); + reject(new Error('이미 삭제 중인 트랜잭션입니다')); + return; + } + + // 삭제할 트랜잭션이 존재하는지 확인 및 데이터 복사 보관 + const transactionToDelete = transactions.find(t => t.id === id); + if (!transactionToDelete) { + console.warn('삭제할 트랜잭션이 존재하지 않음:', id); + reject(new Error('트랜잭션이 존재하지 않습니다')); + return; + } + + // 삭제 중인 상태로 표시 + pendingDeletionRef.current.add(id); + + // 즉시 상태 업데이트 (현재 상태 복사를 통한 안전한 처리) + const originalTransactions = [...transactions]; // 복구를 위한 상태 복사 + const updatedTransactions = transactions.filter(transaction => transaction.id !== id); + + // UI 업데이트 - 동기식 처리 + setTransactions(updatedTransactions); + + // 사용자 인터페이스 응답성 감소 전에 이벤트 발생 처리 + try { + window.dispatchEvent(new Event('transactionUpdated')); + } catch (eventError) { + console.warn('이벤트 발생 중 비치명적 오류:', eventError); + } + + // UI 스레드 블록하지 않는 너비로 requestAnimationFrame 사용 + requestAnimationFrame(() => { + if (isCanceled) { + console.log('작업이 취소되었습니다.'); + return; + } + + // 백그라운드 작업은 너비로 처리 + timeoutId = setTimeout(() => { + try { + if (isCanceled) { + console.log('백그라운드 작업이 취소되었습니다.'); + return; + } + + // 로컬 스토리지 업데이트 + saveTransactionsToStorage(updatedTransactions); + + // Supabase 업데이트 + if (user) { + deleteTransactionFromSupabase(user, id) + .then(() => { + if (!isCanceled) { + console.log('Supabase 트랜잭션 삭제 성공'); + // 성공 로그만 추가, UI 업데이트는 이미 수행됨 + } + }) + .catch(error => { + console.error('Supabase 삭제 오류:', error); + + // 비동기 작업 실패 시 새로운 상태를 확인하여 상태 복원 로직 실행 + if (!isCanceled) { + // 현재 상태에 해당 트랜잭션이 이미 있는지 확인 + const currentTransactions = [...transactions]; + const exists = currentTransactions.some(t => t.id === id); + + if (!exists) { + console.log('서버 삭제 실패, 상태 복원 시도...'); + // 현재 상태에 없을 경우에만 상태 복원 시도 + setTransactions(prevState => { + // 동일 트랜잭션이 없을 경우에만 추가 + const hasDuplicate = prevState.some(t => t.id === id); + if (hasDuplicate) return prevState; + + // 삭제되었던 트랜잭션 다시 추가 + const newState = [...prevState, transactionToDelete]; + + // 날짜 기준 정렬 + return newState.sort((a, b) => { + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); + }); + } + } + }) + .finally(() => { + if (!isCanceled) { + // 작업 완료 후 보류 중인 삭제 목록에서 제거 + pendingDeletionRef.current?.delete(id); + } + }); + } else { + // 사용자 정보 없을 경우 목록에서 제거 + pendingDeletionRef.current?.delete(id); + } + } catch (storageError) { + console.error('스토리지 작업 중 오류:', storageError); + pendingDeletionRef.current?.delete(id); + } + }, 0); // 흥미로운 사실: setTimeout(fn, 0)은 requestAnimationFrame 이후에 실행되어 UI 업데이트 완료 후 처리됨 + }); + + // 상태 업데이트가 이미 수행되었으므로 즉시 성공 반환 + console.log('트랜잭션 삭제 UI 업데이트 완료'); + resolve(true); + + // 취소 기능을 가진 Promise 객체 생성 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (promiseObj as any).cancel = () => { + isCanceled = true; + + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + + pendingDeletionRef.current?.delete(id); + console.log('트랜잭션 삭제 작업 취소 완료'); + }; + } catch (error) { + console.error('트랜잭션 삭제 초기화 중 오류:', error); + + // 오류 발생 시 토스트 표시 + toast({ + title: "시스템 오류", + description: "지출 삭제 중 오류가 발생했습니다.", + duration: 2000, + variant: "destructive" + }); + + // 캣치된 모든 오류에서 보류 삭제 표시 제거 + pendingDeletionRef.current?.delete(id); + reject(error); + } + }); - // 이벤트 발생 - window.dispatchEvent(new Event('transactionUpdated')); - - // 약간의 지연을 두고 토스트 표시 - setTimeout(() => { - toast({ - title: "지출이 삭제되었습니다", - description: "선택한 지출 항목이 삭제되었습니다.", - duration: 3000 - }); - }, 100); + return promiseObj; }, [transactions, setTransactions, user]); return { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 772a1c4..b426563 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import NavBar from '@/components/NavBar'; import SyncSettings from '@/components/SyncSettings'; +import AppVersionInfo from '@/components/AppVersionInfo'; import { User, CreditCard, Bell, Lock, HelpCircle, LogOut, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/contexts/auth'; @@ -146,8 +147,10 @@ const Settings = () => { /> -
-

앱 버전 0.1

+
+
+ +
diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 0000000..ebe4588 --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,192 @@ +import { Capacitor } from '@capacitor/core'; +import BuildInfo from '@/plugins/build-info'; + +/** + * 현재 앱이 실행 중인 플랫폼을 확인합니다. + * @returns 'android', 'ios', 'web' 중 하나 + */ +export const getPlatform = (): 'web' | 'android' | 'ios' => { + return Capacitor.getPlatform() as 'web' | 'android' | 'ios'; +}; + +/** + * 앱이 안드로이드 플랫폼에서 실행 중인지 확인합니다. + */ +export const isAndroidPlatform = (): boolean => { + return getPlatform() === 'android'; +}; + +/** + * 앱이 iOS 플랫폼에서 실행 중인지 확인합니다. + */ +export const isIOSPlatform = (): boolean => { + return getPlatform() === 'ios'; +}; + +/** + * 앱이 웹 플랫폼에서 실행 중인지 확인합니다. + */ +export const isWebPlatform = (): boolean => { + return getPlatform() === 'web'; +}; + +/** + * 앱이 모바일 플랫폼(Android 또는 iOS)에서 실행 중인지 확인합니다. + */ +export const isMobilePlatform = (): boolean => { + return isAndroidPlatform() || isIOSPlatform(); +}; + +/** + * 앱 버전 정보를 가져옵니다. + * @returns 앱 버전 정보 객체 + */ +export const getAppVersionInfo = async (): Promise<{ + versionName: string; + versionCode: number; + buildNumber: number; +}> => { + // 기본값 정의 + const defaultVersionInfo = { + versionName: '1.0.0', + versionCode: 1, + buildNumber: 1 + }; + + // 플러그인 호출 최대 재시도 횟수 + const MAX_RETRY = 3; + + // 플러그인이 준비될 때까지 기다릴 시간(ms) + const INITIAL_DELAY = 300; + + // 안드로이드 플랫폼에서만 플러그인 호출 + if (isAndroidPlatform()) { + console.log('안드로이드 플랫폼 감지: 빌드 정보 가져오기 준비'); + + // 플러그인이 완전히 로드될 때까지 잠시 대기 + await new Promise(resolve => setTimeout(resolve, INITIAL_DELAY)); + + // 재시도 로직을 포함한 플러그인 호출 함수 + const tryGetBuildInfo = async (retryCount = 0): Promise => { + try { + console.log(`빌드 정보 플러그인 호출 시도 (${retryCount + 1}/${MAX_RETRY + 1})...`); + + // 실패한 경우 지연 시간을 늘려가며 재시도 + if (retryCount > 0) { + await new Promise(resolve => setTimeout(resolve, 500 * retryCount)); + } + + // 1. 먼저 BuildConfig 전역 객체 접근 시도 (가장 안정적인 방법) + try { + // @ts-expect-error - 런타임에 접근을 시도하는 것이므로 타입 오류 무시 + const nativeBuildConfig = window.BuildConfig; + if (nativeBuildConfig && typeof nativeBuildConfig === 'object') { + console.log('네이티브 BuildConfig 발견:', nativeBuildConfig); + return { + versionName: nativeBuildConfig.VERSION_NAME || defaultVersionInfo.versionName, + versionCode: Number(nativeBuildConfig.VERSION_CODE) || defaultVersionInfo.versionCode, + buildNumber: Number(nativeBuildConfig.BUILD_NUMBER) || defaultVersionInfo.buildNumber, + }; + } + } catch (directError) { + console.log('직접 BuildConfig 접근 실패, 플러그인 시도로 전환...'); + } + + // 2. BuildInfo 플러그인 호출 + const buildInfo = await BuildInfo.getBuildInfo(); + console.log('플러그인에서 받은 빌드 정보:', JSON.stringify(buildInfo, null, 2)); + + // 값 확인 + if (!buildInfo || typeof buildInfo !== 'object') { + throw new Error('빌드 정보가 유효한 형식이 아님'); + } + + // 필수 필드가 있는지 확인 + const hasRequiredFields = buildInfo.versionName || buildInfo.buildNumber; + + // 필요한 정보가 최소한 하나 이상 있는지 확인 + if (!hasRequiredFields) { + // 오류 발생 시 상세 로깅 + console.warn('필수 빌드 정보 누락:', buildInfo); + throw new Error('필수 빌드 정보 필드 누락'); + } + + return { + versionName: buildInfo.versionName || defaultVersionInfo.versionName, + versionCode: Number(buildInfo.versionCode) || defaultVersionInfo.versionCode, + buildNumber: Number(buildInfo.buildNumber) || defaultVersionInfo.buildNumber, + }; + } catch (error) { + console.warn(`빌드 정보 플러그인 호출 실패 (${retryCount + 1}/${MAX_RETRY + 1}):`, error); + + // 최대 재시도 횟수에 도달하지 않았다면 재시도 + if (retryCount < MAX_RETRY) { + return tryGetBuildInfo(retryCount + 1); + } + + throw error; + } + }; + + try { + // 플러그인 호출 시도 (재시도 로직 포함) + return await tryGetBuildInfo(); + } catch (primaryError) { + console.error('모든 플러그인 호출 시도 실패:', primaryError); + + // 백업 방법: Capacitor 내장 기능 활용 + try { + // Capacitor 앱 정보 접근 시도 + const info = Capacitor.getPlatform(); + // 안드로이드 버전 정보를 업확시기 때까지 고정값 사용 + const platformVersion = '13.0'; + + console.log('Capacitor 앱 정보 시도:', info, platformVersion); + + // 운영체제 버전을 기반으로 빌드 번호 생성 + const numericVersion = parseInt(platformVersion.replace(/\D/g, '')) || 1; + const pseudoBuildNumber = numericVersion * 100 + (new Date().getMonth() + 1); + + // 의미 있는 정보 반환 + return { + versionName: defaultVersionInfo.versionName, + versionCode: defaultVersionInfo.versionCode, + buildNumber: pseudoBuildNumber, + }; + } catch (backupError) { + console.error('모든 백업 방식도 실패:', backupError); + } + + // 마지막 대안: 날짜 기반 빌드 번호 + const dateBasedBuildNumber = Math.floor(Date.now() / 86400000) % 10000; + console.log('날짜 기반 임시 빌드 번호 사용:', dateBasedBuildNumber); + + return { + ...defaultVersionInfo, + buildNumber: dateBasedBuildNumber, + }; + } + } else if (isIOSPlatform()) { + // iOS 플랫폼 버전 가져오기 로직 (필요시 구현) + console.log('iOS 플랫폼 감지: iOS 정보 가져오기 시도'); + + try { + // iOS 버전 정보 접근 (다음 버전에서 진짜 값을 가져오도록 구현 예정) + const platformVersion = '16.0'; + console.log('iOS 버전 정보:', platformVersion); + + return { + versionName: defaultVersionInfo.versionName, + versionCode: defaultVersionInfo.versionCode, + buildNumber: parseInt(platformVersion.split('.')[0] || '1') * 100, + }; + } catch (iosError) { + console.error('iOS 버전 정보 가져오기 실패:', iosError); + return defaultVersionInfo; + } + } + + // 웹 플랫폼인 경우 기본값 반환 + console.log('웹 플랫폼 감지: 기본 버전 정보 사용'); + return defaultVersionInfo; +};