0. 인증을 뚫고 나면

앞 글에서 봇은 로그인에 성공했다. 통신 엔진을 갈아끼우고, 서명을 맞추고, V1.0 엔진 로그인 성공!을 봤다.

그런데 자동매매는 로그인부터가 아니라 로그인 다음부터가 진짜다. 계좌에 붙었다고 주문이 나가는 게 아니다. 이 글은 주문이 안 나가던 사흘의 기록이다. 원인은 두 종류였다 — 기계가 숫자를 잘못 다룬 것, 그리고 사람이 숫자를 잘못 다룰 여지가 남아 있던 것.

이 글이 다루는 네 버전
05-26 22:05  v1.07  642줄  +27/-14  지수 표기 방지
05-28 19:20  v1.10  704줄  +49/-13  금액 ↔ 수량 자동 환산
05-28 19:27  v1.14  697줄  +30/-37  폰트 통일 · grid 수정
05-28 19:34  v1.12  744줄  +58/-11  Max 라벨 클릭 입력
--------------------------------------------------
공통 주제: 사람이 숫자를 직접 치는 자리를 지운다

번호가 뒤엉킨 건 오타가 아니다. 그 얘기는 뒤에서 따로 한다.


1. 수량이 1.234e-05로 나가서 주문이 죽었다

1-1. 파일 헤더가 전부를 말한다

첫 번째 사건은 인증에 성공한 그날 밤 10시 5분이다. 642줄, +27/-14줄. 파일 헤더에 이렇게 적혀 있다 — “지수 에러 방지 완벽 패치.”

bithumb_bot_v1_7.py — 주문 수량 직렬화
>>> units = budget / price
>>> str(units)
'1.234e-05'          # ← 이게 그대로 API 로 나갔다
⚠️ 에러: {'status':'5500','message':'Invalid Parameter'}
--------------------------------------------------
+ formatted_units = f"{units:.4f}"
+ res = self.api.buy_market(ticker, formatted_units)
>>> f"{units:.4f}"
'0.0000' → 자릿수 고정. 지수 표기는 더 이상 안 나온다
💥 [수동 매수] 50,000원 완료 (수량: 12.1359)

1-2. 파이썬이 숫자를 문자열로 바꿀 때

파이썬은 아주 작거나 아주 큰 실수를 문자열로 바꿀 때 지수 표기를 쓴다. 기준은 대략 소수점 아래 다섯 자리 근처다.

어디서부터 지수 표기가 되는가
>>> str(0.001)
'0.001'
>>> str(0.0001)
'0.0001'
>>> str(0.00001)
'1e-05'        ← 여기서 넘어간다
>>> str(0.00001234)
'1.234e-05'
--------------------------------------------------
값은 같다. 표기만 다르다.
그런데 API 는 표기를 본다.

값이 바뀐 게 아니다. 표기만 바뀐다. 파이썬 안에서만 놀면 아무 문제가 없다. 문제는 그 문자열이 그대로 HTTP 요청 본문에 실려 나갈 때다.

거래소 서버 입장에서는 숫자가 아니라 이상한 문자열이다. 1.234e-05를 수량으로 파싱하는 API는 별로 없다. 요청은 거부되고 status 5500이 돌아온다.

1-3. 제일 나쁜 종류의 버그

여기서 진짜 문제는 에러 코드가 아니다. 봇이 그걸 모르고 다음 루프로 넘어간다는 점이다.

조용히 실패하는 루프
tick 001  조건 성립 → 매수 요청 → 5500 (무시됨)
tick 002  조건 성립 → 매수 요청 → 5500 (무시됨)
tick 003  조건 성립 → 매수 요청 → 5500 (무시됨)
--------------------------------------------------
화면: 아무 일도 안 일어남
잔고: 그대로
사람: "오늘은 조건이 안 서나 보다"
→ 봇이 죽은 게 아니라 일하는 척을 하고 있었다

봇이 멈추면 알아챈다. 에러 창이 뜨면 알아챈다. 최악은 평소처럼 돌아가는데 아무것도 안 사는 것이다. 시장이 조용한 건지 봇이 고장 난 건지 구분할 방법이 없다.

