모바일 지도와 위치: MapKit(iOS)과 Google Maps(Android) 구현

모바일 개발

MapKitGoogle Maps위치 서비스지오펜싱실시간 위치

이 글은 누구를 위한 것인가

  • 배달/차량 위치를 실시간으로 지도에 표시해야 하는 팀
  • 지도에 1,000개 이상 마커를 효율적으로 표시해야 하는 개발자
  • 지오펜싱으로 특정 구역 진입/이탈을 감지해야 하는 엔지니어

들어가며

배달 앱에서 라이더 위치를 실시간으로 보여주고, 반경 1km 매장을 표시하고, 특정 구역 진입 시 알림을 보낸다. MapKit과 Google Maps로 이 모든 것을 구현할 수 있다.

이 글은 bluebutton.kr의 모바일 위치 서비스 가이드 를 참고하여 작성했습니다.


1. 지도 구현 전략

[iOS MapKit vs Google Maps SDK]

MapKit (Apple):
  비용: 무료 (Apple Developer)
  강점: SwiftUI 통합, 개인정보 보호, Apple 생태계
  약점: 커스터마이징 제한, 교통 데이터 약함
  적합: 기본 지도, 애플 서비스 연동

Google Maps iOS SDK:
  비용: 무료 (월 $200 크레딧 후 유료)
  강점: 상세 교통/POI, Street View, 글로벌 데이터
  약점: 비용, 개인정보
  적합: 배달, 네비게이션, 글로벌 서비스

[위치 권한 전략]

Always (항상):
  백그라운드에서도 위치 추적
  사용자 거부율 높음
  배달 기사 앱, 트래킹 앱만

When In Use (사용 중):
  앱 활성화 시만
  일반 지도 앱 권장

항상 설명하기:
  "현재 위치 반경 3km 매장을 보여줍니다"
  WHY를 먼저 설명

[마커 클러스터링]
  1,000개 마커 개별 표시 → FPS 하락
  클러스터링: 줌 레벨에 따라 그룹화
  iOS: MapKit Annotation Clustering
  Android: Google Maps Android Utility Library

2. 지도 구현

// iOS MapKit + SwiftUI
import MapKit
import SwiftUI
import CoreLocation

// 실시간 배달 위치 추적
struct DeliveryMapView: View {
    @StateObject private var viewModel = DeliveryMapViewModel()
    
    var body: some View {
        Map(
            position: $viewModel.mapPosition,
            interactionModes: .all,
        ) {
            // 배달 기사 위치 (실시간)
            if let riderLocation = viewModel.riderLocation {
                Annotation("배달 기사", coordinate: riderLocation) {
                    ZStack {
                        Circle().fill(.orange).frame(width: 40, height: 40)
                        Image(systemName: "bicycle")
                            .foregroundColor(.white)
                            .font(.system(size: 18))
                    }
                    .shadow(radius: 3)
                }
            }
            
            // 주문 목적지
            if let destination = viewModel.destination {
                Marker("배달지", systemImage: "house.fill", coordinate: destination)
                    .tint(.blue)
            }
            
            // 경로 표시
            if let route = viewModel.route {
                MapPolyline(route.polyline)
                    .stroke(.blue, lineWidth: 4)
            }
            
            // 반경 원 표시
            MapCircle(center: viewModel.storeLocation, radius: 3000)
                .foregroundStyle(.blue.opacity(0.1))
                .stroke(.blue, lineWidth: 1)
        }
        .mapControls {
            MapUserLocationButton()
            MapCompass()
            MapScaleView()
        }
        .task { await viewModel.startTracking() }
    }
}

@MainActor
class DeliveryMapViewModel: ObservableObject {
    @Published var mapPosition: MapCameraPosition = .automatic
    @Published var riderLocation: CLLocationCoordinate2D?
    @Published var destination: CLLocationCoordinate2D?
    @Published var route: MKRoute?
    
    let storeLocation = CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780)
    
    func startTracking() async {
        // WebSocket으로 실시간 위치 수신
        for await location in trackRiderLocation() {
            withAnimation(.easeInOut(duration: 0.5)) {
                riderLocation = location
            }
        }
    }
    
    private func trackRiderLocation() -> AsyncStream<CLLocationCoordinate2D> {
        AsyncStream { continuation in
            // WebSocket 연결
            let ws = WebSocketTracker()
            ws.onLocationUpdate = { coordinate in
                continuation.yield(coordinate)
            }
            ws.connect()
            continuation.onTermination = { _ in ws.disconnect() }
        }
    }
    
    func calculateRoute(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async {
        let request = MKDirections.Request()
        request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
        request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
        request.transportType = .automobile
        
        let directions = MKDirections(request: request)
        if let response = try? await directions.calculate() {
            route = response.routes.first
        }
    }
}

// 지오펜싱 (특정 구역 진입/이탈 감지)
class GeofenceManager: NSObject, CLLocationManagerDelegate {
    private let locationManager = CLLocationManager()
    
    override init() {
        super.init()
        locationManager.delegate = self
    }
    
    func addGeofence(identifier: String, center: CLLocationCoordinate2D, radius: CLLocationDistance) {
        let region = CLCircularRegion(center: center, radius: radius, identifier: identifier)
        region.notifyOnEntry = true
        region.notifyOnExit = true
        locationManager.startMonitoring(for: region)
    }
    
    func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
        NotificationCenter.default.post(name: .geofenceEntered, object: region.identifier)
    }
    
    func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
        NotificationCenter.default.post(name: .geofenceExited, object: region.identifier)
    }
}

extension Notification.Name {
    static let geofenceEntered = Notification.Name("geofenceEntered")
    static let geofenceExited = Notification.Name("geofenceExited")
}

class WebSocketTracker {
    var onLocationUpdate: ((CLLocationCoordinate2D) -> Void)?
    func connect() {}
    func disconnect() {}
}
// Android Google Maps 클러스터링
import com.google.android.gms.maps.GoogleMap
import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.ClusterItem

data class StoreItem(
    private val position: com.google.android.gms.maps.model.LatLng,
    private val title: String,
    val storeId: String,
) : ClusterItem {
    override fun getPosition() = position
    override fun getTitle() = title
    override fun getSnippet() = storeId
    override fun getZIndex() = 0f
}

class MapFragment : Fragment() {
    private lateinit var clusterManager: ClusterManager<StoreItem>
    
    fun setupClusterManager(googleMap: GoogleMap, stores: List<Store>) {
        clusterManager = ClusterManager(requireContext(), googleMap)
        
        googleMap.setOnCameraIdleListener(clusterManager)
        googleMap.setOnMarkerClickListener(clusterManager)
        
        clusterManager.setOnClusterItemClickListener { item ->
            // 개별 매장 클릭
            showStoreDetail(item.storeId)
            true
        }
        
        val items = stores.map { store ->
            StoreItem(
                position = com.google.android.gms.maps.model.LatLng(store.lat, store.lng),
                title = store.name,
                storeId = store.id,
            )
        }
        clusterManager.addItems(items)
        clusterManager.cluster()
    }
    
    private fun showStoreDetail(storeId: String) {}
}

data class Store(val id: String, val name: String, val lat: Double, val lng: Double)

마무리

실시간 위치 추적은 WebSocket으로 서버에서 좌표를 받아 지도에 표시하는 패턴이 가장 효율적이다. 마커 클러스터링은 500개 이상부터 반드시 적용하라 — 개별 마커가 너무 많으면 지도가 버벅인다. 지오펜싱은 배터리를 최소화하는 시스템 API라 직접 폴링보다 훨씬 효율적이다.