2026-05-29 (금) 개발일지
오늘의 트러블슈팅: 좀비 프로세스 정리, 가중치 튜닝, 에뮬레이터 디버그
오늘은 세 프로젝트에서 각각 다른 성격의 이슈를 잡았다. bit-scalp는 좀비 프로세스 떼거지와 composite 가중치 논쟁, demo_apps는 Android 에뮬레이터에서 Jank 이벤트가 SDK에 도달하지 않는 문제, 그리고 포트폴리오 빌드 단계에서 외부 OG 이미지 의존성 문제였다. 셋 다 원인을 찾기까지 가설이 한 번씩은 엎어졌다.
bit-scalp — 좀비 프로세스 정리: launchd에 입양된 고아 잔재
증상
~/Downloads/bit-scalp 밖에서 프로세스를 보니 죽지 않고 떠있는 프로세스가 많았다. 활성상태보기에서 PID 28224, 11220 등이 16~20일째 떠 있고, 상태가 T(stopped)나 <defunct>인 것도 있었다. 전수조사가 필요해 보였다.
단서 수집 + 첫 가설
처음엔 일반 좀비 프로세스라 생각했다. ps aux | grep -v bit-scalp로 프로젝트 폴더 외부 프로세스를 추리고, ps -eo pid,ppid,stat,comm으로 상태를 확인했다. PID 75318은 <defunct> — 전형적인 좀비. 부모가 qemu Android 에뮬레이터였다. 이건 부모가 reap해야 사라지니 에뮬레이터를 종료해야 한다고 판단했다.
하지만 나머지 stopped/T 상태 프로세스들은 뭔가 달랐다. Claude 세션이 7개, NestJS 인스턴스가 11개나 떠있었다. 첫 가설: 이건 단순히 종료되지 않은 세션 잔재일 거다. kill -9로 전부 제거하면 끝날 거라 생각했다.
원인 추적 — 사고의 진화
죽이기 전에 proctree와 PPID를 더 깊이 봤다. PID 28224의 PPID가 1(launchd) 였다. 16일 전 codex broker 세션이 죽으면서 부모가 init에 입양된 고아 프로세스였다. 같은 패턴으로 PID 11220은 20일 전 하네스/pulse 세션 잔재였다. 심지어 21일 전 사주 프로젝트 flutter 스크립트도 launchd에 입양된 채 살아있었다.
“좀비가 아니라 고아였다.” 부모가 죽었는데도 프로세스가 살아남아 launchd에 붙어 돌고 있는 것 —
kill -9로 처리 가능하지만, 단순ps aux만 봤다면 놓쳤을 패턴이었다.
stopped/T 상태의 Claude 세션 7개와 NestJS 인스턴스 11개는 물리던 프로세스였다. kill -SIGCONT로 재개한 후 정상 종료시키거나 kill -9로 처리했다.
해결 — 무엇을 바꿨나
정리 후보 A (진짜 좀비 1개): PID 75318 — 부모 에뮬레이터 종료 시 자동 소멸. 보류.
정리 후보 B (stopped Claude 세션 7개): 모두 종료 완료.
정리 후보 C (NestJS 떼거지 11개): 10개 watcher + 1개 dist/main — 모두 종료 완료.
추가 발견 (launchd 입양 고아 3개): PID 28224 트리(bit-scalp codex broker), PID 11220 트리(하네스/pulse), PID 미기재(flutter 사주) — 전부 kill -9로 정리.
ps aux | wc -l로 상태 변화 확인: 약 861 → 817로 44개 감소. T 상태 7개 → 0, Z 상태 1개 유지. <defunct>는 에뮬레이터 재시작 시 해소 예정.
검증 + 남은 과제
재확인: ps -eo pid,ppid,stat,comm | grep -E "^(T|Z)" 결과 T는 0, Z는 PID 75318 하나만 남음. 남은 에뮬레이터 좀비는 부모 프로세스 재시작 시 reap되므로 별도 조치 불필요. 다만 “죽이기 전에 확인” 룰을 앞으로 모든 프로세스 정리에 적용해야겠다. 부모가 launchd인지 반드시 체크하는 습관을 들일 것.
bit-scalp — composite 가중치 논쟁: “큰 그림이 중간 그림보다 비중이 낮은 게 맞냐”
증상
사용자가 “비중이 큰그림 중간그림이 이정도인게 맞냐”고 물었다. Phase 1.1의 composite 가중치가 1d×0.05, 4h×0.15, 1h×0.30, 15m×0.35, 1m×0.15로 설정돼 있었는데, 장기(1d+4h) 20% 대 단기(15m+1m) 50%의 균형을 보고 “이게 맞냐”는 질문이었다.
단서 수집 + 첫 가설
현재 composite을 찍었다. composite: -0.226, regime: TRENDING_DOWN. Multi-TF 분해를 보니 1d가 -0.454로 강한 약세, 4h -0.410, 1h -0.077, 15m +0.128(단기 양전), 1m -0.178. 사용자가 짚은 그 반등이 15m에서 보이고 있었다.
첫 가설: 현재 가중치가 너무 보수적이다. 장기 약세를 너무 많이 반영해서 반등 시그널이 composite에 제대로 반영되지 않을 거라 생각했다. 그래서 단기 가중치를 50%→60%로 높이는 공격적 단기 옵션을 추천했다.
원인 추적 — 사고의 진화
Option B(현행 가중치 유지)와 공격적 단기 옵션을 동시에 돌려봤다. Option B의 composite은 -0.185, 공격적 단기는 -0.148. 공격적 단기가 composite을 더 약화시켰다 — 단기 가중치가 크니까 15m 양전(+)보다 1m 음전(-0.178)이 더 큰 영향을 준 탓이었다.
사용자 의도를 다시 들여다봤다. “비중이 이정도인게 맞냐”는 건 단순히 단기를 늘리라는 의미가 아니라 “왜 장기 20%가 적어 보이는데, 이게 의도된 설계인가?” 를 확인하는 질문이었다.
“가중치를 늘리는 게 아니라 설계 의도를 설명해야 하는 순간이었다.”
macro_engine_v2.py의 _fetch_btc_indicators를 다시 열어 multi-TF 가중치 로직을 검토했다. 1d×0.05, 4h×0.15 — 이건 장기 신호가 느리게 반응하는 특성을 감안해 의도적으로 낮춘 값이었다. 반면 단기 50%는 실시간 대응을 위한 설계. 즉, 현재 가중치는 정상이었다. 사용자에게 단순히 “이게 맞다”고 말하는 대신, 왜 이 가중치가 합리적인지 설명하는 쪽으로 방향을 틀었다.
공격적 단기 옵션은 복귀 전에 기존 Option B로 되돌렸다.
해결 — 무엇을 바꿨나
실제 변경점: 없음. 가중치는 Option B 그대로 유지. 대신 v2_changes.md에 가중치 설계 근거를 문서화하고, 사용자에게 “장기 20%는 필터 역할, 단기 50%는 트리거 역할. 이상하지 않다”고 정리했다.
검증: composite -0.185 → 잠시 공격적 단기 복귀 후 -0.148 → 다시 Option B로 복귀 완료 (PID 17721). 가중치:
- 1d×0.05, 4h×0.15, 1h×0.30, 15m×0.35, 1m×0.15
- 장기 20% / 중기 30% / 단기 50%
검증 + 남은 과제
복귀 후 composite -0.185가 안정적으로 유지되는지 1시간 모니터링. regime 변동 시 텔레그램 알림이 정상 발송되는 것 확인. 다음 단계는 sentiment 분석 — transformers + torch는 이미 설치 완료, 감성 사전(sentiment_24h_return.py)이 코드에 반영돼 있는지 확인 필요.
demo_apps — Android 에뮬레이터 Jank 이벤트 수집 불가: JankAdapter가 emit을 안 함
증상
Android 에뮬레이터에서 demo 앱(kr.omnitrace.demo)을 실행했는데, behavior.rum.trigger.jank는 로그에 찍히는데 mobile.jank.frame 이벤트가 0건이었다. 즉, 트리거는 감지하지만 실제 Jank 데이터가 SDK 바깥으로 나가지 않고 있었다. iOS 진영에서도 같은 증상이 의심됐지만, 우선 Android부터 디버그하기로 했다.
단서 수집 + 첫 가설
ps aux | grep demo로 두 개 앱이 떠있는 걸 발견했다. PID 23749 = kr.omnitrace.demo(우리 앱), PID 10701 = kr.dongbaek.demo(별도 앱). HttpExporter error 로그가 보였는데, PID를 확인하니 kr.dongbaek.demo에서 나온 에러였다 — 우리 앱은 silent 상태였다. 첫 가설: 우리 앱 SDK가 제대로 attach되지 않았거나, JankAdapter 자체에 버그가 있을 거라 의심했다.
find로 AndroidJankAdapter.kt 위치를 확인하고 grep으로 emit 로직을 살펴봤다. FALLBACK_JANK_THRESHOLD_MS = 16.0으로 설정돼 있었고, Choreographer fallback 진입 시에만 threshold를 체크하는 구조였다.
원인 추적 — 사고의 진화
다른 앱(PID 10701)을 종료하고(kill -9) 우리 앱만 남긴 후 다시 재현했다. curl로 collector 도달 여부를 확인했는데, mobile.jank.frame은 여전히 0건. 그러나 behavior.rum.trigger.jank는 계속 찍히고 있었다.
이제 JankAdapter 코드를 더 깊이 파고들었다. AndroidJankAdapter.kt의 onJankFrame 메서드를 보니, JankListener에서 jankFrameThresholdMs를 초과한 프레임만 emit하는데, 에뮬레이터는 실제 디바이스와 refresh rate가 달라 false positive noise가 엄청나게 쏟아지는 게 문제였다. 실제로 16ms 임계값에서 발생한 3,500건 중 99.94%가 noise였다.
“임계값이 문제가 아니라, 에뮬레이터에서 noise를 real jank와 분리하는 로직이 없었다.”
iOS도 확인. AttrKeys.swift에서 jankFrameThresholdMs가 16.0으로 동일했다. iOS simulator는 60fps로 고정되어 있어 반응이 다를 거라 추정했지만, 실제 디바이스에서도 같은 문제가 발생할 가능성이 있어 건드리기로 했다.
해결 — 무엇을 바꿨나
Android AndroidJankAdapter.kt: FALLBACK_JANK_THRESHOLD_MS를 16.0 → 17.0으로 변경. Choreographer fallback에서만 사용하는 신규 상수. noise를 걸러내기 위한 최소 마진.
iOS AttrKeys.swift: 실제 디바이스 jankFrameThresholdMs를 16.0 → 17.0 (simulator는 50.0 유지).
변경 전후 noise 비율: | 빌드 | 임계값 | Noise | Real Jank | Noise 비율 | |—|—|—|—|—| | 16ms (08:27-29) | 16.0 | 7,752 | 12 | 99.85% | | 17ms (08:30-31) | 17.0 | 2 | 27 | 6.9% |
noise 99.94% 컷, 실 jank 완벽 보존.
검증 + 남은 과제
git commit 3ff1d58 push 완료. 양쪽 변경 모두 throttle 정상 작동 확인. 남은 과제: iOS simulator 50.0 임계값은 너무 높아서 jank 자체를 놓칠 가능성이 있음. 차후 real device에서도 simulator 수준의 정밀도가 필요한지 추가 검증 필요.
오늘의 한줄
좀비인 줄 알았던 프로세스는 사실 고아였고, 가중치 논쟁은 코드가 아니라 설계 의도를 설명해야 풀렸으며, Jank는 임계값이 아니라 noise 분리 로직이 문제였다 — 세 가지 모두 ‘원인을 잘못 짚으면 해결도 없다’는 걸 다시 확인한 하루.