AI를 활용한 재밌는 것들을 개발합니다

2026년 5월 3일 일요일

Claude Code로 키움 REST API 클라이언트 구현하기 [Claude Code · 자동매매 리밸런싱 · 2편]

Claude Code 키움 REST API FastAPI httpx

Claude Code로 키움 REST API
클라이언트 구현하기

Access Token 자동 갱신, 계좌 조회, 계좌별 포트폴리오 설정까지 — 실제 코드 공개

📌 시리즈 2편입니다
1편에서 Claude AI와 함께 기획·명세서를 완성했습니다. 이번 편부터는 Claude Code로 실제 구현을 시작합니다. 키움 REST API 클라이언트와 계좌 관련 백엔드를 다룹니다.

Claude Code란? — 일반 챗봇과 무엇이 다른가

Claude Code는 Anthropic이 만든 CLI 기반 AI 코딩 도구입니다. 일반 ChatGPT나 Claude 웹 인터페이스와 근본적으로 다릅니다.

항목일반 AI 챗봇Claude Code
파일 접근붙여넣기로만 가능프로젝트 전체 파일 직접 읽기
코드 수정답변에 코드 제시파일 직접 수정
명령 실행불가터미널 명령 실행
컨텍스트대화 내용만전체 코드베이스 파악
작업 단위답변 1개여러 파일 동시 수정

즉, Claude Code는 파일을 직접 열고, 읽고, 수정하고, 서버를 실행하는 실제 개발자처럼 동작합니다. 저는 이 프로젝트 전체를 Claude Code와 페어 프로그래밍 하듯 진행했습니다.

프로젝트 구조 — Claude Code가 만든 뼈대

첫 프롬프트로 1편의 명세서를 넘겼더니, Claude Code가 아래와 같은 프로젝트 구조를 잡아줬습니다.

rebalancing/
├── main.py               # 진입점, FastAPI 앱 실행
├── kiwoom_client.py      # 키움 REST API 클라이언트
├── account_manager.py    # 계좌/잔고 조회
├── rebalancing_engine.py  # 리밸런싱 계산 로직
├── order_queue.py        # 10초 간격 순차 주문 큐
├── order_manager.py      # 주문 접수 및 정정 관리
├── schedule_manager.py   # 매도/매수 시간 스케줄 관리
├── web_server.py         # FastAPI 앱, REST + WebSocket
├── portfolio_manager.py   # 종목 제외/편입/비중 관리
├── history_manager.py    # 이력 저장 (SQLite)
├── telegram_notifier.py   # 텔레그램 알림
├── config.py             # 설정값 로드
├── config.json           # App Key, Secret Key
└── data/
    ├── history.db      # SQLite DB
    └── settings.json   # 제외 종목, 비중 등

config.json — API 키 설정

키움 openapi.kiwoom.com에서 앱키와 시크릿키를 발급받아 config.json에 저장합니다. 이 파일은 반드시 .gitignore에 추가해야 합니다.

{
  "app_key": "발급받은 App Key",
  "secret_key": "발급받은 Secret Key",
  "is_mock": true,   // 모의투자 여부
  "host": "0.0.0.0",
  "port": 8000,
  "web_password": "접속 비밀번호"
}
⚠ 모의투자 모드
"is_mock": true로 설정하면 실제 주문 대신 모의투자 서버로 주문이 나갑니다. 개발 초기에는 반드시 모의투자로 테스트하세요.

KiwoomRestClient — 토큰 관리의 핵심

키움 REST API는 OAuth2 방식의 Access Token이 필요합니다. 토큰 만료 시간이 있어 자동 갱신 로직이 필수입니다. Claude Code가 구현한 핵심 코드입니다.

import httpx, asyncio, datetime

class KiwoomRestClient:
    BASE = "https://openapi.kiwoom.com"
    MOCK = "https://mockapi.kiwoom.com"

    def __init__(self, app_key, secret_key, is_mock=False):
        self.app_key = app_key
        self.secret_key = secret_key
        self.is_mock = is_mock
        self._base = self.MOCK if is_mock else self.BASE
        self._token = ""
        self._expires_at = None
        self._refresh_task = None
        self._client = httpx.AsyncClient(timeout=30.0)

    async def login(self):
        # POST /oauth2/token 으로 Access Token 발급
        resp = await self._client.post(
            f"{self._base}/oauth2/token",
            data={
                "grant_type": "client_credentials",
                "appkey": self.app_key,
                "secretkey": self.secret_key,
            }
        )
        data = resp.json()
        self._token = data["token"]
        expires_in = int(data.get("expires_in", 86400))
        self._expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in)
        self._schedule_refresh(expires_in)
        return data

    def _schedule_refresh(self, expires_in):
        # 만료 10분 전 자동 갱신
        delay = max(expires_in - 600, 60)
        if self._refresh_task:
            self._refresh_task.cancel()
        self._refresh_task = asyncio.create_task(self._auto_refresh(delay))

    async def request(self, path, tr_id, params):
        headers = {
            "Content-Type": "application/json",
            "authorization": f"Bearer {self._token}",
            "appkey": self.app_key,
            "secretkey": self.secret_key,
            "tr_id": tr_id,
        }
        resp = await self._client.get(
            f"{self._base}{path}",
            headers=headers, params=params
        )
        return resp.json()
