키움증권 REST API로 주식 DB 만들기: Python·SQLite 일봉 수집

1. 주식 DB는 많이 받는 것보다 다시 받아도 망가지지 않는 게 중요합니다

주식 데이터를 처음 모을 때는 일봉을 얼마나 오래 받을 수 있는지부터 궁금해집니다. 그러나 자동 수집을 운영하면 더 자주 만나는 문제는 따로 있습니다. 프로그램을 다시 실행할 때 같은 날짜가 두 번 들어가거나, 중간에 연결이 끊겨 일부 페이지만 저장되는 문제입니다.

이 글에서는 키움증권 REST API의 주식일봉차트조회요청(ka10081)으로 일봉을 받고 SQLite에 저장하는 구조를 살펴봅니다. 핵심은 한 번의 조회가 아니라 다음 세 가지입니다.

  • 앱키와 시크릿키를 코드 밖에 보관합니다.
  • cont-ynnext-key로 다음 페이지를 이어 받습니다.
  • (종목코드, 거래일)을 기본키로 두어 재실행 때도 중복되지 않게 합니다.

기존 OCX 방식이 필요한 경우 키움 OpenAPI+로 주식 DB 만들기: 32비트 환경·이벤트·검증 체크리스트를 참고할 수 있습니다. OpenAPI+는 Windows와 영웅문 로그인 환경을 사용하고, 이 글의 REST 방식은 HTTP와 JSON으로 통신한다는 차이가 있습니다.

키움 REST API 일봉 수집과 SQLite 저장 흐름

2. 준비 사항: REST API 사용 신청과 두 개의 환경변수

키움 REST API 소개에서 서비스 사용 요건과 호출 제한을 먼저 확인합니다. 국내주식 조회 TR은 계좌별·토큰별 초당 5회, 모의투자는 TR 하나당 초당 1회로 안내되어 있습니다. 제한은 바뀔 수 있으므로 많은 종목을 수집하기 전에는 공식 안내를 다시 확인하는 편이 안전합니다.

사용 신청 후 발급한 앱키와 시크릿키는 소스에 직접 적지 않습니다. 터미널의 환경변수로 분리합니다.

TEXT
export KIWOOM_APP_KEY="발급받은_앱키"
export KIWOOM_SECRET_KEY="발급받은_시크릿키"
pip install requests

Windows PowerShell에서는 다음처럼 설정할 수 있습니다.

TEXT
$env:KIWOOM_APP_KEY="발급받은_앱키"
$env:KIWOOM_SECRET_KEY="발급받은_시크릿키"
pip install requests

시크릿키는 계좌 비밀번호와 같은 값이 아닙니다. REST API 사용 신청 화면에서 앱키와 함께 발급되는 인증값입니다. Git 저장소를 사용한다면 .env, 데이터베이스 파일, 로그 파일도 .gitignore에 넣는 편이 좋습니다.

3. 먼저 DB 구조를 정합니다

일봉 테이블은 복잡하게 시작할 필요가 없습니다. 종목코드와 거래일을 합친 기본키가 가장 중요합니다.

열 이름 자료형 의미
code TEXT 종목코드
trade_date TEXT 거래일, YYYYMMDD
open, high, low, close INTEGER 시가·고가·저가·종가
volume INTEGER 거래량, 주
trade_value INTEGER 거래대금, 백만원
TEXT
CREATE TABLE IF NOT EXISTS daily_price (
    code        TEXT    NOT NULL,
    trade_date  TEXT    NOT NULL,
    open        INTEGER NOT NULL,
    high        INTEGER NOT NULL,
    low         INTEGER NOT NULL,
    close       INTEGER NOT NULL,
    volume      INTEGER NOT NULL,
    trade_value INTEGER NOT NULL,
    PRIMARY KEY (code, trade_date)
) WITHOUT ROWID;

거래일을 TEXT로 둔 이유는 키움 응답의 YYYYMMDD 형식을 그대로 보존하면서 정렬과 범위 비교도 가능하기 때문입니다. 여러 종목에 같은 거래일이 존재하므로 날짜 하나만 기본키로 두면 안 됩니다.

