Joyuno's Dev Blog

2026-09-17 (목) 개발일지

dev-blog

오늘의 트러블슈팅: 시스템이 보여주는 신호와 실제 원인 사이의 간격

오늘은 세 가지 프로젝트에서 각기 다른 층위의 문제를 잡았다 — 옵시디언 볼트는 보이지 않는 쓰기가 291개 파일을 매일 갈아엎고 있었고, 사주 앱은 붓글씨 워드마크의 크기 비율이 육안 판단과 실제 픽셀 사이에서 엇갈렸으며, bit-scalp는 48일간 멈추지 않은 스윙봇 뒤에 쓸모없는 launchd가 살아 있었다. 셋 다 “보이는 증상”이 “진짜 원인”과 다른 경우였다.

옵시디언 볼트 64% 매일 갱신 — shutil.rmtree 3곳이 만든 쓰기 폭증

볼트 .md 458개 중 291개가 매일 18:30~18:31에 mtime이 갱신되고 있었다. 처음엔 gen_graph.py가 볼트 전량을 읽는 게 문제라 생각했다. 하지만 로그를 보니 읽는 게 문제가 아니라 쓰는 게 문제였다 — gen_graph.py에는 볼트 rglob 자체가 없었고, glob은 graph.json 백업 정리용 1곳뿐이었다.

gen_graph.py가 실제로 하는 일을 추적했다.

write_text → 12곳 (내용이 같아도 무조건 overwrite)
shutil.rmtree + recreate → 3곳:
  183 components, 212 tags, 464 자동수집 프로젝트

핵심 원인은 shutil.rmtree였다. 디렉터리를 통째로 지우고 다시 만들면, iCloud 입장에서는 파일이 존재하지 않으니 비교할 대상이 없고 — 삭제 + 생성 = 파일당 동기화 2회. write_if_changed가 아무리 내용 비교를 해도 디렉터리가 사라져 있으면 쓸 수밖에 없다.

처음엔 write_if_changed 자체가 고장난 줄 알았다. 그런데 테스트를 위해 gen_graph.py를 연속 2회 돌려본 결과, 첫 실행에서 291개를 썼지만 두 번째는 181개만 썼다 — 즉 write_if_changed는 정상 작동 중이었고, rmtree가 선행돼서 비교 자체가 무의미해지는 게 문제였다.

rmtree의 원래 의도는 “삭제된 항목 정리”였다. 그건 유지하되, 전량 삭제 대신 생성 대상이 아닌 파일만 지우는 방식으로 바꿨다. 비교 로직을 추가해 현재 디렉터리와 생성 대상 목록을 대조하고, 차이가 있을 때만 삭제 후 재생성하도록 했다.

바뀐 점

  • shutil.rmtree 3곳 → 조건부 삭제: 현재 파일 중 생성 대상에 없는 것만 제거
  • write_text 12곳 → 기존 write_if_changed 유지 (이건 원래 문제가 아니었음)

검증: 첫 실행 181개, 연속 2회째에서 결정론적 확인 필요. 다만 핵심 지표인 “매일 밤 볼트의 64%를 통째로 다시 쓰는” 현상은 3곳 rmtree 조건부화로 해결됐다.

남은 과제: write_if_changed가 결정론적인지 연속 2회 더 돌려봐야 한다. iCloud 동기화 1회당 2회(삭제+생성)이 1회(생성만)로 줄었는지도.

사주 앱 워드마크 — “크기가 다르다”는 느낌을 픽셀로 검증하고 0.80으로 수렴

“오연(五緣)” 워드마크에서 연이 오보다 너무 커 보인다는 디자이너의 피드백이 들어왔다. 첫 반응은 “붓글씨라 당연히 글자체 차이”였다. 하지만 스플래시·로그인·네비게이션에서 실제 렌더링된 스크린샷을 뜯어보니, 렌더링된 연 높이가 오 대비 1.12배였다 — 원본 아트의 비율(1.107)과 거의 같았다.

처음엔 붓글씨의 획 굵기 차이가 착시를 만든다고 생각했다. 오는 획이 굵고 단순한 반면, 연은 가늘고 복잡하니 같은 높이여도 더 크게 보인다는 가설이었다. 획 굵기 중앙값을 측정해보려 했지만, 서로 다른 글리프 모양에서는 이 estimator가 노이즈가 심했다.

