Geofencing? 그게 뭐야

h2on ·2023년 7월 31일
1

Android

목록 보기
3/4
post-thumbnail

시작하기 전에

위치 관련 기능을 개발하거나, 지도를 활용하여 개발을 해본 사람이라면 한 번쯤은 들어봤을 용어 Geofence(지오펜스).
우연히 회사에서 개발 중인 기능과 사이드 프로젝트로 진행 중인 DODO가 서로 Geofence기능을 요구하여 해당 내용을 정리하고자 한다.

삽질을 진짜 많이 해서 기록용으로 남기는 건 덤 🤯

지오펜싱? 지오펜스?

지오펜스(Geofence)를 사용하는 것을 지오펜싱(Geofencing)이라 하며, 지오펜스는 쉽게 말해 특정 위치로부터 반경 x미터의 가상 구역을 만드는 것을 뜻한다.

사진으로 보면 이해가 쉽다. 파란색 원이 지오펜스이며, 해당 구역에 들어오면 조건에 따라 이벤트를 핸들링할 수 있다.

특별한 내용은 없다. 진짜 내용이 이게 다임


선행 지식

'지오펜스 진입 여부는 어떻게 판단하지?'
지오펜싱을 개발하기 전, 이 물음에 답하기 위해 선행되어야 할 지식이 필요하다.

Haversine Formula (하버사인 공식)

구면 삼각법에서 지구 와 같은 구면의 두 점 사이의 대원 거리를 계산하는 공식이다. - Google
뭐라는 거야.. 🤨🧐🫨

두 위도/경도 좌표 사이의 거리를 구하는 공식이다.
여기 직선과 곡선이 있다. 선의 길이로 따지면 직선이 곡선보다 당연히 더 짧다. 하지만 실제 거리는 원호(검정선)로 표시된 경로가 더 짧다. 왜일까? 일단 우리가 가장 흔하게 볼 수 있는 세계지도(지도)는 수많은 투영법 중, 메르카토르 투영법으로 그린 지도다. 이 투영법의 특징은 두 지점 사이의 직선이 위도/경도와 이루는 각을 정확하게 보여준다는 것이 특징이다.
하지만 적도 부근은 거의 정확하나, 고위도로 갈 수록 심하게 왜곡된다.

어느정도냐면..

러시아가 남아메리카보다 훨씬 커 보이지만, 실제로는 남아메리카가 더 크다.

가장 중요한 것은, 지구는 평면이 아니다. 곡률이 있기 때문에 직선보다 곡선이 실제 거리가 더 짧은 것이다.
이 최단 거리를 쉽게 구하기 위해 우리 선조들이 공식을 만들었다.


예?


val radius = 6371000.0
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)

val a = (Math.sin(dLat / 2) * Math.sin(dLat / 2)
        + Math.cos(Math.toRadians(lat1)) 
        * Math.cos(Math.toRadians(lat2))
        * Math.sin(dLon / 2) * Math.sin(dLon / 2))

val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
val distance = (radius * c)

공식을 이해하기엔 시간이 너무 오래걸린다. 코드로 빨리 보자 😵‍💫

Android에는 지오펜싱 API가 있잖아?

친절하게 공식문서까지 있다. 근데 옛날 문서, 업데이트를 안 해서 최신 SDK에서는 동작이 안 한다. (당장 내일이 테스트 빌드 전달인데 언제 기다리고 있을까) 그래서 직접 구현했다.

GeofenceStatus

우선 나도 안드로이드 Geofence API를 참고하여 만들었다. 사용자의 위치 상태를 나타내는 Enum Class이다.

enum class GeofenceStatus {
    GEOFENCE_CURRENT_ENTER,
    GEOFENCE_CURRENT_DWELL,
    GEOFENCE_CURRENT_EXIT
}

총 세 가지의 상태가 존재한다.
GEOFENCE_CURRENT_ENTER : 사용자가 지오펜스 내부로 진입함
GEOFENCE_CURRENT_DWELL : 진입한 후 특정 시간(duration)동안 머뭄
GEOFENCE_CURRENT_EXIT : 사용자가 지오펜스를 나감

Permission

사용자의 위치를 받아오려면 당연히 권한이 필요하다.

