실시간 웹소켓 리팩토링

This commit is contained in:
2026-02-23 15:37:22 +09:00
parent 276ef09d89
commit c17797061e
8 changed files with 408 additions and 71 deletions

View File

@@ -27,6 +27,7 @@ export function useKisTradeWebSocket(
onTick?: (tick: DashboardRealtimeTradeTick) => void,
options?: {
orderBookSymbol?: string;
orderBookMarket?: "KOSPI" | "KOSDAQ";
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
},
) {
@@ -45,6 +46,7 @@ export function useKisTradeWebSocket(
useOrderbookSubscription({
symbol: options?.orderBookSymbol,
market: options?.orderBookMarket,
isVerified,
credentials,
marketSession,

View File

@@ -3,18 +3,23 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-ru
import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
import {
extractKisRealtimeTrId,
hasMeaningfulOrderBookPayload,
parseKisRealtimeOrderbook,
resolveOrderBookTrIds,
shouldAcceptRealtimeMessageByPriority,
} from "@/features/trade/utils/kisRealtimeUtils";
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
interface UseOrderbookSubscriptionParams {
symbol: string | undefined; // orderBookSymbol
market: "KOSPI" | "KOSDAQ" | undefined;
isVerified: boolean;
credentials: KisRuntimeCredentials | null;
marketSession: DomesticKisSession;
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
}
const STABLE_SOURCE_STALE_MS = Number.POSITIVE_INFINITY;
/**
* @description 실시간 호가(Orderbook) 구독 로직을 담당하는 훅입니다.
@@ -24,6 +29,7 @@ interface UseOrderbookSubscriptionParams {
*/
export function useOrderbookSubscription({
symbol,
market,
isVerified,
credentials,
marketSession,
@@ -31,6 +37,8 @@ export function useOrderbookSubscription({
}: UseOrderbookSubscriptionParams) {
const { subscribe, connect } = useKisWebSocketStore();
const onOrderBookMessageRef = useRef(onOrderBookMessage);
const activeOrderBookTrIdRef = useRef<string | null>(null);
const activeOrderBookTrUpdatedAtRef = useRef(0);
useEffect(() => {
onOrderBookMessageRef.current = onOrderBookMessage;
@@ -41,12 +49,34 @@ export function useOrderbookSubscription({
connect();
const trIds = resolveOrderBookTrIds(credentials.tradingEnv, marketSession);
const trIds = resolveOrderBookTrIds(
credentials.tradingEnv,
marketSession,
market,
);
const unsubscribers: Array<() => void> = [];
const handleOrderBookMessage = (data: string) => {
const incomingTrId = extractKisRealtimeTrId(data);
if (!incomingTrId) return;
// UI 흐름: 소켓 수신 -> TR 우선순위 고정(시장별 상위 소스 우선) -> 파싱 -> 호가 상태 반영
const shouldAccept = shouldAcceptRealtimeMessageByPriority({
incomingTrId,
preferredTrIds: trIds,
activeTrId: activeOrderBookTrIdRef.current,
activeTrUpdatedAtMs: activeOrderBookTrUpdatedAtRef.current,
// 정규장/동시호가에서는 한 번 잡은 상위 소스를 유지해 소스 간 왕복 반영을 막습니다.
staleAfterMs: STABLE_SOURCE_STALE_MS,
});
if (!shouldAccept) return;
const ob = parseKisRealtimeOrderbook(data, symbol);
if (ob) {
if (hasMeaningfulOrderBookPayload(ob)) {
activeOrderBookTrIdRef.current = incomingTrId;
activeOrderBookTrUpdatedAtRef.current = Date.now();
}
ob.tradingEnv = credentials.tradingEnv;
onOrderBookMessageRef.current?.(ob);
}
@@ -58,6 +88,8 @@ export function useOrderbookSubscription({
return () => {
unsubscribers.forEach((unsub) => unsub());
activeOrderBookTrIdRef.current = null;
activeOrderBookTrUpdatedAtRef.current = 0;
};
}, [symbol, isVerified, credentials, marketSession, connect, subscribe]);
}, [symbol, market, isVerified, credentials, marketSession, connect, subscribe]);
}

View File

@@ -3,12 +3,15 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-ru
import type { DashboardRealtimeTradeTick } from "@/features/trade/types/trade.types";
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
import {
extractKisRealtimeTrId,
parseKisRealtimeTickBatch,
resolveTradeTrIds,
shouldAcceptRealtimeMessageByPriority,
} from "@/features/trade/utils/kisRealtimeUtils";
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
const MAX_TRADE_TICKS = 10;
const STABLE_SOURCE_STALE_MS = Number.POSITIVE_INFINITY;
interface UseTradeTickSubscriptionParams {
symbol: string | undefined;
@@ -38,6 +41,8 @@ export function useTradeTickSubscription({
>([]);
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
const seenTickRef = useRef<Set<string>>(new Set());
const activeTradeTrIdRef = useRef<string | null>(null);
const activeTradeTrUpdatedAtRef = useRef(0);
const { subscribe, connect } = useKisWebSocketStore();
const onTickRef = useRef(onTick);
@@ -59,6 +64,8 @@ export function useTradeTickSubscription({
// Ref는 렌더링 도중 수정하면 안 되므로 useEffect에서 초기화
useEffect(() => {
seenTickRef.current.clear();
activeTradeTrIdRef.current = null;
activeTradeTrUpdatedAtRef.current = 0;
}, [symbol]);
// 2. 실시간 데이터 구독
@@ -71,12 +78,29 @@ export function useTradeTickSubscription({
const unsubscribers: Array<() => void> = [];
const handleTradeMessage = (data: string) => {
const incomingTrId = extractKisRealtimeTrId(data);
if (!incomingTrId) return;
// UI 흐름: 소켓 수신 -> TR 우선순위 고정(ST 우선) -> 파싱 -> 상태 반영
const shouldAccept = shouldAcceptRealtimeMessageByPriority({
incomingTrId,
preferredTrIds: trIds,
activeTrId: activeTradeTrIdRef.current,
activeTrUpdatedAtMs: activeTradeTrUpdatedAtRef.current,
// 정규장/동시호가에서는 KRX(ST*) 소스를 한 번 잡으면 유지해 통합(UN*) 값으로 되내려가지 않게 합니다.
staleAfterMs: STABLE_SOURCE_STALE_MS,
});
if (!shouldAccept) return;
const ticks = parseKisRealtimeTickBatch(data, symbol);
if (ticks.length === 0) return;
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
if (meaningfulTicks.length === 0) return;
activeTradeTrIdRef.current = incomingTrId;
activeTradeTrUpdatedAtRef.current = Date.now();
const dedupedTicks = meaningfulTicks.filter((tick) => {
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
if (seenTickRef.current.has(key)) return false;