실제 운용하며 만난 버그들과 보안 강화 후기
데이터 오염·JS 스택오버플로우·UI 오계산, 그리고 HTTP Basic Auth에서 쿠키 인증까지 — 자동매매 시스템의 실전 배포 이야기
4편까지는 "기능 구현"의 이야기였습니다. 5편은 실제로 돌려보고 나서야 드러난 버그들, 그리고 "모바일 IP로 접속이 되네?"라는 한 마디에서 시작된 보안 강화 작업의 이야기입니다.
코드는 완성됐고 테스트도 통과했는데, 실제 자동매매 중 갑자기 원하지 않는 종목이 매수 대상으로 올라왔습니다. "이 종목 지정한 적 없는데?" — 이 한 마디로 디버깅이 시작됐습니다.
버그 ①: 흥국생명이 매수 대상에 나타났다
자동 매수가 시작된 직후, 주문 계획 화면에 흥국생명(005930)이 매수 대상으로 표시됐습니다. 한 번도 편입 종목으로 지정한 적이 없는 종목이었습니다.
주문 계획 화면에 흥국생명(005930)이 신규 매수 대상으로 표시됨
실제로 매수 주문까지 발송된 상태
원인 1: settings.json 데이터 오염
settings.json의 added_stocks(편입 종목 목록)를 열어보니 충격적인 내용이 있었습니다.
// settings.json — added_stocks 항목 { "added_stocks": [ { "code": "005930", "name": "흥국생명" } ← 여기! ] }
종목코드 005930은 실제로 삼성전자입니다. 흥국생명의 코드가 아닙니다. 언제인가 편입 종목을 추가하는 UI에서 종목코드와 종목명이 어긋난 채로 저장된 것으로 추정됩니다. 데이터 오염(data corruption)이었습니다.
원인 2: 리밸런싱 엔진의 방어 로직 부재
더 심각한 문제는 엔진 로직에 있었습니다. rebalancing_engine.py는 "신규 매수 종목"을 이렇게 계산합니다.
# 기존 코드 (버그 있음) existing_codes = {p['code'] for p in positions} added_codes = {s['code'] for s in added_stocks} ← 제외 종목 필터 없음 new_stock_codes = added_codes - existing_codes
삼성전자(005930)는 excluded_stocks(제외 종목)에도 등록되어 있었습니다. 제외된 종목은 실제 보유 종목 목록인 existing_codes에 포함되지 않습니다 — 조회 단계에서 이미 걸러지니까요. 결과적으로 005930은 added_codes에는 있고 existing_codes에는 없으니 new_stock_codes(신규 매수 대상)가 되어버린 겁니다.
두 가지 수정
settings.json에서 오염된 항목을 제거하고 DELETE /api/portfolio/add/005930 API를 호출해 인메모리 상태도 초기화했습니다.added_stocks를 필터링할 때 excluded_codes를 먼저 빼도록 수정했습니다.# 수정 후 — rebalancing_engine.py excluded_codes = {s for s in excluded_stocks} added_codes = { s['code'] for s in added_stocks if s['code'] not in excluded_codes ← 방어 로직 } new_stock_codes = added_codes - existing_codes
UI에서 받은 데이터를 그대로 신뢰하지 말 것. 특히 "제외 목록과 편입 목록이 겹치는" 경우는 엔진 레벨에서도 방어해야 합니다. 데이터와 로직, 두 군데 모두 고쳐야 완전한 수정입니다.
버그 ②: Maximum call stack exceeded (JS 함수 이중 선언)
리밸런싱 스케줄을 시작하려고 버튼을 눌렀더니 브라우저 콘솔에 붉은 에러가 떴습니다.
Uncaught RangeError: Maximum call stack size exceededat setScheduleRunning (app.js:247)
at setScheduleRunning (app.js:312)
스택 트레이스가 같은 함수 이름을 반복 출력했습니다. 원인은 단순했습니다 — app.js 안에 setScheduleRunning 함수가 두 번 선언되어 있었습니다.
// app.js — 247번째 줄 function setScheduleRunning(running) { updateStatusBadge(running); setScheduleRunning(running); ← 자기 자신 호출! (원래는 다른 함수였어야 함) } // app.js — 312번째 줄 (중복 선언) function setScheduleRunning(running) { toggleButtons(running); updateTimerDisplay(running); }
기능 추가 과정에서 Claude Code가 두 번에 걸쳐 이 함수를 작성하면서 첫 번째 버전이 두 번째 버전을 참조하지 못하고 자기 자신을 재귀 호출하는 구조가 만들어진 것이었습니다. JavaScript에서 function 선언은 나중에 쓴 것이 이전 것을 덮어쓰므로 런타임에는 하나만 존재하지만, 호이스팅 때문에 내부에서 자신을 호출하는 형태가 됩니다.
두 함수를 하나로 합치고
updateStatusBadge, toggleButtons, updateTimerDisplay를 순서대로 호출하도록 정리했습니다. 함수 이름 중복은 대형 단일 파일 JS에서 자주 발생하는 문제입니다. 파일이 길어질수록 Ctrl+F로 함수명을 검색하는 습관이 필요합니다.
버그 ③: 가중치 합계가 124.9%로 표시됨
포트폴리오 설정 화면에서 각 종목의 배분 비율을 수동으로 입력하는 기능이 있었습니다. 그런데 합계가 100%가 되지 않는다는 경고가 계속 떴습니다 — 124.9%로요.
보유 종목 7개의 비율을 모두 입력했는데 합계 표시가 124.9%
"합계가 100%가 아닙니다" 경고창이 계속 출력
합산 로직을 보니 문제가 바로 보였습니다.
// 기존 코드 (버그 있음) function calcWeightSum() { const inputs = document.querySelectorAll('.weight-input'); let sum = 0; inputs.forEach(inp => { sum += parseFloat(inp.value) || 0; ← disabled 여부 확인 안 함 }); return sum; }
포트폴리오 화면에는 "현재 보유 종목"과 "편입 예정 종목" 두 섹션이 있었습니다. 편입 예정 종목의 입력 필드는 비활성화(disabled) 상태로 임시 값이 채워진 채로 렌더링됩니다. querySelectorAll은 disabled 여부를 구분하지 않고 모든 .weight-input을 잡아버렸고, 비활성 필드의 값까지 합산된 것입니다.
// 수정 후 function calcWeightSum() { const inputs = document.querySelectorAll('.weight-input:not([disabled])'); let sum = 0; inputs.forEach(inp => { sum += parseFloat(inp.value) || 0; }); return sum; }
CSS 선택자
:not([disabled]) 하나로 해결됩니다. UI에서 "비활성화 = 무관한 값"이라는 사용자 의도를 코드도 알아야 합니다. 폼 합산 로직은 반드시 활성 필드만 대상으로 해야 합니다.
보안: "이 IP로 아무나 접속할 수 있는 거 아니야?"
어느 날 모바일에서 로컬 IP(192.168.0.36:8000)로 자동매매 앱에 접속이 잘 된다는 걸 확인한 뒤, 문득 이런 생각이 들었습니다.
당연히 맞는 걱정이었습니다. 아무런 인증 없이 IP:포트로 접속하면 바로 거래 화면이 열리는 상태였습니다. 보안 미들웨어를 추가하기로 했습니다.
1차 시도: HTTP Basic Auth (실패)
처음에는 FastAPI의 BaseHTTPMiddleware로 HTTP Basic 인증을 구현했습니다.
# 1차 시도 — BaseHTTPMiddleware + HTTP Basic Auth from starlette.middleware.base import BaseHTTPMiddleware class BasicAuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): auth = request.headers.get("Authorization", "") if not _is_valid(auth): return Response( status_code=401, headers={"WWW-Authenticate": 'Basic realm="키움 리밸런싱"'} )
두 가지 문제가 터졌습니다.
HTTP 헤더는 latin-1 인코딩만 허용합니다. realm 값에 한글 "키움 리밸런싱"이 들어가자 서버가 500 에러를 뱉었습니다.
realm을 영문
'Basic realm="Kiwoom"'으로 고쳤지만, 안드로이드 Chrome은 Basic Auth 다이얼로그를 제대로 표시하지 않았습니다. 브라우저가 그냥 빈 화면을 보여줬고 결국 접속 불가 상태가 됐습니다.
2차 시도: 순수 ASGI 미들웨어 (부분 성공)
BaseHTTPMiddleware 자체가 일부 엣지 케이스에서 스트리밍/WebSocket 호환 문제를 일으킬 수 있다는 걸 알고, 순수 ASGI 미들웨어로 바꿨습니다.
# 2차 시도 — 순수 ASGI 미들웨어 class _BasicAuthMiddleware: def __init__(self, app, password): self.app = app self._token = password async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return headers = dict(scope["headers"]) auth = headers.get(b"authorization", b"").decode() if not _is_valid(auth): await send({"type": "http.response.start", "status": 401, "headers": [[b"www-authenticate", b'Basic realm="Kiwoom"']]}) await send({"type": "http.response.body", "body": b""}) return await self.app(scope, receive, send)
서버 오류는 없어졌지만 Chrome 모바일 인증 다이얼로그 문제는 여전했습니다. 근본 문제가 HTTP Basic Auth 프로토콜 자체에 대한 모바일 브라우저의 UX에 있다는 걸 깨달았습니다.
최종: 쿠키 기반 인증 (성공)
HTTP Basic Auth를 포기하고 로그인 페이지 + 쿠키 세션 방식으로 완전히 바꿨습니다. 웹 서비스의 표준적인 인증 방식입니다.
# 최종 미들웨어 — 쿠키 기반 순수 ASGI import hashlib, secrets def _mk_token(password: str) -> str: return hashlib.sha256(f"kiwoom:{password}".encode()).hexdigest() class _CookieAuthMiddleware: def __init__(self, app, password: str): self.app = app self._token = _mk_token(password) if password else "" async def __call__(self, scope, receive, send): if not self._token or scope["type"] != "http": await self.app(scope, receive, send) return path = scope.get("path", "") if path == "/login": # 로그인 페이지는 통과 await self.app(scope, receive, send) return # 쿠키에서 토큰 파싱 headers = {k.lower(): v for k, v in scope.get("headers", [])} cookie_str = headers.get(b"cookie", b"").decode("latin-1", errors="replace") cookies = {} for part in cookie_str.split(";"): part = part.strip() if "=" in part: k, v = part.split("=", 1) cookies[k.strip()] = v.strip() if cookies.get("kiwoom_auth") == self._token: await self.app(scope, receive, send) return # 미인증 → /login 리다이렉트 await send({"type": "http.response.start", "status": 302, "headers": [[b"location", b"/login"]]}) await send({"type": "http.response.body", "body": b""})
로그인 폼은 별도의 파일 없이 Python 문자열 상수로 서버에 내장했습니다. 외부 파일 의존성을 없애기 위해서입니다.
# POST /login — 비밀번호 검증 후 쿠키 발급 @app.post("/login") async def login_submit(req: Request): body = await req.json() pwd = str(body.get("password", "")) if secrets.compare_digest(_mk_token(pwd), self._auth_token): resp = Response(content='{"ok":true}', media_type="application/json") resp.set_cookie("kiwoom_auth", self._auth_token, httponly=True, max_age=60*60*24*30) return resp return Response(content='{"ok":false}', status_code=401, media_type="application/json")
일반
== 비교는 앞 글자부터 다를 경우 빨리 종료됩니다. 타이밍 공격(Timing Attack)으로 비밀번호를 추론하는 공격 기법이 가능합니다. secrets.compare_digest는 항상 전체 길이를 비교해 실행 시간이 일정합니다.
모바일 연결 문제와 Windows 방화벽
보안 미들웨어 작업 도중 예상치 못한 문제가 생겼습니다. 기존에 잘 되던 모바일 접속이 아예 안 됐습니다.
PC에서
localhost:8000은 접속 됨. 같은 와이파이의 모바일 192.168.0.36:8000은 연결 자체가 안 됨 (ERR_CONNECTION_REFUSED)
원인은 두 가지가 겹쳤습니다.
| 원인 | 내용 |
|---|---|
| 미들웨어 오류 | 1차 시도 BasicAuthMiddleware가 내부에서 500 에러를 발생시켜 연결이 끊기는 것처럼 보임 |
| Windows 방화벽 | 8000 포트에 대한 인바운드 규칙이 없어 같은 LAN의 외부 기기 접근이 차단됨 |
방화벽 규칙은 관리자 권한 PowerShell에서 한 줄로 추가했습니다.
netsh advfirewall firewall add rule ` name="Kiwoom Rebalancing Port 8000" ` dir=in action=allow protocol=TCP localport=8000
이 규칙은 같은 공유기(LAN) 안에서만 유효합니다. 외부 인터넷에서 접근하려면 공유기 포트포워딩까지 설정해야 하는데, 자동매매 서버를 공인 IP에 직접 노출하는 것은 권장하지 않습니다. LAN 내부 접근으로만 사용하세요.
계좌별 데이터 분리 — 설계 변경
초기에는 settings.json이 단일 계좌를 전제로 설계됐습니다. 여러 계좌를 테스트하면서 "계좌마다 다른 편입/제외 종목 설정이 필요하다"는 걸 깨달았습니다.
// 기존 settings.json — 단일 계좌 가정 { "excluded_stocks": ["005930"], "added_stocks": [], "notification": {} }
// 변경 후 — 계좌별 격리 { "accounts": { "1234567890": { "excluded_stocks": ["005930"], "added_stocks": [] }, "9876543210": { "excluded_stocks": [], "added_stocks": [{"code": "035720", "name": "카카오"}] } }, "notification": {} ← 알림 설정은 계좌 무관, 공통 유지 }
PortfolioManager에 set_account(account_no) 메서드를 추가하고, 로그인 시점과 계좌 전환 시점에 호출하도록 했습니다. 기존 단일 계좌 설정은 __legacy__ 키로 자동 마이그레이션해 이전 데이터가 사라지지 않도록 처리했습니다.
"지금은 계좌가 하나니까"라는 전제로 설계하면 나중에 마이그레이션 비용이 생깁니다. 사용자 식별자(계좌번호, user_id 등)를 최상위 키로 두는 구조를 처음부터 잡는 것이 훨씬 깔끔합니다.
python-multipart AssertionError — 폼 파싱 함정
로그인 폼을 처음에는 HTML <form> submit 방식으로 만들었습니다. 그런데 서버에서 await req.form()을 호출하자 예상치 못한 에러가 났습니다.
FastAPI에서
request.form()을 쓰려면 python-multipart 패키지가 필요합니다.
패키지를 설치하면 해결되긴 하지만, 더 깔끔한 방법이 있었습니다. 폼 submit 대신 JS fetch() + JSON으로 바꾸면 python-multipart 없이도 됩니다.
// 로그인 버튼 클릭 이벤트 — fetch + JSON document.getElementById('loginBtn').addEventListener('click', async () => { const pwd = document.getElementById('pwd').value; const res = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pwd }) }); if (res.ok) location.replace('/'); else document.getElementById('err').textContent = '비밀번호가 틀렸습니다.'; });
# 서버 — req.json() 으로 수신 @app.post("/login") async def login_submit(req: Request): body = await req.json() ← python-multipart 불필요 pwd = str(body.get("password", ""))
FastAPI에서 폼 처리는
python-multipart를 요구합니다. 불필요한 의존성을 추가하기보다 JSON API로 통신하는 방식이 더 가볍고 일관성 있습니다.
전체 버그·해결 요약표
| 버그 | 원인 | 해결 |
|---|---|---|
| 흥국생명 매수 대상 등장 | settings.json 데이터 오염 + 엔진 방어 로직 부재 | 데이터 정리 + excluded_codes 필터 추가 |
| Maximum call stack exceeded | JS 함수 이중 선언 → 자기 자신 재귀 호출 | 두 함수 통합 |
| 가중치 합계 124.9% | disabled 입력 필드까지 합산 | CSS 선택자에 :not([disabled]) 추가 |
| HTTP Basic Auth 모바일 불가 | Chrome 모바일 Basic Auth 다이얼로그 미지원 | 쿠키 기반 인증으로 교체 |
| UnicodeEncodeError | HTTP 헤더에 한글 삽입 | realm 값 ASCII로 변경 (나중엔 방식 자체 교체) |
| 모바일 접속 불가 | 방화벽 인바운드 규칙 없음 | netsh 규칙 추가 |
| python-multipart AssertionError | form() 파싱 의존성 | JSON 방식으로 교체 |
5편을 마치며 — Claude Code와 함께한 개발 회고
키움증권 REST API가 공식 출시된 지 얼마 안 됐을 때 이 프로젝트를 시작했습니다. 문서도 예제도 부족했고, COM 기반 구 API와 달리 HTTP 방식이라 참고할 오픈소스도 거의 없었습니다.
그 상황에서 Claude Code는 단순한 코드 생성기가 아니었습니다. 명세서 작성부터 시작해 — "이 구조가 맞을까?", "이 엣지 케이스는 어떻게 처리하지?" — 같은 설계 질문에 함께 답해가는 페어 프로그래머에 가까웠습니다.
Claude Code가 가장 빛난 순간들
- 흥국생명 버그 추적 — 로그를 보여주자 settings.json 데이터 오염과 엔진 로직을 동시에 지목하고 두 가지 수정을 제안한 것
- 보안 진화 — Basic Auth가 안 된다는 상황을 설명하자 HTTP 표준의 한계를 짚고 쿠키 기반으로 대안 설계를 즉시 제시한 것
- 계좌별 데이터 분리 — "마이그레이션 없이 기존 데이터를 보존하면서" 구조를 바꾸는 방법을
__legacy__패턴으로 제안한 것
물론 Claude Code가 틀리는 경우도 있었습니다. JS 함수 이중 선언처럼 긴 파일의 전체 맥락을 놓치거나, 처음에 BaseHTTPMiddleware를 제안했다가 한계를 맞닥뜨린 것도 그랬습니다. 하지만 "안 된다"고 하면 대안을 빠르게 찾아주는 반응성 덕분에 막히는 시간이 짧았습니다.
이 시리즈의 결론은 하나입니다. 혼자였다면 몇 달이 걸렸을 프로젝트를 훨씬 짧은 시간에 완성했습니다. 이 과정에서 AI가 대체한 건 "타이핑"이 아니라 "혼자 막혀있는 시간"이었습니다. 그리고 그 차이가 생각보다 훨씬 컸습니다.
✔ 데이터 오염 버그 — 두 단계 수정 (데이터 + 로직)
✔ JS 함수 이중 선언 → 스택오버플로우 해결
✔ disabled 입력 필드 합산 오류 수정
✔ HTTP Basic Auth → 쿠키 기반 인증 교체
✔ Windows 방화벽 규칙 추가
✔ 계좌별 설정 분리 마이그레이션
✔ 자동매매 시스템 완전 운용 중
1편 → Claude AI로 주식 자동매매 프로그램 기획한 이야기
2편 → Claude Code로 키움 REST API 클라이언트 구현하기
3편 → TWAP 분할 주문 & 자동 정정 주문 시스템 구현기
4편 → WebSocket + Vanilla JS로 실시간 자동매매 UI 만들기
5편 ✅ 실제 운용하며 만난 버그들과 보안 강화 후기 (현재 글)
6편 → 실제 구동 화면 전체 공개 — 로그인부터 대시보드까지
댓글 없음:
댓글 쓰기