키움 REST API 오류 — return_code 3의 함정
키움 REST API 는 실패도 HTTP 200 으로 내려보내고, 본문의 return_code 마저 3 처럼 뭉뚱그려 옵니다. 진짜 오류코드는 return_msg 안 대괄호에 숨어 있습니다 — 인증에 실패했습니다[8001:app key와 secret key 인증에 실패했습니다] 같은 형태이고, 웹소켓에서는 같은 정보가 CODE=8005 로 옵니다. 그래서 return_code 만 찍으면 앱키 오류·토큰 만료·한도 초과가 전부 같은 3 으로 보입니다. 키움증권 공식 클라이언트는 정규식으로 그 숫자를 꺼내 8개 그룹으로 분류하고, 그중 자동 재발급 후 1회 재시도가 허용된 코드는 8005·8031·8103 셋뿐입니다.
이 글에서 다루는 것
- 왜 오류가 로그에 안 보이는가
return_code는 정수로도 문자열로도 온다- 진짜 코드는
return_msg안에 있다 - 공식 클라이언트의 코드 분류표 8그룹
- 자동 재시도해도 되는 코드는 셋뿐
- 자주 만나는 네 가지 — 8001·8005·1700·8104
- 최소 구현 — 파싱·분류·재시도
1. 왜 오류가 로그에 안 보이는가
키움 REST API 로 짠 봇이 “오류 없이 도는데 주문이 안 나간다”면, 대개 실패를 성공으로 읽고 있습니다. 원인은 키움의 응답 설계에 있습니다. 키움증권 공식 REST 클라이언트(Kiwoom-Securities/Kiwoom-REST-API)의 kiwoom/core/client.py 에 그 전제가 주석으로 박혀 있습니다.
# kiwoom/core/client.py — 공식 클라이언트 원문
# 키움은 업무 오류도 HTTP 200으로 내려보내고 실패 여부는 본문 return_code에만 담는다.
if return_code not in (None, 0):
raise_for_error_code(
return_code,
str(data.get("return_msg") or "API 요청에 실패했습니다."),
)
즉 res.status_code == 200 으로 성공을 판정하는 코드는 주문이 거부된 상태에서도 다음 줄로 넘어갑니다. 정상 응답의 표준 형태는 이렇습니다.
{
"return_code": 0,
"return_msg": "정상적으로 처리되었습니다"
}
같은 구조를 한국투자증권 KIS 도 씁니다 — 거기서는 이름이 rt_cd 와 msg_cd 로 바뀔 뿐입니다. 두 증권사를 비교하려면 KIS API 에러코드 11가지를 같이 보십시오. 생성형 모델이 짠 코드가 이 지점에서 무너지는 이유는 ChatGPT 자동매매 코드 — 실제로 막히는 6곳에 따로 정리했습니다.
2. return_code 는 정수로도 문자열로도 온다
본문을 보기로 했다면 다음 함정은 타입입니다. 공식 클라이언트는 normalize_return_code 라는 정규화 함수를 따로 두고, 주석에 이유를 적어 두었습니다.
# kiwoom/core/errors.py — 공식 클라이언트 원문
def normalize_return_code(value: object) -> int | None:
"""Normalize a Kiwoom ``return_code`` payload value.
The server emits the code as an int or a numeric string ("0", "0000",
"8005") depending on the endpoint. ``None``, blank, and non-numeric
values mean "no code present".
"""
정리하면 엔드포인트에 따라 0 으로도, "0" 으로도, "0000" 으로도 옵니다. if data["return_code"] == 0: 한 줄로 끝내면 문자열이 오는 엔드포인트에서 성공을 실패로 읽고, 반대로 문자열 비교만 하면 정수 0 을 놓칩니다. 값을 먼저 정수로 정규화한 뒤 판정하는 것이 유일하게 안전한 방법입니다.
3. 진짜 코드는 return_msg 안에 있다
여기가 이 글의 핵심입니다. 키움은 최상위 return_code 에 뭉뚱그린 값을 담고, 구체적인 코드는 return_msg 문자열 안에 넣어 보냅니다. 공식 클라이언트 errors.py 의 주석이 이 구조를 그대로 설명합니다.
# kiwoom/core/errors.py — 공식 클라이언트 원문
# Kiwoom often reports a generic top-level ``return_code`` (e.g. 3 = "인증에
# 실패했습니다") and puts the specific code inside ``return_msg`` as
# ``[8005:Token이 유효하지 않습니다]`` (REST) or ``CODE=8005`` (WebSocket).
_EMBEDDED_CODE_RE = re.compile(r"\[(\d{3,5}):|CODE=(\d{3,5})")
그래서 실제로 받게 되는 오류 문자열은 이런 모양입니다. 공식 클라이언트의 APIError 가 키움 API 오류 ({return_code}): {return_msg} 형식으로 메시지를 만들기 때문에, 콘솔에는 뭉뚱그린 코드와 구체적인 코드가 한 줄에 같이 찍힙니다.
키움 API 오류 (3): 접속에 실패했습니다[8001:app key와 secret key 인증에 실패했습니다]
↑ ↑
뭉뚱그린 코드 진짜 원인 코드
4. 공식 클라이언트의 코드 분류표 8그룹
공식 클라이언트는 꺼낸 코드를 8개 그룹으로 나누고 각각 다른 예외 클래스를 던집니다. 아래는 errors.py 에 정의된 집합을 그대로 옮긴 것입니다.
| 그룹 (예외 클래스) | 코드 | 뜻 | 대응 |
|---|---|---|---|
InvalidCredentialsError | 8001 · 8002 · 8011 · 8012 | 앱키·시크릿 인증 실패 | 재시도 무의미 — 키와 실행 모드의 짝을 확인 |
InvalidTokenError | 8003 · 8005 · 8006 · 8009 · 8015 · 8016 | 접근 토큰 무효·만료 | 캐시 비우고 재발급 |
ModeMismatchError | 8030 · 8031 | 실전·모의 모드 불일치 | 도메인과 키를 같은 환경으로 맞춤 |
DeviceAuthenticationError | 8010 · 8040 · 8050 · 8103 | 단말기 인증 실패 | 등록 단말·보안 설정 확인 |
RateLimitError | 1700 · 1701 · 1702 | 요청 한도 초과 | 재시도가 아니라 호출 간격 조정 |
InputValidationError | 1501 · 1504 · 1505 · 1511~1517 · 1687 · 8020 | 필수값·형식 오류 | 요청 본문 수정 |
SymbolNotFoundError | 1901 · 1902 · 1903 | 종목·시장 코드 오류 | 종목코드 체계 확인 |
DemoUnsupportedError | 8104 | 모의투자 미지원 API | 실전에서만 되는 기능 |
분류에 들어가지 않는 코드는 원문 메시지를 그대로 노출하는 APIError 로 남습니다. 공식 코드의 주석은 그 예로 1999(예기치 못한 오류)와 8200(법인 미지원)을 듭니다 — 분류해도 대응이 달라지지 않기 때문입니다.
이 표는 공식 클라이언트가 분류에 쓰는 집합이지, 키움의 전체 오류코드 목록이 아닙니다. 코드는 추가·변경될 수 있으므로 여기에 없는 값을 만나면 키움 REST API 공식 문서에서 현재 내용을 확인하십시오. 핵심은 개별 숫자를 외우는 것이 아니라 그룹별로 대응이 다르다는 구조입니다.
5. 자동 재시도해도 되는 코드는 셋뿐
오류를 잡았으면 다음 질문은 “다시 보내도 되나”입니다. 공식 클라이언트가 자동 복구 대상으로 지정한 값은 세 개입니다.
# kiwoom/core/errors.py — 공식 클라이언트 원문
# Kiwoom auth-expiry return codes that warrant a one-shot credential
# recovery + retry (shared by the REST client and the WebSocket client).
AUTH_RETRY_RETURN_CODES = frozenset({8005, 8031, 8103})
이 셋이 나오면 토큰 캐시를 비우고 재발급한 뒤 딱 한 번 다시 요청합니다. 두 번은 시도하지 않습니다 — client.py 의 재귀 호출이 retry_on_auth_failure=False 로 내려가기 때문입니다. HTTP 401 이 왔을 때도 같은 경로를 한 번만 탑니다.
나머지를 재시도하면 상황이 나빠집니다. 특히 1700 계열 한도 초과에 재시도를 걸면 스스로 한도를 더 태웁니다. 한도는 재시도가 아니라 호출 간격으로 푸는 문제입니다 — 키움 REST API 429 요청 한도에 따로 정리했습니다. 8001 계열 자격 증명 오류도 몇 번을 다시 보내든 같은 결과입니다.
토큰 쪽을 자동 복구하려면 만료 판정이 먼저 정확해야 합니다. 키움 토큰 응답은 expires_in 이 아니라 expires_dt 라는 절대 시각 문자열로 오므로 그 값을 파싱해 캐시해야 합니다 — 키움 REST 토큰 유효기간과 폐기에 실물이 있습니다.
6. 자주 만나는 네 가지
8001 — 앱키가 아니라 환경이 틀렸을 가능성이 높다
가장 많이 보이는 값이고, 가장 많이 오진됩니다. 키움 공식 저장소의 설치 문서는 이 증상을 이렇게 진단합니다 — “앱 키/시크릿이 틀렸거나, 모의투자 키를 real로(또는 그 반대로) 쓰고 있습니다. 설치 문제가 아닙니다.”
키움은 실전과 모의가 도메인부터 갈립니다.
# kiwoom/core/auth.py — 공식 클라이언트 원문
DEFAULT_BASE_URLS = {
"real": "https://api.kiwoom.com",
"demo": "https://mockapi.kiwoom.com",
}
DEFAULT_WS_BASE_URLS = {
"real": "wss://api.kiwoom.com:10000",
"demo": "wss://mockapi.kiwoom.com:10000",
}
키를 세 번 재발급해도 안 고쳐지는 사례의 대부분이 여기입니다. 재발급 전에 실행 모드와 키의 짝부터 확인하십시오. 키 발급 절차 자체는 키움 REST API 키 발급 가이드에 단계별로 있습니다.
8005 — 토큰이 죽었는데 캐시가 살아 있다
Token이 유효하지 않습니다 계열입니다. 자동 재시도 대상 세 개 중 하나이므로 캐시를 비우고 재발급한 뒤 한 번 다시 보내면 대개 풀립니다. 여기서 중요한 것은 순서입니다 — 거부당한 토큰을 캐시에 남겨 두면, 캐시가 스스로 만료될 때까지 죽은 토큰을 계속 씁니다.
1700 — 한도. 재시도 금지
위에서 다뤘듯 재시도가 아니라 간격 조정으로 풉니다. 특히 종목 목록을 for 문으로 훑는 코드, 연속조회를 빠르게 반복하는 코드에서 터집니다. 연속조회 커서 처리는 키움 REST 연속조회 — cont-yn·next-key를 보십시오.
8104 — 모의투자에서는 애초에 안 되는 API
코드도 키도 멀쩡한데 모의에서만 실패한다면 이 값을 의심하십시오. API 는 존재하지만 모의투자 환경에 서비스되지 않는 경우입니다. 주문 계열 일부와 특수 조회가 여기 해당합니다 — 모의에서 검증이 불가능하므로, 해당 기능은 실전 최소 수량으로 확인하는 절차를 따로 잡아야 합니다. 주문 API 자체의 파라미터는 키움 REST 주식 주문 kt10000에 정리돼 있습니다.
7. 최소 구현 — 파싱·분류·재시도
직접 짠다면 아래 세 조각이면 충분합니다. 공식 클라이언트의 동작을 그대로 축약한 형태입니다.
import re
_EMBEDDED = re.compile(r"\[(\d{3,5}):|CODE=(\d{3,5})")
AUTH_RETRY = {8005, 8031, 8103}
RATE_LIMIT = {1700, 1701, 1702}
CREDENTIAL = {8001, 8002, 8011, 8012}
def normalize(value):
"""0 / "0" / "0000" 을 모두 정수로."""
if value is None or isinstance(value, bool):
return None
if isinstance(value, int):
return value
text = str(value).strip()
return int(text) if text and text.lstrip("-").isdigit() else None
def real_code(return_code, return_msg):
"""뭉뚱그린 코드 뒤에 숨은 진짜 코드."""
m = _EMBEDDED.search(str(return_msg or ""))
if m:
return int(m.group(1) or m.group(2))
return return_code
def handle(resp_json):
code = normalize(resp_json.get("return_code"))
if code in (None, 0):
return "OK"
code = real_code(code, resp_json.get("return_msg"))
if code in AUTH_RETRY:
return "REISSUE_TOKEN_AND_RETRY_ONCE"
if code in RATE_LIMIT:
return "BACK_OFF" # 재시도 아님
if code in CREDENTIAL:
return "STOP_AND_ALERT" # 사람이 봐야 함
return "STOP_AND_ALERT"
웹소켓 쪽도 같은 함수를 씁니다. 공식 클라이언트의 ws_client.py 는 trnm 이 LOGIN 인 응답에서 return_code 를 읽고, 그 값이 자동 복구 대상이면 재로그인을 한 번 시도합니다. 실시간 등록 자체의 절차는 키움 REST 웹소켓 실시간 시세 — 0B·0D 등록에 있습니다.
체크리스트 — 오류 처리 코드를 열었을 때 다섯 줄
status_code말고 본문return_code를 보는가return_code를 정수로 정규화한 뒤 비교하는가return_msg에서 대괄호 안 숫자(또는CODE=)를 꺼내는가- 자동 재시도를
8005·8031·8103으로만 한정하고, 한 번만 하는가 - 로그에
return_code와return_msg를 원문 그대로 남기는가
마지막 줄이 실무에서 가장 값집니다. 메시지를 가공해 저장하면 대괄호 안 숫자가 날아가고, 그러면 사후에 원인을 가를 방법이 없습니다. OpenAPI+ 시절의 OP_ERR_ 코드 체계와는 완전히 다른 체계라는 점도 같이 기억해 두십시오 — 두 체계의 차이는 키움 OpenAPI+ → REST 마이그레이션에서 다뤘습니다.
※ 본 글의 코드 인용은 키움증권이 공개한 공식 REST 클라이언트 저장소 원문이며, 작성 시점 기준입니다. 오류코드 체계와 API 정책은 변경될 수 있으니 반드시 키움증권 공식 문서에서 현재 내용을 확인하십시오. 알고랩(퀀트웍스)은 투자자문업·투자일임업을 영위하지 않으며, 본 글은 도구 제작에 관한 기술 정보 제공입니다. 투자 판단과 그 결과의 책임은 투자자 본인에게 있습니다.
오류 로그만 보내 주셔도 됩니다
어느 그룹의 오류인지, 코드를 고쳐야 하는지 설정을 고쳐야 하는지 먼저 갈라 드립니다. 키움 REST 기반 봇 제작·수정 모두 가능합니다.
24시간 빠른 답변 가능합니다.