MapKit
SwiftUI MapKit 및 현대적인 CoreLocation 비동기 API로 iOS 17+를 대상으로 하는 지도 기반 및 위치 인식 기능을 구축합니다. 뷰에는 Map과 MapContentBuilder를, 위치 스트리밍에는 CLLocationUpdate.liveUpdates()를, 지오펜싱에는 CLMonitor를 사용하세요.
확장된 MapKit 패턴은 references/mapkit-patterns.md를, CoreLocation 패턴은 references/mapkit-corelocation-patterns.md를 참조하세요.
목차
- 워크플로우
- SwiftUI Map View (iOS 17+)
- CoreLocation 현대 API
- 지오코딩
- 검색
- 길찾기
- PlaceDescriptor (iOS 26+)
- 일반적인 실수
- 리뷰 체크리스트
- 참고 자료
워크플로우
1. 마커 또는 주석이 있는 지도 추가
MapKit임포트.- 선택적
MapCameraPosition바인딩과 함께Map뷰 생성. MapContentBuilder클로저 내에Marker,Annotation,MapPolyline,MapPolygon, 또는MapCircle추가..mapStyle()로 지도 스타일 구성..mapControls { }로 지도 컨트롤 추가.selection:바인딩으로 선택 처리.
2. 사용자 위치 추적
- Info.plist에
NSLocationWhenInUseUsageDescription추가. - iOS 18+에서
CLServiceSession을 생성하여 권한 관리. Task에서CLLocationUpdate.liveUpdates()순회.- UI 업데이트 전 거리 또는 정확도별로 업데이트 필터링.
- 위치 추적이 더 이상 필요하지 않으면 태스크 중지.
3. 장소 검색
- 자동완성 제안을 위해
MKLocalSearchCompleter구성. - 사용자 입력 디바운스 (최소 300ms) 후 쿼리 설정.
- 선택된 완성 항목을
MKLocalSearch.Request로 변환하여 전체 결과 얻기. - 결과를 마커 또는 목록으로 표시.
4. 길찾기 및 경로 표시
- 출발지와 목적지
MKMapItem으로MKDirections.Request생성. transportType설정 (.automobile,.walking,.transit,.cycling).MKDirections.calculate()대기.MapPolyline(route.polyline)로 경로 그리기.
5. 기존 지도/위치 코드 검토
이 파일 끝에 있는 리뷰 체크리스트를 실행합니다.
SwiftUI Map View (iOS 17+)
import MapKit
import SwiftUI
struct PlaceMap: View {
@State private var position: MapCameraPosition = .automatic
var body: some View {
Map(position: $position) {
Marker("Apple Park", coordinate: applePark)
Marker("Infinite Loop", systemImage: "building.2",
coordinate: infiniteLoop)
}
.mapStyle(.standard(elevation: .realistic))
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
}
}
}
Marker와 Annotation
// 풍선 마커 -- 위치를 고정하는 가장 간단한 방법
Marker("Cafe", systemImage: "cup.and.saucer.fill", coordinate: cafeCoord)
.tint(.brown)
// Annotation -- 특정 좌표의 커스텀 SwiftUI 뷰
Annotation("You", coordinate: userCoord, anchor: .bottom) {
Image(systemName: "figure.wave")
.padding(6)
.background(.blue.gradient, in: .circle)
.foregroundStyle(.white)
}
오버레이: 폴리라인, 폴리곤, 원
Map {
// 좌표로부터 폴리라인
MapPolyline(coordinates: routeCoords)
.stroke(.blue, lineWidth: 4)
// 폴리곤 (영역 강조)
MapPolygon(coordinates: parkBoundary)
.foregroundStyle(.green.opacity(0.3))
.stroke(.green, lineWidth: 2)
// 원 (특정 지점 주변 반경)
MapCircle(center: storeCoord, radius: 500)
.foregroundStyle(.red.opacity(0.15))
.stroke(.red, lineWidth: 1)
}
카메라 위치
MapCameraPosition은 지도에 표시되는 내용을 제어합니다. 바인딩하여 사용자가 상호 작용하거나 프로그래밍 방식으로 카메라를 이동할 수 있게 하세요.
// 영역 중심
@State private var position: MapCameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.334, longitude: -122.009),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
)
// 사용자 위치 따라가기
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
// 특정 카메라 각도 (3D 원근)
@State private var position: MapCameraPosition = .camera(
MapCamera(centerCoordinate: applePark, distance: 1000, heading: 90, pitch: 60)
)
// 특정 항목 프레임
position = .item(MKMapItem.forCurrentLocation())
position = .rect(MKMapRect(...))
지도 스타일
.mapStyle(.standard) // 기본 도로 지도
.mapStyle(.standard(elevation: .realistic, showsTraffic: true))
.mapStyle(.imagery) // 위성
.mapStyle(.imagery(elevation: .realistic)) // 3D 위성
.mapStyle(.hybrid) // 위성 + 레이블
.mapStyle(.hybrid(elevation: .realistic, showsTraffic: true))
지도 상호 작용 모드
.mapInteractionModes(.all) // 기본: 이동, 확대/축소, 회전, 피치
.mapInteractionModes(.pan) // 이동만
.mapInteractionModes([.pan, .zoom]) // 이동 및 확대/축소
.mapInteractionModes([]) // 정적 지도 (상호 작용 없음)
지도 선택
@State private var selectedMarker: MKMapItem?
Map(selection: $selectedMarker) {
ForEach(places) { place in
Marker(place.name, coordinate: place.coordinate)
.tag(place.mapItem) // 태그는 선택 유형과 일치해야 함
}
}
.onChange(of: selectedMarker) { _, newValue in
guard let item = newValue else { return }
// 선택에 반응
}
CoreLocation 현대 API
CLLocationUpdate.liveUpdates() (iOS 17+)
CLLocationManagerDelegate 콜백을 단일 비동기 시퀀스로 대체합니다. 각 반복은 선택적 CLLocation을 포함하는 CLLocationUpdate를 생성합니다.
import CoreLocation
@Observable
final class LocationTracker: @unchecked Sendable {
var currentLocation: CLLocation?
private var updateTask: Task<Void, Never>?
func startTracking() {
updateTask = Task {
let updates = CLLocationUpdate.liveUpdates()
for try await update in updates {
guard let location = update.location else { continue }
// 수평 정확도로 필터링
guard location.horizontalAccuracy < 50 else { continue }
await MainActor.run {
self.currentLocation = location
}
}
}
}
func stopTracking() {
updateTask?.cancel()
updateTask = nil
}
}
CLServiceSession (iOS 18+)
기능의 수명 동안 권한 요구 사항을 선언합니다. 위치 서비스가 필요한 동안 세션에 대한 참조를 유지하세요.
// 사용 중 권한, 전체 정확도 선호
let session = CLServiceSession(
authorization: .whenInUse,
fullAccuracyPurposeKey: "NearbySearchPurpose"
)
// `session`을 저장 속성으로 유지하고 사용이 끝나면 해제합니다.
iOS 18+에서 CLLocationUpdate.liveUpdates()와 CLMonitor는 명시적으로 생성하지 않으면 암시적 CLServiceSession을 사용합니다. .always 권한 또는 전체 정확도가 필요할 때 명시적으로 생성하세요.
권한 흐름
// Info.plist 키 (필수):
// NSLocationWhenInUseUsageDescription
// NSLocationAlwaysAndWhenInUseUsageDescription (.always가 필요한 경우에만)
// 권한 확인 및 거부된 경우 설정으로 안내
struct LocationPermissionView: View {
@Environment(\.openURL) private var openURL
var body: some View {
ContentUnavailableView {
Label("위치 접근 거부됨", systemImage: "location.slash")
} description: {
Text("이 기능을 사용하려면 설정에서 위치 접근을 활성화하세요.")
} actions: {
Button("설정 열기") {
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
}
}
}
지오코딩
CLGeocoder (iOS 8+)
let geocoder = CLGeocoder()
// 순방향 지오코딩: 주소 문자열 -> 좌표
let placemarks = try await geocoder.geocodeAddressString("1 Apple Park Way, Cupertino")
if let location = placemarks.first?.location {
print(location.coordinate) // CLLocationCoordinate2D
}
// 역방향 지오코딩: 좌표 -> placemark
let location = CLLocation(latitude: 37.3349, longitude: -122.0090)
let placemarks = try await geocoder.reverseGeocodeLocation(location)
if let placemark = placemarks.first {
let address = [placemark.name, placemark.locality, placemark.administrativeArea]
.compactMap { $0 }
.joined(separator: ", ")
}
MKGeocodingRequest 및 MKReverseGeocodingRequest (iOS 26+)
더 풍부한 데이터를 가진 MKMapItem과 유연한 주소 서식을 위한 MKAddress / MKAddressRepresentations를 반환하는 새로운 MapKit 네이티브 지오코딩입니다.
@available(iOS 26, *)
func reverseGeocode(location: CLLocation) async throws -> MKMapItem? {
guard let request = MKReverseGeocodingRequest(location: location) else {
return nil
}
let mapItems = try await request.mapItems
return mapItems.first
}
@available(iOS 26, *)
func forwardGeocode(address: String) async throws -> [MKMapItem] {
guard let request = MKGeocodingRequest(addressString: address) else { return [] }
return try await request.mapItems
}
검색
MKLocalSearchCompleter (자동완성)
@Observable
final class SearchCompleter: NSObject, MKLocalSearchCompleterDelegate {
var results: [MKLocalSearchCompletion] = []
var query: String = "" { didSet { completer.queryFragment = query } }
private let completer = MKLocalSearchCompleter()
override init() {
super.init()
completer.delegate = self
completer.resultTypes = [.address, .pointOfInterest]
}
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
results = completer.results
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
results = []
}
}
MKLocalSearch (전체 검색)
func search(for completion: MKLocalSearchCompletion) async throws -> [MKMapItem] {
let request = MKLocalSearch.Request(completion: completion)
request.resultTypes = [.pointOfInterest, .address]
let search = MKLocalSearch(request: request)
let response = try await search.start()
return response.mapItems
}
// 자연어 쿼리로 특정 영역 내 검색
func searchNearby(query: String, region: MKCoordinateRegion) async throws -> [MKMapItem] {
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
request.region = region
let search = MKLocalSearch(request: request)
let response = try await search.start()
return response.mapItems
}
길찾기
func getDirections(from source: MKMapItem, to destination: MKMapItem,
transport: MKDirectionsTransportType = .automobile) async throws -> MKRoute? {
let request = MKDirections.Request()
request.source = source
request.destination = destination
request.transportType = transport
let directions = MKDirections(request: request)
let response = try await directions.calculate()
return response.routes.first
}
지도에 경로 표시
@State private var route: MKRoute?
Map {
if let route {
MapPolyline(route.polyline)
.stroke(.blue, lineWidth: 5)
}
Marker("시작", coordinate: startCoord)
Marker("끝", coordinate: endCoord)
}
.task {
route = try? await getDirections(from: startItem, to: endItem)
}
ETA 계산
func getETA(from source: MKMapItem, to destination: MKMapItem) async throws -> TimeInterval {
let request = MKDirections.Request()
request.source = source
request.destination = destination
let directions = MKDirections(request: request)
let response = try await directions.calculateETA()
return response.expectedTravelTime
}
자전거 길찾기 (iOS 26+)
@available(iOS 26, *)
func getCyclingDirections(to destination: MKMapItem) async throws -> MKRoute? {
let request = MKDirections.Request()
request.source = MKMapItem.forCurrentLocation()
request.destination = destination
request.transportType = .cycling
let directions = MKDirections(request: request)
let response = try await directions.calculate()
return response.routes.first
}
PlaceDescriptor (iOS 26+)
Place ID 없이 좌표나 주소로 풍부한 장소 참조를 생성합니다. import GeoToolbox가 필요합니다.
@available(iOS 26, *)
func lookupPlace(name: String, coordinate: CLLocationCoordinate2D) async throws -> MKMapItem {
let descriptor = PlaceDescriptor(
representations: [.coordinate(coordinate)],
commonName: name
)
let request = MKMapItemRequest(placeDescriptor: descriptor)
return try await request.mapItem
}
일반적인 실수
하지 마세요: 처음부터 .authorizedAlways를 요청하세요 — 사용자는 광범위한 권한을 신뢰하지 않습니다.
하세요: .requestWhenInUseAuthorization()으로 시작하고, 사용자가 백그라운드 기능을 활성화할 때만 .always로 에스컬레이션하세요.
하지 마세요: iOS 17+에서 단순 위치 가져오기에 CLLocationManagerDelegate를 사용하세요.
하세요: 더 깔끔하고 간결한 코드를 위해 CLLocationUpdate.liveUpdates() 비동기 스트림을 사용하세요.
하지 마세요: 지도/뷰가 보이지 않을 때 위치 업데이트를 계속 실행하세요 (배터리 소모).
하세요: SwiftUI에서 .task { }를 사용하여 사라질 때 업데이트가 자동으로 취소되도록 하세요.
하지 마세요: CLPlacemark 속성을 강제 언래핑하세요 — 모두 선택 사항입니다.
하세요: nil 병합 사용: placemark.locality ?? "알 수 없음".
하지 마세요: MKLocalSearchCompleter 쿼리 업데이트마다 MKLocalSearch.start()를 실행하세요.
하세요: 사용자가 완성 항목을 선택한 후에만 전체 검색을 실행하고, 입력 중에는 MKLocalSearchCompleter.delegate 결과에 의존하세요.
리뷰 체크리스트
정확성
- 모든 런타임 시나리오에서 맵이 올바른 좌표를 표시합니까?
- 영역 및 스팬이 적절합니까 (너무 확대/축소되지 않음)?
- 지오코딩/역지오코딩이 네트워크 오류를 정상적으로 처리합니까?
- 검색 결과가 예상과 일치합니까 (결과 유형, 필터링)?
- 경로가 정확합니까 (시작/끝 지점, 중간 경유지)?
권한
- Info.plist에
NSLocationWhenInUseUsageDescription이 있습니까? - 백그라운드 위치가 사용되는 경우에만
NSLocationAlwaysAndWhenInUseUsageDescription이 있습니까? - 앱이 승인되지 않은 상태와 거부된 상태를 적절히 처리합니까?
- iOS 18+에서 기능별로
CLServiceSession이 사용됩니까? - 앱이 승인 상태를 확인하고 필요할 때 설정으로 안내합니까?
성능
- 위치 업데이트가 거리/정확도 임계값으로 필터링됩니까?
- 지도가 더 이상 필요하지 않을 때 업데이트가 중지됩니까 (
.task범위)? - 검색 쿼리가 디바운스됩니까 (300ms 이상)?
- 많은 수의 주석이 있는 경우 주석 클러스터링이 사용됩니까?
- 배터리 소모를 방지하기 위해 과도한 지도 새로고침을 피합니까?
보안
- 위치 데이터가 불필요하게 지속/공유되지 않습니까?
- 위치 권한이 민감한 데이터가 아닌 기능 동작에만 사용됩니까?
- 전체 정확도가 필요한 경우
fullAccuracyPurposeKey가 Info.plist에 있습니까? - iOS 18+에서
CLServiceSession권한이 적절한 최소 범위로 설정됩니까?
접근성
- 모든 주석/마커에 접근성 레이블이 있습니까?
- 지도 컨트롤에 VoiceOver 힌트가 있습니까 (
.accessibilityHint)? - 축소된 지도가 접근성 요소를 숨기지 않습니까?
- 사용자 지정 어노테이션 뷰에
.accessibilityElement(children: .contain)또는.combine이 있습니까?
오류 처리
- 네트워크 오류 (지오코딩, 검색, 길찾기)가 정상적으로 처리됩니까?
- 위치 서비스를 사용할 수 없을 때 사용자에게 표시가 됩니까?
- 지도 타일 로딩 실패가 (가능한 경우) 처리됩니까?
- 지역 모니터링 실패가 호출됩니까?
유지보수성
- 지도/위치 로직이 SwiftUI 뷰와 분리되어 있습니까?
- 장소 검색, 경로 계산과 같은 반복 작업에 재사용 가능한 함수가 사용됩니까?
- 매직 넘버 (좌표, 반경)가 상수로 정의됩니까?
- 플랫폼 버전 가용성이
#available로 확인됩니까?