보안 점검 및 대시보드 문구 수정

This commit is contained in:
2026-02-13 15:44:41 +09:00
parent 1ac907cd27
commit 7c194d7452
31 changed files with 686 additions and 438 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
@@ -14,6 +14,7 @@ import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocke
import { useStockOverview } from "@/features/trade/hooks/useStockOverview";
import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice";
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import type {
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
@@ -27,9 +28,10 @@ import type {
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function TradeContainer() {
const searchParams = useSearchParams();
const symbolParam = searchParams.get("symbol");
const nameParam = searchParams.get("name");
const router = useRouter();
const consumePendingTarget = useTradeNavigationStore(
(state) => state.consumePendingTarget,
);
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
@@ -60,28 +62,47 @@ export function TradeContainer() {
useStockOverview();
/**
* [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드
* 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다.
* [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
* 과거 링크/브라우저 히스토리로 유입되는 query 오염을 제거하기 위한 방어 로직입니다.
*/
useEffect(() => {
if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) {
// 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행
if (selectedStock?.symbol !== symbolParam) {
setKeyword(nameParam || symbolParam);
appendSearchHistory({
symbol: symbolParam,
name: nameParam || symbolParam,
market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨
});
loadOverview(symbolParam, verifiedCredentials);
}
if (typeof window === "undefined") return;
if (!window.location.search) return;
router.replace("/trade");
}, [router]);
/**
* [Effect] Dashboard에서 넘긴 종목을 1회 소비해 자동 로드합니다.
* @remarks UI 흐름: Dashboard 종목 클릭 -> useTradeNavigationStore.setPendingTarget -> /trade -> consumePendingTarget -> loadOverview
*/
useEffect(() => {
if (!isKisVerified || !verifiedCredentials || !_hasHydrated) {
return;
}
const pendingTarget = consumePendingTarget();
if (!pendingTarget) return;
if (selectedStock?.symbol === pendingTarget.symbol) {
return;
}
setKeyword(pendingTarget.name || pendingTarget.symbol);
appendSearchHistory({
symbol: pendingTarget.symbol,
name: pendingTarget.name || pendingTarget.symbol,
market: pendingTarget.market,
});
loadOverview(
pendingTarget.symbol,
verifiedCredentials,
pendingTarget.market,
);
}, [
symbolParam,
nameParam,
isKisVerified,
verifiedCredentials,
_hasHydrated,
consumePendingTarget,
selectedStock?.symbol,
loadOverview,
setKeyword,

View File

@@ -6,7 +6,7 @@ import type {
DashboardStockSearchItem,
} from "@/features/trade/types/trade.types";
const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1";
const SEARCH_HISTORY_STORAGE_KEY = "joorine:stock-search-history:v1";
const SEARCH_HISTORY_LIMIT = 12;
interface StoredSearchHistory {
@@ -39,7 +39,10 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
version: 1,
items,
};
window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload));
window.localStorage.setItem(
SEARCH_HISTORY_STORAGE_KEY,
JSON.stringify(payload),
);
}
/**
@@ -50,14 +53,16 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
export function useStockSearch() {
// ========== SEARCH STATE ==========
const [keyword, setKeyword] = useState("");
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
const [searchResults, setSearchResults] = useState<
DashboardStockSearchItem[]
>([]);
const [error, setError] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
// ========== SEARCH HISTORY STATE ==========
const [searchHistory, setSearchHistory] = useState<DashboardStockSearchHistoryItem[]>(
() => readSearchHistory(),
);
const [searchHistory, setSearchHistory] = useState<
DashboardStockSearchHistoryItem[]
>(() => readSearchHistory());
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
const abortRef = useRef<AbortController | null>(null);
@@ -142,7 +147,9 @@ export function useStockSearch() {
*/
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
setSearchHistory((prev) => {
const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol);
const deduped = prev.filter(
(historyItem) => historyItem.symbol !== item.symbol,
);
const nextItems: DashboardStockSearchHistoryItem[] = [
{ ...item, savedAt: Date.now() },
...deduped,

View File

@@ -0,0 +1,56 @@
"use client";
import { create } from "zustand";
import type { DashboardStockSearchItem } from "@/features/trade/types/trade.types";
/**
* @file features/trade/store/use-trade-navigation-store.ts
* @description 대시보드 -> 트레이드 이동 시 URL 쿼리 없이 종목 선택 상태를 1회 전달합니다.
*/
export interface TradeNavigationTarget {
symbol: string;
name: string;
market: DashboardStockSearchItem["market"];
requestedAt: number;
}
interface TradeNavigationStoreState {
pendingTarget: TradeNavigationTarget | null;
}
interface TradeNavigationStoreActions {
setPendingTarget: (target: Omit<TradeNavigationTarget, "requestedAt">) => void;
consumePendingTarget: () => TradeNavigationTarget | null;
clearPendingTarget: () => void;
}
/**
* @description 트레이드 화면 진입 시 사용할 종목 이동 상태 store
* @remarks UI 흐름: Dashboard 종목 클릭 -> setPendingTarget -> /trade 이동 -> TradeContainer consumePendingTarget -> 종목 로드
* @see features/dashboard/components/StockDetailPreview.tsx setPendingTarget 호출
* @see features/trade/components/TradeContainer.tsx consumePendingTarget 호출
*/
export const useTradeNavigationStore = create<
TradeNavigationStoreState & TradeNavigationStoreActions
>()((set, get) => ({
pendingTarget: null,
setPendingTarget: (target) =>
set({
pendingTarget: {
...target,
requestedAt: Date.now(),
},
}),
consumePendingTarget: () => {
const target = get().pendingTarget;
if (!target) return null;
set({ pendingTarget: null });
return target;
},
clearPendingTarget: () => set({ pendingTarget: null }),
}));