Claude Code로 키움 REST API
클라이언트 구현하기
Access Token 자동 갱신, 계좌 조회, 계좌별 포트폴리오 설정까지 — 실제 코드 공개
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가 아래와 같은 프로젝트 구조를 잡아줬습니다.
├── 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()
키움 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
API가 종목코드를
A357870 형태로 반환했습니다. 다른 API에서 조회한 코드(숫자만)와 매칭이 안 되는 문제가 생겼습니다.
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 준비 단계 — 앱키 발급
IP가 바뀌면 API 호출이 거부됩니다. 공유기 외부 IP가 변경될 경우 즉시 openapi.kiwoom.com에서 IP를 업데이트해야 합니다. 고정 IP를 사용하거나 DDNS 설정을 권장합니다.
실행 방법
# 의존성 설치 pip install fastapi uvicorn httpx aiosqlite # 서버 실행 cd rebalancing python main.py # 브라우저에서 접속 http://localhost:8000
✔ KiwoomRestClient — Access Token 발급 & 자동 갱신
✔ AccountManager — 계좌 목록, 예수금, 포지션 조회
✔ PortfolioManager — 계좌별 제외/편입/비중 설정
✔ 종목코드 'A' 접두어 처리
✔ settings.json 하위 호환 마이그레이션
✔ 모의투자/실전 전환 config 설정
1편 → Claude AI로 주식 자동매매 프로그램 기획한 이야기
2편 ✅ Claude Code로 키움 REST API 클라이언트 구현하기 (현재 글)
3편 → TWAP 분할 주문 & 자동 정정 주문 시스템 구현기
4편 → WebSocket + Vanilla JS로 실시간 자동매매 UI 만들기
5편 → 실제 운용하며 만난 버그들과 보안 강화 후기
6편 → 실제 구동 화면 전체 공개 — 로그인부터 대시보드까지
댓글 없음:
댓글 쓰기