2026-02-13 12:17:35 +09:00
|
|
|
import { create } from "zustand";
|
2026-02-26 09:05:17 +09:00
|
|
|
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
|
2026-02-13 12:17:35 +09:00
|
|
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
|
|
|
|
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @file features/kis-realtime/stores/kisWebSocketStore.ts
|
|
|
|
|
* @description KIS 실시간 웹소켓 연결을 전역에서 하나로 관리하는 스토어입니다.
|
|
|
|
|
* 중복 연결을 방지하고, 여러 컴포넌트에서 동일한 데이터를 구독할 때 효율적으로 처리합니다.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
type RealtimeCallback = (data: string) => void;
|
|
|
|
|
|
|
|
|
|
interface KisWebSocketState {
|
|
|
|
|
isConnected: boolean;
|
|
|
|
|
error: string | null;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 웹소켓 연결을 수립합니다.
|
|
|
|
|
* 이미 연결되어 있거나 연결 중이면 무시합니다.
|
|
|
|
|
*/
|
|
|
|
|
connect: (options?: { forceApprovalRefresh?: boolean }) => Promise<void>;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 웹소켓 연결을 강제로 재시작합니다.
|
|
|
|
|
* 필요 시 승인키를 새로 발급받아 재연결합니다.
|
|
|
|
|
*/
|
|
|
|
|
reconnect: (options?: { refreshApproval?: boolean }) => Promise<void>;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 웹소켓 연결을 종료합니다.
|
|
|
|
|
* 모든 구독이 해제됩니다.
|
|
|
|
|
*/
|
|
|
|
|
disconnect: () => void;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 특정 TR ID와 종목 코드로 실시간 데이터를 구독합니다.
|
|
|
|
|
* @param trId 거래 ID (예: H0STCNT0)
|
|
|
|
|
* @param symbol 종목 코드 (예: 005930)
|
|
|
|
|
* @param callback 데이터 수신 시 실행할 콜백 함수
|
|
|
|
|
* @returns 구독 해제 함수 (useEffect cleanup에서 호출하세요)
|
|
|
|
|
*/
|
|
|
|
|
subscribe: (
|
|
|
|
|
trId: string,
|
|
|
|
|
symbol: string,
|
|
|
|
|
callback: RealtimeCallback,
|
|
|
|
|
) => () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 구독자 목록 관리 (Key: "TR_ID|SYMBOL", Value: Set<Callback>)
|
|
|
|
|
// 스토어 외부 변수로 관리하여 불필요한 리렌더링을 방지합니다.
|
|
|
|
|
const subscribers = new Map<string, Set<RealtimeCallback>>();
|
|
|
|
|
const subscriberCounts = new Map<string, number>(); // 실제 소켓 구독 요청 여부 추적용
|
|
|
|
|
|
|
|
|
|
let socket: WebSocket | null = null;
|
|
|
|
|
let isConnecting = false; // 연결 진행 중 상태 잠금
|
|
|
|
|
let reconnectRetryTimer: number | undefined;
|
|
|
|
|
let lastAppKeyConflictAt = 0;
|
2026-02-23 15:37:22 +09:00
|
|
|
let reconnectAttempt = 0;
|
|
|
|
|
let manualDisconnectRequested = false;
|
|
|
|
|
|
|
|
|
|
const MAX_AUTO_RECONNECT_ATTEMPTS = 8;
|
|
|
|
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
|
|
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
|
|
|
const RECONNECT_JITTER_MS = 300;
|
2026-02-13 12:17:35 +09:00
|
|
|
|
2026-02-26 09:05:17 +09:00
|
|
|
function isKisWsDebugEnabled() {
|
|
|
|
|
if (typeof window === "undefined") return false;
|
|
|
|
|
return window.localStorage.getItem("KIS_WS_DEBUG") === "1";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wsDebugLog(...args: unknown[]) {
|
|
|
|
|
if (!isKisWsDebugEnabled()) return;
|
|
|
|
|
console.log(...args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wsDebugWarn(...args: unknown[]) {
|
|
|
|
|
if (!isKisWsDebugEnabled()) return;
|
|
|
|
|
console.warn(...args);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|
|
|
|
isConnected: false,
|
|
|
|
|
error: null,
|
|
|
|
|
|
|
|
|
|
connect: async (options) => {
|
|
|
|
|
const forceApprovalRefresh = options?.forceApprovalRefresh ?? false;
|
2026-02-23 15:37:22 +09:00
|
|
|
manualDisconnectRequested = false;
|
|
|
|
|
window.clearTimeout(reconnectRetryTimer);
|
|
|
|
|
reconnectRetryTimer = undefined;
|
2026-02-13 12:17:35 +09:00
|
|
|
const currentSocket = socket;
|
|
|
|
|
|
|
|
|
|
if (currentSocket?.readyState === WebSocket.CLOSING) {
|
|
|
|
|
await waitForSocketClose(currentSocket);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지
|
2026-02-23 15:37:22 +09:00
|
|
|
if (isSocketUnavailableForNewConnect(socket) || isConnecting) {
|
2026-02-13 12:17:35 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
isConnecting = true;
|
|
|
|
|
const { getOrFetchWsConnection, clearWsConnectionCache } =
|
|
|
|
|
useKisRuntimeStore.getState();
|
|
|
|
|
if (forceApprovalRefresh) {
|
|
|
|
|
clearWsConnectionCache();
|
|
|
|
|
}
|
|
|
|
|
const wsConnection = await getOrFetchWsConnection();
|
|
|
|
|
|
|
|
|
|
// 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인
|
2026-02-23 15:37:22 +09:00
|
|
|
if (isSocketOpenOrConnecting(socket)) {
|
2026-02-13 12:17:35 +09:00
|
|
|
isConnecting = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!wsConnection) {
|
|
|
|
|
throw new Error("웹소켓 접속 키 발급에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 소켓 생성
|
|
|
|
|
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
|
2026-02-24 15:43:56 +09:00
|
|
|
const ws = new WebSocket(wsConnection.wsUrl);
|
2026-02-26 09:05:17 +09:00
|
|
|
wsDebugLog("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
|
2026-02-13 12:17:35 +09:00
|
|
|
socket = ws;
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
isConnecting = false;
|
|
|
|
|
// socket 변수가 다른 인스턴스로 바뀌었을 가능성은 낮지만(락 때문),
|
|
|
|
|
// 안전을 위해 이벤트 발생 주체인 ws를 사용 또는 현재 socket 확인
|
|
|
|
|
if (socket !== ws) return;
|
|
|
|
|
|
|
|
|
|
set({ isConnected: true, error: null });
|
2026-02-23 15:37:22 +09:00
|
|
|
reconnectAttempt = 0;
|
2026-02-26 09:05:17 +09:00
|
|
|
wsDebugLog("[KisWebSocket] Connected");
|
2026-02-13 12:17:35 +09:00
|
|
|
|
|
|
|
|
// 재연결 시 기존 구독 복구
|
|
|
|
|
const approvalKey = wsConnection.approvalKey;
|
|
|
|
|
if (approvalKey) {
|
|
|
|
|
subscriberCounts.forEach((_, key) => {
|
|
|
|
|
const [trId, symbol] = key.split("|");
|
|
|
|
|
|
|
|
|
|
// OPEN 상태일 때만 전송
|
|
|
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
sendSubscription(ws, approvalKey, trId, symbol, "1"); // 구독
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-23 15:37:22 +09:00
|
|
|
ws.onclose = (event) => {
|
2026-02-13 12:17:35 +09:00
|
|
|
if (socket === ws) {
|
|
|
|
|
isConnecting = false;
|
|
|
|
|
set({ isConnected: false });
|
|
|
|
|
socket = null;
|
2026-02-23 15:37:22 +09:00
|
|
|
|
|
|
|
|
const hasSubscribers = hasActiveRealtimeSubscribers();
|
|
|
|
|
const canAutoReconnect =
|
|
|
|
|
!manualDisconnectRequested &&
|
|
|
|
|
hasSubscribers &&
|
|
|
|
|
reconnectAttempt < MAX_AUTO_RECONNECT_ATTEMPTS;
|
|
|
|
|
|
|
|
|
|
if (canAutoReconnect) {
|
|
|
|
|
reconnectAttempt += 1;
|
|
|
|
|
const delayMs = getReconnectDelayMs(reconnectAttempt);
|
2026-02-26 09:05:17 +09:00
|
|
|
wsDebugWarn(
|
2026-02-24 15:43:56 +09:00
|
|
|
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
|
2026-02-23 15:37:22 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
window.clearTimeout(reconnectRetryTimer);
|
|
|
|
|
reconnectRetryTimer = window.setTimeout(() => {
|
|
|
|
|
const refreshApproval = reconnectAttempt % 3 === 0;
|
|
|
|
|
void get().reconnect({ refreshApproval });
|
|
|
|
|
}, delayMs);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 15:43:56 +09:00
|
|
|
if (
|
|
|
|
|
hasSubscribers &&
|
|
|
|
|
reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS
|
|
|
|
|
) {
|
2026-02-23 15:37:22 +09:00
|
|
|
set({
|
|
|
|
|
error:
|
|
|
|
|
"실시간 연결이 반복 종료되어 자동 재연결을 중단했습니다. 새로고침 또는 수동 재연결을 시도해 주세요.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reconnectAttempt = 0;
|
2026-02-26 09:05:17 +09:00
|
|
|
wsDebugLog(
|
2026-02-23 15:37:22 +09:00
|
|
|
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
|
|
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onerror = (event) => {
|
|
|
|
|
if (socket === ws) {
|
|
|
|
|
isConnecting = false;
|
2026-02-24 15:43:56 +09:00
|
|
|
const errEvent = event as ErrorEvent;
|
|
|
|
|
console.error("[KisWebSocket] Error", {
|
|
|
|
|
type: event.type,
|
|
|
|
|
message: errEvent?.message,
|
|
|
|
|
url: ws.url,
|
|
|
|
|
readyState: ws.readyState,
|
|
|
|
|
});
|
2026-02-13 12:17:35 +09:00
|
|
|
set({
|
|
|
|
|
isConnected: false,
|
|
|
|
|
error: "웹소켓 연결 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {
|
|
|
|
|
const data = event.data;
|
|
|
|
|
if (typeof data !== "string") return;
|
|
|
|
|
|
|
|
|
|
// PINGPONG 응답 또는 제어 메시지 처리
|
|
|
|
|
if (data.startsWith("{")) {
|
|
|
|
|
const control = parseControlMessage(data);
|
2026-02-23 15:37:22 +09:00
|
|
|
if (!control) return;
|
|
|
|
|
|
|
|
|
|
if (control.trId === "PINGPONG") {
|
|
|
|
|
// KIS 샘플 구현과 동일하게 원문을 그대로 echo하여 연결 유지를 보조합니다.
|
|
|
|
|
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
ws.send(data);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (control.rtCd && control.rtCd !== "0") {
|
2026-02-13 12:17:35 +09:00
|
|
|
const errorMessage = buildControlErrorMessage(control);
|
|
|
|
|
set({
|
|
|
|
|
error: errorMessage,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// KIS 제어 메시지: ALREADY IN USE appkey
|
2026-02-24 15:43:56 +09:00
|
|
|
// 이전 세션이 닫히기 전에 재연결될 때 발생합니다.
|
|
|
|
|
// KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로
|
|
|
|
|
// 충분한 대기 후 재연결합니다.
|
2026-02-23 15:37:22 +09:00
|
|
|
if (control.msgCd === "OPSP8996") {
|
2026-02-26 09:05:17 +09:00
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - lastAppKeyConflictAt > 5_000) {
|
|
|
|
|
lastAppKeyConflictAt = now;
|
|
|
|
|
wsDebugWarn(
|
|
|
|
|
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
|
|
|
|
|
);
|
|
|
|
|
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
|
|
|
|
|
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
ws.close(1000, "ALREADY IN USE - graceful close");
|
2026-02-24 15:43:56 +09:00
|
|
|
}
|
2026-02-13 12:17:35 +09:00
|
|
|
window.clearTimeout(reconnectRetryTimer);
|
|
|
|
|
reconnectRetryTimer = window.setTimeout(() => {
|
|
|
|
|
void get().reconnect({ refreshApproval: false });
|
2026-02-24 15:43:56 +09:00
|
|
|
}, 30_000); // 30초 쿨다운
|
2026-02-13 12:17:35 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-23 15:37:22 +09:00
|
|
|
|
|
|
|
|
// 승인키가 유효하지 않을 때는 승인키 재발급 후 재연결합니다.
|
|
|
|
|
if (control.msgCd === "OPSP0011") {
|
|
|
|
|
window.clearTimeout(reconnectRetryTimer);
|
|
|
|
|
reconnectRetryTimer = window.setTimeout(() => {
|
|
|
|
|
void get().reconnect({ refreshApproval: true });
|
|
|
|
|
}, 1_200);
|
|
|
|
|
}
|
2026-02-13 12:17:35 +09:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data[0] === "0" || data[0] === "1") {
|
|
|
|
|
// 데이터 포맷: 0|TR_ID|KEY|...
|
|
|
|
|
const parts = data.split("|");
|
|
|
|
|
if (parts.length >= 4) {
|
|
|
|
|
const trId = parts[1];
|
|
|
|
|
const body = parts[3];
|
|
|
|
|
const values = body.split("^");
|
2026-02-13 16:41:10 +09:00
|
|
|
const symbol = values[0] ?? "";
|
2026-02-13 12:17:35 +09:00
|
|
|
|
2026-02-13 16:41:10 +09:00
|
|
|
// UI 흐름: 소켓 수신 -> TR/심볼 정규화 매칭 -> 해당 구독 콜백 실행 -> 훅 파서(parseKisRealtime*) -> 화면 반영
|
|
|
|
|
dispatchRealtimeMessageToSubscribers(trId, symbol, data);
|
2026-02-13 12:17:35 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
isConnecting = false;
|
|
|
|
|
set({
|
|
|
|
|
isConnected: false,
|
|
|
|
|
error: err instanceof Error ? err.message : "연결 실패",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
reconnect: async (options) => {
|
|
|
|
|
const refreshApproval = options?.refreshApproval ?? false;
|
2026-02-24 15:43:56 +09:00
|
|
|
// disconnect()는 manualDisconnectRequested=true를 설정하므로 직접 호출 금지
|
|
|
|
|
// 대신 소켓만 직접 닫습니다.
|
2026-02-23 15:37:22 +09:00
|
|
|
manualDisconnectRequested = false;
|
2026-02-24 15:43:56 +09:00
|
|
|
window.clearTimeout(reconnectRetryTimer);
|
|
|
|
|
reconnectRetryTimer = undefined;
|
2026-02-13 12:17:35 +09:00
|
|
|
const currentSocket = socket;
|
2026-02-24 15:43:56 +09:00
|
|
|
if (
|
|
|
|
|
currentSocket &&
|
|
|
|
|
(currentSocket.readyState === WebSocket.OPEN ||
|
|
|
|
|
currentSocket.readyState === WebSocket.CONNECTING)
|
|
|
|
|
) {
|
|
|
|
|
currentSocket.close();
|
|
|
|
|
}
|
2026-02-13 12:17:35 +09:00
|
|
|
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
|
|
|
|
|
await waitForSocketClose(currentSocket);
|
|
|
|
|
}
|
|
|
|
|
await get().connect({
|
|
|
|
|
forceApprovalRefresh: refreshApproval,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
disconnect: () => {
|
2026-02-23 15:37:22 +09:00
|
|
|
manualDisconnectRequested = true;
|
2026-02-13 12:17:35 +09:00
|
|
|
const currentSocket = socket;
|
|
|
|
|
if (
|
|
|
|
|
currentSocket &&
|
|
|
|
|
(currentSocket.readyState === WebSocket.OPEN ||
|
|
|
|
|
currentSocket.readyState === WebSocket.CONNECTING ||
|
|
|
|
|
currentSocket.readyState === WebSocket.CLOSING)
|
|
|
|
|
) {
|
|
|
|
|
currentSocket.close();
|
|
|
|
|
}
|
2026-02-24 15:43:56 +09:00
|
|
|
if (
|
|
|
|
|
currentSocket?.readyState === WebSocket.CLOSED &&
|
|
|
|
|
socket === currentSocket
|
|
|
|
|
) {
|
2026-02-13 12:17:35 +09:00
|
|
|
socket = null;
|
|
|
|
|
}
|
|
|
|
|
set({ isConnected: false });
|
|
|
|
|
window.clearTimeout(reconnectRetryTimer);
|
2026-02-23 15:37:22 +09:00
|
|
|
reconnectRetryTimer = undefined;
|
|
|
|
|
reconnectAttempt = 0;
|
2026-02-13 12:17:35 +09:00
|
|
|
isConnecting = false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
subscribe: (trId, symbol, callback) => {
|
|
|
|
|
const key = `${trId}|${symbol}`;
|
|
|
|
|
|
|
|
|
|
// 1. 구독자 목록에 추가
|
|
|
|
|
if (!subscribers.has(key)) {
|
|
|
|
|
subscribers.set(key, new Set());
|
|
|
|
|
}
|
|
|
|
|
subscribers.get(key)!.add(callback);
|
|
|
|
|
|
|
|
|
|
// 2. 소켓 서버에 구독 요청 (첫 번째 구독자인 경우)
|
|
|
|
|
const currentCount = subscriberCounts.get(key) || 0;
|
|
|
|
|
if (currentCount === 0) {
|
|
|
|
|
const { wsApprovalKey } = useKisRuntimeStore.getState();
|
|
|
|
|
if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) {
|
|
|
|
|
sendSubscription(socket, wsApprovalKey, trId, symbol, "1"); // "1": 등록
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
subscriberCounts.set(key, currentCount + 1);
|
|
|
|
|
|
|
|
|
|
// 3. 구독 해제 함수 반환
|
|
|
|
|
return () => {
|
|
|
|
|
const callbacks = subscribers.get(key);
|
|
|
|
|
if (callbacks) {
|
|
|
|
|
callbacks.delete(callback);
|
|
|
|
|
if (callbacks.size === 0) {
|
|
|
|
|
subscribers.delete(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const count = subscriberCounts.get(key) || 0;
|
|
|
|
|
if (count > 0) {
|
|
|
|
|
subscriberCounts.set(key, count - 1);
|
|
|
|
|
if (count - 1 === 0) {
|
|
|
|
|
// 마지막 구독자가 사라지면 소켓 구독 해제
|
|
|
|
|
const { wsApprovalKey } = useKisRuntimeStore.getState();
|
|
|
|
|
if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) {
|
|
|
|
|
sendSubscription(socket, wsApprovalKey, trId, symbol, "2"); // "2": 해제
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 헬퍼: 구독/해제 메시지 전송
|
|
|
|
|
function sendSubscription(
|
|
|
|
|
ws: WebSocket,
|
|
|
|
|
appKey: string,
|
|
|
|
|
trId: string,
|
|
|
|
|
symbol: string,
|
|
|
|
|
trType: "1" | "2",
|
|
|
|
|
) {
|
|
|
|
|
try {
|
|
|
|
|
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
|
|
|
|
|
ws.send(JSON.stringify(msg));
|
2026-02-26 09:05:17 +09:00
|
|
|
wsDebugLog(
|
2026-02-13 12:17:35 +09:00
|
|
|
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
2026-02-26 09:05:17 +09:00
|
|
|
wsDebugWarn("[KisWebSocket] Send error", e);
|
2026-02-13 12:17:35 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface KisWsControlMessage {
|
2026-02-23 15:37:22 +09:00
|
|
|
trId?: string;
|
|
|
|
|
trKey?: string;
|
|
|
|
|
rtCd?: string;
|
|
|
|
|
msgCd?: string;
|
2026-02-13 12:17:35 +09:00
|
|
|
msg1?: string;
|
2026-02-23 15:37:22 +09:00
|
|
|
encrypt?: string;
|
2026-02-13 12:17:35 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 웹소켓 제어 메시지(JSON)를 파싱합니다.
|
|
|
|
|
* @param rawData 원본 메시지 문자열
|
|
|
|
|
* @returns 파싱된 제어 메시지 또는 null
|
|
|
|
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
|
|
|
|
|
*/
|
|
|
|
|
function parseControlMessage(rawData: string): KisWsControlMessage | null {
|
|
|
|
|
try {
|
2026-02-23 15:37:22 +09:00
|
|
|
const parsed = JSON.parse(rawData) as {
|
|
|
|
|
header?: {
|
|
|
|
|
tr_id?: string;
|
|
|
|
|
tr_key?: string;
|
|
|
|
|
encrypt?: string;
|
|
|
|
|
};
|
|
|
|
|
body?: {
|
|
|
|
|
rt_cd?: string;
|
|
|
|
|
msg_cd?: string;
|
|
|
|
|
msg1?: string;
|
|
|
|
|
};
|
|
|
|
|
rt_cd?: string;
|
|
|
|
|
msg_cd?: string;
|
|
|
|
|
msg1?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!parsed || typeof parsed !== "object") return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
trId: parsed.header?.tr_id,
|
|
|
|
|
trKey: parsed.header?.tr_key,
|
|
|
|
|
encrypt: parsed.header?.encrypt,
|
|
|
|
|
rtCd: parsed.body?.rt_cd ?? parsed.rt_cd,
|
|
|
|
|
msgCd: parsed.body?.msg_cd ?? parsed.msg_cd,
|
|
|
|
|
msg1: parsed.body?.msg1 ?? parsed.msg1,
|
|
|
|
|
};
|
2026-02-13 12:17:35 +09:00
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description KIS 웹소켓 제어 오류를 사용자용 짧은 문구로 변환합니다.
|
|
|
|
|
* @param message KIS 제어 메시지
|
|
|
|
|
* @returns 표시용 오류 문자열
|
|
|
|
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
|
|
|
|
|
*/
|
|
|
|
|
function buildControlErrorMessage(message: KisWsControlMessage) {
|
2026-02-23 15:37:22 +09:00
|
|
|
if (message.msgCd === "OPSP8996") {
|
2026-02-13 12:17:35 +09:00
|
|
|
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
|
|
|
|
}
|
2026-02-26 09:05:17 +09:00
|
|
|
const detail = buildKisErrorDetail({
|
|
|
|
|
message: message.msg1,
|
|
|
|
|
msgCode: message.msgCd,
|
|
|
|
|
});
|
2026-02-24 15:43:56 +09:00
|
|
|
return detail
|
|
|
|
|
? `실시간 제어 메시지 오류: ${detail}`
|
|
|
|
|
: "실시간 제어 메시지 오류";
|
2026-02-13 12:17:35 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-23 15:37:22 +09:00
|
|
|
/**
|
|
|
|
|
* @description 활성화된 웹소켓 구독이 존재하는지 반환합니다.
|
|
|
|
|
* @returns 구독 중인 TR/심볼이 1개 이상이면 true
|
|
|
|
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose
|
|
|
|
|
*/
|
|
|
|
|
function hasActiveRealtimeSubscribers() {
|
|
|
|
|
for (const count of subscriberCounts.values()) {
|
|
|
|
|
if (count > 0) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 자동 재연결 시도 횟수에 따라 지수 백오프 지연시간(ms)을 계산합니다.
|
|
|
|
|
* @param attempt 1부터 시작하는 재연결 시도 횟수
|
|
|
|
|
* @returns 지연시간(ms)
|
|
|
|
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose
|
|
|
|
|
*/
|
|
|
|
|
function getReconnectDelayMs(attempt: number) {
|
|
|
|
|
const exponential = RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1);
|
|
|
|
|
const clamped = Math.min(exponential, RECONNECT_MAX_DELAY_MS);
|
|
|
|
|
const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS);
|
|
|
|
|
return clamped + jitter;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 소켓이 OPEN 또는 CONNECTING 상태인지 검사합니다.
|
|
|
|
|
* @param target 검사 대상 소켓
|
|
|
|
|
* @returns 연결 유지/진행 상태면 true
|
|
|
|
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect
|
|
|
|
|
*/
|
|
|
|
|
function isSocketOpenOrConnecting(target: WebSocket | null) {
|
|
|
|
|
if (!target) return false;
|
|
|
|
|
return (
|
|
|
|
|
target.readyState === WebSocket.OPEN ||
|
|
|
|
|
target.readyState === WebSocket.CONNECTING
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 새 연결을 시작하면 안 되는 소켓 상태인지 검사합니다.
|
|
|
|
|
* @param target 검사 대상 소켓
|
|
|
|
|
* @returns OPEN/CONNECTING/CLOSING 중 하나면 true
|
|
|
|
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect
|
|
|
|
|
*/
|
|
|
|
|
function isSocketUnavailableForNewConnect(target: WebSocket | null) {
|
|
|
|
|
if (!target) return false;
|
|
|
|
|
return (
|
|
|
|
|
target.readyState === WebSocket.OPEN ||
|
|
|
|
|
target.readyState === WebSocket.CONNECTING ||
|
|
|
|
|
target.readyState === WebSocket.CLOSING
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
/**
|
|
|
|
|
* @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다.
|
|
|
|
|
* @param target 대기할 웹소켓 인스턴스
|
|
|
|
|
* @param timeoutMs 최대 대기 시간(ms)
|
|
|
|
|
* @returns close/error/timeout 중 먼저 완료되면 resolve
|
|
|
|
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect/reconnect
|
|
|
|
|
*/
|
|
|
|
|
function waitForSocketClose(target: WebSocket, timeoutMs = 2_000) {
|
|
|
|
|
if (target.readyState === WebSocket.CLOSED) {
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Promise<void>((resolve) => {
|
|
|
|
|
let settled = false;
|
|
|
|
|
const onClose = () => finish();
|
|
|
|
|
const onError = () => finish();
|
|
|
|
|
const timeoutId = window.setTimeout(() => finish(), timeoutMs);
|
|
|
|
|
|
|
|
|
|
const finish = () => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
settled = true;
|
|
|
|
|
window.clearTimeout(timeoutId);
|
|
|
|
|
target.removeEventListener("close", onClose);
|
|
|
|
|
target.removeEventListener("error", onError);
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
target.addEventListener("close", onClose);
|
|
|
|
|
target.addEventListener("error", onError);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-13 16:41:10 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 실시간 데이터(TR/종목코드)와 등록된 구독자를 매칭해 콜백을 실행합니다.
|
|
|
|
|
* 종목코드 접두(prefix) 차이(A005930/J005930 등)와 구독 심볼 형식 차이를 허용합니다.
|
|
|
|
|
* @param trId 수신 TR ID
|
|
|
|
|
* @param rawSymbol 수신 데이터의 원본 종목코드
|
|
|
|
|
* @param payload 웹소켓 원문 메시지
|
|
|
|
|
* @see features/trade/hooks/useTradeTickSubscription.ts 체결 구독 콜백
|
|
|
|
|
* @see features/trade/hooks/useOrderbookSubscription.ts 호가 구독 콜백
|
|
|
|
|
*/
|
|
|
|
|
function dispatchRealtimeMessageToSubscribers(
|
|
|
|
|
trId: string,
|
|
|
|
|
rawSymbol: string,
|
|
|
|
|
payload: string,
|
|
|
|
|
) {
|
|
|
|
|
const callbackSet = new Set<RealtimeCallback>();
|
|
|
|
|
const normalizedIncomingSymbol = normalizeRealtimeSymbol(rawSymbol);
|
|
|
|
|
|
|
|
|
|
// 1) 정확히 일치하는 key 우선
|
|
|
|
|
const exactKey = `${trId}|${rawSymbol}`;
|
|
|
|
|
subscribers.get(exactKey)?.forEach((callback) => callbackSet.add(callback));
|
|
|
|
|
|
|
|
|
|
// 2) 숫자 6자리 기준(정규화)으로 일치하는 key 매칭
|
|
|
|
|
subscribers.forEach((callbacks, key) => {
|
|
|
|
|
const [subscribedTrId, subscribedSymbol = ""] = key.split("|");
|
|
|
|
|
if (subscribedTrId !== trId) return;
|
|
|
|
|
if (!normalizedIncomingSymbol) return;
|
|
|
|
|
|
2026-02-24 15:43:56 +09:00
|
|
|
const normalizedSubscribedSymbol =
|
|
|
|
|
normalizeRealtimeSymbol(subscribedSymbol);
|
2026-02-13 16:41:10 +09:00
|
|
|
if (!normalizedSubscribedSymbol) return;
|
|
|
|
|
if (normalizedIncomingSymbol !== normalizedSubscribedSymbol) return;
|
|
|
|
|
|
|
|
|
|
callbacks.forEach((callback) => callbackSet.add(callback));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 3) 심볼 매칭이 실패한 경우에도 같은 TR 전체 콜백으로 안전 fallback
|
|
|
|
|
if (callbackSet.size === 0) {
|
|
|
|
|
subscribers.forEach((callbacks, key) => {
|
|
|
|
|
const [subscribedTrId] = key.split("|");
|
|
|
|
|
if (subscribedTrId !== trId) return;
|
|
|
|
|
callbacks.forEach((callback) => callbackSet.add(callback));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
callbackSet.forEach((callback) => callback(payload));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 실시간 종목코드를 비교 가능한 6자리 숫자 코드로 정규화합니다.
|
|
|
|
|
* @param value 원본 종목코드 (예: 005930, A005930)
|
|
|
|
|
* @returns 정규화된 6자리 코드. 파싱 불가 시 원본 trim 값 반환
|
|
|
|
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts dispatchRealtimeMessageToSubscribers
|
|
|
|
|
*/
|
|
|
|
|
function normalizeRealtimeSymbol(value: string) {
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
if (!trimmed) return "";
|
|
|
|
|
|
|
|
|
|
const digits = trimmed.replace(/\D/g, "");
|
|
|
|
|
if (digits.length >= 6) {
|
|
|
|
|
return digits.slice(-6);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|