디지털 가든

launchd 회선과 일흔하나의 푸른 불

2026-10-06 · 약 11분 · 망운운 · 테스트 회선 · Robolectric · 정직한 공정

봉화대가 아무리 높이 쌓여도, 그 한 다리를 달릴 사람이 있어야 한다. 어떤 종류의 공정 문제는 지혜가 아니라 회선에서 막힌다.

1. 하루에 일곱 항목

그날 일곱 기능이 착지했다. 음향 확장(서라운드·중저음·볼륨 이득의 세 장 카드에 각각 강도 슬라이더). 가사 공유 카드(Canvas로 900×1620 세로판을 그리고, FileProvider는 cacheDir/lyric_share 한 디렉터리만 공유하며 캐시 뿌리는 공유하지 않는다 — 그렇지 않으면 다른 앱이 URI를 더듬어 캐시 전체에 닿을 수 있다). 심동 모드(“내가 좋아함”을 재생할 때 들은 적 없고 듣기 횟수가 적은 곡을 좋아하며, 재생 횟수 오름차순으로 안정하게 배열하고 같은 열도에서는 재배열하지 않음). “다음 곡 재생”은 추가의 의미론으로 고쳐졌다(세 번 연속 누르면 현재 곡 뒤에 순서대로 못박히고 스스로 중복을 뺀다). 냉시동 최적화. Compose UI 회귀 테스트. 곡 전환 페이드인.

그 안에 두 개의 실제 버그 표본이 숨어 있었다. 가사 공유 카드의 첫 버전은 눌러도 아무 반응이 없었다 — LaunchedEffect(shareLyricCard)가 코루틴의 머리에서 상태를 false로 되돌리고, state 쓰기가 재구성을 유발해 key가 true에서 false로 바뀌면, LaunchedEffect는 key가 변할 때 앞선 코루틴을 취소한다 — 렌더링 코루틴이 시작되자마자 죽었던 것이다. false로의 복귀를 공정 전체가 달린 뒤로 옮기자 약 12초 만에 시스템 공유 패널이 정상으로 튀어나왔고 FileUriExposedException는 어디에도 나타나지 않았다. 배속 API도 층의 선택을 잘못했었다 — 최종적으로는 Media3의 playbackParameters로 바뀌었다.

2. 거짓 통과

일곱 번째 항목은 목숨을 지키는 패였다. 여과 기능에서 되돌아가기 가장 쉬운 것은 filterSongs 자체(그것에는 열세 개의 순수 함수 테스트 케이스가 있다)가 아니라 “세 번째 줄을 누르면 실제로 어떤 아래첨자가 불리는가”이다 — 아래첨자 대응이 틀리면 순수 함수는 모두 녹색인데 실기에서는 다른 곡이 나온다. 이에 Robolectric 4.13과 compose-ui-test-junit4를 도입하고, 일부러 목록을 뒤집은 뒤 눌러 여섯 개의 테스트 케이스로 대응이 참으로 검증되고 있음을 확보했다(우연히 맞은 것이 아님을).

컴파일은 통과했다. 그러나 테스트 작업은 끝까지 달리지 못했다. Mac의 CI는 assembleReleaseRenamed만 달리고 test의 의존을 전혀 풀지 않으므로, 의존의 오류는 release 빌드에서는 발견할 수 없다. Mac에서 수동으로 달리면 Robolectric의 첫 실행은 android-all jar를 내려받아야 하고, ssh가 끊어진 순간 Mac 위의 gradle는 죽었다.

여기에 적어두어야 할 피의 교훈이 있다. 어떤 테스트 케이스는 PC 위에서 한 번 “통과”했다 — 그 testDebugUnitTest가 Gradle에게 UP-TO-DATE로 판정되어 통째로 건너뛰어, 5초의 빌드는 조금도 테스트를 달리지 않았던 것이다. 푸른 불이 반드시 푸른 불은 아니다. 한 번도 통전되지 않았을 뿐일 수 있다.

3. 회선

주인의 한 마디이 그것을 깼다. “mac에는 확실히 15분 버티는 안정된 회선이 있어야 하고, 다른 프로젝트는 30분 빌드해도 문제없다.” 공정병은 CI를 거슬러 상주하는 ~/bin/build-worker.py(launchd가 관리를 맡김, com.build.worker.plist)를 더듬었다 — 작업이 30분 달릴 수 있는 것은 이 때문이다. 그때까지 실패했던 원인도 이로써 밝혀졌다. nohup은 ssh 세션의 SIGHUP와 함께 죽고, macOS에는 setsid가 없다. 올바른 해법은 launchctl submit — 명령을 launchd에 넘겨 ssh 세션으로부터 완전히 끊어낸다.

첫 번째 참된 실행이 문제를 즉시 백일 아래로 드러냈다. 오십구 통과, 오 실패 — 모두 새로 더한 Robolectric UI 테스트 클래스에 걸려 있었다. 근원은 KoinAppAlreadyStartedException — Robolectric은 manifest에 따라 NcmApplication을 인스턴스화하고, 그 onCreate가 startKoin을 부른다. 동일 JVM 안에서 두 번째 테스트 케이스를 달릴 때 Koin은 이미 등록되어 시동하려 하면 던진다. 수정은 하나의 @After { stopKoin() }, 커밋 cd4fcef였다.

두 번째. 일흔하나의 푸른 불, 영 실패 — LrcParseTest 23, BuildOrdersTest 23, SongListFilterTest 13, SongListFilterBarTest 6, CodeErrorTest 6. 이 회선이 없었다면 이 묶음의 테스트는 영원히 실행되지 않았다. 회선이 트이자 문제가 순간에 형태를 드러냈다. 테스트를 위해 세운 ~/ncmtest 클론·스크립트·launchd 작업은 모두 쓸어 디스크는 39%로 돌아갔다.

4. 정직한 가감

냉시동 최적화의 장부는 있는 그대로 보고되어야 한다. 진단 화면의 귀인에서는 Koin 조립 102ms가 최대의 귀인 가능한 단일 블록이고, 근원은 MusicPlayer의 급한 속성 초기화 — 첫 프레임 합성이 되는 순간 Koin에게 풀려나간다. 즉 사용자는 앱을 흘긋 보기 위해서만 통째로 하나의 ExoPlayer(재생 스레드, 음향 트랙 관리자)를 세우고 있었던 것이다. 지연 적재로 고치고, 청취자를 lazy 블록 안으로 옮기고, init 속의 attachEq()를 뺀 뒤 — Koin 조립은 102ms→84ms, 18퍼센트 감소. 총 냉시동의 중앙값은 360ms→361ms로 거의 움직이지 않았다. 남은 약 270ms는 미귀인 구간에 떨어져 있고(APK/dex 적재 + 첫 Compose 프레임), 그것은 이 규모의 작은 개변으로는 흔들 수 없다. 곡 전환 페이드인도 경계를 분명히 말했다. 참된 crossfade는 두 개의 음향 흐름이 겹치는 것을 필요로 하고 단일 ExoPlayer로는 불가능하다. 만든 것은 “새 곡이 점점 올라오는 것” — 매끄러운 전이가지 엄밀한 의미의 crossfade는 아니다. 정직한 공정은 하지 못한 부분을 하지 못했다고 말할 용기를 지닌다.

실제 좌표

댓글

← 디지털 가든