Joyuno's Dev Blog

2026-06-19 (금) 개발일지

dev-blog

오늘의 이슈 세 가지 — 모드 전환 진동, 크래시 리포팅 anonymous_id, OpenRouter 모델 리스트 실제 개수

오늘은 세 가지 프로젝트에서 서로 다른 성격의 문제를 잡았다. 공통점은 초기 가설이 전부 틀렸거나 불완전했다는 점이다. bit-scalp 봇은 모드가 5분마다 깜빡이던 걸 45분간 0회로 안정시켰고, demo_apps iOS 크래시 리포팅은 서버가 중간에 명세를 철회하는 바람에 구현했던 코드를 롤백해야 했으며, auto_market_content 모델 드롭다운은 OpenRouter API를 직접 찔러보기 전까지 34/14/9라는 숫자를 믿고 있었다.


bit-scalp — 모드 전환 진동 0회로 안정화

증상: 봇이 5분마다 모드를 갈아끼우고 있었다. ADX=52.9~53.7, DI_diff=-19.3, BBW=0.044로 충분히 강한 추세인데도 진입 없이 모드만 왔다 갔다. 구버전 로그에서는 5분 단위로 TRENDING_UP → TRENDING_DOWN → TRENDING_UP 같은 깜빡임이 반복됐다.

첫 가설: 단순 임계값 오차 범위 문제라고 생각했다. ADX > 25면 추세장인데 50을 넘고 있으니 조건문 자체는 문제 없어 보였다. 그래서 일단 DI_diff 부호 변화를 모니터링했는데, -19.3이 한 번 찍히고 다음 로그에서도 -18.7로 유지되는 걸 보고 — 부호가 요동치는 게 아니라는 걸 알았다.

원인 추적 — 진짜 범인은 Donchian break 조건이었다: 모드 전환 로직을 하나씩 까봤다. Donchian=0.21~0.30이라는 값이 눈에 띄었다. Donchian 채널 폭이 좁은데(0.21~0.30), BBW_3h_chg=-2~-3%로 밴드폭이 좁아지고 있었다. 원래 모드 전환 조건 중 하나가 “Donchian 채널을 상단/하단으로 가격이 break” 인데, 좁은 채널 안에서 가격이 미세하게 왔다 갔다 하면 조건이 5분마다 충족/해제를 반복하는 구조였다. 6h=+0.3~+0.6%라는 완만한 상승이 Donchian 상단과 하단을 동시에 건드릴 정도로 채널이 좁았던 것.

해결 — 조건에 히스테리시스와 시간 게이트 추가: 모드 전환 함수에 두 가지 보호 장치를 넣었다.

  • min_stable_bars: 같은 모드 시그널이 12분(4회 연속) 이상 유지돼야 전환 허용.
  • Donchian break에 deadband 추가: 현재가가 Donchian 상단보다 +0.5% 이상이어야 TRENDING_UP으로 전환, 하단보다 -0.5% 이하여야 TRENDING_DOWN으로 전환. 좁은 채널 내 미세 움직임은 무시.

검증: 새 모드 로그로 45분간 10회 수집한 결과, 진동 0회. ADX는 52.9~53.7로 안정적이었고, DI_diff도 -19.3에서 벗어나지 않았다. 모드가 확정되니 그 후 진입 시그널도 정상적으로 발생했다. 다만 Donchian deadband 수치(0.5%)는 극단적인 저변동성 구간에서 진입 지연이 생길 수 있어, 며칠 더 모니터링하며 조정할 예정이다.


demo_apps iOS 크래시 리포팅 — 서버 명세 철회로 ① 롤백, ② anonymous_id만 남김

증상: 서버가 보낸 작업 명세(docs/할일.md)에 두 가지 과제가 있었다 — ① 로그인 session.start 제거 + 앱런치 크래시 직전 리포팅, ② anonymous_id를 크래시 리포트에 포함. 나는 ①과 ②를 모두 구현했고, 커밋 직전이었다. 그런데 서버가 갑자기 “①은 철회한다. ②만 적용하라”고 정정했다.

첫 가설: “애초에 명세에 있었는데 왜 철회하지?” 싶었다. 내가 구현한 ①은 OmnitraceMobile.swift에서 session.start emit을 제거하고, 크래시 직전에 앱 상태를 flush하는 로직이었다. 그런데 서버 측에서 “로그인 세션 회전 로직이 망가질 위험이 있다”고 지적했고, 내가 “맹목 적용 금지” 원칙을 들어 재검토를 요청했던 게 계기였다.

