Focus Point
먼저 글을 읽기 전, 다음 부분들에 집중하셔서 글을 읽어주시면 감사하겠습니다!
- Flow of History: 말 그대로 역사의 흐름입니다. 역사 공부를 하듯이 어떤 흐름으로 역사가 진행 되는지에 대해 집중해 주시면 감사하겠습니다.
- What is Service: 위의 Flow of History에 집중하려면 이야기의 중심이 되는 Service에 대해 알아야 합니다. (Service에 대해 잘 아시는 분이라면 넘어가셔도 될 것 같습니다.)
- Why use ???: 무엇인가를 사용하게 되었을 때는 어떤 이유로 사용하게 되었는지에 대해 한번 더 생각해주세요.
What is Service?
백그라운드에서 오래 실행되는 작업들을 수행할 수 있는 컴포넌트입니다.
다른 컴포넌트들이 Service를 시작할 수 있고, 다양한 형태로 Service는 사용될 수 있습니다. 보통은 음악 플레이어, 파일 다운로드 등과 같은 작업에 많이 사용됩니다.
그러면 당연히 다음과 같은 의문이 들 수 있습니다.
백그라운드 작업은 Thread로도 가능하지 않나?
맞습니다, 하지만 Service와 Thread는 큰 차이점이 있습니다.
Thread 말고 왜 Service를 쓰는 걸까?
Thread의 문제점: 안드로이드 컴포넌트가 아니므로 독자적인 생명주기도 없을 뿐더러 Main Thread가 아니기 때문에 앱을 나가면 프로세스가 유지되지 않습니다. 또, 만약에 OOM Killer에 의해서 프로세스가 종료되면 다시 재시작 될 것이라는 보장도 없습니다.
하지만 Service는: 안드로이드 4대 컴포넌트 중 하나로서 독자적인 생명주기를 가지고 있고 Main Thread에서 동작하기 때문에 사용자가 앱을 나가도 프로세스가 유지됩니다. 만약 강제로 프로세스가 죽을 경우 다시 살아날 수도 있습니다.
Service의 사용 유형
Service는 3가지 유형으로 사용이 가능합니다.
- Foreground 형태 (start 방식 사용법): Foreground는 우리에게 익숙한 Service입니다. 보통 음악을 재생하고 있으면 시스템 상태 바 안에 음악이 플레이 되고 있다는 표시가 뜨면서 사용자가 인지를 하는데 이 것이 Foreground Service입니다.
- Background 형태 (start 방식 사용법): 반대로 Background는 사용자가 인지를 못 하는 Service입니다. 서버 동기화, 암호, 복호화 같은 사용자가 인지할 필요가 없는 작업들을 주로 맡는데 문제점으로 인한 변경된 사항이 많아 역사가 깊은 Background Service입니다.
- bind 형태 (bind 방식 사용법): 앞의 Service들과는 다르게 Start 방식이 아닌 connection을 유지하며 bind하는 방식의 Bind Service입니다.
사용하는 방식이 가장 큰 차이점이며, 한마디로 줄여 이야기하면 Service가 서버, Service를 바인드하는 컴포넌트가 클라이언트인 관계라고 볼 수 있습니다. 이런 특성으로 인해 다른 프로세스에서도 접근이 가능하며 프로세스간 통신(IPC)를 가능케 합니다.
Service 선언
manifest에 activity처럼 선언해서 사용해야 합니다.
Service의 시작 방식에 따른 사용 방법
공통된 사용 방법이라고 해도 시작 방식의 차이로 조금은 다르긴 합니다.
다음 그림의 Service Lifecycle을 참고해주세요!
Start 방식 사용법의 Service (startService()를 호출하는 Service): onStartCommand()에 어떤 작업을 할 것인지 구현해야 합니다.
아니면 보통 Service는 한 가지 작업만 처리하니까 이를 위해 만들어진 IntentService가 있는데 이 IntentService를 사용한다면 알아서 Work Thread를 만ㄷㄹ어서 동작하기 때문에 onHandleIntent()만 구현하면 됩니다.
만약 다중 작업을 처리를 해야 한다면 그냥 Service를 사용해야 하지만 Handler도 함께 구현해서 사용해야 함으로 매우 귀찮아 집니다.
또, onStartCommand()는 3가지의 상수 중 하나를 리턴해줘야 하는데,
- START_NOT_STICKY: 반환 후 중단 시키면 서비스를 재생성하면 안 된다는 상수입니다. 가장 안전하며, 단순하게 다시 시작하기 좋은 옵션입니다.
- START_STICKY: 반환 후 서비스를 중단하면 서비스를 다시 생성 -> 다시 onStartCommand()를 호출- > 다시 호출되면 Intent는 null 입니다.
그러므로 미디어 플레이어 같은 무한히 실행 중이고 작업을 기다리는 서비스에 알맞습니다. - START_REDELIVER_INTENT: 반환 후에 서비스를 중단하는 경우 서비스 다시 생성 -> 마지막 Intent를 매개로 onStartCommand()를 호출합니다.
파일 다운로드 같은 능동적인 서비스에 적합합니다.
Service를 start 하는 컴포넌트에서는 startService()와 stopService()를 사용하여 조작할 수 있습니다. service내에서 stopSelf()로 stopService()를 대신할 수도 있습니다.
Bind 방식 사용법의 Service (bindService()를 호출하는 Service): bind 방식은 onStartCommand()가 아닌 onBind를 구현해야 합니다. onBind는 IBinder를 리턴해줘야 하는데 IBinder가 무엇이냐면 서버 — 클라이언트 관계인 Bind Service에서 통신을 위한 인터페이스입니다. 그러니 Service내에서 IBinder도 구현하여야 합니다.
Service를 bind하는 컴포넌트에서는 먼저 Service와의 연결을 위한 ServiceConnection을 구현하고 그 connection을 bindService()를 호출할 때 넣어줍니다. Service가 bind되면 IBinder를 통해 service를 가져오고 그 안의 메서드들을 사용할 수 있습니다. 그리고 unbindService()로 connection을 종료할 수도 있습니다. 만약 모든 connection이 끊기면 해당 Service는 소멸됩니다.
start 방식 Service의 서로 다른 사용 방법
- Background Service: onStartCommand()에 작업만 구현하면 됩니다.
- Foreground Service: 다음 코드와 같이 onStartCommand()에서 PendingIntent와 그 intent를 담은 notification을 만들어 startForeground()를 호출해야 합니다.
What is Background Service’s Problems?
Service가 무엇인지는 이제 어느 정도 감이 오셨을 거라고 생각합니다. 그러면 앞에서 언급되었던 Background Service에는 어떤 문제점들이 있었을까요?
Background Service 특성 자체가 문제?
Background Service의 특성을 다시 한번 짚어봅시다.
Background Service는 사용자가 인지할 필요가 없는 작업을 수행함으로 상호작용 하지 않고 그렇기에 사용자는 인지할 수 없다.
언뜻 보면 장점 같지만 큰 문제를 일으킬 만한 특성입니다.
왜냐하면 무분별하게 Background Service가 사용된다면 사용자는 이를 인지하지도 못할 것이고, 이로 인해 디바이스가 과부화 되어 메모리 부족을 겪을 수 있고 심하면 앱이 갑자기 죽는 일들이 일어날 수 있기 때문입니다.
Google’s Solution 1–1 (Background 제한)
Google은 이 점을 인지하고 Oreo 버전부터 앞으로 Background Service를 제한시켜버립니다. 정확히는 앱이 Closed 상태일 때의 Background Service를 제한한 것입니다. 앞으로는 앱이 Background 상태일 때도 Service를 유지 시키려면 Foreground Service만 사용 가능해진 것입니다. 즉, 사용자가 Service를 계속 돌고 있음을 인지하고 직접 관리할 수 있도록 한 것입니다.
더 나아가 Google은 startForegroundService()라는 메서드를 만들었습니다. 이 메서드는 Service 시작 후 Service 내에서 5초 안에 startForeground()를 호출하지 않으면 ANR을 띄우는 메서드입니다. 그렇게 앞으로 Closed상태의 앱은 startForegroundService() 메서드를 사용하게 되었습니다.
그럼 전부 다 Foreground Service로 바꿔야 하나?
만약 전부 Foreground Service로 바꾼다면 아무리 사용자가 관리한다고 하더라도 조삼모사로 똑같이 과부하가 걸릴 것입니다.
그래서 Google은 예약된 작업을 해결법으로 제시합니다.
Google’s Solution 1–2 (JobScheduler)
Google은 예약된 작업을 위해 JobScheduler를 추천했습니다. JobScheduler란 개발자가 Background task를 정의하고 언제 이 작업이 실행될지 타이밍을 정할 수 있게 도와줍니다.
하지만 JobScheduler에서도 문제가 터집니다. 원인은 버전 때문입니다. JobScheduler는 롤리팝(버전 21)부터 지원을 합니다. 지원은 롤리팝부터이지만 롤리팝 버전에서 JobScheduler는 불안정하다는 이슈가 있었고 제대로 사용하려면 마쉬멜로우(버전 23)부터 사용해야 했습니다. 그 이전의 버전에서는 AlarmManager와 Broadcast Receiver를 사용해야 했습니다.
그럼 그냥 AlarmManager랑 Broadcast Receiver 사용하면 되잖아?
Low 버전부터 지원하는 AlarmManager를 사용하면 되지 않나 라는 생각이 들 수 있습니다. 하지만 AlarmManager도 마쉬멜로우부터 문제가 생겼습니다.
Doze 모드가 생기면서 알림이 울리지 않는 경우가 생겼고 Google은 setAndAllowWhileIdle(), setExactAndAllowWhileIdle() 같은 메서드를 지원하여 해결할 수 있다고 합니다만 이외에도 AlarmManager를 잘못 설계하면 배터리 소모가 심해질 수 있다는 문제점도 있고 오레오(버전 26)부터 Background Service와 함께 암시적 Broadcast가 제약을 먹으면서 사용하기 더 어려워졌습니다.
결국 개발자들은 버전을 나눠서 코드를 짜야 하는 비생산적인 상황에 놓였습니다.
Firebase’s Support (JobDispatcher)
이런 상황을 인지한 Firebase가 JobDispatcher를 제공합니다. JobDispatcher는 버전이 마쉬멜로우 버전 이상이라면 JobScheduler를, 미만이라면 AlarmManager를 사용하도록 해줍니다.
이로 인해 개발자들이 조금 편해지는 듯 했으나…
Firebase와 관련된 것을 사용하려면 Google Play Service에 의존성을 가지게 되는데 이로 인해서 글로벌을 타게팅으로 한 앱들에 문제가 생깁니다. 바로 중국 시장 때문입니다. 중국은 Google Play Service를 사용하지 못하게 막혀있었기 때문에 많은 글로벌 앱들은 중국 시장을 포기할 수 없었다고 합니다.
그래서 Evernote 사에서 만든 Thrid party library를 사용하면서 개발자들은 슬퍼할 수 밖에 없었습니다.
그러다가 결국엔 2018 구글 I/O에서…
Google’s Solution 2 (WorkManager)
드디어 WorkManager가 세상에 나옵니다!
WorkManager는 완전히 새로운 방법으로 처리하는 것이 아니라 이전의 JobDispatcher 처럼 OS 버전별로 필요한 처리를 핸들링할 수 있게 도와줍니다.
하지만 Google Play Service에 대한 의존성은 전혀 없습니다. 게다가 더 확장된 여러 기능들을 제공합니다.
Merit of WorkManager
WorkManager의 장점들은 다음과 같습니다.
- Android가 제시하는 best practice에 가까워 전력소비를 줄일 수 있습니다.
- 일회성이나 주기적인 작업을 지원합니다.
- Chaining을 지원하여 보다 정확하고 명확한 순서를 볼 수 있습니다.
- 예약된 작업은 내부적으로 관리되는 SQLite 데이터베이스에 저장되어서 기기를 재부팅해도 작업이 유지되고 다시 예약되도록 보장합니다.
- RxJava와 Coroutine을 지원하여 개발자가 편합니다.
When to use WorkManager
WorkManager를 사용하는 방법은 크게 두 가지 입니다.
- WorkManager Only: 서버 로그 수집, 다운, 업로드할 컨텐츠 암호화, 복호화 등 지연해도 괜찮은 작업 (앞에서 이야기한 예약된 작업)
- WorkManager + FCM: 온라인 이벤트 트리거로 동작하는 작업
How to use WorkManager
어떤 작업을 할 건지 구현한 Worker를 만들어줍니다.
그리고 간단하게 Worker를 WorkRequestBuilder로 WorkRequest 형태로 빌드한 후 WorkManager에 enqueue() 해줍니다.
그러면 정상적으로 doWork()에 구현된 작업이 작동합니다.
이외에도 다음 코드들과 같이 다양한 기능들을 제공합니다.
더욱 자세한 내용은 Android Developers에서 확인하실 수 있습니다.
Self Question
추가적으로, 학습하면서 들었던 의문점이 두 가지가 있었습니다.
1. Download and Upload on WorkManager?
다운로드와 업로드는 WorkManager에서 작업하면 안 될까 라는 의문이었습니다.
저는 당연히 안 될 것이라고 생각했었습니다. 왜냐하면 WorkManager 데이터베이스는 SQLite로 구성되어 있고 이 데이터베이스에는 예약된 작업과 그 작업의 성공, 실패, 취소 여부만 저장하기 때문입니다.
그래도 혹시 몰라 한번 간단히 Worker로 로그를 찍다가 앱을 종료하고 다시 앱을 실행시켜보는 실험을 해봤습니다.
하지만 이변 없이 Worker는 취소된 상태로 저장이 되었기 때문에 처음부터 다시 실행되었습니다.
그렇다면 다운로드, 업로드와 같은 작업은 무조건 Foreground Service를 써야 하나라고 생각하다가 비교적 최근에 지원을 시작한 WorkManager의 장기 실행자를 알게 되었습니다.
WorkManager의 장기 실행자는 작업이 실행되는 동안 가능하면 프로세스를 활성 상태로 유지해야 한다는 신호를 OS에 제공할 수 있으며 장기 실행 작업자는 10분 넘게 실행될 수 있습니다.
이를 통해 다운로드, 업로드 작업을 WorkManager로 처리할 수 있습니다.
그렇더라도 음악 플레이어 같은 미디어 플레이어는 무한히 실행 가능해야 함으로 컴포넌트인 Foreground Service를 사용하는 것이 좀 더 안정적이고 좋다고 생각합니다.
2. why didn’t deprecate startService()
왜 startService()는 deprecated 안 되었을까요? 저는 Google이 문제점을 인지하고 다른 대안이 나온 것들은 deprecated 되는 것이 일반적인 경우라고 알고 있습니다. 즉, deprecated 되지 않았다는 이야기는 오레오 이후로 제한된 Background Service가 어디선가는 필요해서 사용하고 있다는 뜻이 됩니다.
아니면 Google이 말로는 제한시켰다고 했으면서 그냥 쓰면 동작하는 것이 아닐까라는 생각에 한번 로그를 찍다가 앱을 종료하는 실험을 해봤습니다만 역시나 앱이 종료되면 프로세스가 유지되지 못하고 Service도 함께 종료되었습니다.
이 부분에 대해 한참을 고민하다가 마침 Firebase의 Cloud Messaging인 FCM이 떠올랐습니다. FMS(FirebaseMessagingService)이라는 Service로 Message를 수신하는 걸로 아는데 그러면 FCM은 앱이 Background 상태여도 어떻게 수신할 수 있는 것인가 라는 의문이었습니다.
그래서 FCM의 구동원리에 대해 찾아보게 되었고 점점 FCM의 구동원리에 대해 이해하다보니 제가 잘못된 생각에 질문을 던지고 있다는 것을 깨닫았습니다.
FCM은 항상 FMS로 Message를 수신하는 것이 아니었습니다. 앱이 켜져있을 때는 이전에 생각한대로 FMS가 수신하지만 앱이 꺼진 상태에서는 앱이 아닌 System Tray가 Notification을 받고 그 Notification을 Tap한 후에 앱이 켜지면 Intent로 Message가 전달되는 방식이었습니다.
이 원리가 뜻하는 것은 이 FMS는 앱이 켜져 있을 때만 Background에서 Message를 수신하고 앱이 꺼졌을 때는 작동하지 않는다는 뜻입니다. 완전 startService()로 시작하는 Background Service 같습니다.
좀 더 확실한 증명을 위해 Firebase Android SDK를 확인해보았습니다. 그 결과, FMS는 물론이고 부모 클래스인 EIS(EnhancedIntentService)도 onStartCommand()에 startForeground()가 호출된 적이 없는 Background Service 였습니다.
그럼 어떻게 System Tray는 FCM을 알고 Notification을 띄울 수 있는 걸까요?
그래서 Firebase Android SDK를 한번 더 살펴보았습니다. 그러고 알게 된 사실이 FMS에는 onBind()가 구현되어 있었습니다. 이 사실로 저는 이 Service가 Bind Service도 될 수 있다는 것을 알게 되었습니다. 이 후 더 SDK를 찾아보았지만 SDK 내에서 bind하는 부분은 없었습니다.
이로 인해 더 정확한 근거는 찾기 어려웠지만 스스로 추측하기는 System이 FMS를 프로세스간 통신이 가능한 Bind Service로 사용하고 그 이유는 앱이 꺼졌을 때 System Tray가 FCM Notification을 수신하기 위해서라고 생각됩니다. (이 부분은 제 추측이니 정확하지 못할 수 있습니다.)
이렇게 FCM이 어떻게 동작하는지와 Background Service가 어떤 상황에서 쓰이는지는 알게 되었습니다.
예전 같았으면 왜 하필이면 Background Service를 쓸까? 앱이 켜져 있을 때만 돌릴 거면 Coroutine 같은 비동기 처리를 하면 좀 더 비용이 절감되고 편하지 않을까? 라는 질문을 한번 더 던졌겠지만 이제 Android Background History를 파악했기 때문에 이미 답을 알고 있습니다.
FCM은 항상 계속해서 Message를 수신할 수 있는 상태에 있어야 합니다. 앱이 켜져 있는 한 종료되어서는 안 되고 종료되더라도 다시 시작되어야 합니다. 그렇기 때문에 Thread를 활용하는 비동기 처리보다는 OOM Killer의 위협에서 벗어날 수 있고 재시작될 수 있다는 안정성까지 가지고 있는 Background Service를 쓰는 것이 알맞을 것입니다.
제가 스스로 던진 질문은 여기까지 지만 더 궁금한 점이 있다면 질문을 남겨주시거나 한번 Firebase Android SDK에서 찾아보는 것을 추천 드립니다. 그리고 그 부분에 대해 공유도 해주시면 감사하겠습니다.
End of Contents
여기까지가 제가 Android Developers와 여러 문서들을 찾아보면서 학습한 내용입니다. 이전에는 Service나 WorkManager 같은 것을 해본 적도 전무하고 아는 것도 하나도 없었는데 이렇게 History를 조사하니까 Android Background에 대한 이해가 조금은 늘은 것 같습니다.
확실히 History를 알고 나니까 어떤 기술을 사용하는 이유에 대해서도 정확하게 알게 되고 적절한 상황에 적절한 기술을 사용할 수 있겠다는 자신감과 앞으로 Android Background가 변화하더라도 빠르게 대응할 수 있겠다는 자신감도 생겼습니다.
물론 잘못된 정보도 있겠지만 이 글을 읽으시는 분들이 피드백 해주실 것이라 믿고 있습니다 :)
부족하고 긴 글 읽어주셔서 감사합니다!