이 경험이 지금 봇의 로그 설계를 만들었다. 현행 봇은 게이트를 통과하지 못한 종목에 대해서도 어느 게이트에서 왜 떨어졌는지를 남긴다. “안 샀다"와 “못 샀다"를 구분할 수 있어야 하기 때문이다.

1-4. 패치는 한 줄, 적용은 전 구간

고치는 방법 자체는 단순하다. 주문 직전에 소수점 자릿수를 고정한 문자열로 강제 변환한다. 서식 지정자를 쓰면 지수 표기가 나올 수 없다.

중요한 건 그다음이다. 수량이 API로 나가는 지점 전부에 같은 처리를 넣었다.

같은 변환을 넣은 지점들
 수동 매수
 수동 매도
 알고리즘 자동 진입
 자동 청산 (익절 / 손절)
 잔량 정리
--------------------------------------------------
그리고 로그에도 변환된 수량을 같이 찍는다
💥 [수동 매수] 50,000원 완료 (수량: 12.1359)
→ 또 터지면 눈으로 잡겠다는 뜻

버그를 한 군데서만 고치면 나머지 네 군데는 시한폭탄으로 남는다. 그리고 그 폭탄은 하필 자동 청산 경로에서 터진다 — 사람이 안 보고 있을 때.

1-5. 왜 이제야 터졌나

추정이지만, 이 버그는 저가 코인에서만 터졌을 것이다.

가격대별 주문 수량 (5만원 기준 · 개념 예시)
가격 5,000만원  → 수량 0.001       정상 표기
가격    4,000원  → 수량 12.5        정상 표기
가격        1원  → 수량 50000.0     정상 표기
가격 매우 낮음   → 수량 0.0000xx    지수 표기 위험 구간
--------------------------------------------------
※ 수량 = 주문금액 ÷ 현재가. 실제 값은 종목마다 다르다

코인 가격이 높으면 수량이 0.001 근처라 지수 표기가 안 나온다. 특정 종목에서만 재현됐다면, 원인을 찾는 시간의 대부분은 “왜 얘만 안 되지"에 쓰였을 것이다. 그리고 이런 버그는 재현 조건을 못 찾으면 영원히 못 잡는다.

1-6. 여기서 굳어진 원칙

전략을 아무리 잘 짜도 숫자가 문자열로 바뀌는 한 줄에서 다 무너진다. 이후로 나는 API 경계에서 타입과 자릿수를 항상 고정한다.

API 경계 규칙 — 이때 생겨서 지금까지
· 숫자를 문자열로 넘길 땐 자릿수를 명시한다
· 자동 변환에 맡기지 않는다
· 나가는 값을 로그에 그대로 찍는다
· 호가 단위 / 최소 주문 수량은 경계에서 검사한다
--------------------------------------------------
현행 봇의 게이트 10개 중 하나가 호가 단위 검사인 것도
이 계보에 속한다

자릿수를 명시하는 건 미신이 아니라 방어다.


2. 급할 때 암산을 시키면 안 된다

2-1. 13분짜리 패치

이틀 뒤인 5월 28일 저녁. 직전 버전으로부터 13분. 704줄, +49/-13줄. 겉보기엔 편의 기능인데 막으려던 건 사고다.

수동 긴급 제어 — 금액 ↔ 수량 연동
──[ 수동 긴급 제어 ]──────────────────────
 주문 금액 (KRW)  [    50,000 ]
      ↓ 실시간 시세로 자동 환산
 주문 수량 (개)   [   12.1359 ]
──────────────────────────────────────────
# current_market_price 를 시세 루프에서 계속 갱신
calc_qty = num / self.current_market_price
self.ent_manual_sell.insert(0, f"{calc_qty:.4f}")
# 한쪽을 비우면 반대쪽도 같이 비운다

실시간 시세를 담아두는 변수를 새로 두고, 수동 주문창 두 개를 서로 묶었다. 금액 칸에 50,000을 치면 현재가로 나눠 수량 칸이 자동으로 채워진다. 반대로 수량을 치면 금액이 채워진다.

작지만 중요한 디테일이 하나 있다. 한쪽을 비우면 반대쪽도 같이 비운다. 이걸 안 하면 금액을 지우고 다시 쳤을 때 예전 수량이 남아 있게 된다. 화면에 남은 낡은 숫자만큼 위험한 게 없다.

