대시보드 중간 커밋

This commit is contained in:
2026-02-10 11:16:39 +09:00
parent 851a2acd69
commit 105d75e1f8
52 changed files with 6826 additions and 1287 deletions

View File

@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import {
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
} from "@/features/dashboard/utils/kis-realtime.utils";
const KRX_ORDERBOOK_TR_ID = "H0STASP0";
const KRX_OVERTIME_ORDERBOOK_TR_ID = "H0STOAA0";
const DEFAULT_ORDERBOOK_TR_ID = "H0UNASP0";
/**
* @description 한국 시간 기준으로 시간외 단일가 구간(16:00~18:00)인지 확인합니다.
* @see resolveOrderBookTrId 시간대별 TR ID 선택
*/
function isKrxOvertimeInKst(now = new Date()) {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Seoul",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(now);
const partMap = new Map(parts.map((part) => [part.type, part.value]));
const weekday = partMap.get("weekday");
if (weekday === "Sat" || weekday === "Sun") {
return false;
}
const hour = Number(partMap.get("hour") ?? "0");
const minute = Number(partMap.get("minute") ?? "0");
const totalMinutes = hour * 60 + minute;
return totalMinutes >= 16 * 60 && totalMinutes < 18 * 60;
}
/**
* @description 시장/시간대에 맞는 국내주식 호가 TR ID를 선택합니다.
* @see .tmp/open-trading-api/examples_user/domestic_stock/domestic_stock_functions_ws.py
*/
function resolveOrderBookTrId(market: "KOSPI" | "KOSDAQ" | undefined) {
if (market === "KOSPI" || market === "KOSDAQ") {
return isKrxOvertimeInKst()
? KRX_OVERTIME_ORDERBOOK_TR_ID
: KRX_ORDERBOOK_TR_ID;
}
return DEFAULT_ORDERBOOK_TR_ID;
}
/**
* @description KIS 실시간 호가 웹소켓 훅
* @see parseKisRealtimeOrderbook 웹소켓 payload 파싱
*/
export function useKisOrderbookWebSocket(
symbol: string | undefined,
market: "KOSPI" | "KOSDAQ" | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
) {
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const trId = resolveOrderBookTrId(market);
useEffect(() => {
setRealtimeOrderBook(null);
setError(null);
if (!symbol || !isVerified || !credentials) {
socketRef.current?.close();
socketRef.current = null;
approvalKeyRef.current = null;
return;
}
let disposed = false;
let socket: WebSocket | null = null;
const connect = async () => {
try {
console.log("[OrderBook WS] 연결 시작", { symbol, trId, market });
const approvalKey = await useKisRuntimeStore
.getState()
.getOrFetchApprovalKey();
if (!approvalKey) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
if (disposed) return;
approvalKeyRef.current = approvalKey;
const wsBaseUrl =
process.env.NEXT_PUBLIC_KIS_WS_URL ||
"ws://ops.koreainvestment.com:21000";
const wsUrl = `${wsBaseUrl}/tryitout/${trId}`;
socket = new WebSocket(wsUrl);
socketRef.current = socket;
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
const subscribeMessage = buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
trId,
"1",
);
socket?.send(JSON.stringify(subscribeMessage));
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
const orderBook = parseKisRealtimeOrderbook(event.data, symbol);
if (!orderBook) {
return;
}
orderBook.tradingEnv = credentials.tradingEnv;
setRealtimeOrderBook(orderBook);
};
socket.onerror = () => {
if (disposed) return;
setIsConnected(false);
};
socket.onclose = () => {
if (disposed) return;
setIsConnected(false);
};
} catch (err) {
if (disposed) return;
setError(
err instanceof Error
? err.message
: "실시간 호가 웹소켓 초기화 중 오류가 발생했습니다.",
);
setIsConnected(false);
}
};
void connect();
return () => {
disposed = true;
setIsConnected(false);
const approvalKey = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
const unsubscribeMessage = buildKisRealtimeMessage(
approvalKey,
symbol,
trId,
"2",
);
socket.send(JSON.stringify(unsubscribeMessage));
}
socket?.close();
if (socketRef.current === socket) {
socketRef.current = null;
}
approvalKeyRef.current = null;
};
}, [isVerified, symbol, market, credentials, trId]);
return {
realtimeOrderBook,
isConnected,
error,
};
}