4. 토큰을 받고 ka10081을 연속조회합니다

키움 REST API 가이드의 ka10081 명세는 운영 URL을 /api/dostk/chart로 안내합니다. 요청 본문에는 종목코드, 기준일자, 수정주가 구분을 보내고 헤더의 api-id에는 ka10081을 넣습니다.

응답 본문의 일봉 목록 이름은 stk_dt_pole_chart_qry입니다. 다음 데이터가 있으면 응답 헤더의 cont-ynY가 되고 next-key가 함께 옵니다. 이 두 값을 다음 요청 헤더에 그대로 넘겨야 다음 페이지가 이어집니다.

PYTHON
import os
import time
import requests

BASE_URL = "https://api.kiwoom.com"


def issue_token():
    response = requests.post(
        BASE_URL + "/oauth2/token",
        json={
            "grant_type": "client_credentials",
            "appkey": os.environ["KIWOOM_APP_KEY"],
            "secretkey": os.environ["KIWOOM_SECRET_KEY"],
        },
        timeout=15,
    )
    response.raise_for_status()
    return response.json()["token"]


def fetch_daily(token, code, base_date, max_pages=10):
    rows = []
    cont_yn, next_key = "N", ""

    for page_no in range(max_pages):
        response = requests.post(
            BASE_URL + "/api/dostk/chart",
            headers={
                "Content-Type": "application/json;charset=UTF-8",
                "authorization": f"Bearer {token}",
                "cont-yn": cont_yn,
                "next-key": next_key,
                "api-id": "ka10081",
            },
            json={
                "stk_cd": code,
                "base_dt": base_date,
                "upd_stkpc_tp": "1",
            },
            timeout=15,
        )
        response.raise_for_status()
        payload = response.json()
        if int(payload.get("return_code", -1)) != 0:
            raise RuntimeError(payload.get("return_msg", payload))

        rows.extend(payload.get("stk_dt_pole_chart_qry", []))
        cont_yn = response.headers.get("cont-yn", "N").upper()
        next_key = response.headers.get("next-key", "")
        if cont_yn != "Y" or not next_key:
            break
        time.sleep(0.22)

    return rows

upd_stkpc_tp1로 두면 수정주가를 요청합니다. 액면분할이나 권리 발생 이전의 긴 과거 구간을 받을 때는 기준일 설정에 따라 수정주가 적용 범위가 달라질 수 있으므로 공식 명세의 설명도 함께 확인합니다.

모의투자 서버를 사용하려면 도메인을 https://mockapi.kiwoom.com으로 바꾸고, 요청 간격은 1초보다 길게 두는 편이 안전합니다. 모의투자 국내주식은 KRX만 지원된다는 안내도 확인해야 합니다.

5. 날짜가 겹치면 추가하지 말고 갱신합니다

API가 반환하는 가격과 거래량은 문자열이므로 쉼표와 부호를 제거해 숫자로 바꿉니다. 그다음 ON CONFLICT를 사용하면 같은 종목과 날짜를 다시 받아도 행이 두 개로 늘지 않고 최신 값으로 갱신됩니다.

PYTHON
import sqlite3


def to_record(code, row):
    to_int = lambda value: abs(int(str(value or "0").replace(",", "")))
    return (
        code,
        row["dt"],
        to_int(row["open_pric"]),
        to_int(row["high_pric"]),
        to_int(row["low_pric"]),
        to_int(row["cur_prc"]),
        to_int(row["trde_qty"]),
        to_int(row["trde_prica"]),
    )


UPSERT_SQL = """
INSERT INTO daily_price
    (code, trade_date, open, high, low, close, volume, trade_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code, trade_date) DO UPDATE SET
    open=excluded.open, high=excluded.high, low=excluded.low,
    close=excluded.close, volume=excluded.volume,
    trade_value=excluded.trade_value
"""

with sqlite3.connect("data/kiwoom_daily.db") as connection:
    connection.executemany(
        UPSERT_SQL,
        [to_record("005930", row) for row in daily_rows],
    )