2-2. 그 순간은 항상 최악의 타이밍이다

왜 13분 만에 급히 넣었을까. 추정은 이렇다.

이 시절 봇에는 수동 긴급 제어 패널이 있었다. 자동매매가 이상하게 굴 때 사람이 직접 주문을 던지는 용도다. 그런데 그 패널을 쓰는 순간이 언제인지 생각해보면 답이 나온다.

수동 개입이 필요한 순간의 조건
· 가격이 급하게 움직이는 중
· 봇의 판단을 신뢰할 수 없는 상태
· 손실이 커지고 있거나 커질 것 같음
· 시간이 없음
--------------------------------------------------
이 상태에서 "5만원어치면 몇 개지?"를 암산한다
→ 자릿수 하나 틀린 주문이 나간다
→ 계산은 화면이 한다

암산은 평온할 때 잘 된다. 그리고 수동 제어 패널은 평온할 때 쓰지 않는다. 이 둘을 겹쳐놓은 게 애초에 설계 실수였다.

2-3. 사흘의 흐름 위에서 보면

바로 앞 버전에서 지수 표기 때문에 주문이 죽는 걸 겪은 직후라는 점도 겹친다.

같은 문제의 두 얼굴
v1.07  기계가 숫자를 잘못 다뤘다
        0.00001234 → '1.234e-05' → 주문 거부
        해결: 자릿수 고정

v1.10 사람이 숫자를 잘못 다룰 수 있었다 50,000원 → “몇 개지?” → 자릿수 오타 해결: 자동 환산 ————————————————– 둘 다 “숫자가 손을 거치는 지점"의 문제였다

기계가 숫자를 잘못 다뤄 한 번 데였고, 이번엔 사람이 잘못 다룰 여지를 지운 것이다. 이틀 사이에 같은 교훈을 양쪽에서 배웠다.

2-4. 자동화의 완성도는 수동 창구에 달려 있다

역설이 하나 있다. 자동화의 완성도는 수동 개입 창구의 안전성에 달려 있다.

완전 자동을 목표로 하면 수동 패널은 임시방편처럼 느껴진다. 그래서 대충 만든다. 그런데 결국 사람이 손대는 순간은 오고, 그 순간은 항상 최악의 타이밍이다. 그때 임시방편으로 만든 창구가 사고를 낸다.

지금 봇에 GUI는 없다. 터미널 대시보드뿐이다. 그래도 원칙은 같다 — 계산은 화면이 하고 사람은 확인만 한다. 사람이 하는 일은 판단이지 산수가 아니다.


3. Max를 눌러서 넣게 만든 이유

3-1. 7분짜리 응급처치

같은 날 저녁, 7분 뒤. 744줄, +58/-11줄.

수동 제어 — Max 클릭 입력
──[ 수동 제어 ]───────────────────────────
 주문 금액 [   50,000 ]  (Max: 1,234,567원)
 주문 수량 [  12.1359 ]  (Max: 12.345678개)
          라벨을 클릭하면 그대로 입력된다 ↑
──────────────────────────────────────────
+ self.lbl_max_coin.bind("<Button-1>", lambda e: self.fill_max_coin())
# 라벨 텍스트를 되파싱해서 입력창에 넣는다
coin_str = txt.replace("(Max: ","").replace("개)","").replace(",","")
──────────────────────────────────────────
손 입력 12.3456   → 잔량 0.000078 → 슬롯 계산 어긋남
Max 클릭 12.345678 → 잔량 0

주문창 옆에 최대 금액·최대 수량 라벨을 붙이고 그 라벨을 클릭하면 값이 입력창에 자동으로 들어가게 했다.

3-2. 좋은 설계는 아니다

구현은 소박하다. 라벨에 찍힌 텍스트에서 괄호와 콤마를 잘라내고 숫자로 바꿔 입력창에 넣는다. 화면에 그려진 문자열을 다시 파싱하는 방식이다.

화면 문자열 되파싱 — 왜 위험한가
현재: 상태값 → 문자열 포맷 → 라벨 → 되파싱 → 입력창
        포맷을 바꾸면 파싱이 깨진다
        "12.345678개" → "12.345678 개" 만 돼도 실패

