키움 REST API 토큰 유효기간 — expires_dt와 폐기
au10001) 응답에 expires_dt(만료일) 필드가 오고,
공식 예시 값은 "20241107083713" 같은 14자리 문자열입니다.
즉 키움은 남은 시간이 아니라 만료 시각을 알려 줍니다.
그러니 코드에 86400이나 24 * 3600을 박지 말고
이 값을 파싱해서 그 시각 이전에 재발급하십시오.
폐기가 필요하면 au10002 — POST /oauth2/revoke에
appkey·secretkey·token을 넣어 호출합니다.
키움 REST API 자동매매 가이드대로
OpenAPI+에서 넘어와 첫 호출까지 성공하고 나면, 다음으로 반드시 마주치는 질문이 이겁니다.
"이 토큰, 언제까지 쓸 수 있지?"
검색해 보면 답이 제각각입니다. 어디는 하루, 어디는 몇 시간이라고 합니다.
그런데 키움증권 REST API 공식 가이드의 au10001 명세를 직접 열어 보면
기간을 나타내는 항목 자체가 없습니다.
응답 필드는 expires_dt·token_type·token 셋뿐입니다.
이 글은 그 사실에서 출발해 토큰 수명을 어떻게 다뤄야 하는지만 다룹니다.
목차
- 발급 요청과 응답 실물 (au10001)
- expires_dt 읽는 법 — 형식과 타임존 함정
- "24시간"을 코드에 박으면 안 되는 이유
- 캐싱 + 선제 재발급 구현
- 폐기 au10002 — 언제 쓰나
- 키움 vs 한국투자증권 KIS 필드명 대조
- 만료 증상과 재시도 설계
1. 발급 요청과 응답 실물 (au10001)
| 항목 | 값 |
|---|---|
| API ID | au10001 (접근토큰 발급) |
| Method / URL | POST /oauth2/token |
| 실전 도메인 | https://api.kiwoom.com |
| 모의투자 도메인 | https://mockapi.kiwoom.com |
| Content-Type | application/json;charset=UTF-8 |
| 요청 필드 | grant_type(=client_credentials) · appkey · secretkey |
| 응답 필드 | expires_dt · token_type · token |
공식 문서의 예시를 그대로 옮깁니다.
# 요청
{
"grant_type": "client_credentials",
"appkey": "AxserEsdcredca.....",
"secretkey": "SEefdcwcforehDre2fdvc...."
}
# 응답
{
"expires_dt": "20241107083713",
"token_type": "bearer",
"token": "WQJCwyqInphKnR3bSRtB9NE1lv...",
"return_code": 0,
"return_msg": "정상적으로 처리되었습니다"
}
여기서 이미 두 가지가 갈립니다.
① 토큰이 담기는 키 이름이 access_token이 아니라 그냥 token입니다.
② 만료 정보가 남은 초(expires_in)가 아니라 시각(expires_dt)입니다.
다른 증권사 예제를 복사해 온 코드가 KeyError: 'access_token'으로 죽는 가장 흔한 이유가 이겁니다.
2. expires_dt 읽는 법 — 형식과 타임존 함정
"20241107083713"은 14자리입니다. 앞에서부터 끊으면 이렇습니다.
| 구간 | 자리 | 값 |
|---|---|---|
| 연 | 1~4 | 2024 |
| 월 | 5~6 | 11 |
| 일 | 7~8 | 07 |
| 시 | 9~10 | 08 |
| 분 | 11~12 | 37 |
| 초 | 13~14 | 13 |
from datetime import datetime
from zoneinfo import ZoneInfo
KST = ZoneInfo("Asia/Seoul")
def parse_expires(expires_dt: str) -> datetime:
"""'20241107083713' -> tz-aware datetime (KST)"""
return datetime.strptime(expires_dt, "%Y%m%d%H%M%S").replace(tzinfo=KST)
exp = parse_expires("20241107083713")
print(exp) # 2024-11-07 08:37:13+09:00
print(exp - datetime.now(KST)) # 남은 시간
타임존을 빠뜨리면 9시간이 어긋납니다.
국내 증권사 API가 돌려주는 시각은 한국 시간 기준입니다.
그런데 클라우드 인스턴스나 도커 컨테이너는 기본이 UTC인 경우가 많습니다.
datetime.now()(naive)와 strptime 결과를 그대로 빼면
UTC 서버에서는 토큰이 아직 9시간 남았다고 착각하거나
이미 만료됐다고 착각합니다.
전자는 장중 인증 실패로, 후자는 불필요한 재발급 폭주로 나타납니다.
서버 타임존을 Asia/Seoul로 고정하거나 위 코드처럼 명시적으로 붙이십시오.
3. "24시간"을 코드에 박으면 안 되는 이유
인터넷에서 특정 시간 수치를 본 적이 있을 겁니다. 그 값이 지금 맞을 수도 있습니다. 문제는 그게 공식 명세에 적힌 값이 아니라 관측값이라는 점입니다. 관측값을 상수로 박아 두면 세 가지 상황에서 조용히 깨집니다.
- 증권사가 정책을 바꿀 때 — 명세에 숫자가 없다는 건 바꿀 여지를 남겨 뒀다는 뜻이기도 합니다.
- 실전과 모의투자가 다를 때 —
api.kiwoom.com과mockapi.kiwoom.com은 별개 환경입니다. - 발급 시점이 만료 경계에 걸릴 때 — 만료 시각이 고정 스케줄이라면 "발급 + 24시간"은 애초에 틀린 계산입니다.
규칙 — 유효기간은 추측하지 말고 받아서 쓰십시오.
키움은 expires_dt, 한국투자증권 KIS는 access_token_token_expired입니다.
둘 다 "언제까지"를 알려 주는 값이라 코드가 상수에 의존할 이유가 없습니다.
4. 캐싱 + 선제 재발급 구현
구조는 짧습니다. 토큰과 만료 시각을 한 쌍으로 들고 다니고, 요청 직전에 남은 시간을 확인해 부족하면 새로 받습니다.
import requests
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
KST = ZoneInfo("Asia/Seoul")
HOST = "https://api.kiwoom.com" # 모의: https://mockapi.kiwoom.com
class KiwoomToken:
def __init__(self, appkey, secretkey, margin_sec=120):
self.appkey, self.secretkey = appkey, secretkey
self.margin = timedelta(seconds=margin_sec)
self._token, self._exp = None, None
def _issue(self):
r = requests.post(
f"{HOST}/oauth2/token",
headers={"Content-Type": "application/json;charset=UTF-8"},
json={"grant_type": "client_credentials",
"appkey": self.appkey, "secretkey": self.secretkey},
timeout=10)
r.raise_for_status()
body = r.json()
if body.get("return_code") != 0:
raise RuntimeError(f"au10001 실패: {body.get('return_msg')}")
self._token = body["token"] # access_token 아님
self._exp = datetime.strptime(body["expires_dt"], "%Y%m%d%H%M%S") \
.replace(tzinfo=KST)
return self._token
def get(self):
if self._token and self._exp - self.margin > datetime.now(KST):
return self._token # 캐시 재사용
return self._issue()
def revoke(self):
"""au10002 — 키 유출이 의심될 때만"""
if not self._token:
return
requests.post(
f"{HOST}/oauth2/revoke",
headers={"Content-Type": "application/json;charset=UTF-8"},
json={"appkey": self.appkey, "secretkey": self.secretkey,
"token": self._token},
timeout=10)
self._token, self._exp = None, None
실제 요청에는 token_type이 bearer이므로
authorization 헤더에 Bearer {token} 형태로 싣고,
키움은 여기에 어떤 TR을 부르는지 알리는 api-id 헤더가 추가로 필요합니다.
연속조회를 쓸 때 같이 나가는
cont-yn·next-key도 이 헤더 묶음에 들어갑니다.
토큰·요청 제한·재접속은 봇 안정성의 기본 3종입니다. 어디까지 직접 만들고 어디부터 맡길지 먼저 정리해 드립니다.
30초 상담으로 범위 잡기 →5. 폐기 au10002 — 언제 쓰나
| 항목 | 값 |
|---|---|
| API ID | au10002 (접근토큰폐기) |
| Method / URL | POST /oauth2/revoke |
| 요청 필드 | appkey · secretkey · token |
| 응답 필드 | return_code · return_msg (토큰 정보 없음) |
# 요청
{
"appkey": "AxserEsdcredca.....",
"secretkey": "SEefdcwcforehDre2fdvc....",
"token": "WQJCwyqInphKnR3bSRtB9NE1lv..."
}
# 응답
{
"return_code": 0,
"return_msg": "정상적으로 처리되었습니다"
}
평소에는 부를 일이 없습니다. 폐기가 정답인 상황은 하나입니다 — 앱키·시크릿키·토큰이 밖으로 샜을 가능성이 있을 때. 로그에 토큰을 통째로 찍어 뒀거나, 설정 파일을 저장소에 커밋했거나, 화면 공유 중에 노출된 경우입니다.
봇 종료 시 자동 폐기는 신중하게.
"프로세스 끝날 때 revoke"를 넣어 두는 구성이 깔끔해 보이지만,
같은 앱키로 여러 프로세스가 도는 구조라면
한쪽의 종료가 다른 쪽이 쓰던 토큰까지 무효화할 수 있습니다.
장중에 조회 프로세스와 주문 프로세스를 분리해 돌리는 봇에서 실제로 겪는 사고입니다.
멀티 프로세스라면 폐기는 수동 운영 명령으로만 두세요.
그리고 당연한 이야기지만, 앱키·시크릿키·토큰은 코드에 넣지 말고 환경변수나 별도 설정 파일로 분리하고 그 파일은 버전관리에서 제외하십시오. 폐기를 부를 일 자체를 안 만드는 것이 가장 좋습니다.
6. 키움 vs 한국투자증권 KIS 필드명 대조
두 증권사를 함께 쓰는 봇이 늘었습니다. 그런데 인증 부분이 이름부터 전부 다릅니다.
| 구분 | 키움 REST API | 한국투자증권 KIS |
|---|---|---|
| 발급 URL | /oauth2/token | /oauth2/tokenP |
| API ID / 식별 | au10001 | tr_id 체계와 별도 |
| 시크릿 키 이름 | secretkey | appsecret |
| 토큰 필드 | token | access_token |
| 만료 필드 | expires_dt (시각) | access_token_token_expired (시각) |
| 폐기 | au10002 · /oauth2/revoke | 별도 폐기 절차 확인 필요 |
| 결과 코드 | return_code · return_msg | rt_cd · msg1 · msg_cd |
이 표가 말하는 건 하나입니다 — 브로커별 어댑터를 두고 내부에서는 하나의 형태로 정규화하라. 키움과 KIS 비교에서 다룬 구조 차이가 인증 계층에서 가장 먼저 드러납니다. KIS 쪽 토큰 수명 처리는 KIS access_token 재발급 규칙에 따로 정리해 뒀습니다.
7. 만료 증상과 재시도 설계
만료된 토큰으로 조회나 주문을 부르면 인증 실패로 떨어집니다. 여기서 코드가 흔히 저지르는 실수는 이 실패를 일시적 네트워크 오류로 취급하는 것입니다.
# 나쁜 예 — 같은 만료 토큰으로 계속 두드린다
for _ in range(10):
r = call_api(token) # 계속 인증 실패
if r.ok: break
time.sleep(1) # 실패 10회 + 호출 제한 소모
# 좋은 예 — 한 번만 재발급하고 한 번만 재시도
r = call_api(tokens.get())
if is_auth_error(r):
r = call_api(tokens._issue()) # 강제 재발급 후 1회 재시도
if is_auth_error(r):
raise RuntimeError("재발급 후에도 인증 실패 — 키 상태 확인 필요")
무한 재시도가 위험한 이유는 실패가 해결되지 않는데 호출 횟수만 늘기 때문입니다. 키움 REST API 429 — 허용된 요청 개수 초과가 이 패턴에서 자주 따라옵니다. 인증 실패와 호출 제한이 겹치면 원인 파악이 훨씬 어려워집니다.
토큰 발급 자체도 아껴야 할 호출입니다. 매 요청마다 발급받는 구현은 초보 단계에서 놀랄 만큼 흔한데, 장중 수백 회 호출하는 봇이라면 그것만으로 제한에 닿습니다. VI 발동 구간처럼 이벤트가 몰리는 시간대에 이 문제가 먼저 터집니다.
자주 묻는 것
모의투자와 실전의 토큰을 같이 쓸 수 있나요?
안 됩니다. api.kiwoom.com과 mockapi.kiwoom.com은 별개 환경이라
앱키도 토큰도 각각 받아야 합니다.
설정에서 도메인만 바꾸고 토큰을 재사용하면 인증 실패가 납니다.
키움 OpenAPI 입문 단계에서 모의로 붙였다가
실전 전환할 때 가장 자주 걸리는 지점입니다.
토큰을 파일에 저장해도 되나요?
프로세스를 자주 재시작한다면 저장하는 편이 낫습니다. 매번 새로 발급받는 것보다 호출을 아낍니다.
다만 토큰은 비밀번호와 같은 등급으로 다루십시오 —
권한을 제한한 경로에 두고, 로그에 찍지 말고, 저장소에 올리지 마십시오.
저장 시에는 token과 expires_dt를 같이 저장해야 의미가 있습니다.
토큰만 저장하면 만료 판단을 못 해서 결국 매번 재발급하게 됩니다.
이 값들을 그대로 믿어도 되나요?
이 글의 API ID·URL·요청/응답 필드명·예시 값은 2026-08-16 시점 키움증권 REST API 공식 가이드에서 확인한 것입니다. 한국투자증권 쪽 필드명은 공개된 예제 코드 기준입니다. 증권사 명세는 예고 없이 바뀌므로, 운영에 넣기 전 각 개발자 포털의 현재 명세로 대조하시고 도메인·필드명은 코드 상수 대신 설정으로 빼 두시기 바랍니다.