실시간 차트를 구성하기에 한국투자증권 api 명세서를 확인해보면 소켓 연결 부분에서 오픈소스를 읽지 않으면 구현하기 쉽지 않다. 그렇기에 이번 신한투자증권 프로디지털아카데미 프로젝트에서 구축한 실시간 차트 구현 방식을 설명하고자 한다.
모의 투자 계정으로 사용했기에 실전 투자 계좌를 사용하시는 분들은 api 문서에서 주소를 잘 확인하셔야 됩니다.

결과물부터 보자면 1Day 차트는 1Day 시세, 실시간 시세는 1분봉 차트로 구성하였다.

실시간 차트에서 필요한 요소 중 하나는 소켓을 연결하기 위한 approval key가 필요하다. hantu api에서 요청해서 받아야 하는 구조이다.
여기서 https://openapi.koreainvestment.com:9443은 실전투자용이고, 모의투자용을 쓰는 경우 https://openapivts.koreainvestment.com:29443을 사용합니다
// ("/api/earnings/hantu/token")
exports.getHantuToken = async (req, res) => {
try {
const appKey = process.env.APP_KEY;
const appSecret = process.env.APP_SECRET;
const response = await fetch(
"https://openapivts.koreainvestment.com:29443/oauth2/tokenP",
{
method: "POST",
body: JSON.stringify({
grant_type: "client_credentials",
appkey: appKey,
appsecret: appSecret,
}),
}
);
const token = await response.json();
try {
await savePeriodToken(token);
} catch (err) {
console.error("Redis 저장 실패 (savePeriodToken):", err);
throw new Error("토큰 저장 중 문제가 발생했습니다.");
}
res.status(200).json(token);
} catch (err) {
console.error("Error getHantuToken", err);
res
.status(500)
.json({ success: false, message: "Error getHantuToken 오류" });
}
};
https://openapivts.koreainvestment.com:29443/uapi/overseas-price/v1/quotations/inquire-time-itemchartprice?${queryParams}// api/earings/hantu/minutesChart
exports.getMinutesChart = async (req, res) => {
try {
const appKey = process.env.APP_KEY;
const appSecret = process.env.APP_SECRET;
let {
AUTH = "", // 공백
SYMB, // 종목코드
GUBN = "0", // (필요 시 사용, 기본은 0)
EXCD, // 거래소 코드
NMIN = "1", // 분 단위 (1분봉)
PINC = "1", // 전일 포함 여부 (1: 전일 포함)
NEXT = "", // 처음 조회 시 공백
NREC = "100", // 요청할 레코드 수 (최대 120)
FILL = "", // 공백
KEYB = "", // KEYB 시간 포맷: YYYYMMDDHHMMSS (처음 조회 시 공백)
} = req.query;
...
const response = await fetch(
`https://openapivts.koreainvestment.com:29443/uapi/overseas-price/v1/quotations/inquire-time-itemchartprice?${queryParams}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${getToken}`,
"content-type": "application/json; charset=utf-8",
appKey: appKey,
appSecret: appSecret,
tr_id: "HHDFS76950200",
custtype: "P", // 개인 (B는 법인)
},
}
);
async function getApprovalKey(appKey, appSecret) {
const url = "https://openapi.koreainvestment.com:29443/oauth2/Approval";
const body = {
grant_type: "client_credentials",
appkey: appKey,
secretkey: appSecret,
};
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
return json.approval_key;
}
ws://ops.koreainvestment.com:31000
try {
const approvalKey = await getApprovalKey(appKey, appSecret);
const ws = new WebSocket("ws://ops.koreainvestment.com:31000");
// 전역 WebSocket 인스턴스 저장 (Redis 오류 시 재연결용)
currentWebSocket = ws;
...
ws.on("open", () => {
clearTimeout(connectionTimeout);
isConnected = true;
console.log("✅ WebSocket 연결됨");
retryCount = 0; // 연결 성공 시 재시도 횟수 초기화
// 전역 변수 설정
currentApprovalKey = approvalKey;
currentWebSocketInstance = ws;
// 클라이언트 핸들러에 함수들 설정
setHantuHandlers(subscribeToSymbol, unsubscribeFromSymbol);
// 초기 구독 메시지 전송 (기본 종목들)
PRE_SUBSCRIBE_LIST.forEach(({ tr_id, tr_key }) => {
const msg = {
header: {
approval_key: approvalKey,
custtype: "P",
tr_type: "1",
"content-type": "utf-8",
},
body: {
input: { tr_id, tr_key },
},
};
ws.send(JSON.stringify(msg));
// console.log("📤 구독 메시지 전송:", msg.body.input.tr_key);
});
});
실전 투자(WebSocket URL: ws://ops.koreainvestment.com:21000), 모의투자는 ws://ops.koreainvestment.com:31000를 사용합니다.
exports.handleConnection = (ws) => {
clientSubscriptions.set(ws, new Set()); // 새 클라이언트 구독 목록 초기화
ws.on("message", (message) => {
const msg = JSON.parse(message);
if (msg.type === "subscribe") {
// 1) 클라이언트 구독 목록에 종목 추가
clientSubscriptions.get(ws).add(msg.symbol);
// 2) 서버 구독 목록에 없으면 한투 WS에 구독
if (!serverSubscriptions.has(msg.symbol)) {
serverSubscriptions.add(msg.symbol);
subscribeToSymbol?.(msg.symbol);
}
// 3) 최신 데이터 있으면 즉시 전송
const latest = symbolDataMap.get(msg.symbol);
if (latest) ws.send(JSON.stringify({ type: "realtime", symbol: msg.symbol, data: latest }));
}
if (msg.type === "unsubscribe") {
// 1) 해당 클라이언트 구독 목록에서 제거
clientSubscriptions.get(ws).delete(msg.symbol);
// 2) 다른 구독자가 없으면 서버 구독 해제
const hasOther = [...clientSubscriptions.values()].some(symbols => symbols.has(msg.symbol));
if (!hasOther) {
serverSubscriptions.delete(msg.symbol);
unsubscribeFromSymbol?.(msg.symbol);
}
}
});
ws.on("close", () => {
// 연결 끊기면 해당 클라이언트 구독 해제 처리
for (const symbol of clientSubscriptions.get(ws) || []) {
const hasOther = [...clientSubscriptions.entries()]
.some(([clientWs, symbols]) => clientWs !== ws && symbols.has(symbol));
if (!hasOther) {
serverSubscriptions.delete(symbol);
unsubscribeFromSymbol?.(symbol);
}
}
clientSubscriptions.delete(ws);
});
};
exports.broadcastRealtime = (symbol, data) => {
symbolDataMap.set(symbol, data);
for (const [ws, symbols] of clientSubscriptions.entries()) {
if (symbols.has(symbol) && ws.readyState === 1) {
ws.send(JSON.stringify({ type: "realtime", symbol, data }));
}
}
};

자세한 코드는 깃허브를 참고해 주세요.
https://github.com/fomo-sol/FOMO-Server