최초 수집은 필요한 만큼 연속조회하고, 이후에는 DB의 MAX(trade_date)를 읽어 그 날짜를 만났을 때 조회를 멈추면 됩니다. 마지막 거래일도 한 번 더 UPSERT하면 장중에 받은 미완성 일봉을 장 마감 뒤 값으로 교체할 수 있습니다.

6. 저장 뒤에는 네 가지를 확인합니다

DB에 오류 없이 들어갔다고 데이터가 정상이라는 뜻은 아닙니다. 다음 검사는 수집 직후 매번 실행하는 편이 좋습니다.

TEXT
-- 종목별 행 수와 날짜 범위
SELECT code, COUNT(*), MIN(trade_date), MAX(trade_date)
FROM daily_price
GROUP BY code;

-- 고가와 저가가 OHLC 범위를 벗어난 행
SELECT * FROM daily_price
WHERE high < MAX(open, close, low)
   OR low > MIN(open, close, high);

-- 음수 거래량
SELECT * FROM daily_price WHERE volume < 0;

기본키가 있으면 중복 행은 DB 단계에서 막을 수 있습니다. 여기에 날짜 범위, OHLC 관계, 음수 거래량까지 확인하고 임의 종목 몇 개를 공식 시세와 대조합니다. 백테스트 결과가 이상할 때 전략보다 수집 데이터를 먼저 의심할 근거가 생깁니다.

이번 예제의 저장 로직은 공식 예시와 같은 필드로 만든 2페이지 모의 응답을 사용해 연속조회, 3행 UPSERT, 동일 날짜 갱신, 중복·OHLC·거래량 검사를 통과시켰습니다. 앱키가 없는 작성 환경에서는 실서버 호출을 대신할 수 없으므로, 처음에는 삼성전자처럼 익숙한 한 종목과 짧은 페이지로 결과를 교차 확인합니다.

7. 자주 막히는 부분

토큰이 발급되지 않을 때

환경변수 이름과 공백을 먼저 확인합니다. 앱키나 시크릿키 전체를 오류 로그에 출력하면 안 됩니다. HTTP 상태 코드와 return_msg만 남기는 편이 안전합니다.

첫 페이지만 저장될 때

응답 본문이 아니라 응답 헤더cont-ynnext-key를 읽었는지 확인합니다. 다음 요청에도 api-id: ka10081과 접근 토큰이 함께 들어가야 합니다.

재실행 뒤 행이 늘어날 때

PRIMARY KEY (code, trade_date)가 만들어졌는지 확인합니다. INSERT만 사용하면 재수집 때 충돌하거나 중복 테이블을 만들기 쉬우므로 UPSERT 구조가 필요합니다.

장기 차트가 분할 전후로 어색할 때

수정주가 구분을 1로 보냈는지, 기준일이 권리 발생일 이후인지 확인해야 합니다. 이미 원주가와 수정주가를 섞어 저장했다면 같은 테이블에 덧붙이지 말고 수집 조건을 통일해 다시 만드는 편이 낫습니다.

8. 정리

키움 REST API로 주식 DB를 만들 때 중요한 것은 요청 한 번을 성공시키는 것보다 반복 가능한 구조입니다.

  1. 인증값은 환경변수에 두고 토큰이나 시크릿키를 기록하지 않습니다.
  2. cont-ynnext-key로 페이지를 빠짐없이 이어 받습니다.
  3. (code, trade_date) 기본키와 UPSERT로 재실행을 안전하게 만듭니다.

이렇게 모은 일봉은 이동평균선이나 기술지표 계산에 바로 연결할 수 있고, 전략을 검증할 때도 같은 원천 데이터를 반복해서 사용할 수 있습니다. 수집과 검증을 한 프로그램 안에 묶어 두면 데이터가 조용히 틀어지는 문제를 훨씬 빨리 찾을 수 있습니다.

본 포스팅은 API와 데이터 저장 구조를 설명하기 위한 참고 자료입니다. 서비스 요건과 호출 제한은 키움증권 공식 안내를 우선하며, 수집한 데이터의 이용 범위도 해당 약관을 확인합니다.