이 글은 누구를 위한 것인가
- 배달/차량 위치를 실시간으로 지도에 표시해야 하는 팀
- 지도에 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라 직접 폴링보다 훨씬 효율적이다.