View File

@@ -0,0 +1,348 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import {
appendRealtimeTick,
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
toTickOrderValue,
} from "@/features/dashboard/utils/kis-realtime.utils";
const KIS_REALTIME_TR_ID_REAL = "H0STCNT0";
const KIS_REALTIME_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가
const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0";
/** 호가 TR ID (정규장 / 시간외) */
const ORDERBOOK_TR_ID_KRX = "H0STASP0";
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
function resolveOrderBookTrId() {
const now = new Date();
const h = now.getHours();
const m = now.getMinutes();
const t = h * 100 + m;
return t >= 1600 && t < 1800 ? ORDERBOOK_TR_ID_OVERTIME : ORDERBOOK_TR_ID_KRX;
}
const MAX_TRADE_TICKS = 10; // 체결 테이프용 최대 개수
function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) {
if (tradingEnv === "mock") return KIS_REALTIME_TR_ID_MOCK;
// 현재 시간(KST) 체크 - 사용자 브라우저 시간 기준
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
const time = hours * 100 + minutes;
// 16:00 ~ 18:00 사이는 시간외 단일가 TR ID 사용
if (time >= 1600 && time < 1800) {
return KIS_REALTIME_TR_ID_OVERTIME;
}
return KIS_REALTIME_TR_ID_REAL;
}
/**
* @description 통합 실시간 체결 웹소켓 훅 (H0STCNT0)
* - StockHeader: 실시간 현재가, 등락률, 시가/고가/저가/거래량
* - StockLineChart: 실시간 캔들 (분봉/일봉 등)
* - OrderBook (TradeTape): 최근 체결 내역 리스트
*/
export function useKisTradeWebSocket(
symbol: string | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
onTick?: (tick: DashboardRealtimeTradeTick) => void,
options?: {
/** 호가도 같은 WS에서 구독 (KIS 동시 연결 제한 우회) */
orderBookSymbol?: string;
orderBookMarket?: "KOSPI" | "KOSDAQ";
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
},
) {
// 1. StockHeader용 최신 데이터
const [latestTick, setLatestTick] =
useState<DashboardRealtimeTradeTick | null>(null);
// 2. StockLineChart용 캔들 데이터
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
[],
);
// 3. TradeTape용 최근 체결 리스트
const [recentTradeTicks, setRecentTradeTicks] = useState<
DashboardRealtimeTradeTick[]
>([]);
// 연결 상태
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const lastTickOrderRef = useRef<number>(-1);
const seenTickRef = useRef<Set<string>>(new Set());
const realtimeTrId = credentials
? resolveRealtimeTrId(credentials.tradingEnv)
: null;
// 데이터 없음 감지 (8초)
useEffect(() => {
if (!isConnected || lastTickAt) return;
const noTickTimer = window.setTimeout(() => {
setError(
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.",
);
}, 8000);
return () => {
window.clearTimeout(noTickTimer);
};
}, [isConnected, lastTickAt]);
const obSymbol = options?.orderBookSymbol;
const onOrderBookMsg = options?.onOrderBookMessage;
const obTrId = obSymbol ? resolveOrderBookTrId() : null;
// 웹소켓 연결 로직
useEffect(() => {
// 초기화
setLatestTick(null);
setRealtimeCandles([]);
setRecentTradeTicks([]);
setError(null);
seenTickRef.current.clear();
if (!symbol || !isVerified || !credentials) {
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
}
approvalKeyRef.current = null;
setIsConnected(false);
return;
}
let disposed = false;
let socket: WebSocket | null = null;
const trId = resolveRealtimeTrId(credentials.tradingEnv);
const connect = async () => {
try {
setError(null);
setIsConnected(false);
const approvalKey = await useKisRuntimeStore
.getState()
.getOrFetchApprovalKey();
if (!approvalKey) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
if (disposed) return;
approvalKeyRef.current = approvalKey;
const wsBaseUrl =
process.env.NEXT_PUBLIC_KIS_WS_URL ||
"ws://ops.koreainvestment.com:21000";
socket = new WebSocket(`${wsBaseUrl}/tryitout/${trId}`);
socketRef.current = socket;
console.log("[WS URL]", `${wsBaseUrl}/tryitout/${trId}`);
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
// 체결 구독
const subscribeMessage = buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
trId,
"1",
);
socket?.send(JSON.stringify(subscribeMessage));
// 호가 구독 (같은 소켓에서)
if (obSymbol && obTrId && approvalKeyRef.current) {
const obSubscribe = buildKisRealtimeMessage(
approvalKeyRef.current,
obSymbol,
obTrId,
"1",
);
socket?.send(JSON.stringify(obSubscribe));
}
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
// 호가 메시지인지 먼저 확인 (TR ID: H0STASP0 / H0STOAA0)
if (obSymbol && onOrderBookMsg) {
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
if (orderBook) {
if (credentials) {
orderBook.tradingEnv = credentials.tradingEnv;
}
onOrderBookMsg(orderBook);
return;
}
}
const parsedTicks = parseKisRealtimeTickBatch(event.data, symbol);
if (parsedTicks.length === 0) {
// 구독 확인 JSON 메시지 등은 무시
return;
}
// 1. 데이터 정제 및 중복 제거 (TradeTape용)
const meaningfulTicks = parsedTicks.filter(
(tick) => tick.tradeVolume > 0,
);
const dedupedForTape = meaningfulTicks.filter((tick) => {
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
if (seenTickRef.current.has(key)) return false;
seenTickRef.current.add(key);
return true;
});
// 2. 최신 틱 업데이트 (StockHeader용)
// 배치 중 가장 마지막(최신) 틱을 사용
const lastTickInBatch = parsedTicks[parsedTicks.length - 1];
setLatestTick(lastTickInBatch);
// 3. 캔들 업데이트 (StockLineChart용)
// 지연 도착 틱 필터링
const nextTickOrder = toTickOrderValue(lastTickInBatch.tickTime);
if (nextTickOrder > 0) {
if (lastTickOrderRef.current <= nextTickOrder) {
lastTickOrderRef.current = nextTickOrder;
const candlePoint: StockCandlePoint = {
time: formatTime(lastTickInBatch.tickTime),
price: lastTickInBatch.price,
// 필요한 경우 open, high, low, volume 등을 여기서 조합 가능
// 현재 차트 컴포넌트는 Point 단위로 price만 주로 씀
};
setRealtimeCandles((prev) =>
appendRealtimeTick(prev, candlePoint),
);
}
}
// 4. 체결 테이프 업데이트
if (dedupedForTape.length > 0) {
setRecentTradeTicks((prev) =>
[...dedupedForTape.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
);
}
// 5. 콜백 및 상태 업데이트
setError(null);
setLastTickAt(Date.now());
// onTick 콜백용 데이터 구성
if (onTick) {
onTick(lastTickInBatch);
}
};
socket.onerror = () => {
if (disposed) return;
setIsConnected(false);
// setError("실시간 체결 연결 중 오류가 발생했습니다.");
};
socket.onclose = () => {
if (disposed) return;
setIsConnected(false);
};
} catch (err) {
if (disposed) return;
const message =
err instanceof Error
? err.message
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.";
setError(message);
setIsConnected(false);
}
};
void connect();
const seenTickRefCurrent = seenTickRef.current;
return () => {
disposed = true;
setIsConnected(false);
const approvalKey = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
// 체결 구독 해제
const unsubscribeMessage = buildKisRealtimeMessage(
approvalKey,
symbol,
trId,
"2",
);
socket.send(JSON.stringify(unsubscribeMessage));
// 호가 구독 해제
if (obSymbol && obTrId) {
const obUnsubscribe = buildKisRealtimeMessage(
approvalKey,
obSymbol,
obTrId,
"2",
);
socket.send(JSON.stringify(obUnsubscribe));
}
}
socket?.close();
if (socketRef.current === socket) {
socketRef.current = null;
}
approvalKeyRef.current = null;
seenTickRefCurrent.clear();
};
}, [
isVerified,
symbol,
credentials,
onTick,
obSymbol,
obTrId,
onOrderBookMsg,
]);
return {
latestTick, // Header용
realtimeCandles, // Chart용
recentTradeTicks, // Tape용
isConnected,
error,
lastTickAt,
realtimeTrId: realtimeTrId ?? KIS_REALTIME_TR_ID_REAL,
};
}
function formatTime(hhmmss: string) {
if (!hhmmss || hhmmss.length !== 6) return "실시간";
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
}

