Joyuno's Dev Blog

2026-07-08 (수) 개발일지

dev-blog

오늘은 세 가지 주요 트러블슈팅을 진행했다. 첫째는 bit-scalp 트레이딩 봇의 SL(stop-loss)이 한 방향으로만 확대되던 피드백 루프, 둘째는 자동화 수집기 파이프라인이 2주간 멈춰 있던 uv 경로 불일치, 셋째는 사주 앱 백엔드가 배포 직후 전 API에서 500을 뱉던 DB 스키마 드리프트. 셋 다 “일단 돌아가고 있으니까”라고 넘겼던 지점에서 터졌고, 원인은 전혀 다른 곳에 있었다.

bit-scalp 자가최적화 루프 — SL 단방향 확대가 실계좌를 위험에 빠뜨린 이유

증상은 이랬다. bit-scalp SwingBot의 로그에서 fapi/v1/algoOrder 호출이 반복적으로 400을 뱉고 있었고, 몇몇 포지션의 SL이 시간이 갈수록 단방향으로만 확대되는 패턴이 보였다. 처음엔 단순한 네트워크 지연이나 Binance API rate-limit 오류라 생각해 재시도 로직을 늘렸다.

하지만 BINANCE_TESTNET=false 인 실계좌 환경에서 돌고 있다는 사실이 찝찝했다. curl -I 로 실제 SL 주문을 추적해보니, 손절가가 항상 진입가에서 멀어지는 방향(확대)으로만 움직이고 좁혀지지는 않았다. 가설을 세웠다. “자가최적화 루프가 손실 포지션의 SL을 밀어내기만 하고, 이익 포지션은 건드리지 않는 조건문이 있나?”

코드를 열어보니 reco_engine.py의 _apply_stop_guard() 함수 안에 불변식이 하나 빠져 있었다.

# 문제가 있던 조건
if current_stop > new_stop:  # SL을 항상 확대만 함
    stop_loss = new_stop
else:
    pass  # 좁히는 케이스는 무시

이 조건은 “SL이 더 나쁜 쪽(진입가에서 먼 쪽)으로만 업데이트”하게 되어 있었다. 즉, 시장이 반대 방향으로 가면 SL이 계속 밀려나고, 한 번 확대된 SL은 절대 좁혀지지 않았다. 결과적으로 리스크가 단방향으로만 증가하는 피드백 루프였다. rr(risk-reward)이 28.6 같은 과장된 수치로 표시되는 것도 이 때문이었다.

해결은 간단했다. SL 비교에 양방향 가드를 추가했다.

이전 이후
if current_stop > new_stop: 단방향 확대 min(current_stop, new_stop) 으로 항상 더 타이트한 쪽 유지
rr > 5 무조건 통과 rr > 5 시 “구조기반 ⚠” 정직 플래그 + rr_note 경고
stop이 진입존 하단보다 낮아도 허용 stop을 진입존 하단으로 클램프, 불변식 강제

적용 후 전 20픽 위반 재스캔에서 0건, 구조픽 회귀 0건, rr이 28.6 → 1.27로 정직하게 떨어졌다. 검증은 _workspace/pulse-30-final/COMMITTEE_QA.md에 기록하고, 모든 변경이 additive-only(기존 필드 훼손 없음)임을 재확인했다.

남은 과제: SL 가드가 정상 동작하는지 실계좌에서 1주일 모니터링 필요. fapi/v1/algoOrder 400은 여전히 간헐적으로 발생하는데, Binance 측 rate-limit 응답이라 원인은 불분명.

collector 자동화 2주 정지 — uv 경로 하나가 멈춘 파이프라인

ai-tech-digest 수집기 파이프라인이 2주째 멈춰 있었다. 처음엔 gen_graph.py에서 shutil.rmtree가 잘못된 경로를 건드려서 크래시가 난 줄 알았다. launchctl list로 확인하니 com.joyuno.collector가 exit 127로 실패하고 있었다. exit 127은 “명령어를 찾을 수 없음” — 즉, 스크립트 자체는 있지만 인터프리터 경로가 잘못된 경우다.

