Geofencing? 그게 뭐야

h2on ·2023년 7월 31일
2

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가 있잖아?

친절하게 공식문서까지 있다. 하지만 지오펜싱 외에도 상호작용 해야할 요소가 많다. 그래서 직접 구현했다.

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개의 답글