View File

@@ -0,0 +1,61 @@
import { useState, useCallback } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import { fetchOrderCash } from "@/features/dashboard/apis/kis-stock.api";
export function useOrder() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<DashboardStockCashOrderResponse | null>(
null,
);
const placeOrder = useCallback(
async (
request: DashboardStockCashOrderRequest,
credentials: KisRuntimeCredentials | null,
) => {
if (!credentials) {
setError("KIS API 자격 증명이 없습니다.");
return null;
}
setIsLoading(true);
setError(null);
setResult(null);
try {
const data = await fetchOrderCash(request, credentials);
setResult(data);
return data;
} catch (err) {
const message =
err instanceof Error
? err.message
: "주문 처리 중 오류가 발생했습니다.";
setError(message);
return null;
} finally {
setIsLoading(false);
}
},
[],
);
const reset = useCallback(() => {
setError(null);
setResult(null);
setIsLoading(false);
}, []);
return {
placeOrder,
isLoading,
error,
result,
reset,
};
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import { fetchStockOrderBook } from "@/features/dashboard/apis/kis-stock.api";
import { toast } from "sonner";
/**
* @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다.
* 웹소켓 호가 데이터는 DashboardContainer에서 useKisTradeWebSocket을 통해
* 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다.
* @see features/dashboard/components/DashboardContainer.tsx 호가 데이터 흐름
* @see features/dashboard/components/orderbook/OrderBook.tsx 호가창 렌더링 데이터 공급
*/
export function useOrderBook(
symbol: string | undefined,
market: "KOSPI" | "KOSDAQ" | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
options: {
enabled?: boolean;
/** 체결 WS에서 받은 실시간 호가 데이터 (단일 WS 통합) */
externalRealtimeOrderBook?: DashboardStockOrderBookResponse | null;
} = {},
) {
const { enabled = true, externalRealtimeOrderBook = null } = options;
const isRequestEnabled = enabled && !!symbol && !!credentials;
const requestSeqRef = useRef(0);
const lastErrorToastRef = useRef<string>("");
const [initialData, setInitialData] =
useState<DashboardStockOrderBookResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isRequestEnabled || !symbol || !credentials) {
return;
}
const requestSeq = ++requestSeqRef.current;
let isDisposed = false;
const loadInitialOrderBook = async () => {
setInitialData(null);
setIsLoading(true);
setError(null);
try {
const data = await fetchStockOrderBook(symbol, credentials);
if (isDisposed || requestSeq !== requestSeqRef.current) return;
setInitialData(data);
} catch (err) {
if (isDisposed || requestSeq !== requestSeqRef.current) return;
console.error("Failed to fetch initial orderbook:", err);
const message =
err instanceof Error
? err.message
: "호가 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.";
setError(message);
if (lastErrorToastRef.current !== message) {
lastErrorToastRef.current = message;
toast.error(message);
}
} finally {
if (isDisposed || requestSeq !== requestSeqRef.current) return;
setIsLoading(false);
}
};
void loadInitialOrderBook();
return () => {
isDisposed = true;
};
}, [isRequestEnabled, symbol, credentials]);
// 외부 실시간 호가 → 초기 데이터 → null 순 우선
const orderBook = isRequestEnabled
? (externalRealtimeOrderBook ?? initialData)
: null;
const mergedError = isRequestEnabled ? error : null;
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
return {
orderBook,
isLoading: mergedLoading,
error: mergedError,
isWsConnected: !!externalRealtimeOrderBook,
};
}

