AI를 활용한 재밌는 것들을 개발합니다

2026년 5월 6일 수요일

실제 운용하며 만난 버그들과 보안 강화 후기 [Claude Code · 자동매매 리밸런싱 · 5편]

Claude Code 키움리밸런싱 디버깅 보안 5편 · 최종

실제 운용하며 만난 버그들과 보안 강화 후기

데이터 오염·JS 스택오버플로우·UI 오계산, 그리고 HTTP Basic Auth에서 쿠키 인증까지 — 자동매매 시스템의 실전 배포 이야기

4편까지는 "기능 구현"의 이야기였습니다. 5편은 실제로 돌려보고 나서야 드러난 버그들, 그리고 "모바일 IP로 접속이 되네?"라는 한 마디에서 시작된 보안 강화 작업의 이야기입니다.

코드는 완성됐고 테스트도 통과했는데, 실제 자동매매 중 갑자기 원하지 않는 종목이 매수 대상으로 올라왔습니다. "이 종목 지정한 적 없는데?" — 이 한 마디로 디버깅이 시작됐습니다.

버그 ①: 흥국생명이 매수 대상에 나타났다

자동 매수가 시작된 직후, 주문 계획 화면에 흥국생명(005930)이 매수 대상으로 표시됐습니다. 한 번도 편입 종목으로 지정한 적이 없는 종목이었습니다.

🚨 증상
주문 계획 화면에 흥국생명(005930)이 신규 매수 대상으로 표시됨
실제로 매수 주문까지 발송된 상태

원인 1: settings.json 데이터 오염

settings.jsonadded_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에 포함되지 않습니다 — 조회 단계에서 이미 걸러지니까요. 결과적으로 005930added_codes에는 있고 existing_codes에는 없으니 new_stock_codes(신규 매수 대상)가 되어버린 겁니다.

기존 흐름: added_stocks = [{code: "005930", name: "흥국생명"}] ← 오염된 데이터 excluded_stocks = ["005930"] ← 삼성전자는 제외 existing_codes = {현재 보유 종목들} ← 005930은 제외됐으므로 없음 new_stock_codes = added_codes - existing_codes = {"005930"} ← 매수 대상!

두 가지 수정

1
데이터 정리settings.json에서 오염된 항목을 제거하고 DELETE /api/portfolio/add/005930 API를 호출해 인메모리 상태도 초기화했습니다.
2
엔진 방어 로직 추가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 exceeded
at 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) 상태로 임시 값이 채워진 채로 렌더링됩니다. querySelectorAlldisabled 여부를 구분하지 않고 모든 .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 주소를 모르는 사람이 알게 된다면… 내 계좌 마음대로 조작할 수 있는 거 아닌가요?"

당연히 맞는 걱정이었습니다. 아무런 인증 없이 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="키움 리밸런싱"'}
            )

두 가지 문제가 터졌습니다.

문제 1: UnicodeEncodeError
HTTP 헤더는 latin-1 인코딩만 허용합니다. realm 값에 한글 "키움 리밸런싱"이 들어가자 서버가 500 에러를 뱉었습니다.
문제 2: Chrome 모바일에서 인증 다이얼로그 미표시
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를 포기하고 로그인 페이지 + 쿠키 세션 방식으로 완전히 바꿨습니다. 웹 서비스의 표준적인 인증 방식입니다.

인증 흐름: ① 미인증 요청 → /login 페이지로 302 리다이렉트 ② 사용자가 비밀번호 입력 → POST /login ③ 서버: SHA256(kiwoom:{비밀번호}) 검증 → Set-Cookie: kiwoom_auth={token} ④ 이후 요청: Cookie 헤더로 토큰 자동 첨부 → 인증 통과 ⑤ 쿠키 만료: 30일 (httpOnly, 탈취 방지)
# 최종 미들웨어 — 쿠키 기반 순수 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")
secrets.compare_digest 를 쓰는 이유
일반 == 비교는 앞 글자부터 다를 경우 빨리 종료됩니다. 타이밍 공격(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": {}   ← 알림 설정은 계좌 무관, 공통 유지
}

