feat: Add CI/CD pipeline and code quality improvements

- Add GitHub Actions workflow for automated CI/CD
- Configure Node.js 18.x and 20.x matrix testing
- Add TypeScript type checking step
- Add ESLint code quality checks with enhanced rules
- Add Prettier formatting verification
- Add production build validation
- Upload build artifacts for deployment
- Set up automated testing on push/PR
- Replace console.log with environment-aware logger
- Add pre-commit hooks for code quality
- Exclude archive folder from linting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hansoo
2025-07-12 15:27:54 +09:00
parent 6a208d6b06
commit 9851627ff1
411 changed files with 14458 additions and 8680 deletions

View File

@@ -1,13 +1,23 @@
import { format, parse, addMonths, subMonths } from 'date-fns';
import { ko } from 'date-fns/locale';
import { format, parse, addMonths, subMonths } from "date-fns";
import { logger } from "@/utils/logger";
import { ko } from "date-fns/locale";
/**
* 월 이름 배열 (한국어)
*/
export const MONTHS_KR = [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월",
];
/**
@@ -22,7 +32,7 @@ export const isValidMonth = (month: string): boolean => {
* 현재 년월 가져오기
*/
export const getCurrentMonth = (): string => {
return format(new Date(), 'yyyy-MM');
return format(new Date(), "yyyy-MM");
};
/**
@@ -31,19 +41,19 @@ export const getCurrentMonth = (): string => {
export const getPrevMonth = (month: string): string => {
// 입력값 검증
if (!isValidMonth(month)) {
console.warn('유효하지 않은 월 형식:', month);
logger.warn("유효하지 않은 월 형식:", month);
return getCurrentMonth();
}
try {
// 월 문자열을 날짜로 파싱
const date = parse(month, 'yyyy-MM', new Date());
const date = parse(month, "yyyy-MM", new Date());
// 한 달 이전
const prevMonth = subMonths(date, 1);
// yyyy-MM 형식으로 반환
return format(prevMonth, 'yyyy-MM');
return format(prevMonth, "yyyy-MM");
} catch (error) {
console.error('이전 월 계산 중 오류:', error);
logger.error("이전 월 계산 중 오류:", error);
return getCurrentMonth();
}
};
@@ -54,19 +64,19 @@ export const getPrevMonth = (month: string): string => {
export const getNextMonth = (month: string): string => {
// 입력값 검증
if (!isValidMonth(month)) {
console.warn('유효하지 않은 월 형식:', month);
logger.warn("유효하지 않은 월 형식:", month);
return getCurrentMonth();
}
try {
// 월 문자열을 날짜로 파싱
const date = parse(month, 'yyyy-MM', new Date());
const date = parse(month, "yyyy-MM", new Date());
// 한 달 이후
const nextMonth = addMonths(date, 1);
// yyyy-MM 형식으로 반환
return format(nextMonth, 'yyyy-MM');
return format(nextMonth, "yyyy-MM");
} catch (error) {
console.error('다음 월 계산 중 오류:', error);
logger.error("다음 월 계산 중 오류:", error);
return getCurrentMonth();
}
};
@@ -78,16 +88,16 @@ export const formatMonthForDisplay = (month: string): string => {
try {
// 입력값 검증
if (!isValidMonth(month)) {
console.warn('유효하지 않은 월 형식:', month);
return format(new Date(), 'yyyy년 MM월', { locale: ko });
logger.warn("유효하지 않은 월 형식:", month);
return format(new Date(), "yyyy년 MM월", { locale: ko });
}
// 월 문자열을 날짜로 파싱
const date = parse(month, 'yyyy-MM', new Date());
const date = parse(month, "yyyy-MM", new Date());
// yyyy년 MM월 형식으로 반환 (한국어 로케일)
return format(date, 'yyyy년 MM월', { locale: ko });
return format(date, "yyyy년 MM월", { locale: ko });
} catch (error) {
console.error('월 형식 변환 중 오류:', error);
logger.error("월 형식 변환 중 오류:", error);
return month;
}
};

View File

@@ -1,11 +1,11 @@
import { useCallback } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { useAuth } from '@/contexts/auth/useAuth';
import { toast } from '@/hooks/useToast.wrapper';
import { saveTransactionsToStorage } from './storageUtils';
import { deleteTransactionFromSupabase } from './supabaseUtils';
import { addToDeletedTransactions } from '@/utils/sync/transaction/deletedTransactionsTracker';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/components/TransactionCard";
import { useAuth } from "@/contexts/auth/useAuth";
import { toast } from "@/hooks/useToast.wrapper";
import { saveTransactionsToStorage } from "./storageUtils";
import { deleteTransactionFromSupabase } from "./supabaseUtils";
import { addToDeletedTransactions } from "@/utils/sync/transaction/deletedTransactionsTracker";
/**
* 트랜잭션 삭제 기능을 위한 훅
@@ -19,72 +19,87 @@ export const useDeleteTransaction = (
/**
* 트랜잭션 삭제 처리
*/
const deleteTransaction = useCallback(async (transactionId: string): Promise<boolean> => {
try {
console.log(`[트랜잭션 삭제] 시작: ID=${transactionId}`);
// 트랜잭션 존재 확인
const transaction = transactions.find(t => t.id === transactionId);
if (!transaction) {
console.warn(`[트랜잭션 삭제] 트랜잭션을 찾을 수 없음: ${transactionId}`);
const deleteTransaction = useCallback(
async (transactionId: string): Promise<boolean> => {
try {
logger.info(`[트랜잭션 삭제] 시작: ID=${transactionId}`);
// 트랜잭션 존재 확인
const transaction = transactions.find((t) => t.id === transactionId);
if (!transaction) {
logger.warn(
`[트랜잭션 삭제] 트랜잭션을 찾을 수 없음: ${transactionId}`
);
return false;
}
logger.info(
`[트랜잭션 삭제] 삭제 대상: "${transaction.title}", 금액: ${transaction.amount}`
);
// 트랜잭션 목록에서 제거
const updatedTransactions = transactions.filter(
(t) => t.id !== transactionId
);
// 로컬 스토리지 업데이트
saveTransactionsToStorage(updatedTransactions);
logger.info(`[트랜잭션 삭제] 로컬 저장소 업데이트 완료`);
// 상태 업데이트
setTransactions(updatedTransactions);
// 클라우드 동기화 (Supabase)
if (user) {
try {
logger.info(`[트랜잭션 삭제] Supabase 삭제 시작: ${transactionId}`);
await deleteTransactionFromSupabase(user, transactionId);
logger.info(`[트랜잭션 삭제] Supabase 삭제 성공: ${transactionId}`);
} catch (syncError) {
logger.error(`[트랜잭션 삭제] Supabase 삭제 실패:`, syncError);
// 삭제 실패하더라도 로컬에서는 삭제됨, 추적 목록에 추가
addToDeletedTransactions(transactionId);
logger.info(
`[트랜잭션 삭제] 삭제 트랜잭션 추적 목록에 추가: ${transactionId}`
);
}
} else {
// 오프라인 상태이거나 로그인되지 않은 경우 추적 목록에 추가
addToDeletedTransactions(transactionId);
logger.info(
`[트랜잭션 삭제] 오프라인 삭제: 추적 목록에 추가 ${transactionId}`
);
}
// 이벤트 발생
window.dispatchEvent(new Event("transactionUpdated"));
window.dispatchEvent(
new CustomEvent("transactionDeleted", {
detail: { id: transactionId },
})
);
// 토스트 메시지 표시
toast({
title: "지출이 삭제되었습니다",
description: `${transaction.title} 항목이 삭제되었습니다.`,
duration: 3000,
});
logger.info(`[트랜잭션 삭제] 완료: ${transactionId}`);
return true;
} catch (error) {
logger.error(`[트랜잭션 삭제] 오류 발생:`, error);
toast({
title: "삭제 실패",
description: "지출 항목 삭제 중 오류가 발생했습니다.",
variant: "destructive",
});
return false;
}
console.log(`[트랜잭션 삭제] 삭제 대상: "${transaction.title}", 금액: ${transaction.amount}`);
// 트랜잭션 목록에서 제거
const updatedTransactions = transactions.filter(t => t.id !== transactionId);
// 로컬 스토리지 업데이트
saveTransactionsToStorage(updatedTransactions);
console.log(`[트랜잭션 삭제] 로컬 저장소 업데이트 완료`);
// 상태 업데이트
setTransactions(updatedTransactions);
// 클라우드 동기화 (Supabase)
if (user) {
try {
console.log(`[트랜잭션 삭제] Supabase 삭제 시작: ${transactionId}`);
await deleteTransactionFromSupabase(user, transactionId);
console.log(`[트랜잭션 삭제] Supabase 삭제 성공: ${transactionId}`);
} catch (syncError) {
console.error(`[트랜잭션 삭제] Supabase 삭제 실패:`, syncError);
// 삭제 실패하더라도 로컬에서는 삭제됨, 추적 목록에 추가
addToDeletedTransactions(transactionId);
console.log(`[트랜잭션 삭제] 삭제 트랜잭션 추적 목록에 추가: ${transactionId}`);
}
} else {
// 오프라인 상태이거나 로그인되지 않은 경우 추적 목록에 추가
addToDeletedTransactions(transactionId);
console.log(`[트랜잭션 삭제] 오프라인 삭제: 추적 목록에 추가 ${transactionId}`);
}
// 이벤트 발생
window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new CustomEvent('transactionDeleted', {
detail: { id: transactionId }
}));
// 토스트 메시지 표시
toast({
title: "지출이 삭제되었습니다",
description: `${transaction.title} 항목이 삭제되었습니다.`,
duration: 3000
});
console.log(`[트랜잭션 삭제] 완료: ${transactionId}`);
return true;
} catch (error) {
console.error(`[트랜잭션 삭제] 오류 발생:`, error);
toast({
title: "삭제 실패",
description: "지출 항목 삭제 중 오류가 발생했습니다.",
variant: "destructive"
});
return false;
}
}, [transactions, setTransactions, user]);
},
[transactions, setTransactions, user]
);
return { deleteTransaction };
};