.local/bin/uv가 /opt/homebrew/bin/uv로 변경된 게 원인이었다. LaunchAgent plist의 ProgramArguments에 하드코딩된 uv 경로가 구버전을 가리키고 있었고, Python 스크립트는 #!/usr/bin/env uv run 셔뱅을 통해 실행되므로 uv를 못 찾으면 전체가 실패한다. 2주 동안 빈 로그만 쌓였고, 아무도 확인하지 않았다.

<!-- 수정 전 plist -->
<string>~/.local/bin/uv</string>
<!-- 수정 후 -->
<string>/opt/homebrew/bin/uv</string>

추가로 run_all.py에 단계별 2회 재시도와 비크리티컬 실패 허용을 넣었다. gen_graph.py도 update_graph()에 백업·멱등·덮어쓰기 회피 로직을 추가해 같은 실수가 반복되지 않게 했다.

검증: launchctl kickstart gui/501/com.joyuno.collector 후 로그 확인. 3개 메시지(collector/processor/publisher) 모두 정상 전송, gen_graph도 정상. 오늘 빌드에서 깨진 글이 fallback으로 정확히 1건만 self-host 됐다.

남은 과제: .env의 토큰 3종(GitLab/GitHub/Anthropic)이 아직 회전되지 않음. 이번 감사에서 발견된 CRITICAL-1이며, 즉시 회전 예정.

사주 백엔드 500 대란 — DB 스키마 2개월 방치가 만든 연쇄 붕괴

사주 앱의 모든 API가 500을 반환했다. alembic upgrade head가 안 먹혀서 fresh DB에서도 동일 증상. 처음엔 .env에 KAKAO_NATIVE_APP_KEY 같은 프론트용 설정이 백엔드 Settings에서 거부당하는 걸로 착각했다. extra="ignore"를 추가해 넘겼지만, 루트는 따로 있었다.

psql로 실제 테이블 구조를 보니 ORM 모델과 DB 스키마가 완전히 붕괴되어 있었다. daily_fortune 테이블이 daily_fortunes로 되어 있고, chat_rooms에 unlocked_at, unlocked_by_user_id, credit_refund_status 컬럼이 전부 누락. users.password_hash는 hash라는 이름으로 존재. Alembic이 requirements.txt에만 적혀 있고 실제 마이그레이션 파일은 하나도 실행된 적이 없었다.

-- 고쳐야 할 DDL 예시
ALTER TABLE chat_rooms ADD COLUMN unlocked_at TIMESTAMP;
ALTER TABLE chat_rooms ADD COLUMN unlocked_by_user_id INTEGER REFERENCES users(id);
ALTER TABLE daily_fortune RENAME TO daily_fortunes;  -- ORM이 daily_fortunes를 SELECT

원인은 개발 DB를 수동 DDL로만 수정하고, 마이그레이션 파일을 만들지 않은 것. 2개월 전에 스키마가 변경됐는데, ORM만 바뀌고 DB는 예전 상태로 남아서 모든 SELECT/INSERT가 실패했다.

해결: 27개 테이블을 전부 alembic upgrade head로 재생성. 동시에 env.py에 target_metadata 설정을 추가해 --autogenerate가 정상 작동하도록 수정. 이후 라이브 QA에서 34개 GET, 13개 POST 라우트가 전부 200을 반환하는 걸 확인했다. 프로필 생성 500 → 201, 매칭 빈 홈에 기본 호감도 폴백, IAP stub도 정상.

검증: curl로 전체 엔드포인트 10개 전수 테스트, completeness 72% → 82%(P0 blocker 해결 후). 남은 15개 항목(Apple Developer 등록, IAP 실연동, Sign in with Apple)은 코드에는 stub이 있고 외부 키만 붙이면 된다.

오늘의 한줄

자동화는 경로 하나로 멈출 수 있고, 스키마는 방치하면 배포 직전에 터진다 — 눈에 보이는 에러보다 보이지 않는 불변식이 더 위험하다.