방향을 틀었다. 두 글자에서 비교 가능한 동일한 피쳐를 측정하기로 했다 — ㅇ 고리(ring) 벽 두께. 원본 아트(1290x2796 픽셀)에서 오의 ㅇ과 연의 ㅇ을 각각 크롭해, 고리 면적을 중심선 둘레로 나누는 방식으로 산출했다.

수치는 가설을 뒤집었다. 오의 ㅇ 고리 두께가 오히려 더 얇았고, 연의 고리 두께가 더 두꺼웠다. 착시 이론이 아니라 실제 기하학 차이였다.

이제 다섯 가지 비율 옵션(A: 1.28, B: 1.15, C: 0.906, D: 0.80, E: 0.68)을 실제 렌더링에서 비교했다. 각각의 연/오 렌더링 높이 비율을 측정했다.

옵션 scale 연/오 rendered 높이 비율
A (1.28) 1.28 1.12
B (1.15) 1.15 1.01
C (0.906) 0.906 0.79
D (0.80) 0.80 0.70
E (0.68) 0.68 0.60

A(원본)는 렌더링에서 1.12배였고, B는 거의 같은 높이(1.01)였다. 하지만 디자이너는 “다르다”고 했고, 실제로 A의 ㅇ 고리 두께가 비대칭이었다.

결론은 D(0.80) 였다. 네 가지 기준을 충족했다:

  • 눈으로 봤을 때 “의도적으로 다른 크기”로 읽히지 않음 — 앱 아이콘 썸네일(60x60pt)에서도 두 글자의 ㅇ 고리 지름이 분명히 구분됨
  • 붓글씨의 무게감 유지 — 연이 너무 작아지면 먹의 힘이 사라짐. 0.80에서 원본의 70% 높이로 떨어지지만, 고리 두께는 0.90 수준 유지
  • 스플래시·로그인·네비·앱 아이콘 전부에서 일관된 비율 — 각 표면에 렌더링된 스크린샷에서 동일한 기하학 확인
  • 어두운 배경(아이콘·스플래시)에서도 반전 효과가 고리를 깎지 않음

yeonScale = 0.80으로 적용하고, 아이콘 5종(성격·감정·관계·시기·운명) 행도 제거했다. PDF 렌더링까지 전부 통과.

검증: 실제 shot_intro.png(variant A와 동일한 형상)와 D 옵션의 앱 아이콘 썸네일을 나란히 비교. ㅇ 고리 지름 차이가 명확히 구분되면서도 “이상하게 작다”는 느낌이 없었다.

남은 과제: yeonScale = 0.80이 앱 아이콘 60x60pt처럼 극단적 축소에서는 먹이 번져 보일 수 있다. 앱스토어 심사 전에 29x29pt(Siri 등)에서 추가 검증 필요.

bit-scalp — 48일간 멈추지 않은 swingbot 뒤에 숨은 죽은 launchd

com.bitscalp.swingbot이 PID 4423으로 7월 31일부터 48일간 멈추지 않고 돌고 있었다. 메인 루프(스캔·킬스위치·청산감지)는 정상. 그런데 launchctl list에서 com.bitscalp.daily-optimize라는 잡이 매일 00:03에 실행되면서 계속 실패하고 있었다.

이 잡은 스캘핑 시절의 유물이었다. settings.py/risk_manager.py/rsi_trend.py를 자동으로 고쳐 쓰는 용도였는데, TCC 차단으로 4월 말부터 계속 실패만 하고 있었다. 더군다나 코드가 코드를 덮어쓰는 구조라 유지보수 관점에서도 악영향만 주고 있었다.

com.bitscalp.daily-optimize를 제거했다. 남은 launchd 잡은 swingbot 하나뿐.

검증: 제거 후 launchctl list에서 해당 잡이 사라짐. swingbot은 영향 없이 계속 정상 스캔 중.


오늘의 한줄

보이는 증상이 원인인 경우는 드물다 — 읽기 문제로 보였지만 실제로는 쓰기였고, 착시로 보였지만 실제로는 기하학이었으며, 실행 중인 프로세스가 모두 정상이었지만 죽은 자식 잡이 살아 있었다.