리펙토링: Voice Agent 버퍼/F1-F2-F3/메모리 안정화 릴리즈
이 문서는 skmagic_ondeviceai_agent의 오디오 버퍼, F1/F2/F3 상태 전이, LLM/Cloud 호출 자원 정리, 메모리 retained buffer 대응, 실기기 배포와 검증 결과를 한 곳에 모은 릴리즈 기록이다.
요약
| 항목 | 결과 |
|---|---|
| 설치 경로 | /system/priv-app/MaumAi/MaumAi.apk |
| 패키지 | com.skmagic.ondeviceai.agent |
| 설치 버전 | versionCode=124, versionName=1.19.36 |
| 서명 | platform cert SHA-256 c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8 |
| 부팅 | sys.boot_completed=1, app PID 유지 |
| 기기 테스트 | connected Android test 6개 통과 |
| F1/F2/F3 hard test | realistic 300회, aggressive/mixed 200회, public receiver burst 100회 통과 |
| direct service action | start/reset/accept/stop 100회 통과 |
| crash/tombstone/audio abort | 확인된 signature 없음 |
변경 범위
| 파일 | 변경 내용 | 의도 |
|---|---|---|
app/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.kt |
llmDispatcher.close() 추가 |
LLM dispatcher thread pool 누수 방지 |
ForegroundService.kt |
GrowingFloatBuffer drain/clear 후 초기 capacity로 축소 |
긴 main recording 이후 큰 backing array를 계속 잡는 high-water retention 방지 |
ForegroundService.kt |
myService.dispose() 호출 |
수동 생성된 F-key 처리 객체의 timer/binding/listener 정리 |
app/src/main/java/com/skmagic/ondeviceai/agent/service/MyAccessibilityService.kt |
dispose() 추가, F2/F3/recordMinimum timer dispose, bound context unbind |
F1/F2/F3 timer scope와 service reference lifecycle 정리 |
app/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.kt |
NonCancellable.isActive 대신 current coroutine context의 isActive 사용 |
오디오 read loop cancel 조건 명확화 |
app/src/main/java/com/skmagic/ondeviceai/agent/service/repository/ChatRepository.kt |
Cloud API call list lock 추가, finally remove, clear 시 snapshot cancel | Cloud LLM 요청 추적 리스트 동시성/잔존 call 정리 |
app/src/main/cpp/main.cpp |
DsLog::UnInit()에서 GenieLog_free() 호출 |
log handle free 함수 오용 수정 |
app/src/main/java/com/skmagic/ondeviceai/agent/receiver/F123BroadcastReceiver.java |
API 33 이상/이하 getParcelableExtra 분기 |
F1/F2/F3 receiver 호환성 유지 |
app/src/androidTest/java/com/skmagic/ondeviceai/agent/ExampleInstrumentedTest.kt |
package assertion 보정 | 현재 앱 패키지명 기준 instrumentation test 통과 |
app/src/androidTest/java/com/skmagic/ondeviceai/agent/test/FunctionCallTest.kt |
<sk_4>(position=-1) 로컬 멀티턴 시작 instrumentation test 추가 |
첫 멀티턴 응답이 count를 올리고 대화 열린 상태를 유지하는지 회귀 방지 |
app/src/test/resources/*.json |
현재 dictionary asset 기준 positive/negative fixture 보정 | dictionary unit test가 실제 asset 결과를 검증하도록 정렬 |
app/src/test/java/com/skmagic/ondeviceai/agent/service/ForegroundServiceBufferTest.kt |
buffer 단위 테스트 추가 | pre-F2 ring/growing buffer 동작 회귀 방지 |
scripts/repro_audio_read_race.sh |
meminfo before/during/after 수집 추가, CANCEL_AFTER_F2 옵션 추가 |
F1/F2/F3 hard test, 화면 취소 버튼에 대응되는 F2-cancel path, 메모리 추이 evidence 동시 확보 |
라이프사이클 보강 의미
Android Service lifecycle은 크게 started service와 bound service 흐름으로 나뉜다. Android 공식 문서의 Services overview와 Bound services 기준으로, started service는 startService() 또는 startForegroundService() 이후 onStartCommand()를 통해 시작되고 명시적으로 멈출 때까지 실행될 수 있다. bound service는 bindService()로 연결된 component가 있는 동안 client-server 인터페이스로 동작하고, 모든 client가 unbind되면 destroy 대상이 된다. 한 service가 started와 bound 흐름을 동시에 가질 수도 있다.
이 앱은 두 흐름이 섞여 있다. ForegroundService는 F1/F2/F3와 LLM/TTS action을 onStartCommand()로 받는 started foreground service이고, MyAccessibilityService는 ForegroundService에 bind해서 F-key timer와 직접 method 호출 경로를 갖는다. 따라서 이번 lifecycle 보강의 핵심은 단순히 onDestroy()에 코드를 넣었다는 뜻이 아니라, started 경로와 bound 경로 양쪽의 retained reference를 끊는 것이다.
정리하면 다음과 같다.
| lifecycle 지점 | 기존 위험 | 보강 후 의미 |
|---|---|---|
ForegroundService.onStartCommand() |
F1/F2/F3 action 반복 중 buffer와 stop job이 이전 상태를 오래 잡을 수 있음 | F2 reset과 stop/cancel 경로를 hard test로 반복 검증 |
ForegroundService.onDestroy() |
LLM dispatcher, coroutine scope, Cloud call, helper service reference가 남을 수 있음 | dispatcher close, scope cancel, ChatRepository.clear(), myService.dispose()로 정리 |
MyAccessibilityService.bindService() |
수동 생성 helper가 bind context를 잃으면 unbind 타이밍이 불명확해질 수 있음 | boundContext를 저장해 dispose 시 정확히 unbind |
| F-key timers | F2/F3/recordMinimum timer가 다음 입력 cycle까지 살아남을 수 있음 | CancelableTimer.dispose()로 timer scope 자체를 종료 |
| F2-cancel | 사람이 화면의 취소 버튼을 눌러야만 cancel path 확인 가능 | CANCEL_AFTER_F2=1 stress 옵션으로 STOP_RECORDING, IS_CANCEL=true, STATE_IDLE 경로 자동화 |
F2-cancel 자동화는 실제 화면 취소 버튼을 누르는 경로와 같은 service contract를 사용한다. F2 후 시스템이 main recording 상태로 반응할 시간을 주기 위해 비교 테스트에서는 CANCEL_AFTER_F2_DELAY_MS=1000을 사용했다.
설치 절차
- 기존 기기 APK 백업:
adb -s 192.168.123.108:5555 pull /system/priv-app/MaumAi/MaumAi.apk artifacts/device_backup/MaumAi_before_*.apk
- release APK 빌드:
./gradlew app:assembleRelease
- unsigned APK를 platform key로 수동 서명:
/home/silogood/Android/Sdk/build-tools/36.1.0/zipalign -p 4 \
app/build/outputs/apk/release/app-release-unsigned.apk \
app/build/outputs/apk/release/MaumAi-platform-aligned-20260722_012325.apk
/home/silogood/Android/Sdk/build-tools/36.1.0/apksigner sign \
--ks platform.jks \
--ks-key-alias platform \
--out app/build/outputs/apk/release/MaumAi-platform-signed-20260722_012325.apk \
app/build/outputs/apk/release/MaumAi-platform-aligned-20260722_012325.apk
- 서명 검증:
Signer #1 certificate SHA-256 digest:
c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8
- 기기 push:
adb -s 192.168.123.108:5555 root
adb -s 192.168.123.108:5555 remount
adb -s 192.168.123.108:5555 push \
app/build/outputs/apk/release/MaumAi-platform-signed-20260722_012325.apk \
/system/priv-app/MaumAi/MaumAi.apk
adb -s 192.168.123.108:5555 shell sync
adb -s 192.168.123.108:5555 reboot
별도 chmod, chown, restorecon은 수행하지 않았다.
기기 설치 검증
/system/priv-app/MaumAi/MaumAi.apk
package:/system/priv-app/MaumAi/MaumAi.apk
versionCode=124 minSdk=30 targetSdk=33
versionName=1.19.36
lastUpdateTime=2026-07-22 01:23:49
pid=6583
sys.boot_completed=1
기기에서 다시 pull한 /system/priv-app/MaumAi/MaumAi.apk도 platform cert SHA-256과 일치했다.
자동 테스트 결과
| 명령 | 결과 |
|---|---|
./gradlew app:testDebugUnitTest --rerun-tasks |
성공 |
./gradlew app:testStageUnitTest --rerun-tasks |
성공 |
./gradlew app:lintDebug |
성공 |
./gradlew app:assembleDebug |
성공 |
./gradlew app:externalNativeBuildStage |
성공 |
./gradlew app:connectedDebugAndroidTest |
A1-13 기기에서 6개 테스트 성공 |
bash -n scripts/repro_audio_read_race.sh |
성공 |
최종 재확인으로 ./gradlew app:testDebugUnitTest --rerun-tasks app:testStageUnitTest --rerun-tasks app:lintDebug app:assembleDebug도 성공했다.
Hard Test 결과
| 테스트 | 반복 | PID | 결과 | evidence |
|---|---|---|---|---|
| realistic normal F1/F3 + random F2 | 300 | 6583 -> 6583 |
NO_CRASH_SIGNATURE |
artifacts/device_hardtest/20260722_013338 |
| realistic aggressive + mixed F2 edge | 200 | 6583 -> 6583 |
NO_CRASH_SIGNATURE |
artifacts/device_hardtest/20260722_015011 |
| direct service action start/reset/accept/stop | 100 | 6583 -> 6583 |
NO_CRASH_SIGNATURE |
artifacts/device_hardtest/20260722_020237 |
| public F-key receiver burst | 100 | 6583 -> 6583 |
NO_CRASH_SIGNATURE |
artifacts/device_hardtest/20260722_020541 |
| text-input 2턴 처리 | 2 | 6583 -> 6583 |
PID_STABLE |
artifacts/device_hardtest/text_multiturn_20260722_021006 |
각 hard test는 다음 항목을 수집했다.
tombstones_before.txttombstones_after.txtlogcat_audio_tail.txtmeminfo_before.txtmeminfo_after.txtmeminfo_samples.logresult.txt
releaseBuffer, mUnreleased, Fatal signal, SIGABRT, Error processing audio data signature는 수집 로그에서 발견되지 않았다.
메모리 관찰
| 테스트 | before PSS KB | first sample | last sample | max sample | after PSS KB | 해석 |
|---|---|---|---|---|---|---|
| realistic normal 300 | 342045 | 741655 | 935588 | 936152 | 934560 | 테스트 중 LLM/native model이 로딩되며 working set 상승 |
| aggressive mixed 200 | 935556 | 934386 | 964717 | 965349 | 964265 | edge 반복 중 crash 없이 완만한 working set 증가 |
| service action 100 | 1133613 | 1106041 | 1111576 | 1113360 | 1108924 | 반복 후 after가 before보다 낮아짐 |
| public F-key 100 | 1105455 | 1105439 | 1113792 | 1114020 | 1114823 | receiver burst 후 약 9MB 증가, crash signature 없음 |
am send-trim-memory com.skmagic.ondeviceai.agent RUNNING_CRITICAL 이후에도 PID는 유지되었고 MEMORY_LOW action 로그가 확인되었다. Java heap은 trim 이후 낮아졌다.
192.168.10.5 기존/신규 APK 비교
2026-07-22에 새 보드 192.168.10.5:5555에서 기존 설치본과 신규 리팩토링 적용본을 같은 stress 계열로 비교했다.
| 구분 | APK | 설치 경로 | 버전 | PID | 비고 |
|---|---|---|---|---|---|
| baseline | 기존 설치본 | /system/priv-app/MaumAi/MaumAi.apk |
1.19.37 |
3308, reinit 후 3889 |
테스트 전 이미 설치되어 있던 APK |
| after | 신규 platform-signed APK | /system/priv-app/MaumAi/MaumAi.apk |
1.19.36 |
3320 |
DB 삭제/재부팅 후 initialize 확인, lastUpdateTime=2026-07-22 09:51:51 |
Stress 결과 비교
| 테스트 | baseline evidence | baseline PSS KB | after evidence | after PSS KB | 판정 |
|---|---|---|---|---|---|
| realistic normal 300 | artifacts/device_compare_192_168_10_5/baseline/20260722_091505 |
993566 -> 1041460 |
artifacts/device_compare_192_168_10_5/after/20260722_100138 |
848822 -> 828719 |
둘 다 PID 유지, after는 감소 |
| aggressive mixed 200 | baseline/20260722_093045 |
1034716 -> 1033425 |
after/20260722_101719 |
827149 -> 828286 |
둘 다 안정, after +1137 KB |
| direct service action 100 | baseline/20260722_094230 |
1033385 -> 1035553 |
after/20260722_102918 |
828007 -> 833267 |
둘 다 안정, after +5260 KB |
| public F-key receiver burst 100 | baseline/20260722_094426 |
1039929 -> 1037274 |
after/20260722_103125 |
828243 -> 831943 |
둘 다 안정, after +3700 KB |
| F2-cancel 80 | baseline/20260722_094828 |
263531 -> 788616 |
after/20260722_103332 |
828484 -> 831024 |
baseline은 reinit 직후 첫 native load 포함, after warm 상태 +2540 KB |
| F2-cancel 80 warm repeat | 없음 | - | after/20260722_103654 |
828638 -> 834191 |
cancel 반복 후 +5553 KB, 누적 폭증 없음 |
모든 baseline/after run은 before_pid == after_pid, result=NO_CRASH_SIGNATURE, tombstones_before.txt와 tombstones_after.txt 0 line이었다. Fatal signal, SIGABRT, SIGSEGV, ANR, OutOfMemory, releaseBuffer, mUnreleased, Error processing audio data signature도 수집 로그에서 발견되지 않았다.
F2-cancel 메모리 해석
초기 baseline 20260722_094828은 263531 KB -> 788616 KB로 크게 증가했다. 이 값은 cancel path만의 누수로 보기 어렵다. 같은 run의 category diff에서 Java heap은 67492 KB -> 40624 KB로 줄었고, Native Heap PSS가 121872 KB -> 708324 KB로 증가했다. 즉 force-stop/reinit 후 첫 음성 경로 진입에서 native/STT/TTS/LLM working set이 로딩된 영향이 크다.
신규 APK에서는 이미 warm 상태에서 F2-cancel 80회를 두 번 반복했다.
- 1회차
CANCEL_AFTER_F2_DELAY_MS=1000:828484 KB -> 831024 KB - 2회차
CANCEL_AFTER_F2_DELAY_MS=1000:828638 KB -> 834191 KB
로그에는 F2_CANCEL delay_ms=1000, ForegroundService - onStartCommand : action(STOP_RECORDING), stopRecording: recording canceled : true, Status set to STATE_IDLE가 확인되었다. 따라서 현재 evidence 기준으로는 F2-cancel 자체가 반복될수록 native heap을 계속 크게 누적시키는 패턴은 보이지 않는다.
멀티턴 영향 확인
F1/F2/F3 hard test와 direct service action은 기존 상태 전이 경로를 반복해서 통과했다.
F1 -> PRE_RECORDINGF2 -> resetRecordingBuffer() -> ACCEPT_RECORDING -> MAIN_RECORDINGF3/timeout -> STOP_RECORDING- public receiver path
android.intent.action.GLOBAL_BUTTON - direct service action path
START_RECORDING,RESET_RECORDING_BUFFER,ACCEPT_RECORDING,STOP_RECORDING
자동화에서 확인된 것은 상태 전이/버퍼/프로세스 안정성, 그리고 로컬 function-call 멀티턴 시작 조건이다. FunctionCallTest#testSK4_localMultiturnStartKeepsConversationOpen은 <sk_4>(position=-1)<sk_end> 처리 후 LlmStore.multiTurnCount == 1이고 LlmStore.isResetMultiturn == false임을 A1-13 instrumentation에서 확인한다.
실제 대화형 멀티턴에서 multiTurnBusy=true가 되는지는 Cloud 응답이 질문형으로 돌아오고 사용자가 후속 발화를 넣는 실음성 시나리오가 필요하다. 이번 자동화에서는 text-input 2턴 처리까지는 PID 안정성을 확인했지만, 입력이 dictionary fallback으로 종료되어 multiTurnBusy=false 로그가 남았다.
따라서 멀티턴 회귀 확인은 다음 수동 실음성 절차를 추가 gate로 둔다.
- 하이나무 호출 후 질문형 응답이 나오는 발화를 수행한다.
- 응답이 끝난 뒤 F1 없이 후속 발화를 넣는다.
- 로그에서
multiTurnBusy=true,Updated cloud conversation state, 후속MAIN_RECORDING진입을 확인한다. - 후속 발화 종료 후
resetState: multiTurnBusy=false로 정상 수렴하는지 확인한다.
결론
이번 리펙토링은 버퍼 소유권, retained memory, F1/F2/F3 timer lifecycle, Cloud API call 추적, native log handle 해제를 보강했다. 실기기 기준으로 system priv-app 배포, platform signing, 부팅, package load, instrumentation test 6개, F-key stress, direct service action, memory sampling까지 통과했다.
남은 리스크는 실제 음성 기반 Cloud 멀티턴의 의미 동작 검증이다. 상태 전이와 버퍼 안정성은 hard test에서 유지되었지만, 제품 수준의 멀티턴 응답 품질은 실음성 시나리오로 별도 확인해야 한다.