본문 바로가기

Android/개인 기록

[Android Tip] Immortal Service (죽지 않는 서비스)

[Immortal Service: 죽지않는 서비스]

에어맨이 안죽음ㅋㅋ

앱을 개발하다보면 죽지 않는 서비스(Immortal Service)가 필요한 순간들이 있다.

필자는 사용자의 위치정보를 지속적으로 가져오는 GPS 서비스가 필요했다.

당장 생각나는 다른 예시로는, 실시간 영상 스트리밍 어플을 구현할 때, Socket 통신 부분을 서비스 컴포넌트로 작성하여 스트리밍을 계속 유지할 수도 있겠다.

이번 글에서는 앱의 필수 기능으로, 절대로 죽으면 안되는 서비스를 구현하는 방법에 대해서 알아보자.

[주의]

이후로 작성된 내용은 필자가 직접 구현하고 작성한 것으로, 검증을 받은 것이 아니다. 때문에 환경에 따라 문제가 발생할 수 있다는 것을 미리 고지한다.


[다양한 구현 방법]

죽지않는 서비스는 생각보다 다양한 방법으로 구현할 수 있다.

  • BroadcastReceiver 사용 : 서비스가 종료될 때 브로드캐스트를 보내고, 해당 브로드캐스트를 수신하는 ‘BroadcastReceiver’를 등록하여 서비스의 종료를 감지. 서비스가 종료됨을 감지하면 서비스를 재시작하는 로직을 실행한다.
  • 서비스 바인딩 : ServiceConnection을 통해 서비스에 바인드한 후, onServiceDisconnceted() 콜백 메서드를 통해 서비스의 종료를 감지한다. 이 방법은 액티비티와 서비스가 직접 통신할 수 있다.
  • 서비스 상태 확인 : MyService.isServiceRun(this)와 같은 커스텀 메서드를 만들어 서비스의 상태를 주기적으로 확인하고, 종료를 판단.
  • 이외에도 EventBus, Livedata, RxJava와 같은 라이브러리를 이용하는 방식, Shared Preferences, DB, Handler and Message 등, 다양한 방법들이 있다.

위처럼 다양한 방법이 있다곤 하지만, 핵심은 결국 하나다.

서비스가 종료됨을 감지하면 다시 실행시키는 것

필자는 회사 프로젝트에 Jetpack과 Compose를 적극 도입 중인 관계로 LiveData를 사용하여 죽지않는 서비스를 구현하였다.


[기본 개념]

Android의 Immortal Service는 Foreground Service로 구현한다.

Foreground Service에 관련된 글은 [이 글](독수리아님ㅎ)을 참조해 주기 바란다.

Oreo 이전 버전들은 그냥 startService()로 서비스를 만들면 된다.

필자는 얼마전 자사 프로젝트의 최소 Sdk를 Oreo로 설정하였기에 이 글에서는 이전 버전에서의 구현은 고려하지 않겠다.

앞서 핵심은 “서비스가 종료됨을 감지하면 다시 실행시키는 것”이라고 했다.

서비스는 종료될 때, 액티비티와 동일하게 onDestroy()메서드가 실행된다.

필자는 이 메서드가 호출될 때, 미리 선언해 둔 mutableLiveData의 값을 변경시켜 서비스가 종료되었음을 알렸다.

이후, 액티비티에서 서비스가 mutableLiveData의 값 변경 감지하면 다시 서비스를 실행시킨다.

그림으로 정리해보면 다음과 같다.


프로젝트는 GpsWorker, GpsService, GpsData(object), Activity로 구성된다.

아래 설명은 이번 게시글과 연관있는 GpsService, GpsData, Activity만 작성되어있으며, 전체 코드가 아닌 필요한 일부분만 다룬다.

고로, 전체 소스는 [Github 링크]를 참고바란다.


[결과물]

우선, 작동하는 모습을 보도록하자.

이제 대략적인 코드를 살펴본 후, 필요한 부분에 대한 리뷰를 작성하겠다.

상단을 잘 보면 서비스 실행 여부에 따라 노티가 나타났다 없어졌다하는걸 볼 수 있다.

