Android Project/지키미

[Android/Kotlin] 하버사인 공식 사용해, 지도에 대피소 효율적으로 표시하기

juwon2 2025. 5. 28. 00:56

 

🤔 고민해본 사항

우선 나는 공공API를 통해 대피소관련 데이터를 불러와서, 내위치 근처에있는 대피소를 지도에 마커로 표시해주는 앱을 구현중이였다.

 

근데 전체 데이터를 다 불러오면 API호출이 너무 잦고, UI면에서도 안좋을것같았다. 그리고 무엇보다 전체 데이터를 다 불러오는건 내앱 취지에 안맞았다.

 

그래서 사용자 위치를 기준으로 반경 5km이내의 대피소만 화면에 표시해주는 방식으로 구현했다.

이 방식은 Haversine 공식을 사용해서, 사용자 위치와 대피소 위치간의 거리를 계산하여 필터링해주는 방식으로 구현했다!

그리고 맵이 시작될때 CircleOverlay를 생성하여 바운더리를 만든다음, 반경5km밖에 있는 마커들은 제거해주는 방식도 같이구현해서 좀 더 깔끔하게 보이도록 했다.

 

💻 구현하기

1. 현재위치 가져와서 표시하기

Naver Map 지도 띄우는 방법은 따로 포스팅을 작성해놨다.

현재위치를 표시하는 방법부터 작성해볼것이다.

 

(1) Naver Map 객체가져오기

먼저 Naver Map 객체를 가져와야한다.

 

아래는 공식문서에 작성되어있는 내용이다. 

네이버지도 공식문서

MapFragment 및 MapView는 지도에 대한 뷰 역할만을 담당하므로 API를 호출하려면 인터페이스 역할을 하는 NaverMap 객체가 필요합니다. MapFragment또는 MapView의 getMapAsync()메서드로 OnMapReadyCallback을 등록하면 비동기로 NaverMap 객체를 얻을 수 있습니다. NaverMap객체가 준비되면 onMapReady()콜백 메서드가 호출됩니다.

 

따라서 OnMapReadyCallback을 등록해서 NaverMap객체를 얻어와야한다.

@AndroidEntryPoint
class EvacuateFragment : Fragment(), OnMapReadyCallback {
    private val binding get() = _binding!!
    private var _binding: FragmentEvacuateBinding? = null
    
    private lateinit var naverMap: NaverMap
    
    ...

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentEvacuateBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        initializeMap()
        ...
    }


    // mapView 초기화
    private fun initializeMap() {
        val mapFragment = childFragmentManager.findFragmentById(R.id.map) as MapFragment?
            ?: MapFragment.newInstance().also {
                childFragmentManager.beginTransaction().add(R.id.map, it).commit()
            }
        mapFragment.getMapAsync(this)
    }
    
    @UiThread
    override fun onMapReady(naverMap: NaverMap) {
        this.naverMap = naverMap
        ...
    }

EvacuateFragment.kt

 

NaverMap을 Fragment 전역에서 사용할것이기 때문에 lateinit var로 따로 선언을 해주었다.

NaverMap 객체가 준비되면 onMapReady() 콜백 메서드가 호출된다. onMapReady() 메서드에서 naverMap객체를 초기화 시켜주면 된다.

 

(2) 현재위치 표시

NaverMap객체를 가져왔으니 본격적으로 현재위치를 가져와서 표시해야한다.

 

역시 아래 공식문서를 참고해 작성했다

네이버지도 공식문서(1)

네이버지도 공식문서(2)

 

AndroidManifest.xml에 다음과 같이 권한을 작성해준다.

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

AndroidManifest.xml

// Naver Map
implementation(libs.map.sdk)
implementation(libs.play.services.location)

의존성추가 (build.gradle)

private lateinit var locationSource: FusedLocationSource    //현재위치
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    initializeMap()
    ...
}


private fun initializeMap() {
    // NaverMap 객체 가져오기
    ...
}

