본문 바로가기

Android/개인 기록

[Android: Basic] GPS Location Service (위치정보 서비스)

어플리케이션 개발을 하다보면, 사용자의 위치 정보가 필요해지는 경우가 있습니다.

많은 방식들이 있겠지만, 저는 무엇보다 포그라운드 서비스로 위치 정보를 받는것을 선호합니다.

일전에 Jetpack의 WorkManager로 위치정보를 받아보려고 한 적이 있는데, 막상 코드를 작성하고 돌아가는 것을 보니 왜 구글에서 위치정보 획득을 Foreground service로 권장하는지 알겠더군요. 언급한 Work Manager 부분은 따로 글을 작성해서 코드와 함께 더욱 자세히 설명하도록 하겠습니다.

 

아무튼, 저는 2개의 게시글에 걸쳐, 제가 작성해온 위치 정보 서비스의 코드들과 그 동작 방식을 설명하려 합니다. 이를 위해서는 Forground Service에 대한 사전지식이 필요하며, 해당 내용은 이 글에 작성해 두었으니 한번 읽어보시는 것을 추천드립니다. 앞으로의 내용들을 이해하는 데에 미력하게나마 도움이 될 것입니다. 

 


먼저 GIF이미지로 완성된 어플리케이션의 모습을 먼저 살펴보겠습니다.

위와 같이 버튼을 눌렀을 때 위치 정보를 갱신하고, 갱신된 위도와 경도를 기반으로 geocoder를 통해 상세 주소를 얻는 예제입니다.

 

코드 살펴보겠습니다.

class GpsTracker(private val mContext: Context) : Service(), LocationListener {
    private var location: Location? = null
    private var latitude = 0.0
    private var longitude = 0.0

    private var locationManager: LocationManager? = null

    init {
        getLocation()
    }
    
    // 위치가 변경될 때 호출되며, 현재 위치 정보를 업데이트합니다.
    override fun onLocationChanged(p0: Location) {
        latitude = location!!.latitude
        longitude = location!!.longitude
    }

    override fun onProviderEnabled(provider: String) {}

    override fun onProviderDisabled(provider: String) {}

    override fun onStatusChanged(provider: String, status: Int, extras: Bundle) {}


    @SuppressLint("MissingPermission")
    private fun getLocation() {
        try {
            locationManager = mContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
            val isGPSEnabled = locationManager!!.isProviderEnabled(LocationManager.GPS_PROVIDER)
            val isNetworkEnabled =
                locationManager!!.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
            if (!isGPSEnabled && !isNetworkEnabled) {
                // 어떤 작업을 수행해야할지 고민 중
            } else {
                if (isNetworkEnabled) {
                    locationManager!!.requestLocationUpdates(
                        LocationManager.NETWORK_PROVIDER,
                        MIN_TIME_BW_UPDATES,
                        MIN_DISTANCE_CHANGE_FOR_UPDATES.toFloat(),
                        this
                    )
                    if (locationManager != null) {
                        location =
                            locationManager!!.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
                        if (location != null) {
                            latitude = location!!.latitude
                            longitude = location!!.longitude
                        }
                    }
                }
                if (isGPSEnabled) {
                    if (location == null) {
                        locationManager!!.requestLocationUpdates(
                            LocationManager.GPS_PROVIDER,
                            MIN_TIME_BW_UPDATES,
                            MIN_DISTANCE_CHANGE_FOR_UPDATES.toFloat(),
                            this
                        )
                        if (locationManager != null) {
                            location =
                                locationManager!!.getLastKnownLocation(LocationManager.GPS_PROVIDER)
                            if (location != null) {
                                latitude = location!!.latitude
                                longitude = location!!.longitude
                            }
                        }
                    }
                }
            }
        } catch (e: java.lang.Exception) {
            e.printStackTrace()
        }
    }

    fun getLatitude(): Double {
        if (location != null) {
            latitude = location!!.latitude
        }
        return latitude
    }

    fun getLongtitude(): Double {
        if (location != null) {
            latitude = location!!.longitude
        }
        return longitude
    }


    override fun onBind(p0: Intent?): IBinder? {
        return null
    }

    fun stopUsingGps(){
        if(locationManager!=null){
            locationManager!!.removeUpdates(this@GpsTracker)
        }
    }

    companion object {
        private const val MIN_DISTANCE_CHANGE_FOR_UPDATES: Long = 10
        private const val MIN_TIME_BW_UPDATES: Long = 1000 * 60 * 1
    }
}

먼저 GPS, 즉 위치정보를 추적하는 클래스입니다.

Service 클래스를 상속하며, LocationListener 인터페이스를 구현하여 위치 업데이트 이벤트를 처리합니다.

 

주요 요소들을 간단하게 설명하겠습니다.

