Joyuno's Dev Blog

2026-06-10 (수) 개발일지

dev-blog

오늘은 세 가지 프로젝트에서 발견한 서로 다른 성격의 버그와 최적화를 정리했다. 하나는 잘못된 측정 기준이 만든 보이지 않는 상한 오버슛, 하나는 K값이 원인이 아니라 regime 전환을 놓친 전략의 맹점, 하나는 API 오용이 아니라 SDK 수준의 잘못된 판단으로 인한 84% 비용 낭비였다. 세 가지 모두 “보이는 숫자에 속았다”는 공통점이 있었다.

iOS 세션 리플레이 — incremental payload 32KB 상한이 적용되지 않던 진짜 이유

처음 증상은 간단했다. iOS incremental snapshot 의 평균 페이로드가 72KB 로 찍혔고, 분명 serializeAndEmit 에 32KB 상한 체크가 있는데도 전혀 적용되지 않고 있었다. 증분 스냅샷은 diff 만 담는다는 전제로 크기 제한을 우회했지만, 실제로는 프레임마다 새로운 view 가 추가될 때마다 diff 조각들이 쌓여 상한을 넘는 경우가 빈번했다.

처음 가설은 “incremental 분기에서 상한 체크가 아예 누락됐다”였다. serializeAndEmit 의 32KB 초과 처리 부분을 보니 frameKind == "full" 만 체크하고 incremental 은 // 이미 diff 라 크기 작음 — 초과해도 fallback 그대로 사용 이란 주석과 함께 통과시키고 있었다. round-trip 시간을 줄이려는 의도였지만, diff 가 누적되면 72KB 까지 늘어난다는 걸 간과한 상태였다.

수정 자체는 쉬웠다. incremental 분기에도 동일한 32KB 초과 로직을 넣고, 초과 시 frameKind 를 "full" 로 fallback 하도록 serializeAndEmit 을 고쳤다. 그런데 재빌드 후에도 여전히 72KB 가 찍혔다. 상한 체크 로직이 진입하기 전에 이미 페이로드가 결정되고 있었다.

로그를 다시 파헤쳤다. gzipped.count > Self.maxPayloadBytes 가 false 로 나오는데 finalPayload.count 는 72KB 였다. 여기서 비로소 깨달았다 — maxPayloadBytes = 32768 은 gzip 바이트 기준인데, 실제로 onEmit 으로 전달되는 finalPayload 는 gzipped.base64EncodedString() 이었다. Base64 인코딩은 gzip 대비 약 33% 크다.

진짜 원인: 32KB 상한 체크가 gzipped.count (gzip bytes) 기준이었지만, onEmit 으로 전달되는 finalPayload 는 base64 문자열 — 측정 기준과 실제 전송 기준이 달랐다.

해결은 기준 통일이었다. gzipped.base64EncodedString().count 를 maxPayloadBytes 와 비교하도록 ReplaySnapshotCapturer.swift 1132행을 수정했다. full 분기와 incremental 분기 모두 base64 길이 기준으로 통일했다.

검증: base64 payload 상한 체크가 적용된 후 incremental 평균 72KB → 10.4KB 로 줄었다. 검증용 테스트도 추가했다 — base64 문자열이 32KB 초과 시 잘리는지 확인하는 testIncrementalPayloadUnder32KB 통과.

남은 과제: maxPayloadBytes 상수 자체를 gzip → base64 기준으로 리네이밍할지 검토 중. 상수명과 실제 체크 기준이 여전히 다르면 다른 개발자가 같은 실수를 반복할 수 있다.

bit-scalp — K값이 문제가 아니라 regime 전환을 놓친 전략의 패턴 인식 실패

bit-scalp 의 LONG 전략이 며칠째 시그널을 잡지 못했다. K값(상승 모멘텀 임계치)을 30→80 까지 올려봤지만 변화가 없었다. 처음엔 “K값이 너무 낮아서 약한 시그널이 모두 필터링되고, K를 올려도 이미 시장이 약해 시그널 자체가 생성되지 않는 건가” 라고 추정했다. K값을 낮춰 보기도 했지만 마찬가지였다.

매크로 지표를 봤다. 6-08 09:39 시점엔 +0.32 로 TRENDING_UP regime 이었고, 모든 LONG 이 허용됐다. 6-09 낮엔 -0.05 로 RANGING 전환, 여전히 모든 LONG 은 허용됐다. 문제는 6-10 00:01, 매크로가 -0.25 로 떨어지며 regime 이 RANGING → TRENDING_DOWN 으로 전환된 시점이었다. TRENDING_DOWN regime 에서 LONG 진입 정책이 threshold >= 80 으로 제한됐는데, 당시 시장이 하락 추세여서 K=80 을 넘는 시그널이 아예 발생하지 않았다.