tr_id가 핵심
키움 REST API는 URL이 아닌 tr_id 헤더로 어떤 데이터를 요청할지 구분합니다. 예를 들어 계좌 조회 경로(/api/dostk/acnt)라도 tr_id에 따라 예수금 조회, 잔고 조회 등 다른 데이터가 반환됩니다.

AccountManager — 계좌·잔고 조회

AccountManager는 계좌 목록, 예수금, 보유 포지션을 조회합니다. 키움 API 응답 구조가 tr_id마다 달라서 여러 키를 순서대로 시도하는 방어 코드가 필요했습니다.

async def fetch_positions(self, account_no):
    # kt00018 — 계좌평가잔고내역
    data = await self.client.request(
        "/api/dostk/acnt", "kt00018",
        {"acnt_no": account_no, "qry_tp": "1", "dmst_stex_tp": "KRX"},
    )
    items = []
    for key in ("acnt_evlt_remn_indv_tot", "output1", "list"):
        if key in data and isinstance(data[key], list):
            items = data[key]; break

    positions = []
    for item in items:
        raw_code = item.get("stk_cd", "")
        # API가 "A357870" 형태로 반환 — 앞 알파벳 제거
        code = raw_code.lstrip("A") if raw_code[0].isalpha() else raw_code
        qty = int(item.get("rmnd_qty", 0))
        price = int(item.get("cur_prc", 0))
        if code and qty > 0:
            positions.append(StockPosition(code, ...))
    return positions
🔴 발견한 함정: 종목코드 앞에 'A' 붙어옴
API가 종목코드를 A357870 형태로 반환했습니다. 다른 API에서 조회한 코드(숫자만)와 매칭이 안 되는 문제가 생겼습니다.
✅ 해결: lstrip("A")로 앞 알파벳 제거
raw_code.lstrip("A")로 앞의 알파벳 시장구분자를 제거해 일관된 코드 형식을 유지합니다.

PortfolioManager — 계좌별 설정 분리

제외 종목, 편입 종목, 비중 설정은 계좌별로 독립되어야 합니다. 처음엔 단일 계좌만 고려했다가, 여러 계좌를 쓸 수 있음을 나중에 깨달았습니다.

settings.json의 기존 형식에서 계좌별 구조로 마이그레이션이 필요했습니다.

# 기존 (단일 계좌)
{"excluded_stocks": [...], "weights": {...}}

# 변경 후 (계좌별)
{
  "accounts": {
    "계좌번호": {
      "excluded_stocks": [...],
      "added_stocks": [...],
      "weights": {...}
    }
  }
}

하위 호환성을 위해 기존 형식을 __legacy__ 키에 임시 저장해두고, 첫 계좌 선택 시 해당 계좌 키로 자동 이전합니다.

def _migrate_if_needed(self):
    if "accounts" not in self._settings:
        old_excl = self._settings.pop("excluded_stocks", [])
        old_added = self._settings.pop("added_stocks", [])
        old_weights = self._settings.pop("weights", {})
        self._settings["accounts"] = {}
        if old_excl or old_added or old_weights:
            self._settings["accounts"]["__legacy__"] = {
                "excluded_stocks": old_excl,
                "added_stocks": old_added,
                "weights": old_weights,
            }
        self._save()

def set_account(self, account_no):
    self._account_no = account_no
    accounts = self._settings.setdefault("accounts", {})
    # 레거시 데이터를 첫 계좌 선택 시 해당 계좌로 이전
    if "__legacy__" in accounts and account_no not in accounts:
        accounts[account_no] = accounts.pop("__legacy__")
        self._save()

키움 API 준비 단계 — 앱키 발급

1
openapi.kiwoom.com 접속 → 회원가입 (키움증권 계좌 필요)
2
APP 등록 → 사용 API 선택 (조회/주문 모두 선택) → App Key, Secret Key 발급
3
허용 IP 등록 — REST API는 등록된 IP에서만 호출 가능합니다. 집 PC의 외부 IP를 등록하세요.
4
모의투자 계좌 개설 (별도 신청) — 실전 투자 전 반드시 모의투자로 검증
⚠ IP 제한 주의
IP가 바뀌면 API 호출이 거부됩니다. 공유기 외부 IP가 변경될 경우 즉시 openapi.kiwoom.com에서 IP를 업데이트해야 합니다. 고정 IP를 사용하거나 DDNS 설정을 권장합니다.

실행 방법

# 의존성 설치
pip install fastapi uvicorn httpx aiosqlite

# 서버 실행
cd rebalancing
python main.py

# 브라우저에서 접속
http://localhost:8000
✅ 2편 완성 체크리스트
✔ KiwoomRestClient — Access Token 발급 & 자동 갱신
✔ AccountManager — 계좌 목록, 예수금, 포지션 조회
✔ PortfolioManager — 계좌별 제외/편입/비중 설정
✔ 종목코드 'A' 접두어 처리
✔ settings.json 하위 호환 마이그레이션
✔ 모의투자/실전 전환 config 설정

댓글 없음:

댓글 쓰기

가장 많이 본 글