정석: 상태값 ──────────────────────→ 입력창 └→ 문자열 포맷 → 라벨 (표시 전용) ————————————————– 7분 만에 넣은 걸 감안하면 이건 설계가 아니라 응급처치다

상태 값을 따로 들고 쓰는 게 맞다. 다만 그때는 정석을 고를 여유가 없었다. 그리고 이런 코드는 나중에 반드시 물린다 — 표시 형식을 한 번만 바꿔도 조용히 깨지기 때문이다. 이 글에서 굳이 짚어두는 이유다.

3-3. 부스러기가 만드는 문제

목적은 명확하다. 전량 매도를 손으로 타이핑하지 않게 만드는 것.

잔량 0.000078이 만드는 연쇄
보유 12.345678
손 입력 12.3456  → 매도 체결
                 잔량 0.000078 남음· 최소 주문 수량 미만이라 팔 수도 없다
· 봇은 "보유 중"으로 인식한다
· 슬롯이 비지 않는다 → 다음 진입 차단
· 평단·수익률 계산이 어긋난다
--------------------------------------------------
Max 클릭 → 잔량 0 → 슬롯 정상 반환

보유 수량이 12.345678인데 손으로 12.3456을 치면 잔량이 남는다. 남은 부스러기는 최소 주문 수량 아래라 정리도 안 된다. 그런데 봇은 그 종목을 여전히 보유 중으로 본다. 슬롯 하나가 영원히 잠기는 것이다.

클릭 한 번이면 그 오차가 통째로 사라진다. 7분짜리 패치의 값어치가 여기 있다.

3-4. 사흘 동안 지운 것들

앞선 두 버전과 방향이 정확히 같다.

사람이 숫자를 치는 자리 — 하나씩 지우기
v1.07  주문 수량의 표기를 기계가 정한다   
v1.10  금액 ↔ 수량 변환을 기계가 한다     
v1.12  최대값 입력을 기계가 채운다        
--------------------------------------------------
남은 것: 사람은 "얼마를" 정하고 "확인" 버튼만 누른다

사람이 숫자를 직접 치는 구간을 하나씩 지워나간 사흘이었다. 지금 봇엔 이런 버튼이 없다. 애초에 사람이 수량을 칠 일이 없으니까. 하지만 거기까지 오는 데, 실수할 수 있는 자리를 손으로 하나하나 막는 과정이 있었다.


4. 폰트를 바꾼 게 아니라 깨진 창을 고친 것

4-1. 줄 수가 줄어든 드문 버전

같은 날 저녁, 앞선 버전들 사이에 낀 시각이다. 697줄, +30/-37줄. 줄 수가 줄어든 드문 버전이다. 기능을 넣은 게 아니라 정리한 버전이라는 뜻이다.

bithumb_bot_v1_14.py — 폰트 통일 · grid 에러
- self.std_font   = ("나눔스퀘어", 10)
- self.input_font = ("나눔스퀘어", 11, "bold")
+ # [수정] 모든 폰트를 '맑은 고딕'으로 통일하고 bold 속성 제거
+ self.std_font   = ("맑은 고딕", 10)
+ self.input_font = ("맑은 고딕", 11)
+ # [에러 해결 구역] grid 괄호를 정상적으로 닫았습니다.
--------------------------------------------------
폴더명 bt_v1_14  ↔  파일 헤더 "v1.12 (맑은 고딕 UI 최적화)"
→ 이관 중 폴더 이름이 뒤바뀐 것으로 추정. 정답은 파일 시각.

4-2. 폰트가 없으면 창이 깨진다

폰트 체계를 통째로 갈았다. 기본 폰트를 맑은 고딕으로 통일하고 입력창의 bold를 뺐다. 주석에도 그대로 적어뒀다.

추정: 폰트가 없어서 창이 깨졌을 것이다.

왜 맑은 고딕인가
나눔스퀘어  설치돼 있어야 쓴다
            개발 PC엔 있고 다른 PC엔 없을 수 있다
맑은 고딕   윈도우 기본 탑재
            어느 윈도우에서도 같은 폭으로 그려진다
--------------------------------------------------
tkinter는 폰트가 없으면 대체 폰트로 그린다.
글자 폭이 달라지고 → 위젯 크기가 달라지고
→ 레이아웃이 통째로 밀린다