// FusedLocationSource 초기화
private fun initializeLocationSource() {
    locationSource = FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE)
}

@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<String>,
    grantResults: IntArray
) {
    // 위치 권한처리
    if (locationSource.onRequestPermissionsResult(requestCode, permissions, grantResults)) {
        if (!locationSource.isActivated) {	//권한 거부됨
            naverMap.locationTrackingMode = LocationTrackingMode.None
        }
        return
    }
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}

@UiThread
    override fun onMapReady(naverMap: NaverMap) {
        this.naverMap = naverMap
        naverMap.locationSource = locationSource
        naverMap.uiSettings.isLocationButtonEnabled = true          // 현위치 버튼 컨트롤을 활성화
        naverMap.locationTrackingMode = LocationTrackingMode.Follow //위치이동에따라 카메라도 이동(위치추적기능)

    }
    
    companion object {
        // 권한 요청 코드
        private const val LOCATION_PERMISSION_REQUEST_CODE = 1000
    }

EvacuateFragment.kt

 

위치추적 모드는 공식문서보면 다양하게 확인할 수 있다. 구현하고싶은 방식에따라 구현하면될듯하다.

  • naverMap.uiSettings.isLocationButtonEnabled = true
    -> 사용자가 버튼클릭하면 위치추적모드 변경가능함
  • naverMap.locationTrackingMode = LocationTrackingMode.Follow 
    -> Follow: 위치 추적이 활성화, 현위치 오버레이와 카메라의 좌표가 사용자의 위치를 따라 움직임

 

오버라이딩한 onMapReady()함수에서 위치변경 이벤트도 구현가능하다.

나는 위치변경될때마다 현재위치와 이전위치사이의 거리를 비교해서, 지정한 일정범위를 초과했을때만 API를 재요청해서 마커를 찍어주는 로직을 작성해주었다. (위치가 크게 변경이 되야지만 호출되도록해서, 무분별한 API호출 막기위해 이렇게 작성해줬다.)

naverMap.addOnLocationChangeListener { location ->
    // 위치변경될때마다 호출할 코드작성
}

 

여기까지 작성해주면 현재위치가 잘 표시되는걸 볼수있을것이다.

 

2. 하버사인 공식 사용하기

이제 Haversine 공식을 사용해서, 사용자 위치와 대피소 위치간의 거리를 계산하여 API데이터를 효율적으로 필터링해서 보여줄것이다.

 

기존에는 아래와 같이 안드로이드 내에서 사용가능한 Location.distanceBetween()을 사용해서 두점간의 거리를 계산해주었다.

이렇게 사용해주어도 두점사이의 거리를 구할 수 있다. 그치만 하버사인공식을 사용하는것이 정확도가 더 높다고하여, 해당 공식을 사용하기로 결정했다.

// 두지점간의 거리 계산 확장함수
fun LatLng.distanceExtention(other: LatLng): Double {
    val results = FloatArray(1)
    Location.distanceBetween(this.latitude, this.longitude, other.latitude, other.longitude, results)
    return results[0].toDouble()
}

Extenstion.kt

 

하버사인 공식이란?

하버사인 공식은 구의 표면 위의 두 점 사이의 최단 거리를 계산하는 수학적 공식.

지구가 완벽한 구형이라고 가정하고, 위도와 경도를 이용하여 두 점 간의 거리를 구하는 공식이다.

 

import kotlin.math.*
import com.naver.maps.geometry.LatLng

// 하버사인 공식
fun LatLng.haversineDistance(other: LatLng): Double {
    val earthRadius = 6371e3 // 지구 반지름 (미터)
    val lat1 = this.latitude.toRadians()
    val lat2 = other.latitude.toRadians()
    val deltaLat = (other.latitude - this.latitude).toRadians()
    val deltaLon = (other.longitude - this.longitude).toRadians()

    val a = sin(deltaLat / 2).pow(2) +
            cos(lat1) * cos(lat2) * sin(deltaLon / 2).pow(2)
    val c = 2 * atan2(sqrt(a), sqrt(1 - a))

    return earthRadius * c // 중심각 c에 지구반지름을 곱해 거리를 반환
}