View File

@@ -1,9 +1,13 @@
import { useCallback, useEffect } from 'react';
import { Transaction } from '@/contexts/budget/types';
import { getCurrentMonth, getPrevMonth, getNextMonth } from '../dateUtils';
import { filterTransactionsByMonth, filterTransactionsByQuery, calculateTotalExpenses } from '../filterUtils';
import { parseTransactionDate } from '@/utils/dateParser';
import { useCallback, useEffect } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/contexts/budget/types";
import { getCurrentMonth, getPrevMonth, getNextMonth } from "../dateUtils";
import {
filterTransactionsByMonth,
filterTransactionsByQuery,
calculateTotalExpenses,
} from "../filterUtils";
import { parseTransactionDate } from "@/utils/dateParser";
interface UseTransactionsFilteringProps {
transactions: Transaction[];
@@ -22,27 +26,32 @@ export const useTransactionsFiltering = ({
selectedMonth,
setSelectedMonth,
searchQuery,
setFilteredTransactions
setFilteredTransactions,
}: UseTransactionsFilteringProps) => {
// 필터링 적용
useEffect(() => {
console.log('트랜잭션 필터링 적용:', { 선택된월: selectedMonth, 검색어: searchQuery });
logger.info("트랜잭션 필터링 적용:", {
선택된월: selectedMonth,
검색어: searchQuery,
});
try {
// 먼저 월별 필터링 - 개선된 날짜 처리 기능 사용
const monthFiltered = filterTransactionsByMonth(transactions, selectedMonth);
console.log('월별 필터링 결과:', monthFiltered.length);
const monthFiltered = filterTransactionsByMonth(
transactions,
selectedMonth
);
logger.info("월별 필터링 결과:", monthFiltered.length);
// 그 다음 검색어 필터링
const searchFiltered = searchQuery
const searchFiltered = searchQuery
? filterTransactionsByQuery(monthFiltered, searchQuery)
: monthFiltered;
console.log('최종 필터링 결과:', searchFiltered.length);
logger.info("최종 필터링 결과:", searchFiltered.length);
setFilteredTransactions(searchFiltered);
} catch (error) {
console.error('트랜잭션 필터링 중 오류 발생:', error);
logger.error("트랜잭션 필터링 중 오류 발생:", error);
// 오류 발생 시 원본 데이터 유지
setFilteredTransactions(transactions);
}
@@ -59,16 +68,19 @@ export const useTransactionsFiltering = ({
}, [selectedMonth, setSelectedMonth]);
// 총 지출 계산 - 개선된 계산 로직 사용
const getTotalExpenses = useCallback((filteredTransactions: Transaction[]): number => {
console.log('총 지출 계산 중...', filteredTransactions.length);
const total = calculateTotalExpenses(filteredTransactions);
console.log('계산된 총 지출:', total);
return total;
}, []);
const getTotalExpenses = useCallback(
(filteredTransactions: Transaction[]): number => {
logger.info("총 지출 계산 중...", filteredTransactions.length);
const total = calculateTotalExpenses(filteredTransactions);
logger.info("계산된 총 지출:", total);
return total;
},
[]
);
return {
handlePrevMonth,
handleNextMonth,
getTotalExpenses
getTotalExpenses,
};
};

View File

@@ -1,5 +1,4 @@
import { Transaction } from '@/components/TransactionCard';
import { Transaction } from "@/components/TransactionCard";
export interface FilteringProps {
transactions: Transaction[];

View File

@@ -1,55 +1,65 @@
import { useCallback, useEffect } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { FilteringProps } from './types';
import { MONTHS_KR } from '../dateUtils';
import { useCallback, useEffect } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/components/TransactionCard";
import { FilteringProps } from "./types";
import { MONTHS_KR } from "../dateUtils";
/**
* 거래 필터링 로직
* 선택된 월과 검색어를 기준으로 거래를 필터링합니다.
*/
export const useFilterApplication = ({
transactions,
selectedMonth,
searchQuery,
setFilteredTransactions
}: Pick<FilteringProps, 'transactions' | 'selectedMonth' | 'searchQuery' | 'setFilteredTransactions'>) => {
export const useFilterApplication = ({
transactions,
selectedMonth,
searchQuery,
setFilteredTransactions,
}: Pick<
FilteringProps,
"transactions" | "selectedMonth" | "searchQuery" | "setFilteredTransactions"
>) => {
// 거래 필터링 함수
const filterTransactions = useCallback(() => {
try {
console.log('필터링 시작, 전체 트랜잭션:', transactions.length);
console.log('선택된 월:', selectedMonth);
logger.info("필터링 시작, 전체 트랜잭션:", transactions.length);
logger.info("선택된 월:", selectedMonth);
// 선택된 월 정보 파싱
const selectedMonthName = selectedMonth;
const monthNumber = MONTHS_KR.findIndex(month => month === selectedMonthName) + 1;
const monthNumber =
MONTHS_KR.findIndex((month) => month === selectedMonthName) + 1;
// 월별 필터링
let filtered = transactions.filter(transaction => {
if (!transaction.date) return false;
console.log(`트랜잭션 날짜 확인: "${transaction.date}", 타입: ${typeof transaction.date}`);
let filtered = transactions.filter((transaction) => {
if (!transaction.date) {
return false;
}
logger.info(
`트랜잭션 날짜 확인: "${transaction.date}", 타입: ${typeof transaction.date}`
);
// 다양한 날짜 형식 처리
if (transaction.date.includes(selectedMonthName)) {
return true; // 선택된 월 이름이 포함된 경우
}
if (transaction.date.includes('오늘')) {
if (transaction.date.includes("오늘")) {
// 오늘 날짜가 해당 월인지 확인
const today = new Date();
const currentMonth = today.getMonth() + 1; // 0부터 시작하므로 +1
return currentMonth === monthNumber;
}
// 다른 형식의 날짜도 시도
try {
// ISO 형식이 아닌 경우 처리
if (transaction.date.includes('년') || transaction.date.includes('월')) {
if (
transaction.date.includes("년") ||
transaction.date.includes("월")
) {
return transaction.date.includes(selectedMonthName);
}
// 표준 날짜 문자열 처리 시도
const date = new Date(transaction.date);
if (!isNaN(date.getTime())) {
@@ -57,31 +67,32 @@ export const useFilterApplication = ({
return transactionMonth === monthNumber;
}
} catch (e) {
console.error('날짜 파싱 오류:', e);
logger.error("날짜 파싱 오류:", e);
}
// 기본적으로 모든 트랜잭션 포함
return true;
});
console.log(`월별 필터링 결과: ${filtered.length} 트랜잭션`);
logger.info(`월별 필터링 결과: ${filtered.length} 트랜잭션`);
// 검색어에 따른 필터링
if (searchQuery.trim()) {
const searchLower = searchQuery.toLowerCase();
filtered = filtered.filter(transaction =>
transaction.title.toLowerCase().includes(searchLower) ||
transaction.category.toLowerCase().includes(searchLower) ||
transaction.amount.toString().includes(searchQuery)
filtered = filtered.filter(
(transaction) =>
transaction.title.toLowerCase().includes(searchLower) ||
transaction.category.toLowerCase().includes(searchLower) ||
transaction.amount.toString().includes(searchQuery)
);
console.log(`검색어 필터링 결과: ${filtered.length} 트랜잭션`);
logger.info(`검색어 필터링 결과: ${filtered.length} 트랜잭션`);
}
// 결과 설정
setFilteredTransactions(filtered);
console.log('최종 필터링 결과:', filtered);
logger.info("최종 필터링 결과:", filtered);
} catch (error) {
console.error('거래 필터링 중 오류:', error);
logger.error("거래 필터링 중 오류:", error);
setFilteredTransactions([]);
}
}, [transactions, selectedMonth, searchQuery, setFilteredTransactions]);
@@ -92,6 +103,6 @@ export const useFilterApplication = ({
}, [transactions, selectedMonth, searchQuery, filterTransactions]);
return {
filterTransactions
filterTransactions,
};
};

View File

@@ -1,34 +1,34 @@
import { useCallback } from 'react';
import { getPrevMonth, getNextMonth } from '../dateUtils';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { getPrevMonth, getNextMonth } from "../dateUtils";
/**
* 월 선택 관련 훅
* 이전/다음 월 이동 기능을 제공합니다.
*/
export const useMonthSelection = ({
selectedMonth,
setSelectedMonth
}: {
export const useMonthSelection = ({
selectedMonth,
setSelectedMonth,
}: {
selectedMonth: string;
setSelectedMonth: (month: string) => void;
}) => {
// 이전 월로 이동
const handlePrevMonth = useCallback(() => {
const prevMonth = getPrevMonth(selectedMonth);
console.log(`월 변경: ${selectedMonth} -> ${prevMonth}`);
logger.info(`월 변경: ${selectedMonth} -> ${prevMonth}`);
setSelectedMonth(prevMonth);
}, [selectedMonth, setSelectedMonth]);
// 다음 월로 이동
const handleNextMonth = useCallback(() => {
const nextMonth = getNextMonth(selectedMonth);
console.log(`월 변경: ${selectedMonth} -> ${nextMonth}`);
logger.info(`월 변경: ${selectedMonth} -> ${nextMonth}`);
setSelectedMonth(nextMonth);
}, [selectedMonth, setSelectedMonth]);
return {
handlePrevMonth,
handleNextMonth
handleNextMonth,
};
};

View File

@@ -1,6 +1,5 @@
import { Transaction } from '@/components/TransactionCard';
import { calculateTotalExpenses } from '../filterUtils';
import { Transaction } from "@/components/TransactionCard";
import { calculateTotalExpenses } from "../filterUtils";
/**
* 총 지출 계산 관련 훅
@@ -13,6 +12,6 @@ export const useTotalCalculation = () => {
};
return {
getTotalExpenses
getTotalExpenses,
};
};

View File

@@ -1,45 +1,54 @@
import { Transaction } from '@/contexts/budget/types';
import { parseTransactionDate } from '@/utils/dateParser';
import { format } from 'date-fns';
import { Transaction } from "@/contexts/budget/types";
import { logger } from "@/utils/logger";
import { parseTransactionDate } from "@/utils/dateParser";
import { format } from "date-fns";
/**
* 트랜잭션을 월별로 필터링
*/
export const filterTransactionsByMonth = (transactions: Transaction[], selectedMonth: string): Transaction[] => {
export const filterTransactionsByMonth = (
transactions: Transaction[],
selectedMonth: string
): Transaction[] => {
if (!transactions || transactions.length === 0) {
return [];
}
console.log(`월별 필터링 시작: ${selectedMonth}, 트랜잭션 수: ${transactions.length}`);
logger.info(
`월별 필터링 시작: ${selectedMonth}, 트랜잭션 수: ${transactions.length}`
);
try {
const [year, month] = selectedMonth.split('-').map(Number);
const filtered = transactions.filter(transaction => {
const [year, month] = selectedMonth.split("-").map(Number);
const filtered = transactions.filter((transaction) => {
const date = parseTransactionDate(transaction.date);
if (!date) {
console.warn(`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`);
logger.warn(
`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`
);
return false;
}
const transactionYear = date.getFullYear();
const transactionMonth = date.getMonth() + 1; // JavaScript 월은 0부터 시작하므로 +1
const match = transactionYear === year && transactionMonth === month;
if (match) {
console.log(`트랜잭션 매칭: ${transaction.id}, 제목: ${transaction.title}, 날짜: ${transaction.date}`);
logger.info(
`트랜잭션 매칭: ${transaction.id}, 제목: ${transaction.title}, 날짜: ${transaction.date}`
);
}
return match;
});
console.log(`월별 필터링 결과: ${filtered.length}개 트랜잭션`);
logger.info(`월별 필터링 결과: ${filtered.length}개 트랜잭션`);
return filtered;
} catch (error) {
console.error('월별 필터링 중 오류:', error);
logger.error("월별 필터링 중 오류:", error);
return [];
}
};
@@ -47,18 +56,25 @@ export const filterTransactionsByMonth = (transactions: Transaction[], selectedM
/**
* 트랜잭션을 검색어로 필터링
*/
export const filterTransactionsByQuery = (transactions: Transaction[], searchQuery: string): Transaction[] => {
if (!searchQuery || searchQuery.trim() === '') {
export const filterTransactionsByQuery = (
transactions: Transaction[],
searchQuery: string
): Transaction[] => {
if (!searchQuery || searchQuery.trim() === "") {
return transactions;
}
const normalizedQuery = searchQuery.toLowerCase().trim();
return transactions.filter(transaction => {
const titleMatch = transaction.title.toLowerCase().includes(normalizedQuery);
const categoryMatch = transaction.category.toLowerCase().includes(normalizedQuery);
return transactions.filter((transaction) => {
const titleMatch = transaction.title
.toLowerCase()
.includes(normalizedQuery);
const categoryMatch = transaction.category
.toLowerCase()
.includes(normalizedQuery);
const amountMatch = transaction.amount.toString().includes(normalizedQuery);
return titleMatch || categoryMatch || amountMatch;
});
};
@@ -68,49 +84,55 @@ export const filterTransactionsByQuery = (transactions: Transaction[], searchQue
*/
export const calculateTotalExpenses = (transactions: Transaction[]): number => {
if (!transactions || transactions.length === 0) {
console.log('계산할 트랜잭션이 없습니다.');
logger.info("계산할 트랜잭션이 없습니다.");
return 0;
}
console.log(`총 지출 계산 시작: 트랜잭션 ${transactions.length}`);
logger.info(`총 지출 계산 시작: 트랜잭션 ${transactions.length}`);
// 지출 타입만 필터링하고 합산
const expenses = transactions
.filter(t => t.type === 'expense')
.filter((t) => t.type === "expense")
.reduce((sum, transaction) => {
const amount = Number(transaction.amount);
if (isNaN(amount)) {
console.warn(`유효하지 않은 금액: ${transaction.amount}, 트랜잭션 ID: ${transaction.id}`);
logger.warn(
`유효하지 않은 금액: ${transaction.amount}, 트랜잭션 ID: ${transaction.id}`
);
return sum;
}
return sum + amount;
}, 0);
console.log(`총 지출 계산 결과: ${expenses}`);
logger.info(`총 지출 계산 결과: ${expenses}`);
return expenses;
};
/**
* 트랜잭션을 날짜별로 그룹화
*/
export const groupTransactionsByDate = (transactions: Transaction[]): Record<string, Transaction[]> => {
export const groupTransactionsByDate = (
transactions: Transaction[]
): Record<string, Transaction[]> => {
const groups: Record<string, Transaction[]> = {};
transactions.forEach(transaction => {
transactions.forEach((transaction) => {
const date = parseTransactionDate(transaction.date);
if (!date) {
console.warn(`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`);
logger.warn(
`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`
);
return;
}
const formattedDate = format(date, 'yyyy-MM-dd');
const formattedDate = format(date, "yyyy-MM-dd");
if (!groups[formattedDate]) {
groups[formattedDate] = [];
}
groups[formattedDate].push(transaction);
});
return groups;
};

View File

@@ -1,5 +1,13 @@
// 트랜잭션 관련 모든 훅과 유틸리티 함수를 재내보내기
export { useTransactions } from './useTransactions';
export { MONTHS_KR, getCurrentMonth, getPrevMonth, getNextMonth } from './dateUtils';
export { filterTransactionsByMonth, filterTransactionsByQuery, calculateTotalExpenses } from './filterUtils';
export { useTransactions } from "./useTransactions";
export {
MONTHS_KR,
getCurrentMonth,
getPrevMonth,
getNextMonth,
} from "./dateUtils";
export {
filterTransactionsByMonth,
filterTransactionsByQuery,
calculateTotalExpenses,
} from "./filterUtils";

View File

@@ -1,90 +1,94 @@
import { Transaction } from '@/contexts/budget/types';
import { toast } from '@/hooks/useToast.wrapper';
import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons';
import { Transaction } from "@/contexts/budget/types";
import { storageLogger } from "@/utils/logger";
import { toast } from "@/hooks/useToast.wrapper";
import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons";
// 로컬 스토리지에서 트랜잭션 데이터 로드
export const loadTransactionsFromStorage = (): Transaction[] => {
try {
// 로컬 스토리지에서 트랜잭션 데이터 가져오기
const localDataStr = localStorage.getItem('transactions');
console.log('로컬 트랜잭션 데이터:', localDataStr);
const localDataStr = localStorage.getItem("transactions");
storageLogger.info("로컬 트랜잭션 데이터:", localDataStr);
if (localDataStr) {
try {
const localData = JSON.parse(localDataStr);
// 지원되는 카테고리로 필터링 및 카테고리명 변환
const filteredData = localData.map((transaction: Transaction) => {
if (transaction.type === 'expense') {
if (transaction.type === "expense") {
// 기존 카테고리명 변환
if (transaction.category === '식비') {
return {
...transaction,
category: '음식',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
if (transaction.category === "식비") {
return {
...transaction,
category: "음식",
paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가
};
} else if (transaction.category === '생활비') {
return {
...transaction,
category: '쇼핑',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
} else if (transaction.category === "생활비") {
return {
...transaction,
category: "쇼핑",
paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가
};
} else if (!EXPENSE_CATEGORIES.includes(transaction.category)) {
return {
...transaction,
category: '쇼핑',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
return {
...transaction,
category: "쇼핑",
paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가
}; // 지원되지 않는 카테고리는 '쇼핑'으로
}
// 기존 데이터에 paymentMethod가 없으면 기본값 추가
if (!transaction.paymentMethod) {
return {
...transaction,
paymentMethod: '신용카드'
paymentMethod: "신용카드",
};
}
}
return transaction;
});
console.log('필터링된 트랜잭션:', filteredData.length);
storageLogger.info("필터링된 트랜잭션:", filteredData.length);
return filteredData;
} catch (parseError) {
console.error('트랜잭션 데이터 파싱 오류:', parseError);
storageLogger.error("트랜잭션 데이터 파싱 오류:", parseError);
return [];
}
}
} catch (err) {
console.error('트랜잭션 로드 중 오류:', err);
storageLogger.error("트랜잭션 로드 중 오류:", err);
}
console.log('로컬 트랜잭션 데이터 없음');
storageLogger.info("로컬 트랜잭션 데이터 없음");
return [];
};
// 로컬 스토리지에 트랜잭션 데이터 저장
export const saveTransactionsToStorage = (transactions: Transaction[]): void => {
export const saveTransactionsToStorage = (
transactions: Transaction[]
): void => {
try {
const dataString = JSON.stringify(transactions);
localStorage.setItem('transactions', dataString);
localStorage.setItem('transactions_backup', dataString); // 백업도 저장
localStorage.setItem("transactions", dataString);
localStorage.setItem("transactions_backup", dataString); // 백업도 저장
// 이벤트 발생
window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new StorageEvent('storage', {
key: 'transactions',
newValue: dataString
}));
console.log('트랜잭션 저장 완료:', transactions.length, '개');
window.dispatchEvent(new Event("transactionUpdated"));
window.dispatchEvent(
new StorageEvent("storage", {
key: "transactions",
newValue: dataString,
})
);
storageLogger.info("트랜잭션 저장 완료:", transactions.length, "개");
} catch (error) {
console.error('트랜잭션 저장 오류:', error);
storageLogger.error("트랜잭션 저장 오류:", error);
toast({
title: "데이터 저장 실패",
description: "트랜잭션 데이터를 저장하는데 실패했습니다.",
variant: "destructive"
variant: "destructive",
});
}
};
@@ -92,13 +96,13 @@ export const saveTransactionsToStorage = (transactions: Transaction[]): void =>
// 예산 데이터 로드
export const loadBudgetFromStorage = (): number => {
try {
const budgetDataStr = localStorage.getItem('budgetData');
const budgetDataStr = localStorage.getItem("budgetData");
if (budgetDataStr) {
const budgetData = JSON.parse(budgetDataStr);
return budgetData.monthly.targetAmount;
}
} catch (e) {
console.error('예산 데이터 파싱 오류:', e);
storageLogger.error("예산 데이터 파싱 오류:", e);
}
return 0;
};

View File

@@ -1,32 +1,42 @@
import { useCallback } from 'react';
import { Transaction } from '@/contexts/budget/types';
import { useBudget } from '@/contexts/budget/BudgetContext';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/contexts/budget/types";
import { useBudget } from "@/contexts/budget/BudgetContext";
export const useTransactionsOperations = (transactions: Transaction[]) => {
const { updateTransaction: budgetUpdateTransaction, deleteTransaction: budgetDeleteTransaction } = useBudget();
const {
updateTransaction: budgetUpdateTransaction,
deleteTransaction: budgetDeleteTransaction,
} = useBudget();
// 트랜잭션 업데이트 함수
const updateTransaction = useCallback((updatedTransaction: Transaction): void => {
try {
budgetUpdateTransaction(updatedTransaction);
} catch (error) {
console.error('트랜잭션 업데이트 중 오류:', error);
}
}, [budgetUpdateTransaction]);
const updateTransaction = useCallback(
(updatedTransaction: Transaction): void => {
try {
budgetUpdateTransaction(updatedTransaction);
} catch (error) {
logger.error("트랜잭션 업데이트 중 오류:", error);
}
},
[budgetUpdateTransaction]
);
// 트랜잭션 삭제 함수
const deleteTransaction = useCallback(async (id: string): Promise<boolean> => {
try {
budgetDeleteTransaction(id);
return true;
} catch (error) {
console.error('트랜잭션 삭제 중 오류:', error);
return false;
}
}, [budgetDeleteTransaction]);
const deleteTransaction = useCallback(
async (id: string): Promise<boolean> => {
try {
budgetDeleteTransaction(id);
return true;
} catch (error) {
logger.error("트랜잭션 삭제 중 오류:", error);
return false;
}
},
[budgetDeleteTransaction]
);
return {
updateTransaction,
deleteTransaction
deleteTransaction,
};
};

View File

@@ -1,5 +1,4 @@
import { Transaction } from '@/components/TransactionCard';
import { Transaction } from "@/components/TransactionCard";
export interface TransactionOperationProps {
transactions: Transaction[];

View File

@@ -1,12 +1,12 @@
import { useCallback } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { useAuth } from '@/contexts/auth/useAuth';
import { toast } from '@/hooks/useToast.wrapper';
import { saveTransactionsToStorage } from '../storageUtils';
import { updateTransactionInSupabase } from '../supabaseUtils';
import { TransactionOperationProps } from './types';
import { normalizeDate } from '@/utils/sync/transaction/dateUtils';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/components/TransactionCard";
import { useAuth } from "@/contexts/auth/useAuth";
import { toast } from "@/hooks/useToast.wrapper";
import { saveTransactionsToStorage } from "../storageUtils";
import { updateTransactionInSupabase } from "../supabaseUtils";
import { TransactionOperationProps } from "./types";
import { normalizeDate } from "@/utils/sync/transaction/dateUtils";
/**
* 트랜잭션 업데이트 기능
@@ -18,88 +18,104 @@ export const useUpdateTransaction = (
) => {
const { user } = useAuth();
return useCallback((updatedTransaction: Transaction) => {
try {
console.log(`[트랜잭션] 업데이트 시작: ID=${updatedTransaction.id}, 제목=${updatedTransaction.title}`);
// 트랜잭션 존재 여부 확인
const existingIndex = transactions.findIndex(t => t.id === updatedTransaction.id);
if (existingIndex === -1) {
console.warn(`[트랜잭션] 업데이트 오류: ID ${updatedTransaction.id}를 찾을 수 없음`);
toast({
title: "업데이트 실패",
description: "해당 지출 항목을 찾을 수 없습니다.",
variant: "destructive"
});
return;
}
// 기존 데이터와 변경 감지
const oldTransaction = transactions[existingIndex];
const hasChanges = JSON.stringify(oldTransaction) !== JSON.stringify(updatedTransaction);
if (!hasChanges) {
console.log(`[트랜잭션] 변경사항 없음: ${updatedTransaction.id}`);
return;
}
// 변경 내용 로깅
console.log(`[트랜잭션] 변경 감지:
return useCallback(
(updatedTransaction: Transaction) => {
try {
logger.info(
`[트랜잭션] 업데이트 시작: ID=${updatedTransaction.id}, 제목=${updatedTransaction.title}`
);
// 트랜잭션 존재 여부 확인
const existingIndex = transactions.findIndex(
(t) => t.id === updatedTransaction.id
);
if (existingIndex === -1) {
logger.warn(
`[트랜잭션] 업데이트 오류: ID ${updatedTransaction.id}를 찾을 수 없음`
);
toast({
title: "업데이트 실패",
description: "해당 지출 항목을 찾을 수 없습니다.",
variant: "destructive",
});
return;
}
// 기존 데이터와 변경 감지
const oldTransaction = transactions[existingIndex];
const hasChanges =
JSON.stringify(oldTransaction) !== JSON.stringify(updatedTransaction);
if (!hasChanges) {
logger.info(`[트랜잭션] 변경사항 없음: ${updatedTransaction.id}`);
return;
}
// 변경 내용 로깅
logger.info(`[트랜잭션] 변경 감지:
제목: ${oldTransaction.title} -> ${updatedTransaction.title}
금액: ${oldTransaction.amount} -> ${updatedTransaction.amount}
카테고리: ${oldTransaction.category} -> ${updatedTransaction.category}
날짜: ${oldTransaction.date} -> ${updatedTransaction.date}
`);
// 로컬 스토리지 업데이트
const updatedTransactions = transactions.map(transaction =>
transaction.id === updatedTransaction.id ? updatedTransaction : transaction
);
saveTransactionsToStorage(updatedTransactions);
console.log(`[트랜잭션] 로컬 저장소 업데이트 완료`);
// 상태 업데이트
setTransactions(updatedTransactions);
// Supabase 업데이트 시도 (날짜 형식 변환 추가)
if (user) {
// ISO 형식으로 날짜 변환
const transactionWithIsoDate = {
...updatedTransaction,
dateForSync: normalizeDate(updatedTransaction.date)
};
console.log(`[트랜잭션] Supabase 업데이트 시작: ${updatedTransaction.id}`);
updateTransactionInSupabase(user, transactionWithIsoDate)
.then(() => {
console.log(`[트랜잭션] Supabase 업데이트 성공: ${updatedTransaction.id}`);
})
.catch(err => {
console.error(`[트랜잭션] Supabase 업데이트 실패:`, err);
// 로컬 스토리지 업데이트
const updatedTransactions = transactions.map((transaction) =>
transaction.id === updatedTransaction.id
? updatedTransaction
: transaction
);
saveTransactionsToStorage(updatedTransactions);
logger.info(`[트랜잭션] 로컬 저장소 업데이트 완료`);
// 상태 업데이트
setTransactions(updatedTransactions);
// Supabase 업데이트 시도 (날짜 형식 변환 추가)
if (user) {
// ISO 형식으로 날짜 변환
const transactionWithIsoDate = {
...updatedTransaction,
dateForSync: normalizeDate(updatedTransaction.date),
};
logger.info(
`[트랜잭션] Supabase 업데이트 시작: ${updatedTransaction.id}`
);
updateTransactionInSupabase(user, transactionWithIsoDate)
.then(() => {
logger.info(
`[트랜잭션] Supabase 업데이트 성공: ${updatedTransaction.id}`
);
})
.catch((err) => {
logger.error(`[트랜잭션] Supabase 업데이트 실패:`, err);
});
} else {
logger.info(`[트랜잭션] 로그인 상태 아님: Supabase 업데이트 건너뜀`);
}
// 이벤트 발생
window.dispatchEvent(new Event("transactionUpdated"));
// 약간의 지연을 두고 토스트 표시
setTimeout(() => {
toast({
title: "지출이 수정되었습니다",
description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`,
duration: 3000,
});
} else {
console.log(`[트랜잭션] 로그인 상태 아님: Supabase 업데이트 건너뜀`);
}
// 이벤트 발생
window.dispatchEvent(new Event('transactionUpdated'));
// 약간의 지연을 두고 토스트 표시
setTimeout(() => {
}, 100);
} catch (error) {
logger.error(`[트랜잭션] 업데이트 중 오류 발생:`, error);
toast({
title: "지출이 수정되었습니다",
description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`,
duration: 3000
title: "업데이트 실패",
description: "지출 수정 중 오류가 발생했습니다.",
variant: "destructive",
});
}, 100);
} catch (error) {
console.error(`[트랜잭션] 업데이트 중 오류 발생:`, error);
toast({
title: "업데이트 실패",
description: "지출 수정 중 오류가 발생했습니다.",
variant: "destructive"
});
}
}, [transactions, setTransactions, user]);
}
},
[transactions, setTransactions, user]
);
};

View File

@@ -1,32 +1,42 @@
import { useCallback } from 'react';
import { Transaction } from '@/contexts/budget/types';
import { useBudget } from '@/contexts/budget/BudgetContext';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/contexts/budget/types";
import { useBudget } from "@/contexts/budget/BudgetContext";
export const useTransactionsOperations = (transactions: Transaction[]) => {
const { updateTransaction: budgetUpdateTransaction, deleteTransaction: budgetDeleteTransaction } = useBudget();
const {
updateTransaction: budgetUpdateTransaction,
deleteTransaction: budgetDeleteTransaction,
} = useBudget();
// 트랜잭션 업데이트 함수
const updateTransaction = useCallback((updatedTransaction: Transaction): void => {
try {
budgetUpdateTransaction(updatedTransaction);
} catch (error) {
console.error('트랜잭션 업데이트 중 오류:', error);
}
}, [budgetUpdateTransaction]);
const updateTransaction = useCallback(
(updatedTransaction: Transaction): void => {
try {
budgetUpdateTransaction(updatedTransaction);
} catch (error) {
logger.error("트랜잭션 업데이트 중 오류:", error);
}
},
[budgetUpdateTransaction]
);
// 트랜잭션 삭제 함수
const deleteTransaction = useCallback(async (id: string): Promise<boolean> => {
try {
budgetDeleteTransaction(id);
return true;
} catch (error) {
console.error('트랜잭션 삭제 중 오류:', error);
return false;
}
}, [budgetDeleteTransaction]);
const deleteTransaction = useCallback(
async (id: string): Promise<boolean> => {
try {
budgetDeleteTransaction(id);
return true;
} catch (error) {
logger.error("트랜잭션 삭제 중 오류:", error);
return false;
}
},
[budgetDeleteTransaction]
);
return {
updateTransaction,
deleteTransaction
deleteTransaction,
};
};

View File

@@ -1,137 +1,157 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Transaction } from '@/components/TransactionCard';
import {
syncTransactionsWithAppwrite,
updateTransactionInAppwrite,
import { useState, useEffect, useCallback, useRef } from "react";
import { appwriteLogger } from "@/utils/logger";
import { Transaction } from "@/components/TransactionCard";
import {
syncTransactionsWithAppwrite,
updateTransactionInAppwrite,
deleteTransactionFromAppwrite,
debouncedDeleteTransaction
} from '@/utils/appwriteTransactionUtils';
import { toast } from '@/hooks/useToast.wrapper';
import { isSyncEnabled } from '@/utils/syncUtils';
debouncedDeleteTransaction,
} from "@/utils/appwriteTransactionUtils";
import { toast } from "@/hooks/useToast.wrapper";
import { isSyncEnabled } from "@/utils/syncUtils";
/**
* Appwrite 트랜잭션 관리 훅
* 트랜잭션 동기화, 추가, 수정, 삭제 기능 제공
*/
export const useAppwriteTransactions = (user: any, localTransactions: Transaction[]) => {
export const useAppwriteTransactions = (
user: any,
localTransactions: Transaction[]
) => {
// 트랜잭션 상태 관리
const [transactions, setTransactions] = useState<Transaction[]>(localTransactions);
const [transactions, setTransactions] =
useState<Transaction[]>(localTransactions);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
// 컴포넌트 마운트 상태 추적
const isMountedRef = useRef<boolean>(true);
// 진행 중인 작업 추적
const pendingOperations = useRef<Set<string>>(new Set());
// 트랜잭션 동기화
const syncTransactions = useCallback(async () => {
if (!user || !isSyncEnabled()) return localTransactions;
if (!user || !isSyncEnabled()) {
return localTransactions;
}
try {
setLoading(true);
setError(null);
// UI 스레드 차단 방지
await new Promise(resolve => setTimeout(resolve, 0));
const syncedTransactions = await syncTransactionsWithAppwrite(user, localTransactions);
await new Promise((resolve) => setTimeout(resolve, 0));
const syncedTransactions = await syncTransactionsWithAppwrite(
user,
localTransactions
);
if (isMountedRef.current) {
setTransactions(syncedTransactions);
setLoading(false);
}
return syncedTransactions;
} catch (err) {
console.error('트랜잭션 동기화 오류:', err);
appwriteLogger.error("트랜잭션 동기화 오류:", err);
if (isMountedRef.current) {
setError(err as Error);
setLoading(false);
}
return localTransactions;
}
}, [user, localTransactions]);
// 트랜잭션 추가/수정
const saveTransaction = useCallback(async (transaction: Transaction) => {
if (!user || !isSyncEnabled()) return;
try {
// 작업 추적 시작
pendingOperations.current.add(transaction.id);
// UI 스레드 차단 방지
await new Promise(resolve => requestAnimationFrame(resolve));
await updateTransactionInAppwrite(user, transaction);
if (!isMountedRef.current) return;
// 로컬 상태 업데이트
setTransactions(prev => {
const index = prev.findIndex(t => t.id === transaction.id);
if (index >= 0) {
const updated = [...prev];
updated[index] = transaction;
return updated;
} else {
return [...prev, transaction];
const saveTransaction = useCallback(
async (transaction: Transaction) => {
if (!user || !isSyncEnabled()) {
return;
}
try {
// 작업 추적 시작
pendingOperations.current.add(transaction.id);
// UI 스레드 차단 방지
await new Promise((resolve) => requestAnimationFrame(resolve));
await updateTransactionInAppwrite(user, transaction);
if (!isMountedRef.current) {
return;
}
});
} catch (err) {
console.error('트랜잭션 저장 오류:', err);
if (isMountedRef.current) {
toast({
title: '저장 실패',
description: '트랜잭션을 저장하는 중 오류가 발생했습니다.',
variant: 'destructive'
// 로컬 상태 업데이트
setTransactions((prev) => {
const index = prev.findIndex((t) => t.id === transaction.id);
if (index >= 0) {
const updated = [...prev];
updated[index] = transaction;
return updated;
} else {
return [...prev, transaction];
}
});
} catch (err) {
appwriteLogger.error("트랜잭션 저장 오류:", err);
if (isMountedRef.current) {
toast({
title: "저장 실패",
description: "트랜잭션을 저장하는 중 오류가 발생했습니다.",
variant: "destructive",
});
}
} finally {
// 작업 추적 종료
pendingOperations.current.delete(transaction.id);
}
} finally {
// 작업 추적 종료
pendingOperations.current.delete(transaction.id);
}
}, [user]);
},
[user]
);
// 트랜잭션 삭제
const removeTransaction = useCallback(async (transactionId: string) => {
if (!user || !isSyncEnabled()) return;
try {
// 작업 추적 시작
pendingOperations.current.add(transactionId);
// 로컬 상태 먼저 업데이트 (낙관적 UI 업데이트)
setTransactions(prev => prev.filter(t => t.id !== transactionId));
// 디바운스된 삭제 작업 실행 (여러 번 연속 호출 방지)
await debouncedDeleteTransaction(user, transactionId);
} catch (err) {
console.error('트랜잭션 삭제 오류:', err);
if (isMountedRef.current) {
toast({
title: '삭제 실패',
description: '트랜잭션을 삭제하는 중 오류가 발생했습니다.',
variant: 'destructive'
});
// 실패 시 트랜잭션 복원 (서버에서 가져오기)
syncTransactions();
const removeTransaction = useCallback(
async (transactionId: string) => {
if (!user || !isSyncEnabled()) {
return;
}
} finally {
// 작업 추적 종료
pendingOperations.current.delete(transactionId);
}
}, [user, syncTransactions]);
try {
// 작업 추적 시작
pendingOperations.current.add(transactionId);
// 로컬 상태 먼저 업데이트 (낙관적 UI 업데이트)
setTransactions((prev) => prev.filter((t) => t.id !== transactionId));
// 디바운스된 삭제 작업 실행 (여러 번 연속 호출 방지)
await debouncedDeleteTransaction(user, transactionId);
} catch (err) {
appwriteLogger.error("트랜잭션 삭제 오류:", err);
if (isMountedRef.current) {
toast({
title: "삭제 실패",
description: "트랜잭션을 삭제하는 중 오류가 발생했습니다.",
variant: "destructive",
});
// 실패 시 트랜잭션 복원 (서버에서 가져오기)
syncTransactions();
}
} finally {
// 작업 추적 종료
pendingOperations.current.delete(transactionId);
}
},
[user, syncTransactions]
);
// 초기 동기화
useEffect(() => {
if (user && isSyncEnabled()) {
@@ -140,14 +160,14 @@ export const useAppwriteTransactions = (user: any, localTransactions: Transactio
setTransactions(localTransactions);
}
}, [user, localTransactions, syncTransactions]);
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
return {
transactions,
loading,
@@ -155,7 +175,7 @@ export const useAppwriteTransactions = (user: any, localTransactions: Transactio
syncTransactions,
saveTransaction,
removeTransaction,
hasPendingOperations: pendingOperations.current.size > 0
hasPendingOperations: pendingOperations.current.size > 0,
};
};

View File

@@ -1,16 +1,16 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect } from 'react';
import { logger } from "@/utils/logger";
/**
* 트랜잭션 삭제 알림 관련 로직을 담당하는 커스텀 훅
*/
export const useDeleteAlert = (onDelete: () => Promise<boolean> | boolean) => {
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 타임아웃 참조 저장 (메모리 누수 방지용)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 클린업 함수 - 메모리 누수 방지
const clearTimeouts = () => {
if (timeoutRef.current) {
@@ -18,32 +18,34 @@ export const useDeleteAlert = (onDelete: () => Promise<boolean> | boolean) => {
timeoutRef.current = null;
}
};
// 컴포넌트 언마운트 시 모든 타임아웃 제거
useEffect(() => {
return () => {
clearTimeouts();
};
}, []);
const handleDelete = async () => {
// 이미 삭제 중이면 중복 실행 방지
if (isDeleting) return;
if (isDeleting) {
return;
}
try {
// 삭제 상태 활성화
setIsDeleting(true);
// 다이얼로그 즉시 닫기 (UI 응답성 개선)
setIsOpen(false);
// UI 애니메이션 완료 후 삭제 실행
timeoutRef.current = setTimeout(async () => {
try {
// 삭제 함수 실행
await onDelete();
} catch (error) {
console.error('삭제 처리 오류:', error);
logger.error("삭제 처리 오류:", error);
} finally {
// 모든 작업 완료 후 상태 초기화 (약간 지연)
timeoutRef.current = setTimeout(() => {
@@ -52,23 +54,25 @@ export const useDeleteAlert = (onDelete: () => Promise<boolean> | boolean) => {
}
}, 150);
} catch (error) {
console.error('삭제 핸들러 오류:', error);
logger.error("삭제 핸들러 오류:", error);
setIsDeleting(false);
setIsOpen(false);
}
};
// 다이얼로그 상태 관리
const handleOpenChange = (open: boolean) => {
// 삭제 중에는 상태 변경 방지
if (isDeleting && !open) return;
if (isDeleting && !open) {
return;
}
setIsOpen(open);
};
return {
isOpen,
isDeleting,
handleDelete,
handleOpenChange
handleOpenChange,
};
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useRef, useState } from 'react';
import { toast } from '@/hooks/useToast.wrapper';
import { useCallback, useRef, useState } from "react";
import { logger } from "@/utils/logger";
import { toast } from "@/hooks/useToast.wrapper";
/**
* 최근 거래내역 관련 로직을 처리하는 커스텀 훅
@@ -10,7 +10,7 @@ export const useRecentTransactions = (
deleteTransaction: (id: string) => void
) => {
const [isDeleting, setIsDeleting] = useState(false);
// 삭제 중인 ID 추적
const deletingIdRef = useRef<string | null>(null);
@@ -21,101 +21,107 @@ export const useRecentTransactions = (
const lastDeleteTimeRef = useRef<Record<string, number>>({});
// 완전히 새로운 삭제 처리 함수
const handleDeleteTransaction = useCallback(async (id: string): Promise<boolean> => {
return new Promise(resolve => {
try {
// 삭제 진행 중인지 확인
if (isDeleting || deletingIdRef.current === id) {
console.log('이미 삭제 작업이 진행 중입니다');
const handleDeleteTransaction = useCallback(
async (id: string): Promise<boolean> => {
return new Promise((resolve) => {
try {
// 삭제 진행 중인지 확인
if (isDeleting || deletingIdRef.current === id) {
logger.info("이미 삭제 작업이 진행 중입니다");
resolve(true);
return;
}
// 급발진 방지 (300ms)
const now = Date.now();
if (
lastDeleteTimeRef.current[id] &&
now - lastDeleteTimeRef.current[id] < 300
) {
logger.warn("삭제 요청이 너무 빠릅니다. 무시합니다.");
resolve(true);
return;
}
// 타임스탬프 업데이트
lastDeleteTimeRef.current[id] = now;
// 삭제 상태 설정
setIsDeleting(true);
deletingIdRef.current = id;
// 안전장치: 타임아웃 설정 (최대 900ms)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
logger.warn("삭제 타임아웃 - 상태 초기화");
setIsDeleting(false);
deletingIdRef.current = null;
resolve(true); // UI 응답성 위해 성공 간주
}, 900);
// 비동기 작업을 동기적으로 처리하여 UI 블로킹 방지
setTimeout(() => {
try {
// BudgetContext의 deleteTransaction 함수 호출
deleteTransaction(id);
// 안전장치 타임아웃 제거
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// 상태 초기화 (지연 적용)
setTimeout(() => {
setIsDeleting(false);
deletingIdRef.current = null;
}, 100);
// 성공 메시지 표시
toast({
title: "항목이 삭제되었습니다",
description: "지출 내역이 성공적으로 삭제되었습니다.",
duration: 1500,
});
} catch (err) {
logger.error("삭제 처리 오류:", err);
// 에러 메시지 표시
toast({
title: "삭제 실패",
description: "항목을 삭제하는 중 오류가 발생했습니다.",
variant: "destructive",
duration: 1500,
});
}
}, 0);
// 즉시 성공 반환 (UI 응답성 향상)
resolve(true);
return;
}
} catch (error) {
logger.error("삭제 처리 전체 오류:", error);
// 급발진 방지 (300ms)
const now = Date.now();
if (lastDeleteTimeRef.current[id] && now - lastDeleteTimeRef.current[id] < 300) {
console.warn('삭제 요청이 너무 빠릅니다. 무시합니다.');
resolve(true);
return;
}
// 타임스탬프 업데이트
lastDeleteTimeRef.current[id] = now;
// 삭제 상태 설정
setIsDeleting(true);
deletingIdRef.current = id;
// 안전장치: 타임아웃 설정 (최대 900ms)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
console.warn('삭제 타임아웃 - 상태 초기화');
// 항상 상태 정리
setIsDeleting(false);
deletingIdRef.current = null;
resolve(true); // UI 응답성 위해 성공 간주
}, 900);
// 비동기 작업을 동기적으로 처리하여 UI 블로킹 방지
setTimeout(() => {
try {
// BudgetContext의 deleteTransaction 함수 호출
deleteTransaction(id);
// 안전장치 타임아웃 제거
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// 상태 초기화 (지연 적용)
setTimeout(() => {
setIsDeleting(false);
deletingIdRef.current = null;
}, 100);
// 성공 메시지 표시
toast({
title: "항목이 삭제되었습니다",
description: "지출 내역이 성공적으로 삭제되었습니다.",
duration: 1500
});
} catch (err) {
console.error('삭제 처리 오류:', err);
// 에러 메시지 표시
toast({
title: "삭제 실패",
description: "항목을 삭제하는 중 오류가 발생했습니다.",
variant: "destructive",
duration: 1500
});
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, 0);
// 즉시 성공 반환 (UI 응답성 향상)
resolve(true);
} catch (error) {
console.error('삭제 처리 전체 오류:', error);
// 항상 상태 정리
setIsDeleting(false);
deletingIdRef.current = null;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
toast({
title: "오류 발생",
description: "처리 중 문제가 발생했습니다.",
variant: "destructive",
duration: 1500,
});
resolve(false);
}
toast({
title: "오류 발생",
description: "처리 중 문제가 발생했습니다.",
variant: "destructive",
duration: 1500
});
resolve(false);
}
});
}, [deleteTransaction, isDeleting]);
});
},
[deleteTransaction, isDeleting]
);
// 컴포넌트 언마운트 시 타임아웃 정리 (리액트 컴포넌트에서 처리해야함)
const cleanupTimeouts = useCallback(() => {
@@ -128,6 +134,6 @@ export const useRecentTransactions = (
return {
handleDeleteTransaction,
isDeleting,
cleanupTimeouts
cleanupTimeouts,
};
};

View File

@@ -1,12 +1,12 @@
import { useState } from 'react';
import { Transaction } from '@/contexts/budget/types';
import { useState } from "react";
import { Transaction } from "@/contexts/budget/types";
/**
* 최근 거래내역의 다이얼로그 상태를 관리하는 커스텀 훅
*/
export const useRecentTransactionsDialog = () => {
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleTransactionClick = (transaction: Transaction) => {
@@ -27,6 +27,6 @@ export const useRecentTransactionsDialog = () => {
isDialogOpen,
handleTransactionClick,
handleCloseDialog,
setIsDialogOpen
setIsDialogOpen,
};
};

View File

@@ -1,5 +1,4 @@
import { useTransactionsCore } from './useTransactionsCore';
import { useTransactionsCore } from "./useTransactionsCore";
/**
* 메인 트랜잭션 훅

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react';
import { useTransactionsState } from './useTransactionsState';
import { useTransactionsFiltering } from './useTransactionsFiltering';
import { useTransactionsLoader } from './useTransactionsLoader';
import { useTransactionsOperations } from './transactionOperations/useTransactionsOperations';
import { useTransactionsEvents } from './useTransactionsEvents';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { useTransactionsState } from "./useTransactionsState";
import { useTransactionsFiltering } from "./useTransactionsFiltering";
import { useTransactionsLoader } from "./useTransactionsLoader";
import { useTransactionsOperations } from "./transactionOperations/useTransactionsOperations";
import { useTransactionsEvents } from "./useTransactionsEvents";
/**
* 핵심 트랜잭션 훅 - 성능 및 안정성 최적화 버전
@@ -12,7 +12,7 @@ import { useTransactionsEvents } from './useTransactionsEvents';
*/
export const useTransactionsCore = () => {
// 상태 관리
const {
const {
transactions,
setTransactions,
filteredTransactions,
@@ -28,42 +28,37 @@ export const useTransactionsCore = () => {
totalBudget,
setTotalBudget,
refreshKey,
setRefreshKey
setRefreshKey,
} = useTransactionsState();
// 데이터 로딩
const { loadTransactions } = useTransactionsLoader(
setTransactions,
setTotalBudget,
setIsLoading,
setTransactions,
setTotalBudget,
setIsLoading,
setError
);
// 필터링 - 성능 개선 버전
const {
handlePrevMonth,
handleNextMonth,
getTotalExpenses
} = useTransactionsFiltering({
transactions,
selectedMonth,
setSelectedMonth,
searchQuery,
setFilteredTransactions
});
const { handlePrevMonth, handleNextMonth, getTotalExpenses } =
useTransactionsFiltering({
transactions,
selectedMonth,
setSelectedMonth,
searchQuery,
setFilteredTransactions,
});
// 트랜잭션 작업 - 단순화된 버전
const {
deleteTransaction
} = useTransactionsOperations(transactions);
const { deleteTransaction } = useTransactionsOperations(transactions);
// 이벤트 리스너 - 메모리 누수 방지 버전
useTransactionsEvents(loadTransactions, refreshKey);
// 데이터 강제 새로고침 - 성능 최적화
const refreshTransactions = useCallback(() => {
console.log('[트랜잭션 코어] 강제 새로고침');
setRefreshKey(prev => prev + 1);
logger.info("[트랜잭션 코어] 강제 새로고침");
setRefreshKey((prev) => prev + 1);
loadTransactions();
}, [loadTransactions, setRefreshKey]);
@@ -71,26 +66,26 @@ export const useTransactionsCore = () => {
// 데이터
transactions: filteredTransactions,
allTransactions: transactions,
// 상태
isLoading,
error,
totalBudget,
// 필터링
selectedMonth,
searchQuery,
setSearchQuery,
handlePrevMonth,
handleNextMonth,
// 작업
deleteTransaction,
// 합계
totalExpenses: getTotalExpenses(filteredTransactions),
// 새로고침
refreshTransactions
refreshTransactions,
};
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef } from 'react';
import { logger } from "@/utils/logger";
/**
* 트랜잭션 이벤트 리스너 훅 - 성능 및 메모리 누수 방지 개선 버전
*/
@@ -11,76 +11,86 @@ export const useTransactionsEvents = (
// 바운싱 방지 및 이벤트 제어를 위한 참조
const isProcessingRef = useRef(false);
const timeoutIdsRef = useRef<number[]>([]);
// 타임아웃 클리어 도우미 함수
const clearAllTimeouts = () => {
timeoutIdsRef.current.forEach(id => window.clearTimeout(id));
timeoutIdsRef.current.forEach((id) => window.clearTimeout(id));
timeoutIdsRef.current = [];
};
useEffect(() => {
console.log('[이벤트] 이벤트 리스너 설정');
logger.info("[이벤트] 이벤트 리스너 설정");
// 이벤트 핸들러 - 부하 조절(throttle) 적용
const handleEvent = (name: string, delay: number = 200) => {
const handleEvent = (name: string, delay = 200) => {
return (e?: any) => {
// 이미 처리 중인 경우 건너뜀
if (isProcessingRef.current) return;
console.log(`[이벤트] ${name} 이벤트 감지:`, e?.detail?.type || '');
if (isProcessingRef.current) {
return;
}
logger.info(`[이벤트] ${name} 이벤트 감지:`, e?.detail?.type || "");
isProcessingRef.current = true;
// 딜레이 적용 (이벤트 폭주 방지)
const timeoutId = window.setTimeout(() => {
loadTransactions();
isProcessingRef.current = false;
// 타임아웃 ID 목록에서 제거
timeoutIdsRef.current = timeoutIdsRef.current.filter(id => id !== timeoutId);
timeoutIdsRef.current = timeoutIdsRef.current.filter(
(id) => id !== timeoutId
);
}, delay);
// 타임아웃 ID 기록 (나중에 정리하기 위함)
timeoutIdsRef.current.push(timeoutId);
};
};
// 각 이벤트별 핸들러 생성
const handleTransactionUpdate = handleEvent('트랜잭션 업데이트', 150);
const handleTransactionDelete = handleEvent('트랜잭션 삭제', 200);
const handleTransactionChange = handleEvent('트랜잭션 변경', 150);
const handleTransactionUpdate = handleEvent("트랜잭션 업데이트", 150);
const handleTransactionDelete = handleEvent("트랜잭션 삭제", 200);
const handleTransactionChange = handleEvent("트랜잭션 변경", 150);
const handleStorageEvent = (e: StorageEvent) => {
if (e.key === 'transactions' || e.key === null) {
handleEvent('스토리지', 150)();
if (e.key === "transactions" || e.key === null) {
handleEvent("스토리지", 150)();
}
};
const handleFocus = handleEvent('포커스', 200);
const handleFocus = handleEvent("포커스", 200);
// 이벤트 리스너 등록
window.addEventListener('transactionUpdated', handleTransactionUpdate);
window.addEventListener('transactionDeleted', handleTransactionDelete);
window.addEventListener('transactionChanged', handleTransactionChange as EventListener);
window.addEventListener('storage', handleStorageEvent);
window.addEventListener('focus', handleFocus);
window.addEventListener("transactionUpdated", handleTransactionUpdate);
window.addEventListener("transactionDeleted", handleTransactionDelete);
window.addEventListener(
"transactionChanged",
handleTransactionChange as EventListener
);
window.addEventListener("storage", handleStorageEvent);
window.addEventListener("focus", handleFocus);
// 초기 데이터 로드
if (!isProcessingRef.current) {
loadTransactions();
}
// 클린업 함수
return () => {
console.log('[이벤트] 이벤트 리스너 정리');
logger.info("[이벤트] 이벤트 리스너 정리");
// 모든 이벤트 리스너 제거
window.removeEventListener('transactionUpdated', handleTransactionUpdate);
window.removeEventListener('transactionDeleted', handleTransactionDelete);
window.removeEventListener('transactionChanged', handleTransactionChange as EventListener);
window.removeEventListener('storage', handleStorageEvent);
window.removeEventListener('focus', handleFocus);
window.removeEventListener("transactionUpdated", handleTransactionUpdate);
window.removeEventListener("transactionDeleted", handleTransactionDelete);
window.removeEventListener(
"transactionChanged",
handleTransactionChange as EventListener
);
window.removeEventListener("storage", handleStorageEvent);
window.removeEventListener("focus", handleFocus);
// 모든 진행 중인 타임아웃 정리
clearAllTimeouts();
// 처리 상태 초기화
isProcessingRef.current = false;
};

View File

@@ -1,5 +1,4 @@
import { useTransactionsFiltering } from './filterOperations';
import { useTransactionsFiltering } from "./filterOperations";
// 기존 훅을 그대로 내보내기
export { useTransactionsFiltering };

View File

@@ -1,9 +1,7 @@
import { useCallback } from 'react';
import { toast } from '@/hooks/useToast.wrapper';
import {
loadTransactionsFromStorage
} from './storageUtils';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { toast } from "@/hooks/useToast.wrapper";
import { loadTransactionsFromStorage } from "./storageUtils";
/**
* 트랜잭션 로딩 관련 훅
@@ -19,41 +17,45 @@ export const useTransactionsLoader = (
const loadTransactions = useCallback(() => {
setIsLoading(true);
setError(null);
try {
const localTransactions = loadTransactionsFromStorage();
setTransactions(localTransactions);
// 예산 데이터에서 직접 월간 예산 값을 가져옴
try {
const budgetDataStr = localStorage.getItem('budgetData');
const budgetDataStr = localStorage.getItem("budgetData");
if (budgetDataStr) {
const budgetData = JSON.parse(budgetDataStr);
// 월간 예산 값만 사용
if (budgetData && budgetData.monthly && typeof budgetData.monthly.targetAmount === 'number') {
if (
budgetData &&
budgetData.monthly &&
typeof budgetData.monthly.targetAmount === "number"
) {
const monthlyBudget = budgetData.monthly.targetAmount;
setTotalBudget(monthlyBudget);
console.log('월간 예산 설정:', monthlyBudget);
logger.info("월간 예산 설정:", monthlyBudget);
} else {
console.log('유효한 월간 예산 데이터가 없습니다. 기본값 0 사용');
logger.info("유효한 월간 예산 데이터가 없습니다. 기본값 0 사용");
setTotalBudget(0);
}
} else {
console.log('예산 데이터가 없습니다. 기본값 0 사용');
logger.info("예산 데이터가 없습니다. 기본값 0 사용");
setTotalBudget(0);
}
} catch (budgetErr) {
console.error('예산 데이터 파싱 오류:', budgetErr);
logger.error("예산 데이터 파싱 오류:", budgetErr);
setTotalBudget(0);
}
} catch (err) {
console.error('트랜잭션 로드 중 오류:', err);
setError('데이터를 불러오는 중 문제가 발생했습니다.');
logger.error("트랜잭션 로드 중 오류:", err);
setError("데이터를 불러오는 중 문제가 발생했습니다.");
toast({
title: "데이터 로드 실패",
description: "지출 내역을 불러오는데 실패했습니다.",
variant: "destructive",
duration: 4000
duration: 4000,
});
} finally {
// 로딩 상태를 약간 지연시켜 UI 업데이트가 원활하게 이루어지도록 함
@@ -62,6 +64,6 @@ export const useTransactionsLoader = (
}, [setTransactions, setTotalBudget, setIsLoading, setError]);
return {
loadTransactions
loadTransactions,
};
};

View File

@@ -1,18 +1,22 @@
import { useCallback } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { useCallback } from "react";
import { Transaction } from "@/components/TransactionCard";
export const useTransactionsOperations = (
transactions: Transaction[],
setTransactions: React.Dispatch<React.SetStateAction<Transaction[]>>
) => {
const updateTransaction = useCallback((updatedTransaction: Transaction) => {
setTransactions(prev =>
prev.map(t => t.id === updatedTransaction.id ? updatedTransaction : t)
);
}, [setTransactions]);
const updateTransaction = useCallback(
(updatedTransaction: Transaction) => {
setTransactions((prev) =>
prev.map((t) =>
t.id === updatedTransaction.id ? updatedTransaction : t
)
);
},
[setTransactions]
);
return {
updateTransaction
updateTransaction,
};
};

View File

@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { getCurrentMonth } from './dateUtils';
import { useState } from "react";
import { Transaction } from "@/components/TransactionCard";
import { getCurrentMonth } from "./dateUtils";
/**
* 트랜잭션 관련 상태 관리 훅
@@ -10,19 +9,21 @@ import { getCurrentMonth } from './dateUtils';
export const useTransactionsState = () => {
// 트랜잭션 상태
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [filteredTransactions, setFilteredTransactions] = useState<Transaction[]>([]);
const [filteredTransactions, setFilteredTransactions] = useState<
Transaction[]
>([]);
// 필터링 상태
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonth());
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
// 로딩 및 에러 상태
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 예산 상태
const [totalBudget, setTotalBudget] = useState(0);
// 새로고침 키
const [refreshKey, setRefreshKey] = useState(0);
@@ -32,25 +33,25 @@ export const useTransactionsState = () => {
setTransactions,
filteredTransactions,
setFilteredTransactions,
// 필터링 상태
selectedMonth,
setSelectedMonth,
searchQuery,
setSearchQuery,
// 로딩 및 에러 상태
isLoading,
setIsLoading,
error,
setError,
// 예산 상태
totalBudget,
setTotalBudget,
// 새로고침 키
refreshKey,
setRefreshKey
setRefreshKey,
};
};