tkinter는 폰트를 못 찾으면 조용히 다른 폰트로 대체한다. 에러도 안 난다. 다만 글자 폭이 달라지고, 폭이 달라지면 위젯 크기가 달라지고, 위젯 크기가 달라지면 배치가 밀린다.

4-3. 잘린 숫자는 자동매매에서 위험하다

여기서 끝났으면 그냥 디자인 이슈다. 문제는 이게 자동매매 화면이라는 점이다.

레이아웃이 밀렸을 때
[ 정상 ]  보유수량  12.345678   평단  4,120원
[ 밀림 ]  보유수량  12.3456…    평단  4,12…
                            ↑ 잘려서 안 보인다
--------------------------------------------------
잔고를 잘못 읽으면 잘못 누른다
앞 절에서 만든 Max 클릭이 필요했던 이유이기도 하다

숫자가 잘려 보이는 대시보드는 꽤 위험하다. 잔고를 잘못 읽으면 잘못 누른다. 앞 절에서 “화면 문자열을 되파싱한다"고 했던 그 구조를 떠올리면 더 그렇다 — 화면이 틀리면 입력도 틀린다.

4-4. 주석으로 남은 로그

또 하나. 주석에 [에러 해결 구역] grid 괄호를 정상적으로 닫았습니다가 남아 있다. 괄호 하나 때문에 실행이 안 되던 걸 잡은 흔적이다.

이 시절엔 이런 게 로그 대신 주석으로 남았다. git이 없으니 “무엇을 왜 고쳤는지"를 적을 데가 코드 안밖에 없었다. 파일 헤더에 버전 설명을 쓰고, 고친 자리마다 대괄호 표시를 남기고. 지금 기준으로는 지저분한 코드지만, 그 덕에 두 달 뒤의 내가 이 글을 쓸 수 있다.

4-5. 아카이브의 정답은 파일 시각

이 버전엔 이상한 점이 하나 있다. 폴더명이 bt_v1_14인데 파일 헤더엔 v1.12로 적혀 있다. 그리고 7분 뒤 버전은 그 반대다 — 폴더명이 bt_v1_12인데 헤더는 v1.14.

폴더명과 헤더가 뒤바뀐 흔적
19:24  704줄  폴더 bt_v1_11   헤더 v1.11   일치
19:27  697줄  폴더 bt_v1_14   헤더 v1.12   불일치
19:34  744줄  폴더 bt_v1_12   헤더 v1.14   불일치
--------------------------------------------------
파일 시각과 헤더 번호는 서로 앞뒤가 맞는다
→ 이관 중 폴더 이름이 뒤바뀐 것으로 추정
→ 아카이브의 정답은 파일 시각

파일 시각과 헤더 번호는 서로 앞뒤가 맞으니, 이관 과정에서 폴더 이름이 뒤바뀐 것으로 추정한다. 아카이브의 정답은 파일 시각이라는 원칙을 여기서 배웠다. 폴더 이름은 사람이 붙인 것이고, 파일 시각은 시스템이 남긴 것이다. 둘이 다투면 시스템 쪽을 믿는다.

이 글에서 버전 번호가 뒤엉켜 보이는 것도 그래서다. 시각 순으로 늘어놓았기 때문이다.


5. 사흘이 남긴 것

5-1. 실패의 세 가지 등급

이 사흘 동안 만난 실패를 등급으로 나눠보면 이렇다.

실패의 등급 — 비용 순
[ 1급 ] 시끄럽게 실패   실행이 안 된다 (grid 괄호)
        즉시 발견. 제일 싸다.
[ 2급 ] 에러로 실패     status 5500
        로그를 보면 안다.
[ 3급 ] 조용히 실패     주문 거부 후 다음 루프
        모른다. 제일 비싸다.
[ 3급 ] 화면이 거짓말   숫자가 잘려 보인다
        사람이 잘못된 판단을 한다.
--------------------------------------------------
개발의 목표는 3급을 1급으로 끌어올리는 것

지금 봇이 로그를 그렇게 많이 찍는 이유가 이거다. 조용한 실패를 시끄러운 실패로 바꾸는 작업이다. 게이트 열 개를 통과하지 못한 종목마다 어느 게이트에서 왜 떨어졌는지를 남기는 것도, 결국 “안 샀다"와 “못 샀다"를 구분하기 위해서다.

