한국에서 미국주식 자동매매 봇을 KIS(한국투자증권) API로 붙이다 보면, 정작 시간을 잡아먹는 건 매매 로직이 아니었습니다. 에러 메시지가 원인을 정확히 가리키지 않는 순간들이었죠. “유효하지 않은 AppKey”라는데 키는 멀쩡하고, HTTP 500이 뜨는데 서버는 정상입니다. 이 글은 실제 운영 중 만난 네 가지 API 연동 에러와, 그 진짜 원인을 찾아간 기록입니다. 모두 봇의 트러블슈팅 로그에 날짜와 함께 남아 있는 실전 사례입니다.
💡 이 글의 한 줄 요약 — API 에러의 절반은 “증상이 범인을 감싸주는” 사건입니다. 표면 메시지(EGW00103, HTTP 500)를 곧이곧대로 믿고 키를 재발급하거나 서버를 탓하기 전에, 클라이언트 쪽부터 의심하는 순서를 익혀 두면 며칠이 몇 분으로 줄어듭니다.
증상은 거짓말을 한다 — 네 사건 한눈에 보기
먼저 전체 그림입니다. 네 사건 모두 “표면 메시지”와 “진짜 원인” 사이에 거리가 있습니다. 이 거리가 바로 디버깅 시간이 됩니다.
| 표면 증상 | 잘못 짚기 쉬운 첫 가설 | 진짜 원인 |
|---|---|---|
| EGW00103 “유효하지 않은 AppKey” |
키가 만료됨 → 재발급 | 토큰 발급 재시도 루프가 rate limit을 자기공격 |
| 모든 호출 HTTP 500 + 빈 응답 body |
KIS 서버 장애 | 셸이 시크릿 끝 1바이트를 삼켜서 잘못된 값 전송 |
| 프로세스 시작 직후 15~60초 HTTP 500 |
토큰 인증 실패 | 서버 세션 priming 지연 (cold-start) |
| HTTP 500 + 빈 body가 또 떴다 |
“이번에도 서버겠지” | 같은 증상, 다른 원인 — 구분하는 진단 순서가 답 |
에러 ① EGW00103 “유효하지 않은 AppKey” — 키는 죄가 없었다
🔴 증상 — KIS API 호출이 EGW00103 "유효하지 않은 AppKey"로 거부됩니다. 콘솔에서 키를 재발급해도 똑같이 거부됩니다.
메시지만 보면 답이 뻔해 보입니다. “AppKey가 유효하지 않다”니 키를 새로 발급받으면 되겠죠. 실제로 처음엔 그렇게 했습니다. KIS 개발자 콘솔에서 키를 재발급하고 .env를 갱신했습니다. 그런데도 증상은 그대로였습니다.
전환점은 수동으로 토큰을 직접 발급해 본 순간이었습니다. 터미널에서 curl로 토큰 발급 엔드포인트를 직접 때려봤더니 200 OK로 정상 토큰이 나왔습니다.
# 키가 진짜 죽었는지 확인하는 한 줄 — 200이면 키는 정상이다
curl -s -X POST https://openapi.koreainvestment.com:9443/oauth2/tokenP \
-H "Content-Type: application/json" \
-d '{"grant_type":"client_credentials","appkey":"'$KEY'","appsecret":"'$SEC'"}'
키가 정상이라면 범인은 봇의 토큰 재시도 코드입니다. 사건 로그를 되짚어 보니 이렇게 흘러갔습니다. 2026년 4월 11일 KIS 서버에 일시 장애가 있었고, 봇의 토큰 발급이 실패했습니다. 그런데 봇은 30초 간격으로 재시도를 반복했습니다. 문제는 KIS의 토큰 발급이 분당 1회로 제한된다는 점입니다. 30초 루프는 이 제한을 계속 위반했고, 위반이 누적되면서 앱/IP 단위로 lockout이 걸렸습니다. 봇이 스스로를 공격하는 self-DoS였던 셈입니다.
토큰 발급 실패
rate limit 누적
해결: 실패하면 물러서는 지수 백오프
고정 간격 재시도가 문제였으니, 실패할수록 더 오래 기다리는 지수 백오프(exponential backoff)로 바꿨습니다. 토큰 발급이 실패하면 대기 시간을 1분 → 5분 → 15분 → 30분 → 1시간으로 단계적으로 늘리고, 한 번 성공하면 카운트를 리셋합니다.
# kis_auth.py — 토큰 발급 백오프 스케줄
# KIS는 tokenP를 rate-limit 하므로, 실패 시 물러섰다가 재시도한다
_BACKOFF_SCHEDULE = (60, 300, 900, 1800, 3600) # 1m → 5m → 15m → 30m → 1h
# 실패 횟수에 맞춰 대기 시간 선택 (스케줄 끝에서 clamp)
idx = min(self._failure_count - 1, len(self._BACKOFF_SCHEDULE) - 1)
delay = self._BACKOFF_SCHEDULE[idx]
🟢 교훈
“유효하지 않은 AppKey”를 보면 키 자체가 아니라 봇의 토큰 재시도 빈도(코드 경로)를 먼저 의심하세요. 수동 curl 발급이 200이면 키는 무죄입니다. 재발급은 시간 낭비입니다. 그리고 어떤 외부 API든 재시도에는 반드시 백오프를 넣으세요. 고정 간격 루프는 상대 서버의 rate limit을 자기 발등 찍는 무기로 만듭니다.
에러 ② 모든 호출이 500 — 셸이 시크릿 1바이트를 삼켰다
🔴 증상 — 모든 KIS 호출이 HTTP 500 + 빈 body(rt_cd:"1", msg_cd:"", msg1:"")로 실패합니다. 헤더·파라미터·토큰은 전부 정상으로 보입니다. 그런데 파이썬 python-dotenv로 직접 읽어 호출하면 200이 나옵니다.
HTTP 500에 빈 body. 누가 봐도 서버 문제처럼 생겼습니다. 하지만 결정적 단서가 있었습니다 — bash 실행 경로에서만 500이 나고, python-dotenv로 .env를 직접 읽어 같은 요청을 보내면 200이 나온 겁니다. 그렇다면 문제는 KIS가 아니라 환경변수를 읽어 들이는 셸 경로에 있습니다.
범인은 .env를 파싱하던 bash 한 줄이었습니다. 기존 코드는 이렇게 생겼습니다.
# ❌ 문제의 코드: value가 '='로 끝나면 마지막 '='를 잘라먹는다
while IFS='=' read -r key value; do
export "$key=$value"
done < .env
bash의 read는 필드 마지막에 붙은 IFS 문자(여기선 =)를 잘라냅니다. 그런데 KIS의 KIS_APP_SECRET은 base64로 인코딩돼 있어 = 패딩으로 끝나는 경우가 있습니다. 그 결과 시크릿의 끝 1바이트가 조용히 잘려 나갔습니다. 딱 1바이트 잘린 시크릿 → KIS 인증 검증 실패 → 빈 body의 500. 헤더도, 토큰 발급도, 파라미터도 다 정상이라 도무지 표가 안 나는 함정이었습니다.
해결: IFS를 비우고 직접 자른다 + 파이썬 이중 방어
# ✅ IFS를 비우면 read가 아무 바이트도 자르지 않는다.
# key/value는 substring expansion으로 직접 분리한다.
while IFS= read -r line || [ -n "$line" ]; do
key="${line%%=*}" # 첫 '=' 앞 = 키
value="${line#*=}" # 첫 '=' 뒤 전부 = 값 (뒤쪽 '='도 보존)
# ... 따옴표/공백 정리 후 export ...
done < .env
여기에 이중 방어를 더했습니다. 파이썬 쪽 config.py에서 load_dotenv(env_path, override=True)로 .env 파일을 다시 읽어 덮어씁니다. 셸이 잘못 export하더라도 파이썬이 파일 원본으로 교정합니다. .env 파일 자체를 단일 진실(single source of truth)로 삼는 구조입니다.
그리고 이런 종류의 사고는 길이 비교 한 방으로 즉시 잡을 수 있습니다.
# bash가 export한 시크릿 길이 vs .env 원본 길이
echo "SEC_LEN=${#KIS_APP_SECRET}"
grep KIS_APP_SECRET .env | wc -c
# 두 길이가 1 byte라도 다르면 → IFS 파싱 의심 확정
🟢 교훈
시크릿이나 토큰처럼 base64로 끝나는 값을 셸에서 다룰 땐 IFS='=' 패턴을 쓰지 마세요. 그리고 인증 실패가 의심되면 값을 눈으로 비교하지 말고 길이(${#VAR})로 비교하세요. 1바이트 차이는 육안으로 절대 안 보입니다.
에러 ③ 시작 직후 15~60초만 500 — cold-start의 함정
🔴 증상 — 봇 프로세스를 새로 시작한 직후 첫 15~60초 동안만 대부분의 KIS GET API가 HTTP 500을 반환합니다. 시세 조회(HHDFS00000300), 예수금 조회(CTRP6504R) 모두요. 그런데 이미 켜져 있던(warm) 프로세스에서는 같은 토큰·같은 파라미터로 200이 나옵니다.
앞의 두 사건과 겉모습이 똑같은 HTTP 500이라, 처음엔 또 토큰을 의심했습니다. 하지만 전환점은 명확했습니다 — 동일한 토큰으로 warm 프로세스에서는 200이 나왔습니다. 토큰이 정상인데 cold 프로세스에서만 500이라면, 원인은 코드가 아니라 KIS 서버 쪽의 세션 priming 지연입니다. 새 연결이 서버에서 "데워지는" 데 시간이 걸리는 것이죠.
이게 자동매매에서 특히 위험한 이유가 있습니다. 봇의 self.capital 초기값은 0.0입니다. 시작 직후 자본을 동기화하는 _sync_capital이 하필 이 cold 구간에 걸려 실패하면, 그날 매수 수량 계산이 이렇게 됩니다.
shares = int(self.capital / price) # capital=0.0 이면
# = int(0.0 / price)
# = 0 → 그날 매매 전면 차단
자본이 0으로 읽히니 주문 수량이 0이 되고, 봇은 아무것도 사지 못한 채 하루를 흘려보냅니다. 게다가 내장 재시도(1초→2초→4초, 총 7초)로는 이 15~60초 lockout 구간을 뚫지 못했습니다.
해결: 매매 전에 서버가 데워질 때까지 워밍업
가벼운 시세 엔드포인트를 10초 간격으로 최대 90초까지 polling하다가 200이 떨어지면 즉시 빠져나오는 warm_up()을 KIS 초기화 직후에 호출하도록 했습니다.
# KISClient.warm_up — 서버가 200을 줄 때까지 기다렸다가 매매 시작
def warm_up(self, max_secs: int = 90, poll_interval: int = 10) -> bool:
deadline = time.time() + max_secs
while time.time() < deadline:
# 핵심: retry=False — 내부 재시도가 polling 예산을 먹으면 안 된다
if self._probe_quote(retry=False): # 200 받으면
return True # 즉시 종료
time.sleep(poll_interval)
return False # 실패해도 봇은 계속 진행 (다음 동기화에서 자동 교정)
한 가지 포인트는 retry=False입니다. 워밍업 probe 자체에 내부 재시도가 붙어 있으면, 그 재시도가 90초 예산을 갉아먹어 정작 polling 횟수가 줄어듭니다. 워밍업은 "한 번 찔러보고 아니면 다음 주기"로 단순하게 가야 합니다. 워밍업이 실패하더라도 봇은 멈추지 않고 진행하며, 다음 자본 동기화(새 날/장 마감) 시점에 자동으로 교정됩니다.
🟢 교훈
에러가 프로세스 수명(cold vs warm)에 따라 갈린다면 코드나 토큰이 아니라 서버 측 초기화 지연을 의심하세요. 자동매매 봇은 시작 직후 capital=0 같은 초기 상태로 매매하면 안 되니, "준비될 때까지 기다리는" 워밍업 단계를 매매 로직 앞에 반드시 두세요.
에러 ④ "또 HTTP 500" — 같은 증상, 다른 범인을 가르는 진단 순서
눈치채셨겠지만 에러 ②와 ③은 표면 증상이 완전히 똑같습니다. 둘 다 "HTTP 500 + 빈 body"죠. 하지만 하나는 셸 파싱 버그이고, 하나는 서버 세션 지연입니다. 원인이 다르니 처방도 다릅니다. 그래서 네 번째로 필요한 건 새 에러가 아니라, 같은 증상에서 범인을 갈라내는 진단 순서입니다.
핵심 원칙 하나만 기억하면 됩니다. "HTTP 500 + 빈 body"는 서버 신호처럼 생겼지만, 실제로는 클라이언트 인증 실패일 때가 더 많습니다. 그러니 서버를 탓하기 전에 아래 순서로 내려가세요.

순서에 담긴 뜻은 이렇습니다. 가장 싸고 결정적인 검사부터 합니다. 시크릿 길이 비교는 명령 두 줄이면 끝나고, 결과가 흑백으로 갈립니다(에러 ②). 길이가 같다면 다음으로 프로세스가 방금 떴는지 봅니다 — 방금 시작했다면 cold-start일 가능성이 높습니다(에러 ③). 이 둘을 다 배제한 뒤에야 비로소 토큰과 서버를 의심합니다. 서버 탓은 가장 나중, 가장 마지막 카드입니다.
🧠 참고 — KIS의 호출 제한 구조
헷갈리기 쉬운데, KIS에는 서로 다른 두 종류의 제한이 있습니다. 하나는 일반 API 호출에 걸리는 초당 20건 제한(초과 시 EGW00201)이고, 다른 하나는 에러 ①에서 만난 토큰 발급 자체의 제한입니다. 접근토큰은 발급 후 상당 시간 유효하므로 한 번 받아 캐싱해 재사용하는 게 정석입니다. 미장봇도 토큰을 config/token.json에 캐싱해 매 호출마다 새로 발급하지 않습니다. "발급을 자주 하면 막힌다"는 게 바로 에러 ①의 뿌리였죠.
🟢 교훈
같은 증상이 여러 원인에서 나온다면, 필요한 건 더 많은 로그가 아니라 배제 순서(가장 싸고 결정적인 검사부터)입니다. 그리고 로깅할 때 KIS 500은 반드시 엔드포인트 + tr_id + 응답 body(msg_cd/msg1)를 한 줄에 같이 남기세요. 이 셋이 없으면 다음 사건 때 또 처음부터 헤맵니다.
네 사건이 남긴 공통 규칙
네 에러의 표면은 제각각이었지만, 진짜 원인을 찾는 길은 하나로 모입니다.
🧭 API 연동 디버깅 3원칙
1. 에러 메시지를 곧이곧대로 믿지 마라. "AppKey 유효하지 않음"이 키 문제가 아니었고, "HTTP 500"이 서버 문제가 아니었습니다. 메시지는 증상이지 진단이 아닙니다.
2. 클라이언트를 먼저, 서버를 나중에 의심하라. 수동 curl 한 방, 길이 비교 두 줄, warm/cold 대조 — 서버 탓하기 전에 내 쪽부터 결정적으로 배제하세요.
3. 재시도에는 백오프를, 시작에는 워밍업을. 고정 간격 재시도는 rate limit을 자기공격으로 바꾸고, 워밍업 없는 시작은 capital=0으로 매매를 막습니다.
API 연동은 결국 "남의 서버와 대화하는 일"입니다. 상대가 왜 화가 났는지 메시지만으로는 알 수 없을 때가 많고, 그 간극을 메우는 건 화려한 코드가 아니라 싸고 결정적인 검사를 올바른 순서로 하는 습관입니다. 다음 편(묶음 ②)에서는 이렇게 세운 API 토대 위에 첫 매매 전략 ORB+FVG를 얹는 이야기로 넘어갑니다.
※ 이 글은 미국주식 자동매매 봇을 직접 만들며 겪은 API 연동 트러블슈팅의 엔지니어링·연구 기록입니다. 특정 종목·전략의 매수나 매도를 권유하지 않으며, 코드·수치·로그는 모두 실제 프로젝트 문서에 근거합니다. 투자 판단과 그 결과는 투자자 본인에게 있습니다.
본 글은 공개된 데이터와 출처를 바탕으로 작성했습니다. 최종 업데이트: 2026-07-13