// Double의 확장함수로 도 단위를 라디안으로 변환
fun Double.toRadians(): Double = Math.toRadians(this)

Extenstion.kt

위도와 경도는 보통 도(Degree) 단위로 제공되는데, 하버사인 공식을 사용하려면 라디안(Radian)단위로 변환해야한다.

  • a는 두 점 사이의 구면 삼각법에 기반한 중심각에 대한 변수
  • c는 두 점 사이의 중심각을 계산
  • atan2 함수는 정확한 삼각 비율을 반환

=> 최종적으로 중심각 c에 지구 반지름을 곱해 최종 거리 계산

 

하버사인 공식 사용했을때의 장점?

  • 지구의 곡률을 고려하므로 평면거리보다 훨씬 정확한 결과를 제공한다.
  • 위치 기반 서비스에서 반경 내 검색, 거리 계산, 필터링 등 다양한 기능에 적합하다

 

 

이제 방금 구현해준 하버사인공식을 활용해서, 불러온 데이터를 필터링해야한다.

@HiltViewModel
class OutdoorEvacuationViewModel @Inject constructor(
    private val outdoorEvacuationRepository: OutdoorEvacuationRepository,
) : ViewModel() {

    private val _shelters = MutableStateFlow<List<EarthquakeOutdoorsShelterResponse.Shelter>>(emptyList())
    val shelters: StateFlow<List<EarthquakeOutdoorsShelterResponse.Shelter>> = _shelters

    // 위치 데이터
    private val _currentLocation = MutableStateFlow<LatLng?>(null)
    val currentLocation: StateFlow<LatLng?> = _currentLocation

    // 로딩 상태
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading

    // API로 currentAddress 데이터를 가져오고 업데이트
    fun fetchOutdoorShelters(currentAddress: String) {
        // 이미 로딩 중이면 중복 호출 방지
        if (_isLoading.value) {
            Log.d("OutdoorEvacuationViewModel", "이미 로딩 중입니다")
            return
        }
        
        viewModelScope.launch {
            try {
                _isLoading.value = true

                // 모든 페이지의 데이터 요청
                val allShelters = outdoorEvacuationRepository.requestAllOutdoorEvacuation()
                Log.d("OutdoorEvacuationViewModel", "가져온 대피소 수: ${allShelters.size}")

                // 현재 위치를 사용할 수 있는 경우 거리별 필터링
                _currentLocation.value?.let { location ->
                    // 반경 5km 이내의 대피소만 필터링
                    val filteredShelters = allShelters.filter { shelter ->
                        val latitude = shelter.la?.toDoubleOrNull() ?: 0.0
                        val longitude = shelter.lo?.toDoubleOrNull() ?: 0.0

                        if (latitude != 0.0 && longitude != 0.0) {
                            val shelterLocation = LatLng(latitude, longitude)
                            val distance = location.haversineDistance(shelterLocation)  //현재위치와 대피소간의 거리계산
                            distance <= 5000.0 // 거리가 5km이하인 대피소만 필터링
                        } else {
                            false
                        }
                    }.sortedBy { shelter ->
                        // 가까운 대피소부터 정렬
                        val latitude = shelter.la?.toDoubleOrNull() ?: 0.0
                        val longitude = shelter.lo?.toDoubleOrNull() ?: 0.0
                        val shelterLocation = LatLng(latitude, longitude)
                        location.haversineDistance(shelterLocation)
                    }

                    // 필터링된 데이터로 대피소 업데이트
                    _shelters.value = filteredShelters
                    Log.d("OutdoorEvacuationViewModel", "5km 내 대피소 수: ${filteredShelters.size}")

                } ?: run {
                    // 주소 기반 필터링
                    val adminKeywords = currentAddress.split(" ").filter { it.length >= 2 }
                    val filteredByAddress = if (adminKeywords.isNotEmpty()) {
                        allShelters.filter { shelter ->
                            val shelterAddress = shelter.eqkAcmdfcltyAdres ?: ""
                            adminKeywords.any { keyword ->
                                shelterAddress.contains(keyword)
                            }
                        }
                    } else {
                        // 주소가 없으면 모든 대피소 반환
                        allShelters
                    }
                    _shelters.value = filteredByAddress
                    Log.d("OutdoorEvacuationViewModel", "주소 기준 필터링 결과: ${filteredByAddress.size}")
                }

            } catch (e: Exception) {
                Log.e("OutdoorEvacuationViewModel", "API 요청 실패: ${e.message}", e)
                _errorMessage.value = "outdoor대피소 정보를 불러오는데 실패했습니다: ${e.message}"
            } finally {
                _isLoading.value = false
            }
        }
    }
}