PortfolioManagerset_account(account_no) 메서드를 추가하고, 로그인 시점과 계좌 전환 시점에 호출하도록 했습니다. 기존 단일 계좌 설정은 __legacy__ 키로 자동 마이그레이션해 이전 데이터가 사라지지 않도록 처리했습니다.

설계 교훈
"지금은 계좌가 하나니까"라는 전제로 설계하면 나중에 마이그레이션 비용이 생깁니다. 사용자 식별자(계좌번호, user_id 등)를 최상위 키로 두는 구조를 처음부터 잡는 것이 훨씬 깔끔합니다.

python-multipart AssertionError — 폼 파싱 함정

로그인 폼을 처음에는 HTML <form> submit 방식으로 만들었습니다. 그런데 서버에서 await req.form()을 호출하자 예상치 못한 에러가 났습니다.

AssertionError: The `python-multipart` library must be installed...
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 exceededJS 함수 이중 선언 → 자기 자신 재귀 호출두 함수 통합
가중치 합계 124.9%disabled 입력 필드까지 합산CSS 선택자에 :not([disabled]) 추가
HTTP Basic Auth 모바일 불가Chrome 모바일 Basic Auth 다이얼로그 미지원쿠키 기반 인증으로 교체
UnicodeEncodeErrorHTTP 헤더에 한글 삽입realm 값 ASCII로 변경 (나중엔 방식 자체 교체)
모바일 접속 불가방화벽 인바운드 규칙 없음netsh 규칙 추가
python-multipart AssertionErrorform() 파싱 의존성JSON 방식으로 교체

5편을 마치며 — Claude Code와 함께한 개발 회고

키움증권 REST API가 공식 출시된 지 얼마 안 됐을 때 이 프로젝트를 시작했습니다. 문서도 예제도 부족했고, COM 기반 구 API와 달리 HTTP 방식이라 참고할 오픈소스도 거의 없었습니다.

그 상황에서 Claude Code는 단순한 코드 생성기가 아니었습니다. 명세서 작성부터 시작해 — "이 구조가 맞을까?", "이 엣지 케이스는 어떻게 처리하지?" — 같은 설계 질문에 함께 답해가는 페어 프로그래머에 가까웠습니다.

Claude Code가 가장 빛난 순간들

  • 흥국생명 버그 추적 — 로그를 보여주자 settings.json 데이터 오염과 엔진 로직을 동시에 지목하고 두 가지 수정을 제안한 것
  • 보안 진화 — Basic Auth가 안 된다는 상황을 설명하자 HTTP 표준의 한계를 짚고 쿠키 기반으로 대안 설계를 즉시 제시한 것
  • 계좌별 데이터 분리 — "마이그레이션 없이 기존 데이터를 보존하면서" 구조를 바꾸는 방법을 __legacy__ 패턴으로 제안한 것

물론 Claude Code가 틀리는 경우도 있었습니다. JS 함수 이중 선언처럼 긴 파일의 전체 맥락을 놓치거나, 처음에 BaseHTTPMiddleware를 제안했다가 한계를 맞닥뜨린 것도 그랬습니다. 하지만 "안 된다"고 하면 대안을 빠르게 찾아주는 반응성 덕분에 막히는 시간이 짧았습니다.

이 시리즈의 결론은 하나입니다. 혼자였다면 몇 달이 걸렸을 프로젝트를 훨씬 짧은 시간에 완성했습니다. 이 과정에서 AI가 대체한 건 "타이핑"이 아니라 "혼자 막혀있는 시간"이었습니다. 그리고 그 차이가 생각보다 훨씬 컸습니다.

✅ 5편 · 시리즈 최종 체크리스트
✔ 데이터 오염 버그 — 두 단계 수정 (데이터 + 로직)
✔ JS 함수 이중 선언 → 스택오버플로우 해결
✔ disabled 입력 필드 합산 오류 수정
✔ HTTP Basic Auth → 쿠키 기반 인증 교체
✔ Windows 방화벽 규칙 추가
✔ 계좌별 설정 분리 마이그레이션
✔ 자동매매 시스템 완전 운용 중

댓글 없음:

댓글 쓰기

가장 많이 본 글