가설 전환: K값 문제가 아니라, regime 이 약세로 바뀌면서 LONG 진입 정책이 K >= 80 조건으로 바뀌었고, K값을 아무리 튜닝해도 시그널 자체가 생성되지 않는 상태였다.

해결은 간단했다. regime 전환 조건을 재정의하는 게 아니라, 전략 로직이 regime 상태를 인지하지 못하고 있었던 게 문제였다. regime == TRENDING_DOWN 일 때는 LONG 시그널을 스킵하거나 대기하도록 명시적으로 분기 처리했다. K값 튜닝이 아니라 regime 의존성 패턴을 전략에 주입한 셈이다.

검증: regime 전환 후 생성된 SHORT 시그널 3건 (HYPEUSDT, BNBUSDT, PAXGUSDT) 에 대한 가상 진입 시뮬레이션에서 모두 +2~+4% 의 양호한 수익이 났고, LONG 시그널 차단이 정당했음을 확인했다.

남은 과제: regime 전환 감지 시점에 알림을 보내는 로직이 아직 없다. 전략이 침묵할 때 K값을 튜닝하는 게 아니라 regime 을 먼저 확인해야 한다는 패턴을 자동화해야 한다.

propermarket-marketing — 배경 생성에서 나노바나나 대신 mai-image 로 전환하며 발견한 84% 비용 낭비

propermarket-marketing 의 제품 배경 생성 파이프라인이 있었다. 기존엔 나노바나나 API 로 배경을 생성하고, 누끼 딴 제품을 합성했다. 그런데 나노바나나가 2K 해상도를 요청했지만 실제로 768×1360(나노바나나 2K의 절반)을 반환했고, 컵 위치가 항상 오른쪽 끝으로 치우쳐 있었다. “컵 위치가 너무 오른쪽에 있잖아” 라는 지적이 결정적이었다.

처음엔 나노바나나의 prompt 파라미터를 조정해 컵 위치를 중앙-오른쪽 안쪽으로 보내려고 했다. soft-beige 배경, 왼쪽 1/3 은 제품 자리 비움 같은 상세 지시를 넣었지만 나노바나나는 "2K" 요청을 무시하고 자체 출력 했고, 해상도도 절반이었다.

여기서 mai-image 모델을 대안으로 테스트했다. 같은 프롬프트를 --model mai-image-2.5 로 바꿔 실행했더니, 나노바나나와 거의 동등한 품질의 배경을 $0.048 (나노바나나의 84% 절약) 로 생성했다. 해상도는 동일하게 768×1360 이었지만, 최종 게재 시 업스케일만 거치면 되는 수준이었다.

진짜 원인: 나노바나나가 가성비 나쁜 공급자였을 뿐만 아니라, 컵 위치 제어가 불가능한 블랙박스였다. API 가격 자체보다도 “내 prompt가 무시된다”는 점이 더 치명적이었다.

해결은 generate_keyframe.py 에 모델별 분기를 정식 추가하는 것이었다. mai-image 는 modalities:["image"] 만 사용, Gemini 계열은 ["image","text"] 로 분기. 컵 위치는 더 구체적인 prompt 로 제어했다 — “컵이 중앙-오른쪽 안쪽에 위치, 왼쪽 여백 충분히 확보”. 이후 PIL 합성(무료) 으로 누끼 파우치를 왼쪽에 배치하고, 조명 매칭을 위해 warm 색보정을 추가했다.

검증: mai-image 배경 + 누끼 합성 파이프라인으로 생성한 최종 이미지가 나노바나나 배경과 거의 동등했고, 컵 위치도 예상대로 중앙-오른쪽 안쪽에 위치했다. 비용은 $0.05 (나노바나나 $0.30 대비 84% 절약).

남은 과제: pipeline.md에 모델별 분기와 비용 비교표를 정식 문서화했지만, 향후 모델 버전 업데이트 시 prompt 호환성 확인이 필요하다. mai-image 의 해상도 업스케일도 자동화 대상.

오늘의 한줄

보이는 수치가 전부가 아니다 — 측정 기준(단위·인코딩·regime·공급자)이 숨겨진 버그를 만든다.