먼저 생성자의 인자로 Context를 받습니다. 저는 간단히 작성한 거라 Activity의 root context를 넘겨 받도록 작성하였지만, applicationContext로 어플리케이션의 전역 컨텍스트를 전달받아도 좋습니다.

 

다음으로는 어쩌면 가장 중요한 locationManager에 대해서 설명하겠습니다.

private var locationManager: LocationManager? = null

초기화 자체는 getLocation메서드에서 진행하며, 현재 위치를 가져오기 위한 LocationManager를 사용하기 위해 선언하였습니다.

LocationManager?
LocationManager는 안드로이드 에서 위치 관련 서비스를 관리하는 클래스입니다.
우리는 이 클래스를 사용해서 사용자의 위치 정보를 얻거나, 위치 업데이트를 요청할 수 있습니다.
GPS하드웨어를 사용하는 GPS_PROVIER, Wi-Fi를 사용하는 NETWORK_PROVIDER를 통해 일정 거리 이동 후, 혹은 일정 시간 간격(인터벌)으로 정보를 갱신합니다.
getLastKnownLocation()을 통해 마지막으로 알려진 위치를 가져올 수 있는 기능을 제공하여, 위치 업데이트 요청을 하지 않고 이전의 마지막으로 알려진 위치를 사용해야하는 경우 유용합니다.
위치 기반 서비스를 사용할때는 거의 필수적이라고 생각합니다.

 

다음으로는 LocationListener 인터페이스를 구현한 내용입니다.

onLocationChanged() 메서드는 위 주석으로도 작성해 뒀지만, 위치가 변경될  호출되며, 현재 위치 정보를 업데이트합니다.

onProviderEnabled(), onProviderDisabled(), onStatusChanged() 이렇게 3개는 위치 공급자 상태 변경에 대한 이벤트를 처리하지만, 저는 딱히 구현하지 않았습니다. 필요한 경우, 내용을 추가하여 사용하면 됩니다.

라고만 쓰면 너무 무책임하니 어떤 경우에 사용할 수 있을지 간단하게 파악해 보겠습니다.

  • onProviderEnabled()
    - 이 메서드는 공급자(Provider)가 사용 가능한 상태로 변경되었을 때 호출됩니다. 그래서 주로 foreground service에서 필수적인 notification을 작성하는 부분이라고 생각하시면 됩니다.
  • onProviderDisabled()
    - 이 메서드는 공급자가 사용 불가능한 상태로 변경되었을 때 호출됩니다. 주로 GPS하드웨어 활성 여부와 네트워크 상태가 맛이 간 경우에 호출하여 사용자에게 이런 상황을 알리는 코드를 작성합니다.
  • onStatusChanged()
    - 이 메서드는 공급자의 상태 변경을 감지할 때 호출됩니다. 정확성 변화, 활성/비활성, 일시적인 오류 등의 상황을 감지하기 때문에, 각각의 상황에 맞게 조치를 취하거나 사용자에게 문제를 알리는 코드를 작성합니다.

 

마지막으로 설명할만 하다고 생각되는 부분은 onBind()함수에서 null을 반환하여 서비스를 바인딩하지 않는 이유입니다.

일단 간단하게 설명하자면, 서비스가 시작된 후 오직 백그라운드에서만 실행되며, 외부로부터의 바인딩 요청을 수락하지 않는다는 것을 의미합니다

 

바인딩은 서비스와 클라이언트 간의 통신을 위해 메모리 CPU 자원을 소비합니다. 위 코드처럼 위치 추적을 주로하는 경우, 백그라운드에서만 동작하면 되어 바인딩을 지원하지 않을 수 있고, onBind()에서 null 반환하여 클라이언트와의 통신 과정을 수행하지 않음으로서 자원을 절약할 수 있습니다. 이를 통해 배터리 소모도 줄일 수 있습니다.

그리고 startService()를 사용해서 명시적으로 서비스를 시작하고 Intent로 데이터를 전달 할 수 있기 때문에, 저는 바인딩을 잘 사용하지 않습니다. 하지만, 서비스와 클라이언트 간의 인터페이스가 필요하거나 양방향 통신이 필요한 경우, 바인딩을 사용하시기 바랍니다.

 


 

다음으로는 Activiy 코드인데.. Permission 코드가 길어서 생략하고 올릴까 했지만, 접은글로 전문을 첨부하겠습니다.

필요하신 분은 

더보기
class MainActivity : AppCompatActivity() {
    private val binding : ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

    private var gpsTracker: GpsTracker? = null

