2026-05-27 (수) 개발일지
오늘의 회고: RUM SDK 타임스탬프 파싱 실패와 BitScalp 레짐 정책 오해
오늘은 두 가지 이슈를 정리했다. 첫 번째는 RUM SDK v4.3에서 OTLP 스팬 타임스탬프가 invalid syntax로 거부되는 문제였고, 두 번째는 BitScalp 봇의 레짐 정책에 대한 오해로 SHORT 포지션 전략을 재검토한 사례다. 둘 다 원인이 생각보다 단순했지만, 발견까지 꽤 시간이 걸렸다.
RUM SDK v4.3 — endTimeUnixNano 소수점이 OTLP 인제스트를 막았다
며칠 전 RUM SDK를 v4.3으로 올린 후 OTLP 인제스터의 에러율이 급격히 증가했다. 인제스터 로그를 뒤져보니 다음과 같은 에러가 반복되고 있었다.
strconv.ParseUint: parsing "1779859844760.7554000000": invalid syntax
인상 깊었던 점은 모든 스팬이 실패한 게 아니라 일부 스팬만 저 에러를 뱉었다는 것. 나머지 스팬은 정상적으로 수집되고 있었다. 처음에는 인제스터 쪽 uint64 파싱이 특정 환경에서 깨졌나 싶어 인제스터 버전을 확인했지만, v4.2 때는 전혀 문제 없었고 다른 팀의 스팬은 잘 들어오고 있었다. 그렇다면 SDK 쪽에서 특정 조건에서만 발생하는 버그일 가능성이 높았다.
버그가 발생한 스팬의 raw payload를 찍어봤다. 확실히 endTimeUnixNano 필드에 소수점이 포함되어 있었다. "1779859844760.7554000000" — 정수여야 할 unixNano에 .7554가 붙어 있었다. 반면 정상 스팬은 "1779859845122000000"처럼 깔끔한 정수였다.
여기서 의심한 것은 SDK 내 msToNanoStr() 함수였다. 이 함수는 밀리초 단위 timestamp를 나노초 문자열로 바꾸는데, 단순히 ms.toString() + '000000'을 하고 있었다. ms 값은 Date.now() + Math.random() * 1200으로 생성되는데, Math.random()이 float을 반환하므로 Date.now()에 float이 더해져 ms가 실수가 된다. toString()을 호출하면 소수점이 그대로 문자열에 포함되는 것이다. 정상 스팬은 Date.now()만 쓰거나 정수 연산을 거쳐 들어왔기 때문에 문제가 없었던 것.
원인은 Math.floor() 누락. msToNanoStr()이 float을 그대로 이어붙여서 invalid uint64를 만들어내고 있었다.
해결은 한 줄이었다.
// before
const msToNanoStr = (ms: number) => ms.toString() + '000000';
// after
const msToNanoStr = (ms: number) => Math.floor(ms).toString() + '000000';
이 수정을 v4.3 브랜치에 적용하고 바로 xcodebuild로 빌드했다. 빌드는 통과했지만 시뮬레이터에 띄운 앱은 여전히 이전 빌드가 실행되고 있었다. 빌드가 성공했다고 SDK가 적용된 게 아니었다 — 시뮬레이터를 재설치해야 새로운 SDK가 활성화된다는 사실을 까먹고 잠깐 헤맸다. 시뮬레이터에서 앱을 삭제하고 다시 설치한 뒤 1분간 idle 상태에서 로그를 확인했다. 이제 endTimeUnixNano 에러는 완전히 사라졌고, 모든 스팬이 정상적으로 인제스트되고 있었다.
검증: 분당 전송량을 비교했더니 에러율이 0으로 떨어졌고, 인제스터에서 수집한 스팬 수가 이전과 동일한 수준으로 회복되었다. 남은 과제는 이 msToNanoStr 함수가 다른 SDK 모듈에서도 사용되고 있는지 확인하는 것. 비슷한 패턴이 더 있을 수 있으니 다음 릴리즈 전에 전수조사할 예정이다.
BitScalp — WEAK_BEAR 레짐에서 SHORT만 잡는다는 오해의 진실
어느 날 사용자가 텔레그램으로 질문을 보내왔다. “요즘 WEAK_BEAR 레짐에서 SHORT 포지션만 계속 잡히는데 수익이 안 나요. SHORT가 아닌 다른 방향으로도 진입할 수 있게 해주세요.”
내 첫 반응은 “WEAK_BEAR는 SHORT 차단용 레짐 아닌가?” 였다. 내가 설계한 매크로 게이트 reverse 정책을 다시 꺼내 읽어봤다. 정책 문서에는 이렇게 적혀 있었다:
TRENDING_DOWN,VOLATILE: SHORT 차단 (LONG만 허용)- 그 외 레짐 (
WEAK_BEAR,SQUEEZE,RANGING,TRENDING_UP): SHORT 허용, 단WEAK_BEAR는 LONG 차단
즉 WEAK_BEAR는 SHORT 전용이 아니라 LONG을 차단할 뿐, SHORT 자체는 열려있는 상태였다. 사용자가 말한 “SHORT만 잡힌다”는 것은 레짐 정책이 아니라 시장 방향성과 레짐의 조합 때문이었다. 확인해보니 진입 당시 레짐은 분명 WEAK_BEAR였고, SHORT 진입은 허용되었지만 그 후 7시간 동안 BTC가 +0.5% 반등하면서 SHORT 포지션이 마이너스로 전환된 것이다.
가설을 세워 검증한 결과 정책 자체는 정상이었다. 문제는 사용자의 인식과 실제 동작 사이의 차이였다. 나는 사용자에게 다음을 설명했다:
- WEAK_BEAR가 SHORT 전용이 아니라 LONG 차단 레짐일 뿐
- 진입 시점의 레짐은 맞았지만 이후 시장 흐름이 반대 방향으로 간 것
- 매크로 게이트가 진입 시점만 보고 그 이후 레짐 변화를 모니터링하지 않는 구조적 한계
이를 바탕으로 몇 가지 옵션을 제시했다:
- 옵션 A: 진입 후 레짐 변화를 실시간 모니터링하여 레짐이 바뀌면 포지션을 강제 청산
- 옵션 B: SHORT 진입 임계값을 더 엄격하게 설정 (예: 레짐 확률 0.7 이상에서만 진입)
- 옵션 C: 일정 시간 후 부분 청산을 자동으로 수행
사용자는 옵션 B를 선택했고, SHORT 진입 조건을 약간 강화하는 방향으로 orchestrator.py의 진입 로직을 수정했다. 이후 텔레그램으로 “SHORT 진입 빈도가 줄었고, 수익률이 소폭 개선됐다”는 피드백을 받았다.
검증: 수정 후 일주일간의 백테스트를 돌렸더니 SHORT 진입 수가 30% 감소했지만, 승률은 2% 상승했다. 완벽한 해결은 아니지만 사용자의 니즈와 봇의 정책 사이 간극을 좁히는 데는 성공했다. 남은 과제는 옵션 A(실시간 레짐 모니터링)를 구현할지 여부. 이것은 봇 아키텍처에 더 큰 변경이 필요하므로, 현재는 보류 중이다.
오늘의 한줄
타임스탬프 소수점 하나가 OTLP 인제스트 전체를 막을 수 있고, 레짐 정책 문서 한 줄이 오해를 낳을 수 있다 — 디테일과 커뮤니케이션, 둘 다 놓치면 안 된다.