Top Sleeping Importance 버그 회피하기
Doze는 안드로이드 6.0 마시멜로에서 새롭게 추가된 절전 모드입니다. 디바이스 스크린이 꺼져있고, 배터리를 사용 중이면서, 일정 시간 동안 움직이지 않는 경우, 디바이스는 Doze 모드에 진입하고, 앱들은 배터리 소모가 많은 기능 - 네트워크 연결, GPS 스캔 등을 활용할 수 없습니다.
그 악명에 비해 Doze 모드의 영향을 받는 앱이 아주 많지는 않습니다. 우선 디바이스가 한 동안 움직이지 않는 경우에만 Doze 모드에 들어감으로, 일상생활 중에는 Doze 모드에 진입하는 경우가 많지 않고, 백그라운드 상에서 위치 정보를 기반으로 서비스를 제공하는 앱들도 기존과 마찬가지로 동작할 수 있습니다.
데이터 백업 등의 이유로 틈틈이 네트워크 연결이 필요한 경우, JobScheduler, GCMNetworkManager 등을 이용해 백그라운드 작업을 등록해놓으면, Doze 모드 중간중간 IDLE_MAINTENANCE 윈도우 중에 해당 작업이 수행됩니다. 실시간으로 중요한 알림을 전달해야 하는 경우라면, AlarmManager 혹은 High Priority GCM 메시지를 활용할 수도 있습니다. 다만, 화면이 꺼져있고 배터리 사용 중이면서 오랜 시간 움직임이 없는 동안에도 끊임없이 네트워크 연결을 지속해야 하는 경우, 문제가 발생할 수 있습니다. 대표적인 예로 음악 스트리밍 서비스를 생각할 수 있겠네요.
사실 Doze상황에서도 끊임없이 음악 스트리밍 서비스를 제공하는 것은 어렵지 않아야 합니다. 안드로이드 개발자 문서에 정확히 명시되어 있지는 않지만, 앱이 Foreground Service로 동작하고 있는 동안 해당 앱은 Doze 중에도 네트워크를 사용할 수 있다고 알려져있습니다. 간단하군요. 스트리밍 서비스가 필요하다면 해당 서비스를 Foreground Service로 동작하도록 코드 한 줄을 추가하면 문제 해결! 짝짝짝. 하지만, 실제로 테스트해보면, 네트워크를 사용할 수 없는 것처럼 보입니다. 무엇이 문제일까요?
Foreground Service로 동작하는 앱을 Doze 예외 처리하는 부분에 Top Sleeping Importance 관련 알려진 버그가 있습니다. 개인적으로 꽤나 재미있는 내용이라고 생각하기에, 버그에 관해 좀 더 자세히 설명해보겠습니다. 안드로이드 프로세스는 프로세스 내부에서 동작하는 컴포넌트에 따라 다양한 단계의 중요도를 갖습니다. 그중 다음 세 가지 중요도 값이 이번 버그와 관련되어 있습니다. 참고로 중요도 값은 더 작을수록 더 중요한 프로세스라는 의미입니다.
IMPORTANCE_FOREGROUND: 현재 사용자가 사용 중인 (onResume 상태인) 액티비티를 갖고 있는 프로세스. 100의 중요도를 가짐.
IMPORTANCE_FOREGROUND_SERVICE: Foreground Service가 동작 중인 프로세스. 125의 중요도를 가짐.
IMPORTANCE_TOP_SLEEPING: 최상단에 위치하는 액티비티를 포함하고 있지만, 화면이 꺼져서 더 이상 해당 액티비티가 사용자에게 보이지 않는 경우. 150의 중요도를 가짐.
버그는 작은 실수에서 비롯되었습니다. 눈치 빠른 분들은 벌써 수상한 냄새를 맡으셨을지도 모르겠네요. 기본적으로 Doze 모드 중에도 FOREGROUND_SERVICE 이상의 중요도를 갖는 프로세스는 네트워크를 사용할 수 있습니다. 따라서 일반적인 경우라면 Foreground Service로 동작중인 앱은 네트워크를 사용할 수 있어야 합니다. 다만, Foreground Service로 동작 중인 앱이 동시에 Foreground Activity를 갖고 있는 경우 문제가 발생합니다.
어떤 프로세스가 Foreground Service와 Foreground Activity를 동시에 갖고 있는 경우, 전체 프로세스의 중요도는 더 높은 FOREGROUND가 될 것입니다. 이때, 화면이 꺼지면 어떻게 될까요? 의도한 대로 동작한다면, 액티비티가 갖게 되는 중요도는 TOP_SLEEPING으로 떨어지지만, 해당 프로세스는 그 보다 높은 Foreground Service를 갖고 있기 때문에, FOREGROUND_SERVICE 값을 가져야 합니다.
하지만, 현재 안드로이드 6.0 마시멜로 버전에서는 이부분에 버그가 있습니다. 화면이 꺼지는 순간 프로세스 전체의 중요도가 FOREGROUND_SERVICE가 아닌 TOP_SLEEPING로 변경됩니다. (안드로이드 N Developer Preview에서는 정상적으로 동작합니다.) 아마도, 대상 프로세스가 Foreground Service를 갖고 있는지 체크하는 부분이 빠져있는 것으로 예상됩니다. 따라서, Foreground Service를 사용하고 있음에도 여전히 네트워크 연결을 사용할 수 없게 됩니다. 이른바 Top Sleeing Importance 버그 혹은 Foreground vs Foreground 버그입니다.
그럼 음악 스트리밍 서비스처럼 Doze 상태에서도 지속적인 네트워크 연결이 필요한 경우 어떻게 해야 할까요? 구글 안드로이드 프레임워크 팀의 대모 Dianne Hackborn이 제안하는 해결책은 바로 Foreground Service를 별도의 프로세스로 분리해서 구현하는 것입니다. 안드로이드 매니페스트상에서 process 속성을 명시적으로 정의하여 서비스를 포함 특정 애플리케이션 구성요소를 별도의 프로세스 상에서 동작하도록 구현할 수 있습니다. 이 경우 Foreground Service가 동작하는 프로세스는 화면이 껴지거나 꺼지거나 관계없이 중요도가 유지되며, Doze 여부와 관계없이 계속 네트워크를 활용할 수 있게 됩니다.
Doze 대응과 별개로 음악 스트리밍 서비스처럼 점유하는 메모리의 크기가 크고, UI 요소와 관계없이 안정적으로 동작해야 하는 애플리케이션 컴포넌트가 있는 경우, 해당 컴포넌트를 별도의 프로세스로 분리하면 좋습니다. 별개의 HEAP 영역을 할당받게 되고, 반갑지 않은 OutOfMemoryException도 효과적으로 방지할 수 있습니다.
다만, 이 경우 기존 앱이 구현된 형태에 따라 추가적인 작업이 필요할 수 있습니다. 서비스와 액티비티가 서로 다른 프로세스 상에서 동작하는 만큼, 두 컴포넌트 간에 데이터를 주고받을 필요가 있는 경우, 정적 변수나 내부 인스턴스를 직접 접근하는 방법은 사용할 수 없습니다. 대신, 안드로이드 플랫폼이 제공하는 IPC(Inter Process Communication) 방법 중 하나를 선택해 활용해아 합니다. 안드로이드는 개발자가 필요에 따라 적절히 구분하여 사용할 수 있도록, Intent와 BroadcastReceiver 조합, Handler, AIDL 등 여러 가지 방법을 제공하고 있습니다. 이와 관련된 보다 자세한 내용은 다음 브런치 포스트 주제로 생각하고 있으니 기대해 주시기 바랍니다 : )
Foreground Service를 별도의 프로세스로 분리하는 것이 가장 권장되는 해결책이지만, 개발 리소스 등 여러 가지 이유로 이를 바로 적용하기 어려울 수 있습니다. 이런 경우, Top Sleeping Importance 버그를 회피하기 위해 활용할 수 있는 두 가지 꼼수(?)를 공유합니다. 두 가지 방법 모두 현재 Foreground Service가 동작하는 프로세스가 Foreground Activity를 갖지 않도록 만든다는 점에서 원리는 동일합니다.
첫 번째 방법은 Doze 진입 시 강제로 현재 Foreground Service가 동작하고 있는 애플리케이션을 백그라운드로 옮기는 것입니다. 해당 애플리케이션 프로세스의 중요도는 다시 FOREGROUND_SERVICE로 조정되고, 따라서 Doze 중에도 네트워크 연결이 가능합니다.
적용은 간단합니다. 안드로이드 6.0 마시멜로 버전에서는 디바이스의 Doze 상태 변화를 확인할 수 있는 ACTION_DEVICE_IDLE_MODE_CHANGED 브로드캐스트 인텐트가 추가되었습니다. 서비스 시작 시 해당 인텐트를 수신하는 리시버를 등록하고, onReceive 콜백 내에서 PowerManager#isDeviceIdleMode() 메서드를 이용해 Doze 여부를 확인한 후 다음과 같은 인텐트를 활용해 홈 런처 애플리케이션을 호출하고, 기존 애플리케이션이 백그라운드로 옮겨지도록 합니다.
Intent startMain = new Intent(Intent.ACTION_MAIN);
startMain.addCategory(Intent.CATEGORY_HOME);
startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(startMain);
첫 번째 방법의 경우 구현이 간단하지만, 사용자가 사용 중이던 애플리케이션이 임의로 백그라운드로 옮겨지는 문제가 있습니다. Doze 모드는 사용자가 꽤 오랫동안 디바이스를 사용하지 않는 경우에 한해 적용됨으로 앱을 백그라운드로 옮기다고 해도 일반적으로 큰 문제가 되지는 않습니다. 다만, 이런 문제를 피하고 싶다면 별도 프로세스에서 동작하는 Mask Activity를 활용하는 것이 한 가지 방법이 될 수 있습니다.
<activity android:name=".MaskActivity"
android:process="com.example.dozetester.empty"/>
적용은 복잡하지 않습니다. 안드로이드 매니페스트 상에 위와 같이 별도 프로세스에서 동작하는 빈 액티비티를 하나 선언합니다. 그리고 디바이스 Doze 상태 변화를 확인하는 리시버를 등록하고, 디바이스가 Doze 상태로 진입할 때, 위에 선언한 MaskActivity를 시작합니다. 화면이 꺼져있는 상태이기 때문에 사용자에게 보이지는 않지만, MaskActivity가 화면을 가리면, 해당 액티비티를 갖고 있는 별개의 프로세스(위 매니페스트와 같이 선언되어 있다면 com.example.dozetester.empty)가 TOP_SLEEPING 중요도를 갖게되고, 기존 Foreground Service를 갖고있던 프로세스는 Foreground Activity를 갖고 있지 않기 때문에, 중요도가 FOREGROUD_SERVICE로 유지됩니다. 문제없이 네트워크 접근을 할 수 있겠지요. 마지막으로 디바이스가 Doze 모드에서 벗어나는 이벤트를 수신하여 소리없이 MaskActivity를 종료시켜줍니다. 화면이 켜지자마자 디바이스는 Doze 모드에서 벗어나기 때문에, 사용자가 MaskActivity를 보는 일은 없습니다.
지금까지 Top Sleeping Importance와 관련된 버그에 관해 살펴보았습니다. 실제 적용에 도움이 될 수 있도록 간단한 샘플 애플리케이션을 다음 GitHub 저장소에 올려두었습니다. 한 번 참고해보시면 좋겠네요.