View File

@@ -0,0 +1,118 @@
import { useCallback, useState, useTransition } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardMarketPhase,
DashboardPriceSource,
DashboardRealtimeTradeTick,
DashboardStockSearchItem,
DashboardStockItem,
} from "@/features/dashboard/types/dashboard.types";
import { fetchStockOverview } from "@/features/dashboard/apis/kis-stock.api";
interface OverviewMeta {
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
fetchedAt: string;
}
export function useStockOverview() {
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(
null,
);
const [meta, setMeta] = useState<OverviewMeta | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, startTransition] = useTransition();
const loadOverview = useCallback(
(
symbol: string,
credentials: KisRuntimeCredentials | null,
marketHint?: DashboardStockSearchItem["market"],
) => {
if (!credentials) return;
startTransition(async () => {
try {
setError(null);
const data = await fetchStockOverview(symbol, credentials);
setSelectedStock({
...data.stock,
market: marketHint ?? data.stock.market,
});
setMeta({
priceSource: data.priceSource,
marketPhase: data.marketPhase,
fetchedAt: data.fetchedAt,
});
} catch (err) {
const message =
err instanceof Error
? err.message
: "종목 조회 중 오류가 발생했습니다.";
setError(message);
setMeta(null);
}
});
},
[],
);
// 실시간 체결 데이터 수신 시 헤더/차트 기준 가격을 갱신합니다.
const updateRealtimeTradeTick = useCallback(
(tick: DashboardRealtimeTradeTick) => {
setSelectedStock((prev) => {
if (!prev) return prev;
const { price, accumulatedVolume, change, changeRate, tickTime } = tick;
const pointTime =
tickTime && tickTime.length === 6
? `${tickTime.slice(0, 2)}:${tickTime.slice(2, 4)}`
: "실시간";
const nextChange = change;
const nextChangeRate = Number.isFinite(changeRate)
? changeRate
: prev.prevClose > 0
? (nextChange / prev.prevClose) * 100
: prev.changeRate;
const nextHigh = prev.high > 0 ? Math.max(prev.high, price) : price;
const nextLow = prev.low > 0 ? Math.min(prev.low, price) : price;
const nextCandles =
prev.candles.length > 0 &&
prev.candles[prev.candles.length - 1]?.time === pointTime
? [
...prev.candles.slice(0, -1),
{
...prev.candles[prev.candles.length - 1],
time: pointTime,
price,
},
]
: [...prev.candles, { time: pointTime, price }].slice(-80);
return {
...prev,
currentPrice: price,
change: nextChange,
changeRate: nextChangeRate,
high: nextHigh,
low: nextLow,
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
candles: nextCandles,
};
});
},
[],
);
return {
selectedStock,
setSelectedStock,
meta,
setMeta,
error,
setError,
isLoading,
loadOverview,
updateRealtimeTradeTick,
};
}