    private val onActivityResultLauncher =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
            if (result.resultCode == Activity.RESULT_OK) {
                if (checkLocationServicesStatus()) {
                    Log.d(TAG, "onActivityResult : 활성화 되있음")
                    checkRunTimePermission()
                }
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // GPS를 사용하기 위해 사용자에게 위치 권한 요청
        requestLocationPermission()

        // GPS 활성화 여부 검사
        checkLocationServicesStatus()

        gpsTracker = GpsTracker(binding.root.context)

        binding.button.setOnClickListener {
            var latitude = gpsTracker!!.getLatitude()
            var longitude = gpsTracker!!.getLongtitude()
            
            // 글쓴이 위치 보호를 위해 좌표는 임의로 아무거나 지정ㅋㅋ!
            var address = getCurrentAddress(37.5318,126.9141)
            binding.textview.setText(address)

            Toast.makeText(this, "현재위치 \n위도 " + latitude + "\n경도 " + longitude, Toast.LENGTH_LONG).show();
        }

    }

    private fun requestLocationPermission(){
        ActivityCompat.requestPermissions(this, REQUIRED_PERMISSIONS, PERMISSIONS_REQUEST_CODE)
    }

    private fun checkRunTimePermission() {
        val hasFineLocationPermission = ContextCompat.checkSelfPermission(
            this,
            android.Manifest.permission.ACCESS_FINE_LOCATION
        )
        val hasCoarseLocationPermission = ContextCompat.checkSelfPermission(
            this,
            android.Manifest.permission.ACCESS_COARSE_LOCATION
        )

        if (hasFineLocationPermission == PackageManager.PERMISSION_GRANTED &&
            hasCoarseLocationPermission == PackageManager.PERMISSION_GRANTED
        ) {
            // 이미 권한이 허용됨
            Log.d(TAG, "checkRunTimePermission : 권한 이미 허용됨")
        } else {
            // 권한 요청
            if (ActivityCompat.shouldShowRequestPermissionRationale(
                    this,
                    REQUIRED_PERMISSIONS[0]
                )
            ) {
                // 사용자가 권한 거부를 한 번 이상 한 경우
                Snackbar.make(
                    binding.root,
                    "권한이 거부되었습니다. 설정(앱 정보)에서 권한을 허용해주세요.",
                    Snackbar.LENGTH_INDEFINITE
                ).setAction("확인") {
                    // 설정 화면으로 이동
                    val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                    val uri = Uri.fromParts("package", packageName, null)
                    intent.data = uri
                    startActivity(intent)
                }.show()
            } else {
                // 권한 요청 팝업 출력
                ActivityCompat.requestPermissions(
                    this,
                    REQUIRED_PERMISSIONS,
                    PERMISSIONS_REQUEST_CODE
                )
            }
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            PERMISSIONS_REQUEST_CODE -> {
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    // 권한 허용됨
                    Log.d(TAG, "onRequestPermissionsResult : 퍼미션 허용됨")
                } else {
                    // 권한 거부됨
                    Log.d(TAG, "onRequestPermissionsResult : 퍼미션 거부됨")
                }
                return
            }
        }
    }

    // 위치 서비스 상태를 확인합니다.
    private fun checkLocationServicesStatus():Boolean{
        val locationManager : LocationManager = getSystemService(LOCATION_SERVICE) as LocationManager

        return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
                ||locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
    }

    fun getCurrentAddress(latitude: Double, longitude: Double): String {
        val geocoder = Geocoder(this@MainActivity, Locale.getDefault())
        var addresses: List<Address>?

        try {
            addresses = geocoder.getFromLocation(
                latitude,
                longitude,
                7
            )
        } catch (ioException: IOException) {
            // 네트워크 문제
            Toast.makeText(this, "지오코더 서비스 사용불가", Toast.LENGTH_LONG).show()
            return "지오코더 서비스 사용불가"
        } catch (illegalArgumentException: IllegalArgumentException) {
            Toast.makeText(this, "잘못된 GPS 좌표", Toast.LENGTH_LONG).show()
            return "잘못된 GPS 좌표"
        }

        if (addresses == null || addresses.isEmpty()) {
            return "주소 미발견"
        }

        val address: Address = addresses[0]
        return address.getAddressLine(0).toString() + "\n"
    }


    companion object{
        private const val TAG = "GPS_TEST"
        private const val PERMISSIONS_REQUEST_CODE = 100
        private val REQUIRED_PERMISSIONS= arrayOf(
            android.Manifest.permission.ACCESS_FINE_LOCATION,
            android.Manifest.permission.ACCESS_COARSE_LOCATION
        )
    }

 

액티비티 코드는 크게 설명이 필요한 부분이 없다고 생각되어 글을 이만 줄이도록 하겠습니다.

 

 

도움이 되셨거나, 궁금한 내용이 있다면 댓글 달아주세요! 제가 아는 선에서 최선을 다해 돕겠습니다!