TL; DR: 안드로이드 앱을 구현할 때 경우에 따라 서비스를 별도의 프로세스로 구분하는 것이 유리할 수 있습니다. 프로세스를 분리하면 어떤 장점이 있는지, 그리고 이때 서비스와 액티비티 간 통신을 위해 어떤 방법을 사용할 수 있는지 정리해보았습니다.
언제 프로세스를 분리할까?
안드로이드는 여러 가지 의미로 유연한 플랫폼입니다. 앱 개발자는 액티비티, 서비스, 리시버, 콘텐츠 프로바이더 등 앱 구성요소를 다양하게 조합하여 앱을 구현할 수 있고, 매니페스트에 android:process 속성을 정의하여 특정 앱 구성요소가 별개의 프로세스에서 동작도록 정의할 수 있습니다.
세상 모든 일이 그렇듯이, 프로세스를 분리하는 것도 장단점이 공존합니다. 프로세스를 분리하면 더 많은 힙 메모리를 확보할 수 있고, 중요한 기능을 더 안정적으로 제공할 수 있습니다. 이전 포스트에서 이야기했던 마시멜로 버전에 존재하는 TOP_SLEEPING 중요도에 관련된 버그를 회피할 수도 있습니다. 하지만, 전체적으로 볼 때 앱이 사용하는 메모리가 조금 더 늘어나고, 무엇보다도 개발 과정에서 서로 다른 프로세스 간에 데이터를 주고받기 위해 추가적인 코딩이 필요하거나 앱 구조를 수정해야 할 수도 있습니다. 또한 하나의 프로세스로 구성된 앱에 비해 보안상의 문제가 발생할 위험성도 더 높아집니다.
여러분의 앱이 액티비티 생명주기와 관계없이 오랜 시간 백그라운드에서 동작하는 서비스를 포함한다면, 프로세서를 분리하는 것을 고려할 수 있습니다. 앱의 자체적인 안정성에도 도움이 되고, 플랫폼 입장에서도 좀 더 효율적으로 가용 메모리를 관리할 수 있습니다. 예를 들어, UI를 구성하는 액티비티와 백그라운드에서 동작하는 서비스가 모두 동일 프로세스에서 동작하는 경우 해당 프로세스는 항상 IMPORTANCE_SERVICE 중요도를 가지며 쉽게 종료되지 않습니다. 만일 서비스와 액티비티가 별개의 프로세스에서 동작한다면, 앱이 백그라운드에 위치할 때 해당 프로세스는 조금 덜 중요한 IMPORTANCE_BACKGROUND 중요도를 갖고, 안드로이드 시스템은 이 프로세스를 종료시키고 백그라운드에서 동작중인 서비스는 유지하면서도 추가적인 메모리를 확보할 수 있습니다.
서비스와 액티비티 간에 활용할 수 있는 IPC
서비스를 별도의 프로세스로 분리하기로 결정했다면, 이제 개발자가 할 일이 늘어납니다. 안드로이드에서 제공하는 IPC(Inter Process Communication) 메커니즘을 이용해 기존 비즈니스 로직을 수정해야 합니다. 안드로이드에서 IPC는 기본적으로 바인더(Binder)라는 고성능의 RPC(Remote Procedure Call) 메커니즘을 근간으로 구현되어있습니다. 다만, 뜯어볼수록 골치 아픈 바인더는 먼 미래의 블로그 주제로 미루어두고, 여기서는 실재 앱 개발에서 활용되는 보다 구체적인 방법들을 살펴보려고 합니다.
Parcel과 Parcelable
시작하기 전에 우선 짚고 넘어가야 할 부분이 있습니다. 프로세스를 분리하면, 두 프로세스는 각기 별도의 힙 공간을 부여받으며, 서로 메모리 공간을 공유하지 않습니다. 다시 말해 데이터를 주고받을 때 객체 인스턴스를 직접 주고받을 수 없고, 프로세스를 건너 객체 인스턴스를전달하기 위해서는 해당 인스턴스를 직렬화(Serialization) 하여 별도의 포맷으로 변경해야 합니다. 안드로이드는 프로세스를 띄어 넘어 전달될 수 있는 Parcel 객체라는 컨테이너 포맷을 제공하며, 모든 객체는 Parcelable 인터페이스를 구현하여 자신이 Parcel 형태로 변환될 수 있다는 것을 나타낼 수 있습니다.
프로세스간 객체를 전달할 때는 Parcel 형태로 해당 객체를 변환합니다. 다시 말해, 만일 여러분의 앱에서 백그라운드 서비스와 액티비티 간에 주고받아야 하는 객체가 있다면, 해당 클래스는 Parcelable 인터페이스를 구현하고 있어야 하며, 해당 객체를 Parcel 형태로 변환하고 Parcel 형태에서 다시 해당 객체를 생성할 수 있도록 직렬화/역 직렬화 방법이 정의되어 있어야 합니다. 이 과정이 익숙하지 않거나, 조금 번거롭게 느껴진다면, 해당 작업을 자동으로 수행해주는 훌륭한 안드로이드 스튜디오 플러그인이 있으니 꼭 한번 활용해보시기 바랍니다.
인텐트(Intent )
많은 안드로이드 앱 개발자분들에게 익숙한 클래스입니다. 인텐트는 액션, 카테고리 등등 정해진 형식의 데이터와 번들(Bundle) 형태로 포장된 임의의 데이터를 포함할 수 있습니다. 그리고 플랫폼에서 제공하는 API를 활용해 프로세스를 건너 다른 애플리케이션 구성요소로 전달될 수 있습니다. 특히, 서비스와 액티비티 간의 통신을 위해서는 인텐트와 startService 그리고 sendBroadcast 메서드를 조합하여 사용할 수 있습니다.
먼저, 액티비티에서 서비스로 데이터를 전달하는 것은 간단합니다. 액티비티가 startService 메서드를 호출할 때마다, 해당 서비스가 이미 동작하고 있는지 여부와 관계없이 서비스의 onStartCommand() 콜백이 호출됩니다. 역으로 서비스 측에서 작업이 완료되거나 아니면 주요한 변경 사항이 있을 경우에는 브로드캐스트 리시버(BroadcastReceiver)를 사용할 수 있습니다. 데이터를 수신해야 하는 액티비티에서 특정 브로드캐스트 인텐트에 반응하는 리시버를 등록해두면, 서비스 측에서 전달한 인텐트를 수신할 수 있습니다.
엑티비티는 startService를 통해 서비스는 sendBroadcast 메서드를 통해 인텐트를 주고받을 수 있습니다. 브로드캐스트 인텐트 활용 시 한 가지 주의점이 있습니다. 브로드캐스트 인텐트는 기본적으로 어떤 애플리케이션이든지 수신할 수 있습니다. 따라서 다른 앱이 해당 인텐트를 악용하거나 불필요하게 플랫폼 성능에 영향을 미칠 수 있습니다. 그러므로, 동일 애플리케이션 내에서만 활용되는 브로드캐스트 인텐트의 경우에는 Signature 수준의 권한을 생성하고, 해당 권한을 갖고 있는 경우에 한해 인텐트 수신과 송신이 가능하도록 제한해야 합니다. sendBroadcast 메서드와 registerReceiver 메서드 모두 브로드캐스트 인텐트 송수신을 위해 요구되는 권한을 명시적으로 전달할 수 있습니다.
그럼 어떤 경우에 인텐트를 통해 서비스와 액티비티 간 데이터 통신을 활용할 수 있을까요? 백그라운드 파일 다운로드처럼, 비교적 UI와의 연결고리가 약하고 비동기로 작업을 수행하는 서비스는 이 방법을 활용할 수 있습니다. 액티비티가 필요한 요청이 있을 때마다 startService를 통해 다운로드할 파일 정보를 전달하고, 다운로드가 진행될 때마다, 브로드캐스트 인텐트를 통해 액티비티에 다운로드 진척상황을 전달합니다. 특히, 인텐트를 수신해 작업을 처리하는데 특화된 인텐트 서비스(IntentService) 클래스를 활용하면 좀 더 쉽게 이러한 구조를 갖는 앱을 구현할 수 있습니다.
메신저(Messenger)
백그라운드 서비스와 액티비티가 매우 빈번하게 데이터를 주고받아야 하는 경우에 인텐트와 브로드캐스트 리시버를 활용하는 것은 너무 무겁게 느껴질 수 있습니다. 두 구성 요소 간 커뮤니케이션이 빈번하지만, 서로 주고받는 메시지의 종류가 복잡하지 않고 작업 요청과 결과 수신을 비동기식으로 처리할 수 있다면, 안드로이드 플랫폼이 제공하는 또 하나의 IPC 수단인 메신저 객체를 활용할 수 있습니다.
메신저는 안드로이드 UI 개발자 분들이라면 익숙한 핸들러(Handler)를 활용하여 구현되었습니다. 일반적으로 핸들러는 서로 다른 스레드 간에 메시지를 주고받을 때 사용됩니다. 거기서 한발 더 나아가 메신저 객체를 활용해 프로세스의 경계를 넘어 서로 다른 프로세스에 존재하는 핸들러로 메시지를 전달하거나 받을 수 있습니다. 메신저는 특정 핸들러 인스턴스 참조를 갖고 있으면서, 동시에 Parcelable 인터페이스를 구현하고 있고, RPC 형태로 호출될 수 있는 간단한 바인더 메서드를 포함하고 있습니다.
메신저 객체는 다른 프로세스에 속한 핸들러 인스턴스를 참조할 수 있습니다. 다시 말해, 특정 핸들러 인스턴스를 기반으로 메신저 객체를 생성한 후, 해당 객체를 다른 프로세스로 전달하고, 이후 메신저의 send() 메서드를 이용해 프로세스 너머에 있는 핸들러에 메시지를 전달할 수 있습니다. 좀 더 구체적인 구현 방법은 유서 깊은 API 데모에 포함된 다음 샘플 코드를 살펴보시기 바랍니다.
- MessengerService.java
- MessengerServiceActivity.java
메신저를 통해 전달할 수 있는 메시지 오브젝트는 몇 가지 정수형 변수와 추가로 번들(Bundle) 데이터를 포함할 수 있습니다. 또한, 수신 측이 응답 메시지를 보낼 때 활용할 수 있도록 replyTo 필드에 메시지 수신을 위한 메신저 인스턴스를 추가할 수도 있습니다. 이를 활용하여 비교적 손쉽게 서비스와 액티비티 양방향으로 커뮤니케이션이 가능한 IPC 채널을 생성할 수 있습니다.
메신저를 활용하는 방법은 브로드캐스트 리시버를 활용하지 않기 때문에 보안상의 이점을 갖습니다. 다만, 메신저의 생명주기에 주의를 기울일 필요가 있습니다. 메신저는 그 자체로 Parcelable 인터페이스를 구현하고 있기 때문에 인텐트 번들을 포함해 다양한 방식으로 상대 프로세스로 전달할 수 있습니다. 다만, 브로드캐스트 리시버와는 달리 안드로이드 플랫폼은 메신저의 생명주기에 신경 쓰지 않습니다. 다시 말해, 메시지를 전달할 때나, 메시지 수신을 기다릴 때는 반드시 해당 메신저가 참조하고 있는 핸들러가 살아있는지 여부를 확인해야 합니다.
여러 가지 골치 아픈 문제를 피하기 위해, 메신저의 생명주기를 서비스 커넥션(ServiceConnection)의 생명 주기와 일치시키면 편리합니다. 백그라운드 서비스는 bindService 요청을 받아 자신에게 메시지를 전달할 수 있는 메신저 인스턴스를 넘겨주고, onUnbind / onRebinde 콜백을 활용해 자신과 연결된 액티비티 단의 메신저를 더 이상 사용할 수 없다는 것을 알 수 있습니다. 보다 자세한 내용은 위에 첨부된 코드를 살펴보시기 바랍니다.
AIDL (Android Interface Definition Language)
백그라운드 서비스와 액티비티 간에 주고받아야 하는 메시지의 종류가 다양하고 특히 동기식으로 함수 호출의 결과를 바로 받아봐할 필요가 있는 경우, 메신저를 활용하는데 어려움이 있을 수 있습니다. 메시지 종류가 많아지면, 필연적으로 이를 처리하는 핸들러 구현도 복잡해지며, 메신저를 통해서는 일반 함수 호출과 같이 바로 특정 함수의 호출 결과를 확인할 수는 없습니다. 이런 경우, AIDL을 이용해 바인더 인터페이스를 정의하고, 해당 바인더를 전달받은 액티비티가 다른 프로세스에서 동작하고 있는 서비스에서 제공하는 API를 RPC 형식으로 바로 호출할 수 있도록 구현할 수 있습니다.
다양한 종류의 함수와 동기식/비동기 호출 방법을 모두 제공하는 안드로이드 시스템 서비스들이 이러한 방식으로 구현되어 있습니다. 서비스가 제공해야 하는 API 명세를 AIDL 스펙에 맞게 정의하면, 안드로이드 개발 도구를 활용해 이를 바인더 인터페이스와 Stub 추상 클래스로 변환할 수 있습니다. 이후, Stub 클래스를 구현하고 서비스 bind/unbind 주기에 맞춰 해당 바인더를 연결된 액티비티에 넘겨주면, 액티비티는 마치 안드로이드 시스템 서비스를 사용하는 것처럼, 자유롭게 프로세스를 건너 AIDL에 정의된 함수를 호출할 수 있습니다.
AIDL을 사용하는 데는 어느 정도의 코드 작업이 필요하긴 하지만, 안드로이드 스튜디오에서는 비교적 수월하게 이를 구현할 수 있습니다. 'File > New > AIDL' 메뉴를 통해 새로운 AIDL 파일을 추가하면 자동으로 필요한 폴더와 참고할 수 있는 코드 조각이 포함된 AIDL 파일이 생성됩니다. 필요한 인터페이스를 정의한 후 'compileDebugAidl' Gradle Task를 수행하면 자동으로 Stub 객체가 생성됩니다. 백그라운드 서비스에서 해당 Stub 객체의 실재 로직을 구현하고, onBind 콜백에서 이를 넘겨줍니다. AIDL을 사용하는 방법에 관해서는 꽤나 자세한 가이드가 제공되고 있고 유서 깊은 API Demo 샘플에도 관련 예제가 첨부되어 있습니다. 한 번 살펴보시면 좋을 것 같습니다.
그 외 영구적으로 저장되는 데이터들
만일 백그라운드 서비스와 액티비티 간에 주고받아야 하는 데이터의 크기가 매우 크거나 (바인더로 주고받을 수 있는 데이터의 크기는 1MB로 제한되어 있습니다.) 음악 플레이리스트 혹은 즐겨찾기 정보 등 영구적으로 저장되어야 하는 경우 이러한 데이터를 주고받기 위해서는 다른 방법을 고민해야 합니다. 저장되는 데이터의 성격에 따라 아래와 같은 방법을 고려해볼 수 있습니다.
1. SharedPrefernece에 저장 하 수 있는 간단한 키/밸류 값들을 전달하고 변경 사항을 확인할 때는 SharedPrefernece.OnSharedPreferenceChangeListener 를 활용할 수 있습니다.
2. 네트워크 응답 캐시 등 로우 파일 기반으로 데이터를 저장하고 관리하고 있고, 이를 통해 서비스와 액티비티 간에 데이터를 주고받는 경우 FileObserver를 활용할 수 있습니다.
3. 보다 규격화된 데이타셋을 갖고 있어 SQLite등에 정보를 저장하고 있다면 ContentProvider와 ContentObserver 조합을 활용할 수 있습니다. 혹시라도 ContentProvider를 이미 구현해서 활용하고 계시다면 말 이조 : p
지금까지, 안드로이드에서 백그라운드 서비스와 액티비티 간 통신이 필요할 때 활용 가능한 몇몇 방법들을 살펴보았습니다. 안드로이드는 IPC를 위해 개발자에게 굉장히 다양한 (가끔은 조금 과한듯한) 방법을 제공합니다. 결국 앱의 성격과 목적에 따라 어떤 방법을 선택하고 어떻게 조합해 기능을 구현할지를 선택하는 것은 개발자의 몫입니다. 이 포스트가 올바른 선택을 하는데 도움이 되었으면 합니다.
안드로이드 백그라운드 서비스와 액티비티 간의 통신 관련해서 다른 생각이나 궁금한 점이 있는 분들은 답글 남겨주시기 바랍니다. 또한 많은 안드로이드 개발자분들이 모여 다양한 주제에 관해 활발히 잡담을 나누는 GDG Korea Android Slack 채널이 열려있습니다. 관심 있는 분들의 많은 참가 기대합니다 : )