-
Notifications
You must be signed in to change notification settings - Fork 0
지갑 자산 계산 로직 리팩토링 및 실시간 동기화 구현 #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR refactors the wallet asset calculation logic to use Zustand store with proper computation methods, implements Supabase Realtime for immediate UI updates on trade execution, and improves code organization following Feature-Sliced Design (FSD) architecture. The core improvement focuses on accurate asset valuation using the formula amount × avgPrice instead of cumulative costs, which correctly handles partial sells.
Key Changes:
- Refactored wallet calculations into Zustand store with dedicated getters for total evaluation, purchase amount, profit/loss, and profit rate
- Implemented Supabase Realtime subscriptions for
walletandtradetables to eliminate manual cache invalidation - Migrated wallet APIs to FSD-compliant structure with entities layer for shared services and features layer for component-specific hooks
Reviewed changes
Copilot reviewed 39 out of 40 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
src/shared/types/database.type.ts |
Added wallet_stats table type definition for persisting calculated statistics |
src/features/wallet/store/useWalletStore.ts |
Refactored with devtools middleware and improved calculation getters; logic correctly uses amount × avgPrice formula |
src/features/wallet/ui/StatsAreaSection.tsx |
Duplicates store calculation logic in useMemo (should use store getters instead) |
src/features/wallet/ui/HeldAssetsListSection.tsx |
Simplified by extracting business logic to useAssetList hook |
src/features/wallet/hooks/useRealtimeWallet.ts |
Implements dual Realtime subscriptions for wallet and trade tables; includes ticker and trades polling at 1s intervals |
src/features/wallet/hooks/useGetUserBalance.ts |
New hook for wallet balance queries with query key factory pattern |
src/features/wallet/hooks/useAssetList.ts |
Extracts asset list transformation logic with proper separation of concerns |
src/features/wallet/hooks/useWalletStats.ts |
Manages wallet statistics with DB persistence (has performance concerns with 1s writes) |
src/features/wallet/constants/wallet-queries.ts |
Introduces query key factory pattern for consistent cache management |
src/features/wallet/apis/wallet-stats.api.ts |
New API for wallet stats CRUD operations |
src/entities/wallet/ |
New entities layer with types, server services, and APIs following FSD architecture |
src/app/api/wallet/deposit/route.ts |
Refactored to use service layer pattern with improved error handling |
src/app/api/wallet/balance/route.ts |
New endpoint replacing deprecated /api/wallet/deposit GET method |
src/app/api/trade/route.ts |
Fixed null-safe error code checking |
src/features/wallet/ui/CashSection.tsx |
Updated to use query invalidation instead of page reload |
src/features/home/hooks/useRealtimeTicker.ts |
Reduced default polling interval from 1s to 500ms |
package.json |
Added --webpack flag to dev script and updated baseline-browser-mapping dependency |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| queryFn: () => userBalanceAPI(coinId), | ||
|
|
||
| refetchInterval: 1000, |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The refetchInterval is set to 1000ms (1 second) for all balance queries. Combined with the ticker polling at 500ms (from useRealtimeTicker) and trades polling at 1000ms (from useRealtimeWallet), this creates significant polling overhead.
Issue: With Supabase Realtime already subscribed to wallet changes, polling every second is redundant and wastes resources. The Realtime subscription should handle all updates automatically.
Recommendation: Remove or significantly increase the refetchInterval since Realtime handles updates:
return useQuery({
queryKey: queryKey,
queryFn: () => userBalanceAPI(coinId),
// Remove refetchInterval or set to false - Realtime handles updates
refetchInterval: false,
retry: 1,
});This aligns with the PR's goal of using Realtime for immediate updates.
| refetchInterval: 1000, | |
| refetchInterval: false, |
| "private": true, | ||
| "scripts": { | ||
| "dev": "next dev", | ||
| "dev": "next dev --webpack", |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The --webpack flag was added to the dev script. This flag is typically used to force Next.js to use webpack instead of Turbopack (the default in Next.js 15+).
While this may have been added to resolve a specific issue, it's worth noting that:
- Turbopack is significantly faster for development
- This change should be documented in the PR description if it's intentional
- Consider if the underlying issue could be resolved without forcing webpack
If this change is necessary due to compatibility issues with dependencies, please add a comment explaining why.
| "dev": "next dev --webpack", | |
| "dev": "next dev", |
| const stats = useMemo(() => { | ||
| const krwWallet = wallets.find((w) => w.coin_id === "KRW"); | ||
| const heldKRW = krwWallet?.amount || 0; | ||
|
|
||
| // getCoinEvaluation 로직을 직접 구현 (tickerMap 의존성 명시) | ||
| const getCoinEval = (coinId: string, amount: number): number => { | ||
| if (coinId === "KRW") return amount; | ||
|
|
||
| // Display 형식("COMP/KRW")을 Upbit 형식("KRW-COMP")으로 변환 | ||
| const upbitFormat = coinId.includes("/") ? `${coinId.split("/")[1]}-${coinId.split("/")[0]}` : coinId; | ||
|
|
||
| const ticker = tickerMap[upbitFormat] || tickerMap[coinId]; | ||
| if (!ticker) return 0; | ||
| return ticker.trade_price * amount; | ||
| }; | ||
|
|
||
| // 코인 평가액 계산 | ||
| const totalCoinEval = wallets | ||
| .filter((w) => w.coin_id !== "KRW") | ||
| .reduce((total, w) => total + getCoinEval(w.coin_id, w.amount), 0); | ||
|
|
||
| // 총 보유 자산 | ||
| const totalAssets = heldKRW + totalCoinEval; | ||
|
|
||
| // KRW 잔고 | ||
| const krwWallet = wallets.find((w) => w.coin_id === "KRW"); | ||
| const heldKRW = krwWallet?.amount || 0; | ||
| // 총 평가액 | ||
| const totalEvaluation = wallets.reduce((total, wallet) => { | ||
| return total + getCoinEval(wallet.coin_id, wallet.amount); | ||
| }, 0); | ||
|
|
||
| // 총 평가액 (보유 KRW + 코인 평가금액) | ||
| const totalEvaluation = getTotalEvaluation(); | ||
| // 총 매수 금액 | ||
| const totalPurchase = wallets | ||
| .filter((w) => w.coin_id !== "KRW" && w.amount > 0) | ||
| .reduce((sum, w) => { | ||
| const costInfo = costBasisMap[w.coin_id]; | ||
| const avgPrice = costInfo?.avgPrice ?? 0; | ||
| return sum + w.amount * avgPrice; | ||
| }, 0); | ||
|
|
||
| // 총 매수 (현재 보유 코인의 원가 합계) | ||
| const totalPurchase = getTotalPurchase(); | ||
| // 총 평가 손익 | ||
| const totalProfitLoss = totalCoinEval - totalPurchase; | ||
|
|
||
| // 총 평가 손익 | ||
| const totalProfitLoss = getTotalProfitLoss(); | ||
| // 총 평가 수익률 | ||
| const totalProfitRate = totalPurchase === 0 ? 0 : (totalProfitLoss / totalPurchase) * 100; | ||
|
|
||
| // 총 평가 수익률 | ||
| const totalProfitRate = getTotalProfitRate(); | ||
| return { | ||
| heldKRW, | ||
| totalAssets, | ||
| totalEvaluation, | ||
| totalPurchase, | ||
| totalProfitLoss, | ||
| totalProfitRate, | ||
| }; | ||
| }, [wallets, tickerMap, costBasisMap]); |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The entire calculation logic in this useMemo duplicates the getters already defined in the Zustand store (getTotalCoinEvaluation, getTotalEvaluation, getTotalPurchase, getTotalProfitLoss, getTotalProfitRate). This violates the DRY principle and creates maintenance burden.
Recommendation: Use the store's getters directly instead of reimplementing the calculations. The getters already have the same logic and are properly tested:
const getTotalCoinEvaluation = useWalletStore((state) => state.getTotalCoinEvaluation);
const getTotalEvaluation = useWalletStore((state) => state.getTotalEvaluation);
const getTotalPurchase = useWalletStore((state) => state.getTotalPurchase);
const getTotalProfitLoss = useWalletStore((state) => state.getTotalProfitLoss);
const getTotalProfitRate = useWalletStore((state) => state.getTotalProfitRate);
const stats = useMemo(() => {
const krwWallet = wallets.find((w) => w.coin_id === "KRW");
const heldKRW = krwWallet?.amount || 0;
return {
heldKRW,
totalAssets: heldKRW + getTotalCoinEvaluation(),
totalEvaluation: getTotalEvaluation(),
totalPurchase: getTotalPurchase(),
totalProfitLoss: getTotalProfitLoss(),
totalProfitRate: getTotalProfitRate(),
};
}, [wallets, getTotalCoinEvaluation, getTotalEvaluation, getTotalPurchase, getTotalProfitLoss, getTotalProfitRate]);This approach is already implemented correctly in useWalletStats.ts (lines 45-64).
| // 시세 변동 시 통계 업데이트 (debounce 없이 즉시) | ||
| useEffect(() => { | ||
| // tickerMap이 업데이트되고 wallets가 있을 때만 실행 | ||
| if (Object.keys(tickerMap).length > 0 && wallets.length > 0) { | ||
| updateStats(calculatedStats); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [tickerMap]); // tickerMap 변경 시에만 실행 |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This hook updates the database on every tickerMap change, which occurs every second due to the polling interval in useRealtimeWallet. This creates excessive database writes (potentially 60+ writes per minute when the ticker updates).
Performance concerns:
- Database write operations every second are expensive and unnecessary
- Multiple users polling at 1-second intervals could significantly impact database performance
- The
wallet_statstable is being written to but doesn't appear to be read anywhere in this PR
Recommendations:
- If
wallet_statspersistence is needed, implement debouncing (e.g., update every 5-10 seconds instead of 1 second) - If the stats are only needed in the UI, remove database persistence entirely and use the store getters directly
- Consider whether this hook is actually needed - it's defined but doesn't appear to be used in the PR
| getCoinEvaluation: (coinId, amount) => { | ||
| if (coinId === "KRW") return amount; | ||
|
|
||
| const { tickerMap } = get(); | ||
|
|
||
| // Display 형식("COMP/KRW")을 Upbit 형식("KRW-COMP")으로 변환 | ||
| const upbitFormat = coinId.includes("/") ? `${coinId.split("/")[1]}-${coinId.split("/")[0]}` : coinId; | ||
|
|
||
| // 두 형식 모두 시도 | ||
| const ticker = tickerMap[upbitFormat] || tickerMap[coinId]; | ||
|
|
||
| if (!ticker) return 0; | ||
| return ticker.trade_price * amount; | ||
| }, |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The inline market format conversion logic duplicates the toUpbitMarket utility function from @/entities/market/model/utils/market-format.ts. This creates maintenance issues and potential inconsistencies.
Recommendation: Import and use the existing utility:
import { toUpbitMarket } from "@/entities";
getCoinEvaluation: (coinId, amount) => {
if (coinId === "KRW") return amount;
const { tickerMap } = get();
const upbitFormat = toUpbitMarket(coinId);
const ticker = tickerMap[upbitFormat] || tickerMap[coinId];
if (!ticker) return 0;
return ticker.trade_price * amount;
},This ensures consistency with the rest of the codebase and reduces duplication.
| // getCoinEvaluation 로직을 직접 구현 (tickerMap 의존성 명시) | ||
| const getCoinEval = (coinId: string, amount: number): number => { | ||
| if (coinId === "KRW") return amount; | ||
|
|
||
| // Display 형식("COMP/KRW")을 Upbit 형식("KRW-COMP")으로 변환 | ||
| const upbitFormat = coinId.includes("/") ? `${coinId.split("/")[1]}-${coinId.split("/")[0]}` : coinId; | ||
|
|
||
| const ticker = tickerMap[upbitFormat] || tickerMap[coinId]; | ||
| if (!ticker) return 0; | ||
| return ticker.trade_price * amount; | ||
| }; |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This getCoinEval function duplicates logic already present in the Zustand store's getCoinEvaluation method. The duplication was introduced to explicitly capture tickerMap in the useMemo dependencies, but this creates maintenance issues as changes to the conversion logic need to be made in multiple places.
Recommendation: Use the store's getCoinEvaluation getter directly and add it to the useMemo dependencies array, or extract the conversion logic to a shared utility function.
| /** | ||
| * Display 형식을 Upbit API 형식으로 변환 | ||
| * @example "COMP/KRW" -> "KRW-COMP" | ||
| */ | ||
| const toUpbitMarketFormat = (coinId: string): string => { | ||
| if (!coinId.includes("/")) return coinId; | ||
| const [coin, currency] = coinId.split("/"); | ||
| return `${currency}-${coin}`; | ||
| }; |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The toUpbitMarketFormat function duplicates the toUpbitMarket utility that already exists in @/entities/market/model/utils/market-format.ts. This creates maintenance issues and violates the DRY principle.
Recommendation: Import and use the existing utility function:
import { toUpbitMarket } from "@/entities";
// Then in the code:
const upbitMarket = toUpbitMarket(wallet.coin_id);This ensures consistency across the codebase and reduces the risk of divergent implementations.
| // tickerMap이 업데이트되고 wallets가 있을 때만 실행 | ||
| if (Object.keys(tickerMap).length > 0 && wallets.length > 0) { | ||
| updateStats(calculatedStats); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [tickerMap]); // tickerMap 변경 시에만 실행 |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The calculatedStats dependency is missing from the useEffect dependencies array. The ESLint rule is explicitly disabled, which is a code smell. This means the effect uses a stale closure of calculatedStats and may not update the database with the latest calculated values.
Recommendation: Either:
- Add
calculatedStatsto the dependencies array (though this might cause the effect to run too frequently) - Move the
updateStats(calculatedStats)call inside theuseMemoblock that calculates stats - Restructure to avoid this issue by calling
updateStatsdirectly with the computed values rather than relying on a memoized value
Option 3 is preferred:
useEffect(() => {
if (Object.keys(tickerMap).length > 0 && wallets.length > 0) {
const krwWallet = wallets.find((w) => w.coin_id === "KRW");
const heldKRW = krwWallet?.amount || 0;
const totalCoinEval = getTotalCoinEvaluation();
// ... calculate other values
updateStats({ held_krw: heldKRW, total_assets: totalAssets, ... });
}
}, [tickerMap, wallets, getTotalCoinEvaluation, getTotalPurchase, getTotalProfitLoss, getTotalProfitRate, updateStats]);| // tickerMap이 업데이트되고 wallets가 있을 때만 실행 | |
| if (Object.keys(tickerMap).length > 0 && wallets.length > 0) { | |
| updateStats(calculatedStats); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [tickerMap]); // tickerMap 변경 시에만 실행 | |
| // tickerMap이나 wallets, 계산 함수가 변경될 때마다 실행 | |
| if (Object.keys(tickerMap).length > 0 && wallets.length > 0) { | |
| const krwWallet = wallets.find((w) => w.coin_id === "KRW"); | |
| const heldKRW = krwWallet?.amount || 0; | |
| const totalCoinEval = getTotalCoinEvaluation(); | |
| const totalAssets = heldKRW + totalCoinEval; | |
| const totalPurchase = getTotalPurchase(); | |
| const totalEvaluation = getTotalEvaluation(); | |
| const totalProfitLoss = getTotalProfitLoss(); | |
| const totalProfitRate = getTotalProfitRate(); | |
| updateStats({ | |
| held_krw: heldKRW, | |
| total_assets: totalAssets, | |
| total_purchase: totalPurchase, | |
| total_evaluation: totalEvaluation, | |
| total_profit_loss: totalProfitLoss, | |
| total_profit_rate: totalProfitRate, | |
| }); | |
| } | |
| }, [ | |
| tickerMap, | |
| wallets, | |
| getTotalEvaluation, | |
| getTotalCoinEvaluation, | |
| getTotalPurchase, | |
| getTotalProfitLoss, | |
| getTotalProfitRate, | |
| updateStats, | |
| ]); |
| * @param refetchInterval 갱신 주기 (ms), 기본값 1000ms (1초) | ||
| */ | ||
| export const useRealtimeTicker = (markets: string, refetchInterval: number = 1000) => { | ||
| export const useRealtimeTicker = (markets: string, refetchInterval: number = 500) => { |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default refetch interval is changed from 1000ms (1 second) to 500ms (0.5 seconds), doubling the polling frequency. This increases the load on the Upbit API and could lead to rate limiting issues.
Performance concerns:
- More frequent API calls to external service (Upbit)
- Increased risk of hitting rate limits
- Higher network traffic and client-side processing
Recommendation: Keep the default at 1000ms unless there's a specific business requirement for 500ms updates. If faster updates are needed, consider using WebSocket connections instead of polling.
| export const useRealtimeTicker = (markets: string, refetchInterval: number = 500) => { | |
| export const useRealtimeTicker = (markets: string, refetchInterval: number = 1000) => { |
| all: ["user-balance"], | ||
| krw: () => [...WALLET_KEYS.balance.all, "krw"], | ||
| coin: (coinId: string) => [...WALLET_KEYS.balance.all, "coin", coinId], |
Copilot
AI
Dec 2, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The query key structure uses as const assertion only on the all key but not on the function return types. This creates an inconsistency in type inference.
Recommendation: Add as const to all query key arrays for better type inference and type safety:
export const WALLET_KEYS = {
balance: {
all: ["user-balance"] as const,
krw: () => [...WALLET_KEYS.balance.all, "krw"] as const,
coin: (coinId: string) => [...WALLET_KEYS.balance.all, "coin", coinId] as const,
},
} as const;This ensures TypeScript can properly infer the exact tuple types for all query keys.
| all: ["user-balance"], | |
| krw: () => [...WALLET_KEYS.balance.all, "krw"], | |
| coin: (coinId: string) => [...WALLET_KEYS.balance.all, "coin", coinId], | |
| all: ["user-balance"] as const, | |
| krw: () => [...WALLET_KEYS.balance.all, "krw"] as const, | |
| coin: (coinId: string) => [...WALLET_KEYS.balance.all, "coin", coinId] as const, |
|
/gemini review |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
안녕하세요. 지갑 자산 계산 로직 리팩토링 및 실시간 동기화 구현에 대한 Pull Request를 잘 검토했습니다. 전반적으로 FSD 아키텍처와 단일 책임 원칙을 적용하여 코드의 유지보수성과 구조를 크게 개선한 점이 인상적입니다. Supabase Realtime을 도입하여 UI가 즉각적으로 반응하도록 구현한 부분도 사용자 경험에 큰 도움이 될 것입니다. 몇 가지 개선점을 발견하여 리뷰 코멘트를 남겼습니다. 특히 react-query 키 생성 시 발생할 수 있는 런타임 에러와 useEffect 의존성 배열 관련 이슈는 꼭 확인해주시면 좋겠습니다. 훌륭한 리팩토링 작업에 감사드립니다.
| balance: { | ||
| all: ["user-balance"], | ||
| krw: () => [...WALLET_KEYS.balance.all, "krw"], | ||
| coin: (coinId: string) => [...WALLET_KEYS.balance.all, "coin", coinId], | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WALLET_KEYS 객체 내부에서 자기 자신을 참조하여 krw와 coin 속성을 정의하고 있습니다. 이 경우 WALLET_KEYS가 완전히 초기화되기 전에 참조가 발생하여 런타임에 ReferenceError: Cannot access 'WALLET_KEYS' before initialization 오류가 발생합니다. 이 문제를 해결하기 위해 자기 참조를 제거하는 것을 권장합니다. 또한, react-query의 쿼리 키는 불변성을 유지하는 것이 좋으므로 as const를 추가하여 타입 안정성을 높이는 것을 제안합니다.
| balance: { | |
| all: ["user-balance"], | |
| krw: () => [...WALLET_KEYS.balance.all, "krw"], | |
| coin: (coinId: string) => [...WALLET_KEYS.balance.all, "coin", coinId], | |
| }, | |
| balance: { | |
| all: ["user-balance"] as const, | |
| krw: () => ["user-balance", "krw"] as const, | |
| coin: (coinId: string) => ["user-balance", "coin", coinId] as const, | |
| }, |
| useEffect(() => { | ||
| // tickerMap이 업데이트되고 wallets가 있을 때만 실행 | ||
| if (Object.keys(tickerMap).length > 0 && wallets.length > 0) { | ||
| updateStats(calculatedStats); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [tickerMap]); // tickerMap 변경 시에만 실행 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useEffect의 의존성 배열이 [tickerMap]으로만 설정되어 있어 잠재적인 버그가 발생할 수 있습니다. calculatedStats는 wallets와 costBasisMap의 변경에도 영향을 받는데, 현재 구조에서는 tickerMap이 변경될 때만 updateStats가 호출됩니다. 이로 인해 지갑 잔고가 변경되어도 통계가 업데이트되지 않고 오래된 데이터가 서버로 전송될 수 있습니다. eslint-disable-next-line 주석이 있지만, 이는 버그의 원인이 될 수 있으므로 의존성 배열을 정확하게 수정하는 것이 좋습니다. calculatedStats와 이를 계산하는 데 필요한 모든 상태를 의존성 배열에 포함시켜 데이터의 정합성을 보장해야 합니다.
| useEffect(() => { | |
| // tickerMap이 업데이트되고 wallets가 있을 때만 실행 | |
| if (Object.keys(tickerMap).length > 0 && wallets.length > 0) { | |
| updateStats(calculatedStats); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [tickerMap]); // tickerMap 변경 시에만 실행 | |
| // 시세 또는 지갑 변동 시 통계 업데이트 | |
| useEffect(() => { | |
| // costBasisMap이 로드된 이후에만 통계를 업데이트하여 초기 렌더링 시 불필요한 호출 방지 | |
| if (Object.keys(costBasisMap).length > 0 && wallets.length > 0) { | |
| updateStats(calculatedStats); | |
| } | |
| }, [calculatedStats, updateStats, costBasisMap, wallets]); |
| // Zustand store의 원시 상태 구독 (tickerMap, wallets, costBasisMap이 변경되면 재계산) | ||
| const wallets = useWalletStore((state) => state.wallets); | ||
| const tickerMap = useWalletStore((state) => state.tickerMap); | ||
| const costBasisMap = useWalletStore((state) => state.costBasisMap); | ||
|
|
||
| // useMemo로 계산 - tickerMap, wallets, costBasisMap 변경 시 재계산 | ||
| const stats = useMemo(() => { | ||
| const krwWallet = wallets.find((w) => w.coin_id === "KRW"); | ||
| const heldKRW = krwWallet?.amount || 0; | ||
|
|
||
| // getCoinEvaluation 로직을 직접 구현 (tickerMap 의존성 명시) | ||
| const getCoinEval = (coinId: string, amount: number): number => { | ||
| if (coinId === "KRW") return amount; | ||
|
|
||
| // Display 형식("COMP/KRW")을 Upbit 형식("KRW-COMP")으로 변환 | ||
| const upbitFormat = coinId.includes("/") ? `${coinId.split("/")[1]}-${coinId.split("/")[0]}` : coinId; | ||
|
|
||
| const ticker = tickerMap[upbitFormat] || tickerMap[coinId]; | ||
| if (!ticker) return 0; | ||
| return ticker.trade_price * amount; | ||
| }; | ||
|
|
||
| // 코인 평가액 계산 | ||
| const totalCoinEval = wallets | ||
| .filter((w) => w.coin_id !== "KRW") | ||
| .reduce((total, w) => total + getCoinEval(w.coin_id, w.amount), 0); | ||
|
|
||
| // 총 보유 자산 | ||
| const totalAssets = heldKRW + totalCoinEval; | ||
|
|
||
| // KRW 잔고 | ||
| const krwWallet = wallets.find((w) => w.coin_id === "KRW"); | ||
| const heldKRW = krwWallet?.amount || 0; | ||
| // 총 평가액 | ||
| const totalEvaluation = wallets.reduce((total, wallet) => { | ||
| return total + getCoinEval(wallet.coin_id, wallet.amount); | ||
| }, 0); | ||
|
|
||
| // 총 평가액 (보유 KRW + 코인 평가금액) | ||
| const totalEvaluation = getTotalEvaluation(); | ||
| // 총 매수 금액 | ||
| const totalPurchase = wallets | ||
| .filter((w) => w.coin_id !== "KRW" && w.amount > 0) | ||
| .reduce((sum, w) => { | ||
| const costInfo = costBasisMap[w.coin_id]; | ||
| const avgPrice = costInfo?.avgPrice ?? 0; | ||
| return sum + w.amount * avgPrice; | ||
| }, 0); | ||
|
|
||
| // 총 매수 (현재 보유 코인의 원가 합계) | ||
| const totalPurchase = getTotalPurchase(); | ||
| // 총 평가 손익 | ||
| const totalProfitLoss = totalCoinEval - totalPurchase; | ||
|
|
||
| // 총 평가 손익 | ||
| const totalProfitLoss = getTotalProfitLoss(); | ||
| // 총 평가 수익률 | ||
| const totalProfitRate = totalPurchase === 0 ? 0 : (totalProfitLoss / totalPurchase) * 100; | ||
|
|
||
| // 총 평가 수익률 | ||
| const totalProfitRate = getTotalProfitRate(); | ||
| return { | ||
| heldKRW, | ||
| totalAssets, | ||
| totalEvaluation, | ||
| totalPurchase, | ||
| totalProfitLoss, | ||
| totalProfitRate, | ||
| }; | ||
| }, [wallets, tickerMap, costBasisMap]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재 StatsAreaSection 컴포넌트 내에서 자산 통계를 계산하는 로직이 중복 구현되어 있습니다. 이 로직은 useWalletStore와 새로 추가된 useWalletStats 훅에도 이미 존재합니다. 이렇게 코드가 중복되면 유지보수가 어려워지고, 향후 계산 로직 변경 시 여러 곳을 수정해야 하는 번거로움이 발생합니다. 단일 책임 원칙(SRP)을 준수하고 코드의 재사용성을 높이기 위해, 이 컴포넌트에서 직접 계산하는 대신 useWalletStats 훅을 사용하도록 리팩토링하는 것을 권장합니다.
다음과 같이 수정할 수 있습니다:
import { useWalletStats } from "../hooks";
// ...
export const StatsAreaSection = () => {
// 실시간 지갑 데이터 구독
useRealtimeWallet();
// useWalletStats 훅을 사용하여 계산된 통계와 로딩 상태를 가져옵니다.
const { stats, isLoaded } = useWalletStats();
const data = {
heldKRW: stats.held_krw.toLocaleString("ko-KR"),
totalAssets: stats.total_assets.toLocaleString("ko-KR"),
totalBuy: (isLoaded ? stats.total_purchase : 0).toLocaleString("ko-KR"),
totalProfitLoss: `${stats.total_profit_loss >= 0 ? "+" : ""}${stats.total_profit_loss.toLocaleString("ko-KR")}`,
totalEvaluation: stats.total_evaluation.toLocaleString("ko-KR"),
totalProfitRate: `${stats.total_profit_rate >= 0 ? "+" : ""}${stats.total_profit_rate.toFixed(2)}`,
};
return (
// ... JSX
);
};
📝 요약 (Summary)
지갑 페이지의 자산 평가 로직(총 매수, 평가 손익 등)을 전면 리팩토링하고, Supabase Realtime을 도입하여 매수/매도 체결 시 지갑 잔고와 KRW 변화가 UI에 즉시 반영되도록 개선했습니다. 또한FSD 아키텍처 준수 및 단일 책임 원칙을 적용하여 코드 유지보수성을 대폭 향상시켰습니다.
✅ 주요 변경 사항 (Key Changes)
useWalletStore)useRealtimeWallet훅으로 wallet/trade 테이블 변경 감지 및 즉시 UI 반영WALLET_KEYS상수 도입으로 체계적인 캐시 관리useAssetList)💻 상세 구현 내용 (Implementation Details)
1. 정확한 자산 계산 로직 (Zustand Store)
useWalletStore에 다음과 같은 계산 공식을 적용했습니다:핵심 개선:
costBasisMap[coinId]?.totalCost단순 합산 (부정확)wallet.amount × avgPrice계산 (정확한 현재 보유분 원가)2. 실시간 동기화 훅 (
useRealtimeWallet)Supabase Realtime으로
wallet와trade테이블을 동시 구독:3. FSD 아키텍처 개선
API 레이어 정리:
getWalletAPI(deprecated, 405 에러 발생)userBalanceAPI(entities 레이어) +useGetUserBalance훅 (features 레이어)@/features/wallet에서 hooks exportBefore:
After:
4. 단일 책임 원칙 적용
비즈니스 로직을 UI 컴포넌트에서 분리:
useAssetList커스텀 훅 생성:HeldAssetsListSection컴포넌트 간소화:장점:
5. Query Key Factory 패턴
🚀 트러블 슈팅 (Trouble Shooting)
1. 매도 후 KRW 잔고 미반영 문제
OrderForm에서queryClient.invalidateQueries누락useRealtimeWallet에 trade 테이블 구독 추가WALLET_KEYS.balance.all무효화OrderForm의 수동 invalidation 제거 (Realtime이 자동 처리)2. 총 보유 자산 계산 오류
getTotalPurchase()가costBasisMap[coinId]?.totalCost사용totalCost는 거래 내역 기반 누적값이므로 부분 매도 시 부정확costBasisMap이 로드되기 전에 렌더링되어 0 표시wallet.amount × avgPrice공식으로 변경 (실제 보유량 기준)StatsAreaSection에서isCostBasisLoaded체크 추가getCoinCostBasis함수 제거 (단순화)3. Trade API null 참조 에러
TypeError: Cannot read properties of null (reading 'code')coinSelectError가 null일 수 있는데.code접근📊 데이터 흐름
성능 관련
데이터 정확성
📸 스크린샷 (Screenshots)
보유 자산 목록
실시간 업데이트
2025-12-02.5.12.46.mov
#️⃣ 관련 이슈 (Related Issues)