1. SwiftUI Map 기본 설정
iOS 17부터 SwiftUI의 Map이 크게 개선되었다.
MapCameraPosition으로 카메라를 제어하고 UserAnnotation()으로 유저 위치를 표시할 수 있다.
@State private var cameraPosition: MapCameraPosition = .userLocation(fallback: .automatic)
var body: some View {
Map(position: $cameraPosition) {
UserAnnotation()
}
.mapControls {
MapUserLocationButton()
MapCompass()
}
}
Swift
복사
왜 .userLocation(fallback: .automatic)인가
초기 카메라 위치를 유저 현재 위치로 잡되, 위치를 아직 못 가져왔을 때는 시스템이 적절한 기본 위치를 보여주도록 fallback을 .automatic으로 설정했다.
서울시청 좌표 같은 값을 하드코딩하는 것보다 유연하다.
MapControls
MapUserLocationButton()은 탭하면 유저 위치로 카메라가 이동하는 시스템 제공 버튼이다.
MapCompass()는 지도가 회전됐을 때 나침반을 보여준다.
직접 구현할 필요없이 시스템 UI를 활용하면 일관된 UX를 제공할 수 있다.
2. 위치 권한 관리: LocationManager
CLLocationManager를 SwiftUI의 @Observable 패턴과 함께 사용하기 위해 별도의 LocationManager 클래스를 만들었다.
@Observable @MainActor
final class LocationManager: NSObject {
private let manager = CLLocationManager()
var authorizationStatus: CLAutorizationStatus = .notDetermined
var lastLocation: CLLocation?
var alertType: LocationAlertType?
override init() {
super.init()
manager.delegate = self
authorizationStatus = manager.authorizationStatus
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = 1
manager.pauseLocationUpdatesAutomatically = true
}
}
Swift
복사
설계 결정 포인트
@Observable @MainActor를 클래스 레벨에 적용한 이유
authorizationStatus, lastLocation, alertType 모두 View가 관찰하는 프로퍼티다.
이 값들이 바뀌면 UI가 업데이트되어야 하고, UI 업데이트는 반드시 메인 스레드에서 이루어져야 한다.
함수마다 @MainActor를 붙이는 것보다 클래스 전체에 적용하면 실수를 방지할 수 있다.
NSObject를 상속하는 이유
CLLocationManagerDelegate가 Objective-C 프로토콜이기 때문이다.
Swift의 순수 클래스로는 채택할 수 없다.
distanceFilter = 1
1미터 이상 이동했을 때만 위치 업데이트를 받는다.
0으로 설정하면 미세한 GPS 흔들림에도 업데이트가 발생해서 불필요한 연산이 생긴다.
3. 권한 상태별 분기 처리
위치 권한은 5가지 상태가 있고, 각각 다른 대응이 필요하다.
func checkLocationPermission() {
switch manager.authorizationStatus {
case .notDetermined: manager.requestWhenInUseAutorization()
case .restricted: alertType = .restricted
case .denied: alertType = .denied
case .authorizedAlways, .authorizedWhenInUse: startLocationServices()
default: break
}
}
Swift
복사
restricted와 denied를 구분하는 이유
처음에는 둘을 같은 케이스로 처리했다가 분리했다.
•
denied:
유저가 직접 거부한 상태. “설정에서 허용해주세요”라고 안내하고 설정 앱으로 보낼 수 있다.
•
restricted:
보호자 통제(자녀 기기) 등으로 유저가 변경할 수 없는 상태. 설정에 가도 바꿀 수 없으므로 “위치 서비스가 제한되어 있습니다”라고만 안내해야 한다.
같은 메시지를 보여주면 restricted 유저가 설정에 갔다가 바꿀 수 없는 옵션을 찾느라 혼란스러워진다.
enum LocationAlertType {
case denied
case restricted
var title: String {
switch self {
case .denied: "위치 권한 허용 필요"
case .restricted: "위치 서비스 사용 제한"
}
}
var message: String {
switch self {
case .denied: "설정에서 위치 권한을 허용해 주세요."
case .restricted: "위치 서비스 사용이 제한된 상태입니다."
}
}
}
Swift
복사
4. Delegate의 @MainActor의 충돌
CLLocationManagerDelegate 메서드를 @MainActor 클래스에서 직접 구현하면 Swift 6 concurrency 검사에서 경고가 발생한다.
Conformance of 'LocationManager' to protocol 'CLLocationManagerDelegate'
crosses into main actor-isolated code and can cause data races
Swift
복사
원인
Delegate 메서드는 시스템이 호출하는데, 어떤 스레드에서 호출할지 보장되지 않는다.
@MainActor 클래스의 메서드는 메인 스레드에서만 실행되어야 하니 충돌이 생긴다.
해결
nonisolcated로 delegate 메서드를 선언하고,
내부에서 Task { @MainActor in }으로 UI 업데이트를 감싼다.
extension LocationManager: CLLocationManagerDelegate {
nonisolated func locationManagerDidChangeAuthorization(
_ manager: CLLocationManager
) {
Task { @MainActor in
authorizationStatus = manager.authorizationStatus
if manager.authorizationStatus == .authorizedAlways ||
manager.authorizationStatus == .authorizedWhenInUse
{
startLocationServices()
}
}
}
}
Swift
복사
nonisolated = 이 메서드는 @MainActor 규칙에서 제외해.
메서드 자체는 아무 스레드에서 호출 가능하고,
안에서 메인 스레드가 필요한 코드만 Task { @MainActor in }으로 감싸는 패턴이다.
5. 배터리 효율을 위한 ScenePhase 연동
지도 앱은 startUpdatingLocation()으로 위치를 계속 추적하는데, 앱이 백그라운드로 가면 불필요한 배터리 소모가 생긴다.
func updateService(for phase: ScenePhase) {
switch phase {
case .active: startLocationServices()
case .inactive, .background: stopLocationServices()
default: break
}
}
Swift
복사
View에서
@Environment(\.scenePhase) private var scenePhase
.onChange(of: scenePhase) { _, newPhase in
viewModel.handleScenePhaseChange(newPhase)
}
Swift
복사
포그라운드에서만 위치를 추적하고, 백그라운드로 가면 멈춘다.
회고
위치 권한 처리는 단순해 보이지만, restricted와 denied의 차이, delegate의 @MainActor의 충돌, 배터리 효율 등 고려할 게 많았다. 특히 Swift 6의 엄격한 concurrency 검사로 인해 nonisolated + Task { @MainActor in } 패턴을 배운 게 가장 큰 수확이었다.
