한국투자증권 OpenAPI 미국 주식 실시간 차트 구현하기 (1) ::cisxo

조정현·2025년 8월 12일

증권프로젝트

목록 보기
1/8

실시간 차트를 구성하기에 한국투자증권 api 명세서를 확인해보면 소켓 연결 부분에서 오픈소스를 읽지 않으면 구현하기 쉽지 않다. 그렇기에 이번 신한투자증권 프로디지털아카데미 프로젝트에서 구축한 실시간 차트 구현 방식을 설명하고자 한다.

  • 해당 프로젝트의 경우 FOMC 발표일 시간에 맞춰 실시간으로 데이터를 수집하고 사용자에게 번역, 요약, 알림을 해주는 맞춤형 서비스이다.
  • 사용자에게 실시간으로 투자에 도움이 되도록 구성하기 위해 실시간 차트를 구현하게 된 것이다.
  • 해외 종목의 경우 S&P 500을 대상으로 symbol을 진행했고 기존에 사용하던 symbol과 EXCD(거래소 코드(NYS뉴욕, NAS나스닥, AMS_아맥스) 값은 별도의 API를 통해 DB에 저장해 두었으며, 요청 시에는 DB에서 먼저 조회하여 사용했다.

모의 투자 계정으로 사용했기에 실전 투자 계좌를 사용하시는 분들은 api 문서에서 주소를 잘 확인하셔야 됩니다.

구현 화면

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

실시간 차트 아키텍처

  • 서버와 한국투자증권 소켓은 1:1 구조로 구성
    • 1:1로 구성한 이유 중 하나는 한국투자증권의 웹소켓 동시 연결 수 제한이다. 그렇기에 서버와 한국투자 증권 사이에서 심볼을 통해 해당 종목의 구독을 요청하여 해당하는 종목들의 실시간 정보를 서버에서 받는 구조로 구성했다.
  • 서버와 클라이언트 소켓은 1:N 구조로 구성
    • 서버와 클라이언트의 경우에는 1:N 구조로 클라이언트에서 해당 심볼로 구독을 요청하면 백엔드와 한국투자증권 소켓에 구독이 요청되었는지 확인하고 구독 되지 않은 경우에는 구독을 요청하고 실시간 정보를 서버에 받아온다. 받아온 실시간 정보를 클라이언트에게 실시간 정보를 전달해준다.
      • 서버가 받은 데이터를 동일 심볼을 구독한 여러 클라이언트에 브로드캐스트 방식으로 재전달하는 구조이다. 그렇기에 클라이언트가 요청한 심볼(종목)이 이미 구독된 심볼(종목)이라면, 해당 클라이언트에게 같은 구조로 전달해준다.
  • 재연결 처리
    • 웹소켓은 네트워크 문제나 서버 이슈로 연결이 끊어질 수 있기 때문에, 자동 재연결 로직을 처리 했다.
    • 특히 한국투자증권 실시간 주가 정보의 경우 장이 열리지 않는 토,일에는 연결을 진행하지 않도록 연결을 끊었고, 한국 시간 기준 오전 10:00 부터 오후 5:00 까지는 소켓 연결을 끊어놓았다. 그 이후에 연결이 끊기는 경우에는 재시도 로직이 돌아가도록 구성햇다.
      • 미장이 열리는 시간은 프리장, 정규장, 장후 등의 실시간 정보를 받을 수 있기에 넉넉하게 연결을 잡아놓았다.

실시간 차트에서 필요한 요소 중 하나는 소켓을 연결하기 위한 approval key가 필요하다. hantu api에서 요청해서 받아야 하는 구조이다.

실시간 차트 구현 방법

  • 일단 기본적으로 app_Key와 app_Secret을 받아와서 .env에 등록하고 유출되지 않게 .gitignore에 등록하여 깃허브에 유출되지 않도록 주의해아 한다.
  • Oauth token을 발급해야 시세 정보 api를 조회할 수 있다.
  • Approval token을 발급해야 실시간 소켓을 연결 할 수 있다.
  • 실시간 시세 프론트
    • 실시간 데이터 수신 시 1분봉 데이터 구조에 맞춰 가공.
    • 클라이언트 차트 라이브러리(TradingView Lightweight 차트)에 실시간 업데이트.
  • 대부분의 경우에는 한국투자증권 api에 나와있다. 그러나 해외 실시간 시세는 오픈소스를 조금 찾아봐야 할 수 있다.

여기서 https://openapi.koreainvestment.com:9443은 실전투자용이고, 모의투자용을 쓰는 경우 https://openapivts.koreainvestment.com:29443을 사용합니다

oauth token(분봉, 일봉 시세 조회 토큰)

  • 한국투자증권 인증 토큰
  • 인증 토큰은 시세 차트 요청 헤더에 Authorization에 들어가야 한다. 한국투자증권 api에 테스트 가능하며 설명이 잘 되어있다.
  • 인증 토큰의 경우 만료 시간이 짧기 때문에 스케줄러로 2시간씩 돌아가게 한 후 레디스에 저장했다.
// ("/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 오류" });
  }
};

분봉 차트

// 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는 법인)
          },
        }
      );
      

approval token (소켓용)

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;
}

한국투자증권 WebSocket

  • 한국투자증권 웹소켓 연결 주소
  • 한국투자증권에서 제공하는 웹소켓 연결 주소에 연결하고 종목별로 구독을 요청할 수 있다. 이는 백엔드 서버에서 한국투자증권 소켓과 연결 과정 일부이다.
    • 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 }));
    }
  }
};

  • 이런식으로 실시간으로 데이터를 수신받고 클라이언트의 1분봉 차트에 그려주면 된다.

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

0개의 댓글