5-2. 사람은 판단하고 기계는 계산한다

네 버전을 관통하는 문장 하나를 뽑으면 이거다. 사람이 숫자를 직접 다루는 자리를 지운다.

지수 표기는 기계가 숫자를 문자열로 바꾸는 자리에서 터졌고, 암산과 자릿수 오타는 사람이 숫자를 입력하는 자리에서 터졌고, 잘린 화면은 사람이 숫자를 읽는 자리에서 터졌다. 세 자리 모두 “숫자가 형태를 바꾸는 경계"다.

숫자가 형태를 바꾸는 경계들
내부 실수값
   │ ① 문자열로 변환  ← v1.07 사고 지점
   ▼
API 요청 문자열
   │
서버 → 응답 → 잔고
   │ ② 화면에 그리기   ← v1.14 사고 지점
   ▼
사람이 읽는 숫자
   │ ③ 사람이 다시 입력 ← v1.10 / v1.12 사고 지점
   ▼
다시 내부 실수값
--------------------------------------------------
경계마다 검증을 넣는다. 이게 이 사흘의 결론.

5-3. 응급처치를 기록해두는 이유

이 사흘의 코드에는 자랑할 게 별로 없다. 화면 문자열을 되파싱하고, 라벨에서 콤마를 잘라내고, 주석에 “에러 해결 구역"이라고 적어놓는다. 7분, 13분 간격으로 나온 결과물이 정교할 리 없다.

그런데 응급처치를 기록으로 남기는 게 정석 코드를 남기는 것만큼 쓸모 있다고 생각한다. 나중에 같은 자리를 다시 지나갈 때, “그때 왜 이렇게 급하게 했나"를 알면 지금 무엇을 갚아야 하는지가 보인다. 정석으로 다시 짤 자리와, 그냥 두어도 되는 자리를 구분할 수 있게 된다.

이 사흘의 부채 — 갚은 것과 남긴 것
갚음  API 경계 자릿수 고정      → 현행에도 그대로 살아 있다
갚음  금액↔수량 자동 환산       → 사람 입력 자체가 사라짐
갚음  화면 문자열 되파싱        → GUI를 버리며 자연 소멸
갚음  환경 의존 폰트            → 터미널로 이주하며 소멸
--------------------------------------------------
부채의 절반은 갚은 게 아니라 구조가 바뀌며 없어졌다
그래도 원칙은 남았다

재밌는 건, 이 사흘의 기술 부채 대부분을 리팩터링으로 갚은 게 아니라는 점이다. GUI를 버리면서 통째로 사라졌다. 응급처치 코드가 나쁜 코드였다는 사실은 변하지 않지만, 그게 남긴 원칙은 구조가 바뀌어도 살아남았다. 코드는 버려도 이유는 안 버린다.

5-4. 그리고 청소도 기록이다

기능을 안 늘리고 줄 수를 줄인 버전이 계보에 남아 있는 게 나쁘지 않다. 하루에 열 개씩 버전을 뽑던 시기에도 가끔은 청소를 했다는 뜻이니까.

지금도 그렇다. 파라미터를 하나도 안 건드리고 로그 형식만 정리한 커밋이 종종 있다. 그런 커밋은 수익률을 1원도 못 올린다. 대신 다음 사고를 더 빨리 찾게 해준다. 매매 성적으로 환산되지 않는 작업의 값어치를 인정하는 게, 자동매매를 오래 돌리는 데 생각보다 중요하다.

마크 더글러스는 트레이딩의 대부분이 실행의 문제라고 했다. 봇도 같더라. 신호가 맞아도 주문 문자열이 틀렸으면 결과는 0원이다.

알렉산더 엘더는 계획대로 거래하지 않으면 시장이 대신 계획을 세운다고 했다. 급할 때 하는 암산은 계획이 아니라 즉흥이다. 그 자리를 UI에서 지우는 게 이 사흘의 작업이었다.

마크 미너비니는 리스크를 먼저 정한 뒤 진입한다고 했다. 나는 아직 오타를 막는 단계였지만 방향은 같았다. 결과를 통제하려면 입력부터 통제해야 한다.