원인 추적 — 서버가 인정한 설계 결함: 서버의 정정 문서(§4)를 읽고 나서야 이해했다. “session.start를 제거하면 기존 로그인 세션의 회전 주기가 깨져서, 앱이 재실행되지 않는 한 세션이 갱신되지 않는다.” 즉, 크래시 직전 flush는 의미가 있지만, session.start 제거는 부작용이 너무 컸다. 서버도 이걸 인지하고 ①을 완전히 철회했다. 구현한 코드를 git revert로 되돌리고 ②만 남겼다.

해결 — ② anonymous_id + C11 크래시 리포팅 only: 최종 커밋은 세 파일만 변경됐다.

  • iOSCrashAdapter.swift: 크래시 리포트 user 딕셔너리에 anonymous_id 추가 (키체인에서 읽어옴).
  • ReplaySnapshotCapturer.swift: C11 구현 — 크래시 직전 UI 스냅샷을 캡처해 디스크에 저장.
  • Keychain.swift: anonymous_id 생성/조회 함수 이미 존재, 크래시 어댑터에서 호출하도록 export.

검증: BUILD SUCCEEDED. CrashTriggerTile(HomeView에 fatalError 크래시 트리거)을 눌러 크래시를 발생시킨 후, 시뮬레이터 컨테이너에서 KSCrash 리포트 파일을 직접 읽었다. report["user"]["anonymous_id"] 필드에 UUID가 정상적으로 기록됐고, timestamp도 크래시 시점과 일치했다. 스냅샷 파일(crash_*.png)도 디스크에 남아 있었다. 앱 재실행 후 flush가 아니라 — 죽어가는 프로세스에서 직접 저장한 게 증명됐다.


auto_market_content — OpenRouter 모델 리스트, 34/14/9는 허구였다

증상: 프론트엔드 models.ts에 OpenRouter의 전체 이미지 생성 모델(34개), 비디오 모델(14개), 보이스 모델(9개)을 드롭다운으로 채워야 했다. 그런데 실제로 OpenRouter API(/v1/models)를 호출해보니 이미지 8개, 오디오 4개, 비디오 0개만 반환됐다.

첫 가설: “API가 generation 모델을 모두 반환하지 않는 건가? 문서에 34개라고 나와 있으니 내 호출이 잘못됐을 거야.” 그래서 role=generation 파라미터를 추가해 재시도하고, 페이지네이션도 확인했다. 결과는 똑같았다.

원인 추적 — OpenRouter의 media surface 분리 구조: 코드를 파고들다 보니 OpenRouter는 chat completion 모델과 image/video generation 모델을 서로 다른 API surface에서 제공한다는 걸 알았다. GET /v1/models는 chat completion 전용이다. Seedance, Veo, Kling 같은 비디오 모델은 별도 media/ 엔드포인트로만 접근 가능하고, 프론트엔드 models.ts에 하드코딩된 비디오 모델들은 실제로는 @ai-sdk/openai 같은 라이브러리에서만 사용되는 식별자일 뿐, OpenRouter API로 조회 가능한 목록이 아니었다. 보이스 9개도 마찬가지 — VoicePanel에 있는 VOICE_OPTIONS는 gpt-audio의 음색 프리셋이지 별도 모델이 아니었다. 즉, 34/14/9라는 숫자는 fabrication이었다.

해결 — 검증된 ID만 리스트에 포함: fabrication을 피하기 위해 직접 확인한 8개 이미지 모델(dall-e-3, flux-pro, stable-diffusion-xl 등)과 4개 오디오 모델(gpt-4o-audio-preview 등)만 IMAGE_MODELS, AUDIO_MODELS 배열에 넣었다. 비디오 모델은 API로 확인 가능한 게 없어 일단 빈 배열로 두고, 추후 OpenRouter가 media surface API를 통합할 때 업데이트하기로 했다. VoicePanel의 드롭다운은 이미 VOICE_OPTIONS 9개로 잘 동작 중이었고 — 이건 모델 선택이 아니라 음색 선택이므로 별도 수정이 필요 없었다.

검증: npx tsc -b와 npx vite build 모두 통과. 드롭다운에 8개 이미지 모델이 표시되고, 오디오 모델 4개도 정상 출력됐다. 빌드 청크 경고는 기존 이슈로 무관.


오늘의 한줄

가설은 검증 전까지는 가설일 뿐이다 — API를 직접 찌르고, 로그를 직접 읽고, 서버의 철회를 수용하는 데 주저하지 말 것.