OutdoorEvacuationViewModel.kt

 

나는 MVVM패턴을 사용해 코드를 구현했기때문에, ViewModel에서  MutableStateFlow를 사용해 UI상태를 실시간으로 관리하도록 해줬다.

fetchOutdoorShelters함수내에서 위도와 경도를 가져온다음,

val distance = location.haversineDistance(shelterLocation)을 활용하여 현재위치와 대피소의 위치가 5km이내인 대피소만 필터링한 로직을 반환하도록 로직을 작성했다. 추가로 sortBy를 사용해, 가까운 대피소부터 먼저정렬되도록 정확도를 더 높여줬다.

 

 

@AndroidEntryPoint
class EvacuateFragment : Fragment(), OnMapReadyCallback {
    private val binding get() = _binding!!
    private var _binding: FragmentEvacuateBinding? = null
   
    private lateinit var locationSource: FusedLocationSource    //현재위치
    private lateinit var naverMap: NaverMap

    private val outdoorViewModel: OutdoorEvacuationViewModel by viewModels()
    private val indoorViewModel: IndoorEvacuationViewModel by viewModels()
    private val sharedViewModel : SharedViewModel by activityViewModels()

    // 위치 업데이트 관련 변수 추가
    private var lastProcessedLocation: Location? = null

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentEvacuateBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        initializeMap()
        initializeLocationSource()
    }

    // 지도 초기화
    private fun initializeMap() {
        val mapFragment = childFragmentManager.findFragmentById(R.id.map) as MapFragment?
            ?: MapFragment.newInstance().also {
                childFragmentManager.beginTransaction().add(R.id.map, it).commit()
            }
        mapFragment.getMapAsync(this)
    }

    // FusedLocationSource 초기화
    private fun initializeLocationSource() {
        locationSource = FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE)
    }

    @Deprecated("Deprecated in Java")
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        // 위치 권한처리
        if (locationSource.onRequestPermissionsResult(requestCode, permissions, grantResults)) {
            if (!locationSource.isActivated) {
                naverMap.locationTrackingMode = LocationTrackingMode.None
            }
            return
        }
        ...
        
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    }
    
    // ViewModel에서 위치 및 대피소 데이터 관찰
    private fun observeViewModels() {
    
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED){
                // 병렬로 수집
                // 야외 대피소 로딩 상태 관찰
                launch {
                    outdoorViewModel.isLoading.collect { isLoading ->
                        binding.progressBar.isVisible = isLoading
                    }
                }
                // 야외 대피소 에러 상태 관찰
                launch {
                    outdoorViewModel.errorMessage.collect { errorMessage ->
                        if (!errorMessage.isNullOrEmpty()) {
                            Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show()
                            outdoorViewModel.clearErrorMessage()
                        }
                    }
                }
                // 야외 대피소 데이터 관찰
                launch {
                    outdoorViewModel.shelters.collect { outdoorShelters ->
                        val currentLocation = outdoorViewModel.currentLocation.value
                        if (currentLocation != null && outdoorShelters.isNotEmpty()) {
                            updateOutdoorSheltersOnMap(outdoorShelters, currentLocation)
                            Toast.makeText(requireContext(), "${outdoorShelters.size}개의 야외대피소를 찾았습니다.", Toast.LENGTH_SHORT).show()
                        }
                    }
                }
                // 실내 대피소 로딩 상태 관찰
                launch {
                    indoorViewModel.isLoading.collect { isLoading ->
                        binding.progressBar2.isVisible = isLoading
                    }
                }
                // 실내 대피소 에러 상태 관찰
                launch {
                    indoorViewModel.errorMessage.collect { errorMessage ->
                        if (!errorMessage.isNullOrEmpty()) {
                            Toast.makeText(requireContext(), errorMessage, Toast.LENGTH_LONG).show()
                            indoorViewModel.clearErrorMessage()
                        }
                    }
                }
                launch {
                    // 실내 대피소 데이터 관찰
                    indoorViewModel.shelter.collect { indoorShelters ->
                        val currentLocation = indoorViewModel.currentLocation.value
                        if (currentLocation != null && indoorShelters.isNotEmpty()) {
                            updateIndoorSheltersOnMap(indoorShelters, currentLocation)
                            Toast.makeText(requireContext(), "${indoorShelters.size} 개의 실내 대피소를 찾았습니다.", Toast.LENGTH_SHORT).show()
                        }
                    }
                }
            }
        }
    }


    @UiThread
    override fun onMapReady(naverMap: NaverMap) {
        this.naverMap = naverMap
        naverMap.locationSource = locationSource
        naverMap.uiSettings.isLocationButtonEnabled = true          // 현위치 버튼 컨트롤을 활성화
        naverMap.locationTrackingMode = LocationTrackingMode.Follow //위치이동에따라 카메라도 이동(위치추적기능)

        // 위치변경될때마다 현재위치와 이전위치사이의 거리를 비교해서, 지정한 일정범위를 초과했을때만 데이터요청하는 로직작성
        ...
    }

    // 서클 오버레이 업데이트 메서드
    private var currentCircleOverlay: CircleOverlay? = null
    private fun updateCircleOverlay(latitude: Double, longitude: Double) {
        // 기존 서클 제거
        currentCircleOverlay?.map = null
        // 새로운 서클 생성 및 표시
        currentCircleOverlay = CircleOverlay().apply {
            center = LatLng(latitude, longitude)
            radius = 5000.0     // 반경 5km
            map = naverMap
            color = Color.argb(0, 128, 0, 128) //투명
        }
    }
     ...

    // 야외대피소 마커표시, 반경밖의 마커는 삭제
    private fun updateOutdoorSheltersOnMap(
        shelters: List<EarthquakeOutdoorsShelterResponse.Shelter>,
        currentLocation: LatLng
    ) {
        // 기존 마커 제거
        val marker = Marker()
        marker.map = null

        shelters.forEach { outdoorShelter ->
            val latitude = outdoorShelter.la?.toDoubleOrNull()?.let { String.format("%.7f", it).toDouble() } ?: 0.0
            val longitude = outdoorShelter.lo?.toDoubleOrNull()?.let { String.format("%.7f", it).toDouble() } ?: 0.0

            // 유효한 좌표인지 확인
            if (latitude != 0.0 && longitude != 0.0) {
                val shelterLocation = LatLng(latitude, longitude)
                val distance = currentLocation.haversineDistance(shelterLocation)

                // 반경 5km 이내의 대피소만 표시
                if (distance <= 5000.0) {
                    val outdoorMarker = Marker().apply {
                        position = LatLng(latitude, longitude)
                        map = naverMap
                        icon = OverlayImage.fromResource(R.drawable.marker_red)
                        captionText = "${outdoorShelter.vtAcmdfcltyNm}\n${String.format("%.2f", distance)} m"
                        captionRequestedWidth = 150
                    }
                    outdoorMarker.setOnClickListener {
                        // 마커 클릭시 BottomSheetFragment로 Row전체데이터(outdoorShelter)와 distance 전달
                        val bottomSheetFragment = BottomSheetFragment.outdoorNewInstance(outdoorShelter, distance)
                        bottomSheetFragment.show(childFragmentManager, bottomSheetFragment.tag)
                        true
                    }
                }
            }
        }
    }

    // 실내대피소 마커표시, 반경밖의 마커는 삭제
    private fun updateIndoorSheltersOnMap(
        shelters: List<EarthquakeIndoorsShelterResponse.EarthquakeIndoor.Row>,
        currentLocation: LatLng
    ) {
        // 기존 마커 제거
        val marker = Marker()
        marker.map = null

        shelters.forEach { indoorShelter ->
            val latitude = indoorShelter.ycord.toDoubleOrNull()?.let { String.format("%.7f", it).toDouble() } ?: 0.0
            val longitude = indoorShelter.xcord.toDoubleOrNull()?.let { String.format("%.7f", it).toDouble() } ?: 0.0

            // 유효한 좌표인지 확인
            if (latitude != 0.0 && longitude != 0.0) {
                val shelterLocation = LatLng(latitude, longitude)
                val distance = currentLocation.haversineDistance(shelterLocation)

                // 반경 5km 이내의 대피소만 표시
                if (distance <= 5000.0) {
                    val indoorMarker = Marker().apply {
                        position = LatLng(latitude, longitude)
                        map = naverMap
                        icon = OverlayImage.fromResource(R.drawable.marker_blue)
                        captionText = "${indoorShelter.vtAcmdfcltyNm}\n${String.format("%.2f", distance)} m"
                        captionRequestedWidth = 150
                    }
                    indoorMarker.setOnClickListener {
                        // 마커 클릭시 BottomSheetFragment로 Row전체데이터(indoorShelter)와 distance 전달
                        val bottomSheetFragment = BottomSheetFragment.indoorNewInstance(indoorShelter, distance)
                        bottomSheetFragment.show(childFragmentManager, bottomSheetFragment.tag)
                        true
                    }
                }
            }
        }
    }

    ...
    
    companion object {
        // 위치권한 요청 코드
        private const val LOCATION_PERMISSION_REQUEST_CODE = 1000
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
        val marker = Marker()
        marker.map = null
        currentCircleOverlay?.map = null // 서클 오버레이 해제
        lastProcessedLocation = null // 메모리 해제
    }
}

