본문 바로가기

Android/Basic

Foreground Service 구현: 바인딩된 서비스

Foreground Service의 개념은 개념편을 참고 바람

이번 글에서 다루는 예제는 포그라운드 서비스 동작의 이해를 위해 Hilt, Coroutine, Flow 와 같은 고급 기능 사용을 지양하여 작성되었다.

목표는 "위치를 가져오는 Location Service 만들기"

 

이게 사실 서비스를 설명하기 가장 간단하면서도 편리한 거라 계속 이것만 만들게 되는 듯 하다.
해당 게시글에 사용되는 예제 코드는 gitHub에 업로드 해두었다.
[Github: Location_ForegroundService_Sample]

 

GitHub - parade621/Foreground_Service-Location-_Sample: ForegroundService(Location) Sample Code 입니다

ForegroundService(Location) Sample Code 입니다. Contribute to parade621/Foreground_Service-Location-_Sample development by creating an account on GitHub.

github.com

이 글에서는 전체 코드를 다루지 않고, 필요한 부분을 코드 스니펫으로 간략하게 다룬다.

그런 이유로 전체 코드가 필요한 분은 위 링크의 내용을 확인 바람


가능한 주석을 상세하게 작성하긴 했는데, 그럼에도 본인의 미천한 코딩 실력 탓에 원만한 이해를 주지 못할 수 있다.

해당 부분은 공유해 주면 같이 고민해서 해결해 보도록 노력하겠다.

 

내용은 정확성과 가독성을 위해 지속적으로 수정되고 있습니다.
틀린 부분에 대한 지적과 질문은 언제든지 환-영


Bounded Service 예제

foreground service는 시작된 서비스(StartService or StartForegroundService)와 바인딩된 서비스(bindService(), 두가지 방식으로 구현 방법을 나눌 수 있다.
이번 글에서는 바인딩된 서비스를 다룬다.

bindService()로 서비스를 바인딩 하는 경우, 서비스 콜백 함수들의 실행 순서는 다음과 같다.

onCreate() -> onBind() -> onStartCommand()

 

시작된 서비스 방식에서 대부분 return null 기능은 하지 않고 자리만 차지 중인 onBind()를 적극 사용한다.
그럼 이제 하나씩 구현 방법을 살펴보자.


1. LifecycleService()

일반적으로 Android에서 서비스를 생성하기 위해서 Service를 상속받는 클래스를 만든다.

class MyService : Service() { }

이렇게 만들어진 서비스는 개발자가 직접 생명주기를 관리해야한다.

Serivce 클래스는 몇 가지 콜백 메서드(onCreate, onStartCommend, onBind, onDestory)를 제공하는데, 이를 통해 서비스의 생성, 시작, 바인딩, 종료 등을 관리할 수 있어 세밀한 제어가 가능하다.

하지만, 이 방법은 복잡하고 오류가 발생하기 쉽다. 특히 서비스와 액티비티 간의 상호작용이 필요한 경우 더욱 그러했다.
이유는 둘의 생명주기가 다르기 때문이다.

 

이런 문제를 해결하기 위해 LifecycleService가 도입되었다.

LifecycleService는 Android Jetpack의 라이브러리로, Service에 생명주기 인식 기능이 추가되었다. Service를 상속하기에 기존 콜백 메서드를 모두 사용할 수 있으며, LifecycleOwner 인터페이스를 구현하여 Lifecycle-aware component(LiveData, ViewModel 등)을 사용할 수 있게 되었다.

 

해당 글의 예제는 LifecycleService를 사용한다.

사용을 위해서 아래의 dependency를 추가해주어야 한다.

implementation("androidx.lifecycle:lifecycle-service:2.7.0")

최신 버전은 Android Developers를 통해 확인 가능하다.

LifecycleService를 상속하는 클래스의 모습은 아래와 같다.

class MyService : LifecycleService() { }

 


2. Foreground Service 기본 구성하기

우선 상단에 작성된 LifecycleServcie()를 상속하는 클래스를 만들어준다.

class LocationService : LifecycleService() {
    override fun onCreate() {
        super.onCreate()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)
    }

    override fun onBind(intent: Intent): IBinder {
        super.onBind(intent)
    }

    override fun onDestroy() {
        super.onDestroy()
    }
}

