2026-06-11 (목) 개발일지
오늘은 운영 중인 두 트레이딩 봇(V2, V5) 사이에서 발생한 slot lock 충돌과, 모바일 리플레이 시스템의 crash 데이터 유실 문제를 잡았다. 전자는 lock 정책의 단순 누락이었고, 후자는 메모리 전용 버퍼의 구조적 한계였다. 둘 다 원인을 좁혀가는 과정에서 의외의 지점을 발견했다.
bit-scalp V2/V5 slot lock — V5 포지션을 V2가 빼앗던 문제
V5에 평균회귀 전략을 붙이고 두 봇을 동시에 돌리던 중, lock DB에서 PAXGUSDT SHORT가 V2에 할당된 채 V5는 동일 종목 시그널을 계속 잡고 있었다. 처음엔 V5의 lock 체크 자체가 빠졌다고 생각했다. orchestrator.py의 스캔 코드를 열어보니 V5 쪽 _scan_for_new_entries에 lock 조회가 들어가 있었고, V2 쪽에도 동일한 lock 테이블을 보고 있었다. 문제는 lock 정책이 비대칭이었다는 점이다.
| 케이스 | 이전 동작 |
|---|---|
| V2 보유 중 V5 시도 | V5 차단 (정상) |
| V5 보유 중 V2 시도 | V2가 진입 성공 ❌ |
V2의 lock 체크 로직(orchestrator.py:660)이 V5가 생성한 lock 레코드를 무시하고 있었다. 조건문을 따라가보니 V2는 자신의 인스턴스 ID로만 락을 걸고, 조회 시에도 자신의 인스턴스만 확인했다. V5가 만든 락은 “우리 봇”이 아니라고 판단한 셈이다. 같은 instance_lock 테이블을 사용하면서도 인스턴스 필터가 서로를 배제하고 있었던 것.
해법은 단순했다. lock 조회에서 인스턴스 구분을 없애고 종목 단위로만 차단하도록 통일했다. create_instance_lock_table.py 스키마는 그대로 두고, orchestrator.py에서 락 체크 쿼리를 WHERE symbol = ?로 바꾸고 인스턴스 조건을 제거했다. V2가 V5의 락을 빼앗던 케이스를 명시적으로 차단하도록 lock 정책 표를 갱신했다.
바뀐 점
- V2
orchestrator.py:660— 락 조회에 인스턴스 필터 제거 - V5
orchestrator.py:796— 동일 (이미 종목 단위) - 락 정책: V5 보유 + V2 시도 → V2 차단 (이전: V2 진입)
검증: 현재 락 DB에 PAXGUSDT [V2] SHORT만 존재하며, V5가 동일 종목 시그널을 발생시켜도 lock에서 차단되는 걸 로그로 확인했다. 동시 보유 위험 0.
남은 과제: V3 macro와 평균회귀 전략 간 시그널 우선순위 규칙은 아직 정의하지 않음 — 같은 종목에 두 전략이 동시 시그널을 내면 현재는 lock이 먼저 잡힌 쪽만 진입. 전략 간 우선순위를 둘지, 단순 선착순으로 둘지는 추가 결정 필요.
demo_apps 리플레이 crash — 메모리 버퍼 너머 디스크 미러링
iOS 리플레이 시스템(ReplaySnapshotCapturer.swift)이 메모리 링버퍼에만 스냅샷을 쌓고 주기적으로 ClickHouse로 업로드하는 구조였다. 앱이 정상 종료되면 마지막 flush로 데이터가 남지만, crash나 hang으로 프로세스가 죽으면 최대 60초 분량의 리플레이가 사라졌다. “crash 발생 직전 60초”가 정확히 필요한 데이터인데 정작 유실되는 아이러니.
처음엔 flush 주기를 5초로 줄이는 쪽을 생각했다. 하지만 업로드 실패 시 재시도 로직이 없어서, 네트워크가 불안정한 순간 crash가 겹치면 여전히 손실이 난다. 근본 원인은 메모리만 있는 단일 저장소였다. 해법은 메모리와 디스크를 이중화하는 것.
ReplayDiskPersist.swift를 새로 만들어 5초 스로틀로 링버퍼 내용을 디스크에 미러링하게 했다. 핵심은 atomic 교체와 비정상 종료 감지다.
- 저장:
BufferedEmitRecord(Codable)를 JSON 직렬화 →replay_pending/<session_id>.bin에 temp 파일로 쓰고 rename으로 원자적 교체 - 비정상 종료 판정: 앱 실행 시
replay_pending/에 남아있는 파일이 있으면 직전 세션이 정상 종료되지 않았다고 판단 → 해당 파일을 업로드 후 삭제 - 정상 종료:
flushOnError나deinit에서 디스크 파일을 삭제하고 마지막 업로드 완료
Android도 동일한 구조(ReplayDiskPersist.kt)로 포팅했다. OmnitraceMobile.kt에 diskPersist 속성을 주입하고, ReplaySnapshotCapturer에서 emitOrBuffer 호출 후 디스크 미러링을 예약한다.
검증: iOS 시뮬레이터에서 앱을 강제 종료(kill -9) 후 재실행 → replay_pending/ 파일이 감지되어 업로드됨. Android 에뮬레이터에서는 start timeout(메모리 부족) 문제가 있었지만 disk-persist 자체 테스트는 통과(107 tests, 1 pre-existing failure).
남은 과제: Android start timeout은 에뮬레이터 메모리 압박(16GB 머신에 할당 4GB)이 원인으로 추정되나, 실제 기기에서는 재현되지 않아 현재로선 모니터링 중.
오늘의 한줄
동기화에서 가장 위험한 패턴은 “내가 만든 락만 내가 본다”는 착각 — 인스턴스 경계를 넘는 자원 보호는 항상 전역 규칙으로 통일해야 한다.