EvacuateFragment.kt

 

Fragment에서는 아까 ViewModel에서 StateFlow로 구현한 데이터를 collect해서 실시간으로 받아와준다.

이때 나는 야외대피소, 실내대피소 각각의 다른 API를 가져오는 로직을 작성했기때문에 위와같이 병렬로 코드를 구현해주었다.

 

그리고 해당 Framgment에서는 UI업데이트(마커 업데이트)관련 로직을 작성해주었다.

즉, ViewModel에서 필터링 작업을 담당하고, Fragment에서는 해당 ViewModel의 데이터상태를 구독하여 데이터가 업데이트되면 UI를 갱신하는 방식이다.

 

 

3. 결과

 

왼쪽하단에 있는 위치버튼을 클릭해, 위치이용에 동의하면 나의위치가 파란점으로 보이고

내 위치기반으로 5km이내의 가까운 대피소를 잘 보여주는것을 확인할 수 있다.

오른쪽은 데이터를 잘 받아오는지 확인하기위해 지도를 축소해본 화면이다.

기본화면 / 축소했을때

 

 

💡깨달은점

위치기반서비스에서 거리계산을 적용할때는 일반 두점간의 거리를 계산하여 적용하는것보다, 하버사인 공식을 사용하는게 정확도가 더 높다는것과, 해당 공식 구현방법에 대해 알게되었다.

 

📚 참고자료

[네이버지도 참고자료(1)]

[네이버지도 참고자료(2)]

네이버지도 공식문서