private fun isLocationPermissionsGranted() =
        ActivityCompat.checkSelfPermission(
            this,
            Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED

private fun requestLocationPermission() {
    ActivityCompat.requestPermissions(
        this,
        arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
        REQUEST_CODE_LOCATION_PERMISSION
    )
}

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,     
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode == REQUEST_CODE_LOCATION_PERMISSION) {
        if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
        	...
            // 위치 업데이트
            ...
        }
    }
}

CheckGeofence

지오펜스 상태, 위치 권한까지 모두 개발했다면, 이제 지오펜스를 생성하고 이벤트를 핸들링한다.

// 하버사인 공식
private fun calculateDistance(
        lat1: Double,
        lon1: Double,
        lat2: Double,
        lon2: Double
    ): Float {
        val radius = 6371000.0
        val dLat = Math.toRadians(lat2 - lat1)
        val dLon = Math.toRadians(lon2 - lon1)

        val a = (Math.sin(dLat / 2) * Math.sin(dLat / 2)
                + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
                * Math.sin(dLon / 2) * Math.sin(dLon / 2))

        val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

        return (radius * c).toFloat()
    }

지오펜스 생성 및 확인

private fun checkGeofence(currentLocation: Location) {
        val geofenceLat = 37.5465 // 지오펜스 생성을 위한 테스트용 위도
        val geofenceLng = 126.9497 // 지오펜스 생성을 위한 테스트용 경도

        val distance = calculateDistance(
            currentLocation.latitude,
            currentLocation.longitude,
            geofenceLat,
            geofenceLng
        )

        if (distance <= GEOFENCE_RADIUS_IN_METERS) {
            val isNotEnterStatus = _statusLiveData.value != GeofenceStatus.GEOFENCE_CURRENT_ENTER && _statusLiveData.value != GeofenceStatus.GEOFENCE_CURRENT_DWELL
            if (isNotEnterStatus) {
                _statusLiveData.postValue(GeofenceStatus.GEOFENCE_CURRENT_ENTER)
            }
        } else {
            val isNotExitStatus = _statusLiveData.value != GeofenceStatus.GEOFENCE_CURRENT_EXIT
            if (isNotExitStatus) {
                _statusLiveData.postValue(GeofenceStatus.GEOFENCE_CURRENT_EXIT)
            }
        }
    }

callback을 통해 사용자의 위치 업데이트 & 지오펜싱

private fun startLocationUpdates() {
        val locationRequest = LocationRequest.create().apply {
            interval = 500
            priority = LocationRequest.PRIORITY_HIGH_ACCURACY
        }
        val locationCallback = object : LocationCallback() {
            override fun onLocationResult(locationResult: LocationResult) {
                super.onLocationResult(locationResult)
                locationResult?.lastLocation?.let { location ->
                    Log.d("Geofence Location", location.toString())
                    checkGeofence(location)
                }
            }
        }
        fusedLocationClient.requestLocationUpdates(
            locationRequest,
            locationCallback,
            Looper.getMainLooper()
        )
    }

이벤트 핸들링

statusLiveData.observe(this) { status ->
            when (status) {
                GeofenceStatus.GEOFENCE_CURRENT_ENTER -> {
                    Log.d("Geofence Enter Event", "지오펜스 영역을 들어왔습니다.")
                    checkDwellTime()
                }
                GeofenceStatus.GEOFENCE_CURRENT_DWELL -> {
                    //timer.cancel()
                    Log.d("Geofence DWELL Event", "지오펜스 영역에 머물렀습니다.")
                    Toast.makeText(this, "지정된 시간 동안 지오펜스 내부에 머물렀습니다.", Toast.LENGTH_LONG).show()
                }
                GeofenceStatus.GEOFENCE_CURRENT_EXIT -> {
                    //timer.cancel()
                    Log.d("Geofence Exit Event", "지오펜스 영역을 벗어났습니다.")
                }
            }
        }

Geofence완성 😙

마치며

두두에 이어, 현업에서 까지 엄청난 삽질을 통해 또 하나의 문제를 해결했다. 비록 알찬 내용은 아닐 수 있겠지만, 그래도 조금이라도 도움이 됐으면 좋겠다.
(실제로 개발한 코드는 Service + Broadcast Receiver + Custom Geofence 조합으로 개발되었습니다. 테스트용 전체 코드는 깃허브 링크를 참조해주세요!)

profile
돈 버는 백수가 꿈

2개의 댓글

comment-user-thumbnail
2023년 7월 31일

공감하며 읽었습니다. 좋은 글 감사드립니다.

1개의 답글