만약 서비스가 처음 생성될 때 특정 작업을 수행하도록 하고싶다면 onCreate()에 작성하면 된다.

하지만, 이번 예제에서는 서비스 최초 실행 당시 수행할 작업이 없어서 onCreate()는 사용하지 않았다.

대신, 서비스 시작을 요청 받을 때 마다 호출되는 onStartCommand(), 다른 컴포넌트(주로 액티비티)가 서비스에 바인드하려 할 때 호출되는 onBind(), 서비스 소멸 시, 호출되는 onDestroy()를 사용한다.

그러므로 앞서 설명한 바인딩된 서비스의 콜백 함수 호출 순서를 바탕으로 onBind() 구성부터 살피겠다.

👀 onBind() 구성

override fun onBind(intent: Intent): IBinder {
    super.onBind(intent)
    isServiceRunning = true
    MyService.bindService()
    handleBind()
    return localBinder
}

// 바인드 함수에서 호출되는 커스텀 함수로, Rebound까지 고려하여 별도로 함수로 분리하여 작성하였습니다.
private fun handleBind() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        // API 26 이상은 포그라운드 서비스 시작
        startForegroundService(Intent(this@LocationService, LocationService::class.java))
    } else {
        // API 26 미만은 일반 서비스로 시작
        startService(Intent(this, LocationService::class.java))
    }
    startForeground(1, createChannel().build())
}

보통 onBind()를 구성하지 않는 경우가 일반적인데, 앱이 백그라운드에 있을 때도 계속 작업을 수행해야 하는 앱들이 그러하다.액티비티와 직접적으로 상호작용하지 않거나, 상호작용이 제한적이기 때문에 굳이 onBind()를 구현할 필요가 없기 때문이다.

 

하지만, 본인은 서비스에서 갱신한 위치정보를 액티비티에서 직접 가져다 쓰기 때문에, 클라이언트(Activity or Fragment)에서 bindService()를 통해 서비스를 바인딩하고, onBind()로 클라이언트에 IBinder인터페이스를 제공한다. 이러면, IBinder 인터페이스를 통해 클라이언트와 서비스 간에 원활한 상호작용이 가능해진다.

 

요컨대, 액티비티와 서비스의 직접적인 통신이 필요하면 onBind가 필수적이라는 것이다.

 

onBind()에서는 서비스 시작 요청 함수를 호출해준다. 그리고 서비스 작동 여부를 검사하기 위한 isServiceRunning Boolean 타입 변수를 True로 설정해 주었다. 이건 본인이 조건 검사를 위해 작성한 변수로 중요한건 아니다.

 

MyService는 ServiceConnection 인터페이스를 상속하는 싱글톤 오브젝트인데, 이후 설명하겠다.

마지막으로, return localBinder, IBinder 인터페이스를 반환해 주는 것이다. 이후에 클라이언트가 서비스의 메서드를 호출할 수 있도록 하기 위해 반드시 반환해 주어야하는 값이다.

internal inner class LocalBinder : Binder() {
    fun getService(): LocationService = this@LocationService
}

이런식으로 Service 클래스 최 하단에 internal inner class로 선언해 주었다.

 

👀 onStartCommand() 구성

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    super.onStartCommand(intent, flags, startId)
    lifecycleScope.launch {
        if (!isServiceRunning) {
            startForeground(1, createChannel().build())
        }
        locationUpdates()
    }
    return START_STICKY
}

onStartCommand는 서비스가 startService(Intent)startForegroundService(Intent)로 명시적으로 시작될 때 호출된다.

 

해당 예제에서는 onBind()가 호출하는 handleBind()에서 서비스 시작 요청을 직접 해주기 때문에, onBind 호출 후 바로 onStartCommand()가 호출된다.

 

onStartCommand에서 startForeground()를 통해 실행 중인 서비스를 Foreground 상태로 만들어준다. 이렇게 해준 이유는 foregroundservicedidnotstartintimeexception 오류를 방지 하기 위함이다.


