서울 리전의 작은 VPS 한 대가 키움증권 미국주식 조건검색을 실시간으로 감시하고, 조건에 새로 걸린 종목의 시세를 계속 갱신해서 올려둡니다. 이 브랜드 사이트의 관리자 페이지는 그 서버에 명령을 전달하고 설정을 바꾸는 역할만 합니다. 코드 수정이나 파일 관리는 일부러 사이트에 넣지 않았습니다 — 웹 화면이 할 수 있는 일이 적을수록, 웹 화면이 뚫렸을 때 잃을 것도 적습니다.
01왜 만들었나
조건검색식은 이미 영웅문Global에 열 개 넘게 만들어 두고 쓰고 있었습니다. 문제는 그 결과를 보려면 PC 앞에 앉아 프로그램을 켜두고 있어야 한다는 점이었습니다. 미국장은 한국 시간으로 밤에 열리고, 프리마켓까지 치면 감시해야 하는 시간은 하루의 절반을 넘습니다. 그래서 감시는 꺼지지 않는 서버에 맡기고, 저는 어디서든 브라우저로 결과만 확인하는 구조가 필요했습니다. 동시에 이 구조가 나중에 주문 실행까지 이어질 수 있어야 했습니다.
02구조 — 세 개의 층, 한 방향의 신뢰
전체는 세 층으로 나뉩니다. 관리자 페이지, 그 사이에서 우체통 역할을 하는 Cloudflare Worker + D1, 그리고 실제로 키움과 통신하는 VPS입니다. 관리자 페이지와 VPS는 서로를 직접 부르지 않습니다. 둘 다 가운데 층에 글을 쓰고, 가운데 층에서 글을 읽을 뿐입니다.
관리자 페이지 (브라우저) │ 명령 전달 · 설정 수정 ▲ 주기적으로 조회만 ▼ │ Cloudflare Worker + D1 — 명령 큐 · 종목 테이블 · 상태값 ▲ 2초 폴링: 명령 수령 / 결과 · 시세 업로드 │ VPS (Vultr Seoul · systemd 상주) — 키움 호출은 오직 여기서만 │ 조건검색 감시 · 시세 순회 · AI 트레이더(같은 프로세스) │ REST: 토큰 · 시세 · 차트 · 주문 / WebSocket: 조건검색 실시간 ├──▶ 키움증권 미국주식 API ├──▶ OpenAI API — 10분마다 가격 구조 판단 └──▶ Telegram — 알림 · 명령 GitHub main ──(1분마다 확인)──▶ VPS — 코드의 원본은 한 곳
전체 구조 — 관리자 페이지와 VPS는 서로를 직접 호출하지 않는다
03커맨드 큐 — 서버가 먼저 묻는다
집에 있는 PC든 VPS든, 밖에서 안으로 요청을 밀어넣으려면 포트를 열어야 합니다. 그 대신 방향을 뒤집었습니다. 관리자 페이지에서 "연결 확인", "검색식 조회", "실시간 검색 시작/중지", "차트 조회" 같은 버튼을 누르면 명령은 D1에 pending 상태로 쌓이기만 합니다. VPS가 2초마다 "할 일 있어?"라고 묻고, 있으면 in_progress로 바꿔서 가져간 뒤 실행하고, 결과를 다시 올려놓습니다. 관리자 페이지는 그 결과가 올라올 때까지 기다렸다가 화면에 그립니다.
# 사이트: 명령을 적어두기만 한다 POST commands { type: "start_search", payload: { seq } } → status: pending # VPS: 2초마다 먼저 묻는다 (이 폴링 자체가 생존 신호 역할도 겸함) GET next-command → status: in_progress POST commands/:id/result { ok, data } → status: done
이 폴링이 곧 하트비트이기도 해서, 별도의 생존 신호 루프를 따로 두지 않았습니다. 마지막으로 물어본 시각이 곧 서버가 살아있던 마지막 시각입니다. 사이트발 수동 명령과 서버 내부의 자동 스케줄이 같은 조건검색을 동시에 건드리지 않도록, 시작·중지는 하나의 잠금(lock)으로 직렬화했습니다.
04미국주식 조건검색 — 문서에 한 줄로 적힌 함정들
처음에는 국내주식 조건검색으로 연동을 검증했습니다. 동작은 했지만, 나중에 붙일 주문 엔진(LOG_01)이 미국주식용이라는 걸 떠올리고 시장을 통일하기로 하면서 다시 연결했습니다. 미국주식 쪽은 공식 문서와 실계좌 테스트를 번갈아 보며 몇 가지를 확정해야 했습니다.
- 다른 문으로 들어가야 한다 — 로그인은 계좌 단위라 어느 웹소켓 경로에서든 되지만, 미국주식 조건검색 프레임은 국내용과 다른 전용 경로에서만 응답합니다.
- 목록을 먼저 물어봐야 한다 — 같은 연결 안에서 조건식 목록조회(
GCNSRLST)를 한 번 거치지 않으면, 실시간 조회 요청(GCNSRREQ)은 에러도 없이 조용히 무시됩니다. 문서에는 "시스템 내부구조상"이라는 한 줄로만 적혀 있었습니다. - 실시간 이벤트에는 가격이 없다 — 편입(
I)·이탈(D) 신호와 종목코드, 거래소 구분만 옵니다. 현재가와 등락률은 시세 조회 TR을 따로 불러서 채워야 합니다. - 서두르면 막힌다 — 스무 종목 이상을 연달아 조회하면 곧바로
429 Too Many Requests가 돌아옵니다. 조회마다 짧은 간격을 두고, 막히면 점점 길게 기다렸다가 다시 시도합니다.
그리고 한 번 리스트에 올라온 종목은 조건을 벗어나도 지우지 않고 "이탈" 표시만 남깁니다. 잠깐 조건을 스쳐간 종목이 오히려 나중에 가장 궁금한 종목일 때가 많기 때문입니다.
051초마다 바뀌는 것처럼 — 두 개의 속도 분리
종목 리스트의 가격이 거의 실시간으로 움직이길 원했지만, 그렇다고 화면을 연 사람 수만큼 키움 API를 두드리면 안 됩니다. 그래서 속도를 둘로 나눴습니다. VPS는 감시 중인 종목들을 라운드로빈으로 한 바퀴씩 돌며 시세를 조회해서 D1에 계속 덮어씁니다. 관리자 페이지는 1초마다 D1만 읽습니다. 화면을 몇 개 띄우든 키움 쪽 호출량은 VPS의 순회 속도 하나로만 결정됩니다.
첫 구현에는 실수가 있었습니다. 순회를 한 바퀴 돌 때마다 접근 토큰을 새로 발급받고 있었던 겁니다. 토큰은 24시간 유효한데, 종목이 몇 개 없으면 1~2초마다 재발급을 요청하는 셈이었습니다. 토큰을 캐싱해서 재사용하도록 고친 뒤에야 순회가 안정적으로 돌기 시작했습니다.
이 "1초"는 나중에 10초로 물러섰습니다. 이유는 키움이 아니라 D1 쪽에 있었습니다 — 자세한 이야기는 10번에 적었습니다.
06재시작해도 끊기지 않게 — 기억은 사이트가 한다
토큰 문제를 고친 뒤에도 가격이 멈춰 있었습니다. 원인은 상태 불일치였습니다. 코드를 배포하느라 VPS 프로세스를 재시작하면 서버의 기억은 전부 초기화되는데, D1에는 "이 검색식 감시 중"이라는 값이 그대로 남아 있었습니다. 화면은 계속 "감시 중"이라고 말하고, 서버는 할 일이 없다며 쉬고 있었던 겁니다. 연결 확인은 정상인데 리스트만 안 바뀌는, 가장 헷갈리는 형태의 고장이었습니다.
그래서 원칙을 정했습니다. 무엇을 감시해야 하는지는 사이트가 기억하고, 서버는 깨어나자마자 그걸 물어본다. VPS는 시작하는 순간 사이트에 직전 상태를 묻고, 감시 중이던 검색식이 있으면 곧바로 다시 구독합니다. 같은 이유로 새로고침한 관리자 페이지도 로컬 상태가 아니라 서버 상태를 기준으로 화면을 복원합니다.
키움 세션이 하루 단위로 끊기는 것에 맞춘 평일 루틴도 이 원칙 위에서 돕니다.
# 평일(KST) 일일 루틴
16:50 실시간 감시 해제 → 오늘의 종목 리스트를 스냅샷으로 보관 → 삭제목록 초기화
17:00 활성 종목 리스트 비움
17:01 마지막으로 쓰던 "기본 검색식"으로 자동 재연결
보관된 스냅샷은 날짜별 "저장된 목록"으로 남아 나중에 복기할 수 있고, 리스트에서 직접 지운 종목은 같은 조건에 다시 걸려도 그날은 돌아오지 않습니다. 종목마다 빨강·노랑·초록 신호등 체크를 따로 남길 수 있게 해서, 눈으로 훑으며 느낀 인상도 데이터로 쌓이게 했습니다.
07차트에서 배운 것 — 키가 있어도 문이 열리진 않는다
종목을 누르면 1일봉과 1분봉 캔들차트, 거래량이 뜨도록 했습니다. 처음에는 "클릭할 때만 필요한 조회니까 서버를 거치지 말고 사이트가 바로 부르자"고 설계했습니다. 앱키도 이미 사이트 설정에 있으니 가능해 보였습니다. 배포 직전, 화면에 IP를 등록하라는 오류가 떴습니다.
키움 앱키는 미리 등록해둔 IP에서 온 요청만 받습니다. VPS는 고정 IP라 등록할 수 있지만, Cloudflare Worker는 요청마다 전 세계 엣지 중 어디서든 나갈 수 있어서 애초에 등록할 IP가 없습니다. 키만 있으면 되는 게 아니라, 부르는 자리 자체가 허락돼 있어야 했던 겁니다. 게다가 키움은 동시접속을 허용하지 않아서, 두 곳에서 따로 세션을 여는 것 자체가 위험했습니다.
결국 차트 조회도 다른 명령과 똑같이 커맨드 큐를 거치도록 되돌렸습니다. 응답이 1~2초 늦어졌지만, 사이트 쪽 코드는 오히려 줄었고 규칙은 하나로 정리됐습니다 — 키움을 부르는 모든 길은 VPS 한 곳을 지난다.
08AI 트레이더 — 사람이 고르고, AI는 가격만
다음 층으로 계획했던 "AI 판단"과 "주문 실행"을 하나의 모듈로 올렸습니다. 가장 먼저 정한 건 AI에게 무엇을 맡기지 않을지였습니다.
- 종목 선택은 사람 — 조건검색 리스트에서 빨강·노랑·초록 세 칸을 모두 체크한 종목만 관찰 대상이 됩니다. 신호등은 원래 복기용 메모였는데, 그대로 "이 종목을 맡긴다"는 스위치가 됐습니다.
- 매수 여부도 사람 — 세 칸 체크는 곧 매수 확정입니다. AI는 "살지 말지"가 아니라 "얼마에 살지"만 답합니다. 수량은 사이트에 정해둔 매수금액을 그 시점 현재가로 나눈 값이고, 이 수량을 넘겨 사는 일은 없습니다.
- AI는 가격 구조만 — 10분마다 최근 1분봉 600개와 이전 판단, 현재 주문 상태를 받아 40분 횡보범위, 과거 파동에서 찾은 목표 기준점 R1~R4, 시장 상태, 중요 지지선의 이탈·회복을 JSON으로 답합니다. 구조가 바뀌지 않았으면 "유지"가 정답입니다.
- 주문은 미리 걸어둔다 — 가격이 닿을 때 주문하는 방식이 아니라, 매수 한 번, 매도는 1차 60% · 2차 40%로 나눠 지정가로 걸어둡니다. 마지막 1주는 어떤 경우에도 팔지 않습니다. 지지선을 깨고 약 30분 안에 회복하지 못하면 기존 목표를 모두 취소하고 하락파동의 중간가에 한 번에 정리합니다.
키움은 동시접속을 허용하지 않기 때문에 AI 트레이더는 별도 프로세스가 아니라 조건검색 에이전트 안에서 같은 이벤트 루프로 돕니다. 토큰, 거래소 구분, 차트 조회를 그대로 공유하고, 키움을 부르는 문은 여전히 하나입니다. 실주문은 두 겹으로 잠가뒀습니다. 기본값은 주문을 흉내만 내는 DRY 모드이고, 실주문으로 바꿔도 사이트에서 "모의투자"가 꺼져 있으면 실전 계좌 주문을 막습니다.
09판단은 프롬프트에, 계산은 코드에
실제로 돌려보니 AI의 판단은 대체로 그럴듯했지만, 숫자가 걸린 부분에서 자주 흔들렸습니다. 평범한 상승에도 목표를 최대로 올려서 팔지 못하거나, 1차와 2차 목표가 지나치게 벌어지거나, 매수 전에 이미 목표까지 올라가 버린 종목을 뒤늦게 사려고 하는 식이었습니다. 그래서 규칙마다 "이건 해석인가, 계산인가"를 먼저 나눴습니다.
# 계산 — 코드가 정하고, AI 답을 받은 뒤 다시 맞춘다 매수가 = 40분 횡보범위 하단에서 1/3 지점 목표가 = 기준점 × (1 − 1%) 1차 목표 ≥ 매수가 + 3% (지지선 이탈 전) 2차 목표 ≤ 1차 목표 + 15% 매수 전 1·2차 목표 모두 도달 → 매수 취소 · 관찰 종료 └ 봉 하나의 긴 윗꼬리였다면 1회는 유지, 두 번째면 종료 # 해석 — AI가 판단하고, 기준이 되는 숫자는 코드가 계산해서 건넨다 "강한 상승"(목표 최대 R4) = 상승구조 + 최근 15분 평균 거래량이 오늘 초반 급등구간 거래량의 30% 이상 → 코드가 비율을 계산해 전달, 미달이면 한 단계 낮춤 "상승 흐름"(매수 전 매수가 상향) = 가격 구조로만 판단(거래량 무관) → 상향 가격은 코드가 범위의 1/2 지점으로 계산
AI 판단 · 프로그램 계산의 역할 분담
"가파른 상승"과 "거래량이 동반된 상승"을 구분하는 일이 특히 그랬습니다. 차트를 보고 강하다고 느끼는 건 AI가 잘하지만, "초반 급등 때 거래량의 몇 %인가"는 계산이지 인상이 아닙니다. 그래서 비율은 코드가 계산해서 입력에 넣어주고, AI가 강한 상승이라고 답해도 비율이 모자라면 코드가 한 단계 낮춥니다. 모든 판단은 원문 그대로 기록해 두어서, 이상한 주문이 나가면 어떤 입력에 어떤 답을 했는지 거슬러 올라갈 수 있습니다.
10무료 플랜의 벽 — 하루 10만 줄
어느 날 텔레그램에 "사이트 반영 실패" 알림이 떴습니다. 원인은 Cloudflare D1 무료 플랜의 하루 쓰기 한도(10만 행)였습니다. 오류 메시지는 AI 관찰 종목 업로드에서 났지만, 한도를 채운 건 다른 곳이었습니다.
- 생존 신호 — VPS가 2초마다 명령을 물으며 그때마다 하트비트를 새로 썼습니다. 이것만으로 하루 4만 3천 행.
- 시세 순회 — "1초처럼 보이는 시세"를 위해 종목 하나를 조회할 때마다 그 행을 갱신했습니다. 종목이 60개 가까이 되자 초당 서너 행씩, 하루로는 한도를 몇 배 넘기는 양이었습니다.
한도가 차면 명령 큐도 멈추기 때문에 차트 조회까지 같이 죽었습니다. 고친 방법은 단순합니다. 하트비트는 30초에 한 번만 쓰고, 시세는 조건식 전체를 JSON 한 덩어리로 묶어 10초마다 한 줄로 덮어쓰며, 종목과 AI 스냅샷은 값이 실제로 바뀐 경우에만 씁니다. 화면의 가격 반응은 1초에서 10초로 느려졌지만, 쓰기는 하루 수천 행 수준으로 내려왔습니다. 보이는 속도와 기록하는 속도는 같을 필요가 없다는 05번의 원칙을 한 번 더 배운 셈입니다.
11원본은 GitHub 하나 — 서버에서 고치지 않기
코드를 정리하다가 불편한 사실을 발견했습니다. VPS에서 돌고 있던 AI 트레이더가 GitHub에 올려둔 버전보다 두 번이나 더 수정된 상태였습니다. 급할 때 서버에 직접 파일을 붙여넣던 습관 때문이었고, 그대로 GitHub 버전을 배포했다면 그 두 번의 수정이 조용히 사라졌을 겁니다.
그래서 두 버전을 합쳐 하나로 만든 뒤, 방향을 고정했습니다. VPS는 GitHub에 읽기 전용 열쇠 하나만 갖고 1분마다 main을 확인합니다. 새 커밋이 있으면 먼저 받아서 문법 검사를 하고, 통과할 때만 코드를 바꾸고 재시작한 뒤 텔레그램으로 알려줍니다. 검사에서 걸리면 기존 코드로 계속 돌면서 실패만 알립니다. 관찰 중인 종목과 주문 상태는 서버의 상태 파일에 남아 있어서 재시작해도 그대로 이어집니다. 비밀값과 상태 파일은 서버에만, 코드는 GitHub에만 — 01번부터 지켜온 "기준은 한 곳에"가 이제 코드에도 적용됩니다.
12관제 화면과 텔레그램
- AI 관찰 종목 패널 — 종목마다 주문 현황, 보유 수량, AI가 본 시장 상태와 기준점을 보여주고, 1분봉 차트 위에는 R1~R4, 횡보범위, 지지선, 매수가, 목표가를 가로선으로 그립니다. AI가 본 구조를 사람 눈으로 바로 대조할 수 있게 하려는 것입니다.
- 관찰 종료 목록 — 끝난 거래는 최초 주문 날짜(미국 시간 기준)별로 묶어 접고 펼 수 있게 했고, 최근 거래가 위로 쌓입니다. 날짜와 시간은 주황색으로 눈에 띄게 했습니다.
- 텔레그램 — 종목 수신, AI 판단, 주문·정정·체결, 관찰 종료, 10분마다 연결 상태를 알려줍니다. "AAPL 거래 종료"처럼 보내면 그 종목의 주문을 모두 취소하고 관찰을 끝내고, "상태"로 현황을 봅니다. 명령어를 잊어도 아무 말이나 보내면 도움말이 돌아옵니다.
13지금 이 자리
조건검색 실시간 감시, 시세 순회, 재시작 자동 복구, 평일 일일 루틴, 일봉·분봉 차트, 그리고 세 칸 체크에서 시작하는 AI 관찰·매수·분할매도·지지선 이탈 정리까지 한 흐름으로 이어졌습니다. 코드는 GitHub에서 VPS로 자동으로 흘러가고, D1은 무료 한도 안으로 돌아왔습니다. 지금은 AI의 판단과 코드의 규칙이 실제 가격 앞에서 어떻게 맞물리는지 지켜보는 단계입니다.
이번 반복(Iterate)에서 남은 원칙은 하나 더 늘었습니다. 토큰은 아껴 쓴다, 기억은 한 곳에 둔다, 키움은 한 문으로만 부른다 — 그리고 해석은 AI에게, 계산은 코드에게. 다음은 쌓여가는 판단 기록을 복기하며 규칙의 숫자를 다듬는(Tune) 일입니다.