위치 관련 기능을 개발하거나, 지도를 활용하여 개발을 해본 사람이라면 한 번쯤은 들어봤을 용어 Geofence(지오펜스).
우연히 회사에서 개발 중인 기능과 사이드 프로젝트로 진행 중인 DODO가 서로 Geofence기능을 요구하여 해당 내용을 정리하고자 한다.
삽질을 진짜 많이 해서 기록용으로 남기는 건 덤 🤯
지오펜스(Geofence)를 사용하는 것을 지오펜싱(Geofencing)이라 하며, 지오펜스는 쉽게 말해 특정 위치로부터 반경 x미터의 가상 구역을 만드는 것을 뜻한다.
사진으로 보면 이해가 쉽다. 파란색 원이 지오펜스이며, 해당 구역에 들어오면 조건에 따라 이벤트를 핸들링할 수 있다.
특별한 내용은 없다. 진짜 내용이 이게 다임
'지오펜스 진입 여부는 어떻게 판단하지?'
지오펜싱을 개발하기 전, 이 물음에 답하기 위해 선행되어야 할 지식이 필요하다.
구면 삼각법에서 지구 와 같은 구면의 두 점 사이의 대원 거리를 계산하는 공식이다. - 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)
공식을 이해하기엔 시간이 너무 오래걸린다. 코드로 빨리 보자 😵💫
친절하게 공식문서까지 있다. 하지만 지오펜싱 외에도 상호작용 해야할 요소가 많다. 그래서 직접 구현했다.
우선 나도 안드로이드 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
: 사용자가 지오펜스를 나감
사용자의 위치를 받아오려면 당연히 권한이 필요하다.
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) {
...
// 위치 업데이트
...
}
}
}
지오펜스 상태, 위치 권한까지 모두 개발했다면, 이제 지오펜스를 생성하고 이벤트를 핸들링한다.
// 하버사인 공식
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)
}
}
}
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", "지오펜스 영역을 벗어났습니다.")
}
}
}
두두에 이어, 현업에서 까지 엄청난 삽질을 통해 또 하나의 문제를 해결했다. 비록 알찬 내용은 아닐 수 있겠지만, 그래도 조금이라도 도움이 됐으면 좋겠다.
(실제로 개발한 코드는 Service + Broadcast Receiver + Custom Geofence 조합으로 개발되었습니다. 테스트용 전체 코드는 깃허브 링크를 참조해주세요!)
공감하며 읽었습니다. 좋은 글 감사드립니다.