diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55b8b9b..9b9a7a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -689,8 +689,9 @@ function App() { }, [symbol]); useEffect(() => { - if (appSettingsLoaded) refreshPaperAccount(); - }, [appSettingsLoaded, refreshPaperAccount]); + if (!appSettingsLoaded || !formalLogin) return; + refreshPaperAccount(); + }, [appSettingsLoaded, formalLogin, refreshPaperAccount]); useEffect(() => { if (!appSettingsLoaded || !appDefaults.fcmPushEnabled) return; @@ -702,8 +703,9 @@ function App() { }, [appSettingsLoaded, appDefaults.fcmPushEnabled]); useEffect(() => { - if (appSettingsLoaded && paperTradingEnabled) refreshPaperAccount(symbol); - }, [symbol, appSettingsLoaded, paperTradingEnabled, refreshPaperAccount]); + if (!appSettingsLoaded || !formalLogin || !paperTradingEnabled) return; + refreshPaperAccount(symbol); + }, [symbol, appSettingsLoaded, formalLogin, paperTradingEnabled, refreshPaperAccount]); const [alerts, setAlerts] = useState>( (initial.alertPrices ?? []).map(p => ({ price: p, label: '' })) @@ -808,8 +810,12 @@ function App() { }, []); useEffect(() => { + if (!formalLogin) { + setActiveLiveSettings([]); + return; + } refreshActiveLiveSettings(); - }, [refreshActiveLiveSettings, isVirtualActive, virtualMonitoredMarkets.join(','), virtualSession.executionType, virtualSession.positionMode]); + }, [formalLogin, refreshActiveLiveSettings, isVirtualActive, virtualMonitoredMarkets.join(','), virtualSession.executionType, virtualSession.positionMode]); useEffect(() => { const onVirtualChanged = () => refreshActiveLiveSettings(); @@ -818,7 +824,7 @@ function App() { }, [refreshActiveLiveSettings]); useEffect(() => { - if (!appSettingsLoaded) return; + if (!appSettingsLoaded || !formalLogin) return; if (isVirtualActive) { const active = activeLiveSettings.find(s => s.market === symbol); setLiveStrategyCandleTypes(active?.strategyCandleTypes); @@ -827,7 +833,7 @@ function App() { loadLiveStrategySettings(symbol) .then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes)) .catch(() => { /* optional */ }); - }, [symbol, appSettingsLoaded, appDefaults.liveStrategyId, isVirtualActive, activeLiveSettings]); + }, [symbol, appSettingsLoaded, formalLogin, appDefaults.liveStrategyId, isVirtualActive, activeLiveSettings]); const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => { if (isVirtualActive) { @@ -876,6 +882,10 @@ function App() { }, [monitoredMarkets, activeLiveSettings, marketSubscriptions, liveStrategyCandleTypes]); useEffect(() => { + if (!formalLogin) { + setMarketSubscriptions([]); + return; + } if (monitoredMarkets.length === 0) { setMarketSubscriptions([]); return; @@ -893,7 +903,7 @@ function App() { loadActiveLiveStrategySettings() .then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list))) .catch(() => { /* liveStompSubscriptions 폴백 */ }); - }, [isVirtualActive, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(','), activeLiveSettings]); + }, [formalLogin, isVirtualActive, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(','), activeLiveSettings]); const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => { if (isVirtualActive) return; @@ -998,8 +1008,12 @@ function App() { }, []); useEffect(() => { + if (!formalLogin) { + setLiveStrategies([]); + return; + } refreshLiveStrategies(); - }, [menuPage, showLivePanel, refreshLiveStrategies]); + }, [menuPage, showLivePanel, formalLogin, refreshLiveStrategies]); const liveNotifierRef = useRef(null); const clearLiveMarkers = useCallback(() => { @@ -2653,6 +2667,7 @@ function App() { onOpenChange={isMobile ? setMobileRightOpen : undefined} activeTab={isMobile ? mobileRightTab : undefined} onTabChange={isMobile ? setMobileRightTab : undefined} + wsFeedEnabled={chartScreenActive && useUpbit} market={chartSymbol} tradePrice={chartTicker?.tradePrice ?? null} asks={orderbook.asks} diff --git a/frontend/src/components/RightSidePanel.tsx b/frontend/src/components/RightSidePanel.tsx index 6946d1d..ff4ec88 100644 --- a/frontend/src/components/RightSidePanel.tsx +++ b/frontend/src/components/RightSidePanel.tsx @@ -46,6 +46,8 @@ export interface RightSidePanelProps { paperTradingEnabled?: boolean; paperAutoTradeEnabled?: boolean; onPaperOrderFilled?: () => void; + /** 차트 화면·업비트 종목일 때만 체결 WS/REST */ + wsFeedEnabled?: boolean; } const RightSidePanel: React.FC = ({ @@ -74,6 +76,7 @@ const RightSidePanel: React.FC = ({ paperTradingEnabled = false, paperAutoTradeEnabled = false, onPaperOrderFilled, + wsFeedEnabled = true, }) => { const [openInternal, setOpenInternal] = useState(false); const [tabInternal, setTabInternal] = useState('trade'); @@ -81,7 +84,7 @@ const RightSidePanel: React.FC = ({ const isOpenControlled = openControlled !== undefined && onOpenChange !== undefined; const open = isOpenControlled ? !!openControlled : openInternal; - const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market, open); + const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market, open && wsFeedEnabled); const setOpen = (next: boolean | ((p: boolean) => boolean)) => { const cur = isOpenControlled ? !!openControlled : openInternal; const value = typeof next === 'function' ? next(cur) : next; diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index ac4ab57..4d80c25 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -5,7 +5,7 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { ReactFlowProvider } from '@xyflow/react'; import DraggableModalFrame from './DraggableModalFrame'; import type { Theme } from '../types/index'; -import { loadStrategies, loadStrategy, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; +import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import type { LogicNode } from '../utils/strategyTypes'; import { @@ -512,6 +512,7 @@ export default function StrategyEditorPage({ theme }: Props) { useEffect(() => { saveStratsLocal(strategies); }, [strategies]); useEffect(() => { + if (!hasRegisteredUser()) return; loadStrategies().then(async list => { if (list?.length) { setStrategies(list.map(s => ({ @@ -647,6 +648,9 @@ export default function StrategyEditorPage({ theme }: Props) { // flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준) void (async () => { + if (!hasRegisteredUser() || s.id == null) return; + const exists = await loadStrategy(s.id); + if (!exists) return; const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout }); if (!synced) return; try { diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 68cd4db..b183199 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -52,6 +52,14 @@ function getDeviceId(): string { return id; } +/** 정식 회원(userId) — 매매·전략·모의 API 필수 */ +export function hasRegisteredUser(): boolean { + const id = localStorage.getItem('gc_user_id'); + if (!id) return false; + const n = Number(id); + return Number.isFinite(n) && n > 0; +} + function authHeaders(): Record { const headers: Record = { 'Content-Type': 'application/json', @@ -90,7 +98,12 @@ async function request(path: string, init?: RequestInit): Promise { }); if (res.status === 204) return null; if (!res.ok) { - console.error(`[backendApi] ${init?.method ?? 'GET'} ${path} → ${res.status}`); + const method = init?.method ?? 'GET'; + const guest403 = res.status === 403 && !hasRegisteredUser(); + const missing404 = res.status === 404 && method === 'GET'; + if (!guest403 && !missing404) { + console.error(`[backendApi] ${method} ${path} → ${res.status}`); + } return null; } return (await res.json()) as T; @@ -759,6 +772,7 @@ export interface PaperOrderRequest { } export async function loadPaperSummary(markPrices?: Record): Promise { + if (!hasRegisteredUser()) return null; if (markPrices && Object.keys(markPrices).length > 0) { return request('/paper/summary', { method: 'POST', @@ -1041,11 +1055,13 @@ export interface StrategyDto { /** 전략 목록 로드 */ export async function loadStrategies(): Promise { + if (!hasRegisteredUser()) return []; return (await request('/strategies')) ?? []; } /** 단일 전략 로드 */ export async function loadStrategy(id: number): Promise { + if (!hasRegisteredUser()) return null; return request(`/strategies/${id}`); } @@ -1290,6 +1306,9 @@ export interface LiveStrategySettingsDto { export async function loadLiveStrategySettings( market: string, ): Promise { + if (!hasRegisteredUser()) { + return { market, strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY' }; + } return (await request( `/strategy/settings?market=${encodeURIComponent(market)}`, )) ?? { market, strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY' }; @@ -1317,6 +1336,7 @@ export function expandLiveStrategySubscriptions( /** 현재 디바이스에서 실시간 체크 ON + 전략 지정된 종목 목록 */ export async function loadActiveLiveStrategySettings(): Promise { + if (!hasRegisteredUser()) return []; return (await request('/strategy/settings/active')) ?? []; } @@ -1376,12 +1396,14 @@ export async function fetchLiveConditionStatus( /** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */ export async function loadStrategyTimeframes(strategyId: number): Promise { + if (!hasRegisteredUser()) return ['1m']; const list = await request(`/strategies/${strategyId}/timeframes`); return list?.length ? list : ['1m']; } /** DB DSL TIMEFRAME 래핑 보정 (3분봉 전략이 1m으로 평가되는 레거시 수정) */ export async function repairStrategyTimeframes(strategyId: number): Promise { + if (!hasRegisteredUser()) return; await request(`/strategies/${strategyId}/repair-timeframes`, { method: 'POST' }); } diff --git a/frontend/src/utils/strategyTimeframeSync.ts b/frontend/src/utils/strategyTimeframeSync.ts index dad9a51..1eb70fe 100644 --- a/frontend/src/utils/strategyTimeframeSync.ts +++ b/frontend/src/utils/strategyTimeframeSync.ts @@ -146,7 +146,7 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded( strategy?: StrategyDto | null, ): Promise { const strat = (await loadStrategy(strategyId)) ?? strategy ?? null; - if (!strat) return true; + if (!strat) return false; const uiTfs = collectUiEvaluationTimeframes(strategyId, strat); if (uiTfs.length === 0) return true; diff --git a/frontend/src/utils/upbitWsBroker.ts b/frontend/src/utils/upbitWsBroker.ts index 6997828..ea45bd9 100644 --- a/frontend/src/utils/upbitWsBroker.ts +++ b/frontend/src/utils/upbitWsBroker.ts @@ -64,6 +64,12 @@ function listenerCount(): number { return listeners.size; } +function mayOpenSocket(): boolean { + if (listenerCount() === 0) return false; + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return false; + return true; +} + function cancelPendingDisconnect(): void { if (disconnectTimer) { clearTimeout(disconnectTimer); @@ -134,7 +140,7 @@ function dispatch(text: string): void { } function scheduleReconnect(): void { - if (listenerCount() === 0) return; + if (!mayOpenSocket()) return; if (reconnectTimer) return; const delay = Math.min(RECONNECT_BASE * 2 ** retryCount, RECONNECT_MAX); retryCount += 1; @@ -236,6 +242,17 @@ export function subscribeUpbitWsConnection(listener: ConnectionListener): () => return () => connectionListeners.delete(listener); } +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + cancelPendingConnect(); + if (listenerCount() === 0) teardownSocket(); + return; + } + if (mayOpenSocket()) scheduleConnect(); + }); +} + /** * 업비트 WS 채널 구독 (참조 카운트). codes 가 비어 있으면 구독하지 않음. */ diff --git a/frontend/src/utils/virtualTargetsHydrate.ts b/frontend/src/utils/virtualTargetsHydrate.ts index 6519a93..1572dcb 100644 --- a/frontend/src/utils/virtualTargetsHydrate.ts +++ b/frontend/src/utils/virtualTargetsHydrate.ts @@ -1,7 +1,7 @@ /** * ui_preferences.virtual.targets 가 비었을 때 서버·레거시에서 투자대상 복구 */ -import { loadActiveLiveStrategySettings, loadWatchlist } from './backendApi'; +import { hasRegisteredUser, loadActiveLiveStrategySettings, loadWatchlist } from './backendApi'; import { loadVirtualSession, loadVirtualTargets, @@ -67,6 +67,8 @@ export async function hydrateVirtualTargetsIfEmpty(): Promise 0) return fromLiveSettings(active, globalId);