Skip to content

Conversation

@Dobbymin
Copy link
Owner

@Dobbymin Dobbymin commented Dec 2, 2025

📝 요약 (Summary)

지갑 페이지의 자산 평가 로직(총 매수, 평가 손익 등)을 전면 리팩토링하고, Supabase Realtime을 도입하여 매수/매도 체결 시 지갑 잔고와 KRW 변화가 UI에 즉시 반영되도록 개선했습니다. 또한FSD 아키텍처 준수 및 단일 책임 원칙을 적용하여 코드 유지보수성을 대폭 향상시켰습니다.

✅ 주요 변경 사항 (Key Changes)

  • Zustand Store 개선: 실시간 시세와 평단가 기반 자산 계산 로직 구현 (useWalletStore)
  • Realtime 동기화: useRealtimeWallet 훅으로 wallet/trade 테이블 변경 감지 및 즉시 UI 반영
  • Query Key Factory: WALLET_KEYS 상수 도입으로 체계적인 캐시 관리
  • FSD 아키텍처 준수: API 레이어 정리 및 entities/features 분리 개선
  • 단일 책임 원칙 적용: 비즈니스 로직을 커스텀 훅으로 분리 (useAssetList)
  • 타입 안정성 강화: TypeScript 에러 제거 및 null-safe 처리 개선

💻 상세 구현 내용 (Implementation Details)

1. 정확한 자산 계산 로직 (Zustand Store)

useWalletStore에 다음과 같은 계산 공식을 적용했습니다:

// 총 매수 금액: 현재 보유 수량 × 평단가 (부분 매도 후에도 정확)
getTotalPurchase: () => {
  return 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);
}

// 총 평가 금액: 보유 수량 × 실시간 현재가
getTotalEvaluation: () => {
  return wallets.reduce((total, wallet) => {
    return total + getCoinEvaluation(wallet.coin_id, wallet.amount);
  }, 0);
}

핵심 개선:

  • 기존: costBasisMap[coinId]?.totalCost 단순 합산 (부정확)
  • 개선: wallet.amount × avgPrice 계산 (정확한 현재 보유분 원가)

2. 실시간 동기화 훅 (useRealtimeWallet)

Supabase Realtime으로 wallettrade 테이블을 동시 구독:

// wallet 테이블 구독
walletChannel = supabase
  .channel(`wallet-changes-${userId}`)
  .on("postgres_changes",
    { event: "*", schema: "public", table: "wallet", filter: `user_id=eq.${userId}` },
    () => {
      queryClient.invalidateQueries({ queryKey: WALLET_KEYS.balance.all });
      refetchWallet();
    }
  )
  .subscribe();

// trade 테이블 구독 (거래 발생 시 지갑도 갱신)
tradeChannel = supabase
  .channel(`trade-changes-${userId}`)
  .on("postgres_changes",
    { event: "*", schema: "public", table: "trade", filter: `user_id=eq.${userId}` },
    () => {
      queryClient.invalidateQueries({ queryKey: WALLET_KEYS.balance.all });
      queryClient.invalidateQueries({ queryKey: ["trades"] });
      refetchWallet();
    }
  )
  .subscribe();

3. FSD 아키텍처 개선

API 레이어 정리:

  • ❌ 제거: getWalletAPI (deprecated, 405 에러 발생)
  • ✅ 통일: userBalanceAPI (entities 레이어) + useGetUserBalance 훅 (features 레이어)
  • ✅ Barrel exports 추가: @/features/wallet에서 hooks export

Before:

// OrderForm.tsx - 직접 API 호출
const { data: walletData } = useQuery({
  queryKey: ["wallet"],
  queryFn: () => userBalanceAPI(),
});

After:

// OrderForm.tsx - FSD 구조 준수
import { useGetUserBalance } from "@/features/wallet";

const { data: walletData } = useGetUserBalance();

4. 단일 책임 원칙 적용

비즈니스 로직을 UI 컴포넌트에서 분리:

useAssetList 커스텀 훅 생성:

// hooks/useAssetList.ts
export const useAssetList = (): AssetItem[] => {
  const { wallets, tickerMap, costBasisMap, getCoinEvaluation } = useWalletStore();

  return useMemo(() => {
    return wallets
      .filter((w) => w.coin_id !== "KRW")
      .map((wallet) => {
        // 평가액, 손익 계산 로직
        const costInfo = costBasisMap[wallet.coin_id];
        const avgPrice = costInfo?.avgPrice ?? 0;
        const buyTotalAmount = wallet.amount * avgPrice;
        // ... 포맷팅 로직
      });
  }, [wallets, tickerMap, costBasisMap, getCoinEvaluation]);
};

HeldAssetsListSection 컴포넌트 간소화:

export const HeldAssetsListSection = () => {
  useRealtimeWallet(); // 데이터 동기화
  const assetList = useAssetList(); // 비즈니스 로직

  return (
    <Table>
      {/* UI 렌더링만 담당 */}
    </Table>
  );
};

장점:

  • 테스트 가능성 향상 (로직 독립적 테스트)
  • 재사용성 증가 (다른 컴포넌트에서도 사용 가능)
  • 관심사 분리로 가독성 향상
  • 성능 최적화 (useMemo 메모이제이션)