View File

@@ -0,0 +1,91 @@
import { useCallback, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
export function useStockSearch() {
const [keyword, setKeyword] = useState("삼성전자");
const [searchResults, setSearchResults] = useState<
DashboardStockSearchItem[]
>([]);
const [error, setError] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
const requestIdRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const loadSearch = useCallback(async (query: string) => {
const requestId = ++requestIdRef.current;
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setIsSearching(true);
setError(null);
try {
const data = await fetchStockSearch(query, controller.signal);
if (requestId === requestIdRef.current) {
setSearchResults(data.items);
}
return data.items;
} catch (err) {
if (controller.signal.aborted) {
return [];
}
if (requestId === requestIdRef.current) {
setError(
err instanceof Error
? err.message
: "종목 검색 중 오류가 발생했습니다.",
);
}
return [];
} finally {
if (requestId === requestIdRef.current) {
setIsSearching(false);
}
}
}, []);
const search = useCallback(
(query: string, credentials: KisRuntimeCredentials | null) => {
if (!credentials) {
setError("API 키 검증이 필요합니다.");
setSearchResults([]);
setIsSearching(false);
return;
}
const trimmed = query.trim();
if (!trimmed) {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
return;
}
void loadSearch(trimmed);
},
[loadSearch],
);
const clearSearch = useCallback(() => {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
}, []);
return {
keyword,
setKeyword,
searchResults,
setSearchResults,
error,
setError,
isSearching,
search,
clearSearch,
};
}