트랜잭션 삭제 안정성 개선 및 앱 버전 정보 UI 개선

1. 트랜잭션 삭제 기능 안정성 개선:
   - 비동기 작업 최적화로 UI 응답성 향상
   - 메모리 누수 방지를 위한 취소 메커니즘 구현
   - 오류 처리 강화 및 UI 상태 복원 메커니즘 추가

2. 앱 버전 정보 표시 개선:
   - AppVersionInfo 컴포넌트 UI 디자인 개선
   - 설정 페이지 버전 정보 영역 스타일링 개선
   - 빌드 정보 즉시 로딩 구현

* 참고: UI 변경 사항이 포함되어 있으므로 Lovable 팀 리뷰 필요
This commit is contained in:
hansoo
2025-03-18 00:37:26 +09:00
parent 5d1d773c15
commit 8efe62d9fd
4 changed files with 478 additions and 28 deletions

View File

@@ -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<AppVersionInfoProps> = ({
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 (
<div className={className}>
{loading ? (
<div className="py-1 text-center">
<p className="text-sm text-gray-400 animate-pulse"> ...</p>
</div>
) : error ? (
<div className="py-1 text-center">
<p className="text-sm text-red-500"> </p>
<button
onClick={handleRetry}
className="text-xs text-blue-500 underline mt-1 px-2 py-0.5 rounded hover:bg-blue-50"
>
</button>
</div>
) : (
<div className="py-1 text-center">
<p className="text-sm"> {versionInfo.versionName} <span className="font-mono">( {versionInfo.buildNumber})</span></p>
{showDevInfo && versionInfo.versionCode && (
<p className="text-xs text-gray-400 mt-1 font-mono">versionCode: {versionInfo.versionCode}</p>
)}
</div>
)}
</div>
);
};
export default AppVersionInfo;

View File

@@ -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<React.SetStateAction<Transaction[]>>
) => {
// 현재 진행 중인 삭제 작업 추적을 위한 ref
const pendingDeletionRef = useRef<Set<string>>(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<boolean> => {
// pendingDeletionRef 초기화 확인
if (!pendingDeletionRef.current) {
pendingDeletionRef.current = new Set<string>();
}
// 기존 promise를 변수로 저장해서 참조 가능하게 함
const promiseObj = new Promise<boolean>((resolve, reject) => {
// 삭제 작업 취소 플래그 초기화
let isCanceled = false;
let timeoutId: ReturnType<typeof setTimeout> | 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 {

View File

@@ -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 = () => {
/>
</div>
<div className="mt-12 text-center text-xs text-gray-400">
<p> 0.1</p>
<div className="mt-10 border-t border-gray-200 pt-4">
<div className="neuro-flat p-3 rounded-lg">
<AppVersionInfo showDevInfo={true} />
</div>
</div>
</div>

192
src/utils/platform.ts Normal file
View File

@@ -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<typeof defaultVersionInfo> => {
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;
};