5. Query Key Factory 패턴

// constants/wallet-keys.ts
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,
  },
};

🚀 트러블 슈팅 (Trouble Shooting)

1. 매도 후 KRW 잔고 미반영 문제

  • 문제: 코인 매도 시 코인 수량은 줄어드는데 KRW 잔고가 즉시 늘어나지 않음
  • 원인:
    • OrderForm에서 queryClient.invalidateQueries 누락
    • Realtime 채널이 wallet만 구독하고 trade는 구독 안 함
  • 해결:
    1. useRealtimeWallet에 trade 테이블 구독 추가
    2. wallet과 trade 양쪽 모두에서 WALLET_KEYS.balance.all 무효화
    3. OrderForm의 수동 invalidation 제거 (Realtime이 자동 처리)

2. 총 보유 자산 계산 오류

  • 문제:
    • COMP 코인 보유 중 손실 발생했으나 총 매수/손익에 반영 안 됨
    • 총 매수가 0 KRW로 표시됨
  • 원인:
    • getTotalPurchase()costBasisMap[coinId]?.totalCost 사용
    • totalCost는 거래 내역 기반 누적값이므로 부분 매도 시 부정확
    • costBasisMap이 로드되기 전에 렌더링되어 0 표시
  • 해결:
    1. wallet.amount × avgPrice 공식으로 변경 (실제 보유량 기준)
    2. StatsAreaSection에서 isCostBasisLoaded 체크 추가
    3. getCoinCostBasis 함수 제거 (단순화)

3. Trade API null 참조 에러

  • 문제: TypeError: Cannot read properties of null (reading 'code')
  • 원인: coinSelectError가 null일 수 있는데 .code 접근
  • 해결: Null-safe 체크 추가
// Before
if ((coinSelectError as { code?: string }).code === "PGRST116") { ... }

// After
if (coinSelectError && coinSelectError.code === "PGRST116") { ... }

📊 데이터 흐름

┌──────────────────────────────────────┐
│         StatsAreaSection             │
│      (UI - 총 자산 통계)              │
└──────────────────────────────────────┘
                 ↓
┌──────────────────────────────────────┐
│       useRealtimeWallet              │
│    (실시간 데이터 동기화)              │
│  ┌────────────┬────────────────────┐ │
│  │ Wallet API │ Ticker API (1s)   │ │
│  │ Trades API │ CostBasis 계산     │ │
│  └────────────┴────────────────────┘ │
└──────────────────────────────────────┘
                 ↓
┌──────────────────────────────────────┐
│         useWalletStore               │
│      (Zustand + DevTools)            │
│  • getTotalEvaluation()              │
│  • getTotalPurchase()                │
│  • getTotalProfitLoss()              │
│  • getTotalProfitRate()              │
└──────────────────────────────────────┘
                 ↑
┌──────────────────────────────────────┐
│      Supabase Realtime               │
│  ┌────────────┬────────────────────┐ │
│  │ wallet 구독 │ trade 구독         │ │
│  │ (INSERT/   │ (INSERT/           │ │
│  │  UPDATE/   │  UPDATE/           │ │
│  │  DELETE)   │  DELETE)           │ │
│  └────────────┴────────────────────┘ │
└──────────────────────────────────────┘

⚠️ 알려진 이슈 및 참고 사항 (Known Issues & Notes)

성능 관련

  • Polling Interval: Ticker와 Trades API를 1초마다 폴링 중
    • 장점: 실시간성 보장
    • 단점: 서버 부하 가능성
    • 대안: WebSocket 또는 폴링 간격 조정 고려

데이터 정확성

  • Dust(자투리) 처리: 소수점 단위의 잔여 수량이 평가 금액에 반영됨
    • 추후 '소액 자산 숨기기' 기능 고려

📸 스크린샷 (Screenshots)

보유 자산 목록

image

실시간 업데이트

2025-12-02.5.12.46.mov

#️⃣ 관련 이슈 (Related Issues)

@Dobbymin Dobbymin self-assigned this Dec 2, 2025
Copilot AI review requested due to automatic review settings December 2, 2025 08:13
@Dobbymin Dobbymin added the 😡 Bug Fix 발생한 버그를 수정하는 경우 label Dec 2, 2025
@Dobbymin Dobbymin linked an issue Dec 2, 2025 that may be closed by this pull request
@vercel
Copy link

vercel bot commented Dec 2, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
dobbit Ready Ready Preview Comment Dec 2, 2025 8:46am

Copy link

Copilot AI left a 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 wallet and trade tables 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,
Copy link

Copilot AI Dec 2, 2025

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.

Suggested change
refetchInterval: 1000,
refetchInterval: false,

Copilot uses AI. Check for mistakes.
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --webpack",
Copy link

Copilot AI Dec 2, 2025

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:

  1. Turbopack is significantly faster for development
  2. This change should be documented in the PR description if it's intentional
  3. 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.

Suggested change
"dev": "next dev --webpack",
"dev": "next dev",

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +71
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]);
Copy link