왜 계속 캡쳐한 이미지가 잘리는지 모르겠는데, 전체 소스를 실행해 보면 하단부의 토스트 메세지로 서비스 상태를 확인할 수 있도록 해뒀다.


[소스 코드]

코드는 Service, Obejct, Activity 순으로 작성하겠다.

1. GpsService

서비스의 실행과 관리를 담당하는 로직

class GpsService : Service() {

		.
		.
		.

    // 서비스 시작 시, 호출되는 메서드
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)

        // 포그라운드 서비스로 시작, 실행 중임을 알리는 노티 표시
        startForeground(NOTIFICATION_ID, createChannel().build())
isServiceRunning.postValue(true)

        // 위치 정보 제공자 생성
        createFusedProvider()

        return START_STICKY
	}

		.
		.
		.

		// GpsWorker 인스턴스를 생성하고 위치 업데이트를 시작하는 메서드
    private fun createFusedProvider() {
        gpsWorker = GpsWorker(this)
        gpsWorker.startLocationUpdates()
    }

    // 서비스가 완전 종료될 때 호출되는 메서드
    override fun onDestroy() {
        super.onDestroy()

        // 위치 업데이트를 중지하고 서비스 실행 상태를 업데이트
        gpsWorker.removeLocationUpdates()
        isServiceRunning.postValue(false)
    }
}

서비스가 시작될 때 호출되는 메서드인 onStartCommand

  • startForeground(NOTIFICATION_ID, createChannel().build())

    → 서비스를 Foreground Service로 시작한다. Foreground Service는 사용자에게 항상 보이는 알림을 표시해야 하기 때문에 createChannel().build()로 알림 객체를 생성하고 반환한다.

  • isServiceRunning.postValue(true)

    isServiceRunningGpsData 클래스의 MutableLiveData 변수이다. 이 변수의 값을 true로 설정하여 서비스가 실행 중임을 알린다. 이 부분을 후에 GpsData 코드에서 다시 자세히 다루겠다.

  • createFusedProvider()

    createFusedProvider 메서드를 호출하여 위치 정보 제공자인 GpsWorker의 인스턴스를 생성하고 위치 업데이트 진행.

  • return START_STICKY

    onStartCommand 메서드는 시작 명령의 결과로 START_STICKY를 반환한다.

START_STICKY가 뭔데?

onStartCommand 메서드에서 서비스는 시작 명령에 어떻게 반응할 것인지를 나타내는 값을 반환해야 한다.

START_STICKY는 반환 값 중 하나로, 이 값을 반환하면 서비스가 강제로 종료된 경우(예: 시스템 리소스 부족) 시스템에 의해 다시 시작된다. (간단하게 “서비스가 중단되면 시스템이 재시작하려고 노력한다”라고 생각하면 된다.)

서비스가 처음 startService(intent)를 통해 시작될 때, 그 intent에는 서비스를 시작하는 데 필요한 데이터나 명령어가 포함될 수 있다. 그런데, START_STICKY를 사용하면 서비스가 시스템에 의해 재시작될 때 onStartCommand에 전달되는 인텐트는 null이 된다. 이는 서비스가 원래의 의도(intent)나 이전에 수행 중이던 내용에 대한 정보 없이 재시작된다는 것을 의미한다.

따라서 서비스의 onStartCommand 메서드에서 인텐트의 내용을 참조하려고 할 때 null 체크를 수행해야 한다. 그렇지 않으면 NullPointerException이 발생할 수 있다.

예로, Music 앱을 떠올려보겠다. 보통 music앱은 사용자가 특정 노래를 재생하도록 요청할 때, 해당 노래의 정보를 인텐트에 담아서 서비스를 시작한다.

만약 이 서비스가 START_STICKY를 반환하고, 시스템에 의해 종료된 후 재시작되면, 재시작될 때 그 노래 정보는 더 이상 인텐트에 포함되지 않다. 따라서 서비스는 어떤 노래를 재생해야 하는지 모르는 상태가 된다.

이를 해결하기 위해 서비스의 상태를 지속적으로 저장하고, 필요할 때 복원하도록 한다. 여기서는 SharedPerferences나 DataStore를 사용할 수 있겠다.