Foreground Service는 startForegroundService -> startForeground 순으로 호출해 주어야 하는데, startForegroundService 이후 반드시 5초 이내에 startForeground가 호출되어야 한다. 안그럼 foregroundservicedidnotstartintimeexception 오류가 발생한다. (시스템에 의해 서비스가 강제 종료 당한다.)
예전에 한동안 이 오류로 꽤나 골치가 아팠던 적이 있다.
그때는 클라이언트에서 startService로 서비스를 시작하고 onStartCommand에서 포그라운드 상태로 만들어 주는 '시작된 서비스' 방식을 사용했었다.
이것저것 해봤는데, 결국 서비스 시작 -> 포그라운드로 변경으로 진행되는 시간이 항상 5초 이내가 아니라 문제를 해결하지 못했었다.
하지만, 이렇게 서비스 클래스 내부에서 모든 작업을 진행하니 깔끔하게 해-결

사실 나머지 서비스 코드는 위치 정보 가져오고, Notification 만들고 하는거라 굳이 설명을 추가할 필요가 없다고 생각되어 생략한다.
해당 부분은 예제의 코드로 참조해주기 바란다.
나름 주석 열심히 썼다(ㅎ)


3. ServiceConnection 객체

서비스와의 상호작용을 위해 사용된다.

// 서비스 연결을 관리하는 객체
object MyService : ServiceConnection {
    private var service: LocationService? = null
    var isBound = false
        private set

    override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
        val binder = service as LocationService.LocalBinder
        this.service = binder.getService()
        isBound = true
    }

    override fun onServiceDisconnected(name: ComponentName?) {
        service = null
        isBound = false
    }
}

작성된 ServiceConncetion 코드 중, 내가 생각하기에 반드시 구현해 주어야할 내용을 간단하게 옮긴 것이다.

👀 onServiceConnected

시스템이 클라이언트와 서비스간의 바인드를 성공적으로 완수하면 onServiceConnected()가 호출된다.
여기서 onServiceConnected 메서드는 두 가지 주요 작업을 수행한다.

1. 서비스 인스턴스 얻기

콜백의 service 파라미터는 IBinder 인터페이스의 인스턴스이다. 이 인스턴스를 서비스의 LocalBinder 클래스로 캐스팅하여, 서비스의 메소드에 접근이 가능한 서비스 객체 인스턴스를 얻는다. 이를 통해 클라이언트는 서비스가 제공하는 데이터나 기능에 접근하게 되는 것이다.

val binder = service as Location.LocalBinder
this.service = binder.getService()
2. 바인드 상태 업데이트

서비스와 연결이 성공적으로 수립되면, isBound플래그를 true로 설정하여, 서비스가 현재 바인드 된 상태임을 앱 전역에 알린다.
이는 클라이언트가 서비스에 안전하게 요청을 보내기 위해 설정해줬다.

 

👀 onServiceDisconnected

onServiceDisconnected() 콜백은 서비스 연결이 예상치 못하게 종료될 때 호출된다.
이는 서비스가 비정상적으로 종료되거나, 프로세스가 Crash된 경우에 발생하는데, 이때는 서비스 참조 해제와 바인드 상태를 업데이트 해주는게 좋다.
본인은 여기서 어떤 문제가 발생했는지 파악하기 위해 Firebase에 로그를 남기기도 한다.

‣ 왜 필요한가

1. 안전성 보장

서비스 바인딩은 클라이언트가 실제로 실행 중인 서비스의 메소드에 안전하게 접근할 수 있도록 한다.
이는 시스템이 클라이언트와 서비스 간의 연결을 명확하게 관리하도록 하며, 허가된 앱만 서비스에 접근할 수 있게 해준다.

2. 자원 관리

바인딩을 통해 서비스의 사용이 시작되고 종료되는 시점이 명확해진다.
이를 통해 서비스가 필요할 때만 자원을 사용하도록 하며, 불필요할 때는 자원을 해제하여 시스템 리소스를 효율적으로 관리할 수 있다.
무엇보다, 시스템에 의해 무작위 Kill을 당할 확율이 더 낮아진다.