Copilot AI Dec 2, 2025

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).

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +73
// 시세 변동 시 통계 업데이트 (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 변경 시에만 실행
Copy link

Copilot AI Dec 2, 2025

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:

  1. Database write operations every second are expensive and unnecessary
  2. Multiple users polling at 1-second intervals could significantly impact database performance
  3. The wallet_stats table is being written to but doesn't appear to be read anywhere in this PR

Recommendations:

  1. If wallet_stats persistence is needed, implement debouncing (e.g., update every 5-10 seconds instead of 1 second)
  2. If the stats are only needed in the UI, remove database persistence entirely and use the store getters directly
  3. Consider whether this hook is actually needed - it's defined but doesn't appear to be used in the PR

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +59
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;
},
Copy link

Copilot AI Dec 2, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +33
// 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;
};
Copy link

Copilot AI Dec 2, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +24
/**
* 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}`;
};
Copy link

Copilot AI Dec 2, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +73
// tickerMap이 업데이트되고 wallets가 있을 때만 실행
if (Object.keys(tickerMap).length > 0 && wallets.length > 0) {
updateStats(calculatedStats);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tickerMap]); // tickerMap 변경 시에만 실행
Copy link

Copilot AI Dec 2, 2025

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:

  1. Add calculatedStats to the dependencies array (though this might cause the effect to run too frequently)
  2. Move the updateStats(calculatedStats) call inside the useMemo block that calculates stats
  3. Restructure to avoid this issue by calling updateStats directly 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]);
Suggested change
// 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,
]);

Copilot uses AI. Check for mistakes.
* @param refetchInterval 갱신 주기 (ms), 기본값 1000ms (1초)
*/
export const useRealtimeTicker = (markets: string, refetchInterval: number = 1000) => {
export const useRealtimeTicker = (markets: string, refetchInterval: number = 500) => {
Copy link

Copilot AI Dec 2, 2025

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:

  1. More frequent API calls to external service (Upbit)
  2. Increased risk of hitting rate limits
  3. 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.

Suggested change
export const useRealtimeTicker = (markets: string, refetchInterval: number = 500) => {
export const useRealtimeTicker = (markets: string, refetchInterval: number = 1000) => {

Copilot uses AI. Check for mistakes.
Comment on lines 3 to 5
all: ["user-balance"],
krw: () => [...WALLET_KEYS.balance.all, "krw"],
coin: (coinId: string) => [...WALLET_KEYS.balance.all, "coin", coinId],
Copy link

Copilot AI Dec 2, 2025

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
@Dobbymin
Copy link
Owner Author

Dobbymin commented Dec 2, 2025

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a 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 의존성 배열 관련 이슈는 꼭 확인해주시면 좋겠습니다. 훌륭한 리팩토링 작업에 감사드립니다.

Comment on lines 2 to 6
balance: {
all: ["user-balance"],
krw: () => [...WALLET_KEYS.balance.all, "krw"],
coin: (coinId: string) => [...WALLET_KEYS.balance.all, "coin", coinId],
},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

WALLET_KEYS 객체 내부에서 자기 자신을 참조하여 krwcoin 속성을 정의하고 있습니다. 이 경우 WALLET_KEYS가 완전히 초기화되기 전에 참조가 발생하여 런타임에 ReferenceError: Cannot access 'WALLET_KEYS' before initialization 오류가 발생합니다. 이 문제를 해결하기 위해 자기 참조를 제거하는 것을 권장합니다. 또한, react-query의 쿼리 키는 불변성을 유지하는 것이 좋으므로 as const를 추가하여 타입 안정성을 높이는 것을 제안합니다.

Suggested change
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,
},

Comment on lines +67 to +73
useEffect(() => {
// tickerMap이 업데이트되고 wallets가 있을 때만 실행
if (Object.keys(tickerMap).length > 0 && wallets.length > 0) {
updateStats(calculatedStats);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tickerMap]); // tickerMap 변경 시에만 실행

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

useEffect의 의존성 배열이 [tickerMap]으로만 설정되어 있어 잠재적인 버그가 발생할 수 있습니다. calculatedStatswalletscostBasisMap의 변경에도 영향을 받는데, 현재 구조에서는 tickerMap이 변경될 때만 updateStats가 호출됩니다. 이로 인해 지갑 잔고가 변경되어도 통계가 업데이트되지 않고 오래된 데이터가 서버로 전송될 수 있습니다. eslint-disable-next-line 주석이 있지만, 이는 버그의 원인이 될 수 있으므로 의존성 배열을 정확하게 수정하는 것이 좋습니다. calculatedStats와 이를 계산하는 데 필요한 모든 상태를 의존성 배열에 포함시켜 데이터의 정합성을 보장해야 합니다.

Suggested change
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]);

Comment on lines +13 to +71
// 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]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 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
  );
};

@Dobbymin Dobbymin merged commit 661e520 into main Dec 2, 2025
3 checks passed
@Dobbymin Dobbymin deleted the refactor#06-wallet branch December 2, 2025 08:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

😡 Bug Fix 발생한 버그를 수정하는 경우

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[버그] 지갑 페이지 실시간 연동

2 participants