서비스가 종료될 때 호출되는 메서드인 onDestroy

  • gpsWorker.removeLocationUpdates()

    GpsWorker 객체의 removeLocationUpdates 메서드를 호출하여 위치 업데이트를 중지한다. 이는 불필요한 리소스 사용을 방지하기 위함이다.

  • isServiceRunning.postValue(false)

    GpsData 클래스 내의 isServiceRunning 값을 false로 업데이트한다. 이는 서비스가 더 이상 실행되고 있지 않음을 알리기 위함이다.

2. GpsData

서비스와 관련된 데이터와 기본 기능들을 제공하는 싱글턴 객체

object  GpsData {

    // 서비스의 실행 성탸룰 나타내는 LiveData이다. 기본값은 false
    val isServiceRunning = MutableLiveData<Boolean>(false)
		
			.
			.
			.

    // GpsService를 시작하는 메서드
    fun startGpsService(context: Context) {
        // 서비스가 실행 중이지 않은 경우에만 서비스를 시작한다.
        // 중복 동작과 ANR 방지 목적이다.
        if(!isServiceRun(context)) {
            val intent = Intent(context, GpsService::class.java)
            ContextCompat.startForegroundService(context, intent)
            Toast.makeText(context, "Service Start", Toast.LENGTH_SHORT).show()
        }
    }

    // GpsService를 중지하는 메서드
    fun stopGpsService(context: Context) {
        val intent = Intent(context, GpsService::class.java)
        context.stopService(intent)
    }

    // 서비스의 실행 상태를 반환하는 메서드
    private fun isServiceRun(context: Context): Boolean {
        if (isServiceRunning.value== true) {
            return true
        }
        return false
    }
}

LiveData를 사용한 이유

  • 데이터 변경을 여러 컴포넌트와 공유할수 있기 때문이다.
  • 여기서는 Service와 Activity에서 동일한 데이터인 isServiceRunning을 관찰하며 데이터 변경을 서비스 재실행 여부에 반영한다.

3. MainActivity

class MainActivity : AppCompatActivity() {

		private val isServiceRunning = Observer<Boolean> { isRunning ->
	      if (!isRunning) {
	          Timber.e("service 상태 변화 감지")
	          GpsData.startGpsService(this@MainActivity)
	          GpsData.isServiceRunning.postValue(true)
	      }
	    }
	
			.
			.
			.
	
		override fun onCreate(savedInstanceState: Bundle?) {
		        super.onCreate(savedInstanceState)
		        setContentView(binding.root)
		
		        binding.apply {
		            mainActivity = this@MainActivity
		        }
		
		        // 권한 요청
		        requestPermissionsLauncher.launch(permissions)
		    }
		
		    fun stopService() {
		        GpsData.stopGpsService(this@MainActivity)
		        Toast.makeText(this@MainActivity, "Service Stop", Toast.LENGTH_SHORT).show()
		    }

		.
		.
		.

}

GpsData.isServiceRunning의 값을 관찰하는 익명 객체 isServiceRunning

  • Observer<Boolean> 타입의 익명 객체이다.
  • isRunning이라는 파라미터를 통해 GpsData.isServiceRunning의 현재 값 (즉, 서비스가 실행 중인지 여부)을 관찰하고 있다가 변경이 감지되면 해당 블록의 코드가 실행된다.
  • isRunning 이 false인 경우(서비스가 실행 X) GpsData.startGpsService(this@MainActivity)를 통해 GPS 서비스를 시작한다.
  • 마지막으로 GpsData.isServiceRunning의 값을 true로 업데이트하여 서비스가 실행 중임을 나타낸다.


[마무리]

지금까지 현재 재직 중인 회사에서 실제로 사용한 방식을 바탕으로 간단한 죽지않는 위치 서비스를 구현해 보았다.

모쪼록 정보를 찾아 이 글을 발견한 사람에게 도움이 되었길 바라며, 질문과 오류에 대한 피드백은 언제든지 환영한다.

그럼 오늘도 빡세게 코딩하고 성장하자


Uploaded by N2T