백그라운드에서 주행 현황 파악하기 - Core Location, Live Activity

윤달·2026년 9월 8일
post-thumbnail

초보 운전자를 위한 운전 및 주차 연습 공유 서비스, Rodi의 구현 과정을 다룬 글입니다.

다운로드 링크: 앱스토어 | 플레이스토어

Rodi는 초보 운전자를 위한 연습 코스를 지도에서 탐색하고, 외부 길안내 앱을 통해 해당 코스로 이동할 수 있도록 돕는 서비스입니다. 사용자는 코스 상세 화면에서 연습하러 가기 버튼을 누른 뒤 카카오내비 같은 길안내 앱으로 이동해 코스를 주행할 수 있습니다.

여기서 한 가지 문제가 생겼습니다. 사용자가 외부 길안내 앱으로 이동했다는 이유만으로 실제 연습을 완료했다고 기록하면 안 되겠죠. 코스에 도착하지 않았을 수도 있고, 위치 오차 때문에 실제 경로와 다른 기록이 남을 수도 있으니까요.

그래서 Rodi는 이 문제를 해결하기 위해 Core Location과 Live Activity를 활용했습니다. 사용자가 운전 연습을 시작하면 다음 세 단계가 하나의 흐름으로 이어집니다.

  • Core Location으로 백그라운드 위치를 수집합니다.
  • 위치가 연습 코스의 polyline 주변에 있는지 판단하고, 유효한 이동 거리가 일정 기준에 도달하면 주행 측정을 완료합니다.
  • 동시에 백그라운드 상태에서도 코스 주행 현황을 확인할 수 있도록 Live Activity를 사용했습니다.

이제 백그라운드에서 위치를 수집하는 방법부터, 주행 상태를 Live Activity로 전달하고 실제 코스 주행 여부를 판단한 기준까지 순서대로 살펴보겠습니다.


1. Core Location으로 위치 수집하기

권한 설정하기

Core Location은 기기의 지리적 위치, 고도, 방향과 같은 정보를 다루는 Apple 프레임워크입니다. 위치 정보는 민감한 개인정보이므로, 위치를 수집하기 전에 반드시 사용자에게 사용 권한을 받아야 합니다.

특히 백그라운드에서 위치 변화를 확인해야 한다면, 권한뿐 아니라 위치 서비스가 활성화되어 있는지와 정확한 위치를 사용할 수 있는지도 함께 확인해야 합니다. Rodi는 연습 측정을 시작하기 전에 위치 서비스 상태, 위치 권한, 정밀 위치 권한을 순서대로 확인합니다.

func locationPrerequisite() -> DrivePracticeStartResult? {
    guard CLLocationManager.locationServicesEnabled() else {
        return .unavailable("위치 서비스를 켠 뒤 연습 기록을 시작해주세요.")
    }

    switch locationManager.authorizationStatus {
    case .notDetermined:
        locationManager.requestWhenInUseAuthorization()
        return .authorizationRequested

    case .authorizedWhenInUse, .authorizedAlways:
        break

    case .denied, .restricted:
        return .unavailable(
            "위치 권한이 없어 이번 길안내에는 연습 기록이 포함되지 않아요."
        )

    @unknown default:
        return .unavailable(
            "위치 권한 상태를 확인하지 못해 이번 길안내에는 연습 기록이 포함되지 않아요."
        )
    }

    guard locationManager.accuracyAuthorization == .fullAccuracy else {
        locationManager.requestTemporaryFullAccuracyAuthorization(
            withPurposeKey: "DrivePractice"
        )
        return .reducedAccuracyRequested
    }

    return nil
}

// 시작 결과
enum DrivePracticeStartResult: Equatable {
    case started
    case authorizationRequested
    case reducedAccuracyRequested
    case unavailable(String)
}

위치 서비스가 꺼져 있거나 권한이 거부된 경우에는 연습 측정을 시작하지 않습니다. 최초 권한 또는 정밀 위치 권한을 요청한 경우에도 즉시 측정을 시작하지 않고, 사용자의 선택이 반영된 뒤 다시 시작하도록 결과를 구분했습니다.

Rodi는 사용자가 앱 안에서 연습 측정을 명시적으로 시작한 뒤에만 위치를 수집합니다. 그래서 최초 권한 요청도 requestWhenInUseAuthorization()로 시작합니다. 이후 사용자가 외부 길안내 앱으로 이동하면, 진행 중인 측정 세션에 백그라운드 위치 업데이트 설정을 적용합니다.

<!-- 위치 정보 사용 목적 -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>현재 위치 주변의 운전 연습 장소를 보여주고, 실시간 주행 기록을 확인하기 위해 위치 정보를 사용해요.</string>

<!-- 정밀 위치 정보 사용 목적 -->
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
	<key>DrivePractice</key>
	<string>주행 연습 시 방문 인증과 실시간 주행 기록을 확인하기 위해 정밀한 위치 정보가 필요해요.</string>
</dict>

<!-- 백그라운드 위치 업데이트 지원 선언 -->
<key>UIBackgroundModes</key>
<array>
	<string>location</string>
</array>

백그라운드에서 위치 수집하기

외부 길안내 앱으로 이동한 뒤에도 위치 업데이트를 받아야 합니다. 이를 위해 CLLocationManager에 백그라운드 위치 업데이트를 허용하고, 표준 위치 업데이트를 시작합니다.

// 위치 수집 시작
func startTracking(phase: DrivePracticePhase, isParking: Bool) {
    // 위치 수집 정책을 주행 현황에 따라 다르게 적용함
    updateTrackingPolicy(phase: phase, isParking: isParking)
		
    locationManager.allowsBackgroundLocationUpdates = true // 핵심
    locationManager.startUpdatingLocation() // 실제 위치 업데이트 시작
}

// 코스 주행 현황
enum DrivePracticePhase: String, Codable, Equatable {
    case headingToCourse // 코스로 이동 중
    case drivingCourse // 코스 주행 중
    case completed // 주행 완료
    case cancelled // 취소됨
    case interrupted // 의도치않게 주행이 종료됨
    
    // ...
}

allowsBackgroundLocationUpdates로 외부 길안내 앱 전환 뒤에도 위치 업데이트를 허용하고, startUpdatingLocation()으로 실제 위치 샘플 수집을 시작합니다.

locationManager.delegate = self

// 위치 사용 목적이 차량 내비게이션임을 시스템에 전달
locationManager.activityType = .automotiveNavigation

// 코스로 이동 중에는 약 100m의 목표 정확도와 최소 100m 이동 간격을 요청
// 코스 주행 중에는 약 10m의 목표 정확도와 최소 20m 이동 간격으로 전환
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
locationManager.distanceFilter = 100

// 정차 상태에서 위치 업데이트 중단하지 않도록 설정
locationManager.pausesLocationUpdatesAutomatically = false

// 백그라운드 위치 사용을 사용자에게 시스템 UI로 표시
locationManager.showsBackgroundLocationIndicator = true

activityType는 차량으로 코스에 이동하고 주행하는 흐름을 반영해 .automotiveNavigation을 사용했습니다. 신호 대기나 정차 중 위치 수집이 자동으로 중단되는 상황을 줄이기 위해 pausesLocationUpdatesAutomatically는 false로 설정했습니다.

그렇다면 위치 수집은 언제 종료해야 할까요?

외부 길안내 앱을 사용한 뒤 Rodi로 돌아오면, 현재 연습 상태에 따라 서로 다른 팝업을 마주하게 됩니다.

  • 아직 코스를 연습 중이신가요?
  • 연습은 잘 다녀오셨나요?

두 팝업 모두 하나의 연습 세션을 정리하는 흐름이지만, 위치 수집이 종료되는 시점은 서로 다릅니다.

먼저 사용자가 코스로 이동 중이거나 코스를 주행 중이라면 아직 코스를 연습 중이신가요? 팝업을 표시합니다. 이때 위치 수집은 아직 진행 중입니다. 사용자가 계속 측정을 선택하면 기존 세션을 이어서 추적하고, 측정 종료를 선택하면 진행 중이던 세션을 취소하면서 위치 업데이트를 종료합니다.

반면 백그라운드에서 주행 완료 기준에 도달했다면, 그 시점에 이미 위치 업데이트를 종료하고 세션을 완료 상태로 전환합니다. 이후 사용자가 Rodi로 돌아오면 연습은 잘 다녀오셨나요? 팝업을 표시합니다. 따라서 이 팝업은 위치 수집을 종료하기 위한 화면이라기보다, 이미 완료된 측정 결과를 사용자가 확인하고 다음 행동을 선택하는 화면에 가깝습니다. 다녀왔어요를 선택하면 후기 작성 흐름으로 이어지고, 안 했어요를 선택하면 완료 후 남아 있던 측정 상태를 정리합니다.

호출되는 시점은 다르지만, 수동 종료와 자동 완료 모두 다음 로직을 통해 위치 업데이트를 정리합니다. 수동 종료에서는 사용자가 측정 종료를 선택한 순간 호출되고, 자동 완료에서는 완료 기준에 도달한 순간 팝업이 표시되기 전에 호출됩니다.

// 위치 수집 종료
func stopTracking() {
    locationManager.stopUpdatingLocation()
    
    // 백그라운드 주행 종료
    locationManager.allowsBackgroundLocationUpdates = false
}

여기까지 Core Location을 이용해 연습 측정을 시작하기 위한 권한을 확인하고, 외부 길안내 앱으로 이동한 뒤에도 위치를 수집하며, 측정이 끝났을 때 업데이트를 정리하는 흐름을 살펴봤습니다. 이렇게 수집한 위치는 이후 코스 진입과 주행 완료 여부를 판단하는 데 사용됩니다. 먼저 다음 장에서는 계산된 연습 상태를 앱 밖에서도 확인할 수 있도록 Live Activity에 전달한 방법을 살펴보겠습니다.


2. Live Activity로 주행 현황 보여주기

위치를 수집하고 주행 상태를 계산하더라도 사용자가 외부 길안내 앱을 보고 있다면 그 결과를 Rodi 화면에서 확인할 수 없습니다. 주행 현황을 확인하기 위해 매번 Rodi로 돌아오게 하는 대신, 잠금 화면과 Dynamic Island에서 진행 상태를 확인할 수 있도록 Live Activity를 사용했습니다.

출처: https://bcut.baemin.com/5767/

Live Activity는 진행 중인 작업의 최신 상태를 잠금 화면과 Dynamic Island에 보여주는 시스템 UI입니다. ActivityKit이라는 프레임워크를 통해 Live Activity의 시작, 갱신, 종료 등의 생명주기를 관리합니다. 이때 일반 Widget과 무슨 차이가 있지? 라는 생각이 들 수 있습니다.

일반 Widget은 보통 Timeline을 기반으로 특정 시점의 정보를 제공하는 반면, Live Activity는 앱이나 서버가 전달하는 상태를 바탕으로 진행 중인 작업을 계속 갱신하는 데 적합합니다. 따라서 배달, 운동, 스포츠 경기처럼 시작과 종료가 명확하고 중간 상태가 계속 달라지는 작업에 주로 사용됩니다.

여기서 중요한 점은 Live Activity가 위치를 수집하거나 주행 여부를 판단하는 주체가 아니라는 것입니다. Core Location으로 위치를 수집하고 코스 진입 여부와 진행률을 계산하는 작업은 앱이 담당합니다. Live Activity에는 앱이 계산한 결과만 전달하고, Widget Extension은 전달받은 상태를 화면에 표현합니다.

<!-- Live Activity -->
<key>NSSupportsLiveActivities</key>
<true />

기능을 사용하려면 앱의 Info.plist에 NSSupportsLiveActivities를 선언해야 합니다. 이는 위치 권한처럼 사용자의 허가를 요청하는 문구가 아니라, 앱이 Live Activity를 지원한다는 사실을 시스템에 알리는 설정입니다.


불변 정보와 갱신 정보 구분하기

Live Activity에 필요한 데이터는 ActivityAttributes와 그 안의 ContentState로 나뉩니다. 두 타입 모두 Live Activity를 구성하는 데이터이지만 생명주기가 다릅니다. ActivityAttributes에는 활동이 시작된 뒤 바뀌지 않는 정보를 두고, ContentState에는 진행 중 update를 통해 교체할 정보를 둡니다.

@available(iOS 16.1, *)
nonisolated struct PracticeLiveActivityAttributes: ActivityAttributes, Sendable {
    struct ContentState: Codable, Hashable, Sendable {
        let phaseRawValue: String
        let progress: Double
        let distanceToCourseStartMeters: Int?

        var phase: PracticeLiveActivityPhase {
            PracticeLiveActivityPhase(rawValue: phaseRawValue) ?? .completed
        }
    }

    let sessionID: UUID
    let courseName: String
    let placeTypeRawValue: String
    let rabbitAssetName: String
}

Rodi에서는 세션 ID, 코스명, 장소 유형, 캐릭터 에셋은 연습이 진행되는 동안 바뀌지 않으므로 attributes에 두었습니다. 반대로 phaseRawValue, progress, distanceToCourseStartMeters는 위치가 들어올 때마다 달라질 수 있습니다. 코스까지 이동 중인지, 코스를 주행 중인지, 주행을 완료했는지에 따라 단계가 바뀌고 거리와 진행률도 계속 변하기 때문에 ContentState로 분리했습니다.

이렇게 나누면 attributes를 유지한 채 ContentState만 갱신할 수 있고, View에서는 context.attributes와 context.state를 통해 두 데이터를 구분해서 읽을 수 있습니다. ContentState는 ActivityKit이 상태를 전달하고 변경된 값을 비교할 수 있도록 Codable, Hashable을 채택합니다. Rodi에서는 Swift 동시성 경계를 안전하게 넘기기 위해 Sendable도 적용했습니다.


Dynamic Island의 형태에 따라 분기 처리하기

Dynamic Island를 지원하는 기기에서는 하나의 Live Activity가 compact, minimal, expanded 세 가지 형태로 표현될 수 있습니다. 개발자가 특정 형태를 직접 선택하는 것은 아니며, 현재 실행 중인 Live Activity의 수와 사용자의 상호작용에 따라 시스템이 표시 방식을 결정합니다. 하나의 Live Activity가 표시될 때는 보통 compact 형태가 사용되고, 사용자가 길게 누르면 expanded 형태로 확장됩니다. 서로 다른 앱의 Live Activity가 동시에 표시될 때는 제한된 공간을 나누기 위해 minimal 형태가 사용될 수 있습니다.

ActivityConfiguration(for: PracticeLiveActivityAttributes.self) { context in
    // 잠금 화면 및 배너에 표시됨
    PracticeActivityView(context: context)
        .widgetURL(
            PracticeLiveActivityDeepLink.url(
                sessionID: context.attributes.sessionID
            )
        )
} dynamicIsland: { context in
    DynamicIsland {
        DynamicIslandExpandedRegion(.leading) {
            // 현재 단계
        }
        DynamicIslandExpandedRegion(.trailing) {
            // 시작점까지의 거리 또는 주행률
        }
        DynamicIslandExpandedRegion(.bottom) {
            PracticeDynamicIslandExpandedView(context: context)
        }
    } compactLeading: {
        // 자동차 또는 완료 아이콘
    } compactTrailing: {
        // 거리, 진행률 또는 완료
    } minimal: {
        // 현재 상태를 나타내는 아이콘
    }
}

잠금 화면과 Dynamic Island를 각각 별도의 상태로 관리하는 것은 아닙니다. 모든 화면이 동일한 attributes와 ContentState를 받고, 사용할 수 있는 공간에 맞춰 정보의 양과 우선순위만 다르게 표현합니다. 덕분에 화면마다 별도의 비즈니스 상태를 만들지 않고도 일관된 주행 현황을 전달할 수 있습니다.


꼭 필요할 때만 상태 갱신하기

Rodi는 Core Location에서 새로운 위치를 받을 때마다 코스와의 거리, 접근 진행률, 코스 진행률을 다시 계산합니다. 하지만 위치 샘플이 들어올 때마다 Live Activity까지 갱신할 필요는 없습니다. 미세한 위치 변화는 불필요한 갱신 요청만 늘릴 수 있기 때문입니다. 따라서 상태 변화가 사용자에게 의미 있을 때만 ContentState를 갱신하도록 별도의 정책을 두었습니다.

func sync(_ session: DrivePracticeSession) {
    guard let activity = resolveActivity(sessionID: session.id) else { return }
    guard updatePolicy.shouldUpdate(.init(session), at: .now) else { return }

    let state = PracticeLiveActivityContentMapper.state(for: session)

    enqueueOperation {
        if #available(iOS 16.2, *) {
		        // 상태 갱신 조건을 만족했을 때
            await activity.update(
                ActivityContent(state: state, staleDate: nil)
            )
        } else {
            await activity.update(using: state)
        }
    }
}

mutating func shouldUpdate(_ snapshot: Snapshot, at now: Date) -> Bool {
    let shouldUpdate =
        lastPhase != snapshot.phase
        || lastUpdatedAt.map {
            now.timeIntervalSince($0) >= 15
        } ?? true
        || abs((lastApproachProgress ?? 0) - snapshot.approachProgress) >= 0.03
        || abs((lastCourseProgress ?? 0) - snapshot.courseProgress) >= 0.03

    if shouldUpdate {
        record(snapshot, at: now)
    }

    return shouldUpdate
}

새로운 ContentState를 Live Activity에 전달하는 조건은 세 가지입니다.

  1. ‘코스로 이동 중’에서 ‘코스 주행 중’처럼 전체 단계가 바뀜
  2. 단계가 그대로더라도 코스로 이동 중의 접근 진행률이나 코스 진행률이 3% 이상 달라졌을 때
  3. 마지막 갱신 후 15초가 지났을 때

단계 변화는 사용자가 즉시 알아야 하는 사건이므로 진행률 변화와 관계없이 바로 반영했습니다. 반면 3% 미만의 변화는 짧은 시간 동안 화면에서 체감하기 어려운 변화라고 판단해 갱신을 미뤘습니다. 다만 진행률 변화가 작다는 이유로 화면이 오랫동안 멈춰 보이지 않도록, 새로운 위치가 계속 들어오는 상황에서는 마지막 갱신 후 15초가 지나면 새로운 상태를 전달하도록 했습니다.

이때 approachProgress는 Live Activity 화면에 직접 전달하는 값이 아니라, 코스에 가까워지는 변화를 감지해 갱신 시점을 결정하는 내부 지표로만 사용합니다. 15초와 3%는 현재 구현에서 선택한 초기 정책이며, 최적의 수치라고 검증된 것은 아닙니다.

연습을 시작하면 Activity.request로 Live Activity를 생성하고, 위 조건을 만족하면 activity.update로 새로운 상태를 전달합니다. 완료 기준에 도달하면 최종 ContentState와 함께 activity.end를 호출해 활동을 종료합니다. 다만 사용자가 완료 결과를 확인할 수 있도록 시스템 UI는 30분 뒤 제거해 달라는 dismissalPolicy를 전달했습니다. 반면 사용자가 연습을 취소한 경우에는 .immediate를 전달해 바로 제거하도록 요청했습니다.

여기까지 Core Location으로 수집한 위치가 앱의 주행 판정을 거쳐 ContentState로 변환되고, 잠금 화면과 Dynamic Island에 표시되는 과정을 살펴봤습니다. Live Activity는 계산된 결과를 앱 밖으로 전달하는 역할을 할 뿐, 그 결과가 올바른지는 보장하지 않습니다. 그렇다면 GPS 오차가 있는 상황에서 현재 위치가 실제 코스 위에 있는지, 누적된 이동 거리를 주행 기록으로 인정해도 되는지는 어떻게 판단할 수 있을까요? 다음으로 Rodi가 위치 샘플과 코스 polyline을 이용해 주행 여부를 판단한 기준을 살펴보겠습니다.


3. 코스 주행 여부 신뢰성 높이기

위치를 수집했다고 해서 사용자가 실제로 코스를 주행했다고 판단할 수는 없습니다. 위치가 갑자기 멀리 튈 수도 있고, 과거에 생성된 위치가 늦게 전달될 수도 있습니다.

Rodi는 이런 문제를 줄이기 위해 위치를 두 단계로 확인합니다.

  • 신뢰할 수 있는 위치 값인가?
  • 코스 polyline 주변에 있는가?

신뢰할 수 있는 위치 값인가?

Core Location이 전달하는 위치는 매번 같은 품질을 보장하지 않습니다. 위치가 오래된 상태로 전달될 수도 있고, GPS 신호나 주변 환경에 따라 오차가 커질 수도 있습니다. 그래서 Rodi는 위치를 바로 사용하지 않고 위치의 정확도와 생성 시각을 먼저 확인합니다.

// 시스템이 추정한 오차 범위
// 값이 작을수록 정확한 위치 (단 음수는 사용할 수 없는 값)
guard location.horizontalAccuracy >= 0,
      location.horizontalAccuracy <= Policy.maximumHorizontalAccuracy, // 60m
      abs(location.timestamp.timeIntervalSinceNow) < 15
else {
    return
}

horizontalAccuracy는 현재 위치가 실제 위치에서 얼마나 벗어날 수 있는지를 시스템이 추정한 수평 방향의 오차 범위입니다. 값이 작을수록 시스템이 위치를 더 신뢰하고 있다는 뜻입니다. Rodi는 값이 유효하면서 60m 이하인 위치만 사용합니다. 생성 시각도 현재로부터 15초 이내인지 확인합니다. 두 조건을 통과하지 못 한 위치는 코스 판정에 사용하지 않습니다.

다만 horizontalAccuracy가 60m 이하라고 해서, 해당 좌표가 실제 위치와 반드시 60m 안에 있다고 보장할 수는 없습니다. 이 값은 시스템이 계산한 추정치입니다. 주변 건물, 터널, GPS 신호 상태처럼 위치 측정 환경이 좋지 않으면 실제 위치와 다른 좌표가 들어올 가능성이 남아 있습니다.

또한 이 값은 위치 하나의 품질만 보여줍니다. 이전 위치와 현재 위치 사이의 이동이 현실적인지는 알려주지 않습니다. 예를 들어 5초 전 위치와 현재 위치가 모두 정확도 60m 이하일 수 있습니다. 하지만 두 위치가 1km 떨어져 있다면, 실제 차량이 5초 동안 이동했다고 보기 어렵습니다. 두 좌표가 각각 기본 품질 조건을 통과했더라도, 둘 사이의 이동은 GPS 오차일 수 있습니다.

그래서 Rodi는 위치 하나의 품질을 확인한 뒤, 이전 위치와 현재 위치 사이의 거리도 추가로 확인합니다.

// 이전 위치와 현재 위치의 생성 시간 차이
let elapsedSeconds = min(
    max(0, timestamp.timeIntervalSince(previousTimestamp)),
    Policy.maximumSampleGap // 60초
)

// 이전 위치와 현재 위치 사이의 직선 거리
let travelledDistance = location.distance(from: previousLocation)

// 시간 차이를 고려한 최대 허용 이동 거리
// 예: 5초 동안 2km 이동한 것처럼 보이는 비정상 샘플을 제외
let maximumPlausibleDistance =
    (elapsedSeconds * Policy.maximumForwardMetersPerSecond)
    + Policy.forwardDistanceToleranceMeters
    
    
guard travelledDistance > 0,
      travelledDistance <= maximumPlausibleDistance
else {
    lastInCourseLocation = location
    return
}

session.drivenRouteDistanceMeters = (session.drivenRouteDistanceMeters ?? 0) + travelledDistance

두 위치의 생성 시각 차이는 최대 60초까지만 사용합니다. 위치 수신이 오래 끊겼다는 이유만으로 지나치게 긴 이동 거리까지 허용하면 GPS 좌표 점프를 걸러내기 어려워지기 때문입니다. 두 위치 사이의 거리인 travelledDistance가 시간에 비해 지나치게 멀다면, 해당 구간은 GPS 좌표가 튄 결과일 가능성이 높다고 봅니다. 이때 그 구간의 거리만 누적하지 않습니다.

즉, horizontalAccuracy는 위치 하나를 기본적으로 사용할 수 있는지 확인하는 기준입니다. 위치 사이의 거리와 시간 차이는, 여러 위치가 이어졌을 때도 자연스러운 이동인지 확인하는 기준입니다. Rodi는 두 조건을 함께 적용해 신뢰하기 어려운 위치가 주행 기록에 반영될 가능성을 줄였습니다.


코스 polyline 주변에 있는가?

위치 품질을 확인했다면 현재 위치가 코스 주변에 있는지 판단합니다. Rodi의 코스는 여러 좌표를 선으로 연결한 polyline입니다. 따라서 현재 위치와 코스에 등록된 좌표만 비교해서는 안 됩니다. 코스가 A → B → C로 이어져 있다고 가정해 보겠습니다. 사용자가 A와 B 사이를 달리고 있다면 A와 B 좌표에서는 모두 멀리 떨어져 있을 수 있습니다. 하지만 A와 B를 연결한 도로와는 가까울 수 있습니다.

그래서 Rodi는 현재 위치와 각 좌표의 거리가 아니라, 현재 위치와 polyline을 구성하는 각 선분의 거리를 계산합니다.

guard let match = PracticeRouteMatcher.match(
    location: location,
    path: session.routePath,
    cumulativeDistanceMeters: session.cumulativeRouteDistanceMeters
), match.distanceToRouteMeters <= Policy.routeCorridorMeters // 150m
else {
    lastInCourseLocation = nil
    self.session = session
    sessionStore.save(session)
    syncLiveActivity(session)
    return
}

PracticeRouteMatcher는 각 선분에서 현재 위치와 가장 가까운 지점을 찾습니다. 그중 가장 짧은 거리를 코스와의 거리로 사용합니다. 계산된 거리가 150m 이하라면 현재 위치가 코스 주변에 있다고 판단합니다.

예를 들어 현재 위치 P가 이미지처럼 코스에서 30m 떨어져 있다면 코스 내부의 위치로 받아들입니다. 반대로 200m 떨어져 있다면 코스를 벗어난 것으로 처리합니다. 사용자가 처음 150m 범위 안에 들어오면 상태를 코스로 이동 중에서 코스 주행 중으로 변경합니다.

150m는 GPS 오차와 도로 폭을 고려해 둔 현재의 초기 기준입니다. 따라서 코스 옆에 다른 도로가 있다면 잘못 판단할 가능성도 있습니다. 현재 구현은 사용자가 polyline 위에 정확히 있는지를 확인하지 않습니다. 코스 주변의 허용 범위 안에 있는지를 확인할 뿐, 전체 polyline을 순서대로 통과했음을 증명하는 방식은 아닙니다.


마무리

이번 글에서는 외부 길안내 앱으로 이동한 뒤에도 백그라운드에서 위치를 수집하고, Live Activity를 통해 주행 상태를 전달하며, GPS 오차를 줄이기 위해 위치 품질과 코스 polyline을 함께 확인한 과정을 정리했습니다.

이 방식이 사용자가 코스를 전체 순서대로 완주했음을 완벽히 증명하지는 않습니다. 터널, 도심, 인접 도로처럼 위치 신호가 불안정한 환경에서는 오판 가능성이 남아 있습니다. 현재 기준은 정확한 답을 단정하기보다 잘못된 기록을 줄이기 위한 초기 기준입니다. 앞으로 실제 주행 환경에서 위치 정확도와 배터리 사용량의 균형을 측정하며 기준을 다듬어 갈 예정입니다.

Flutter 개발자로 일하며 여러 앱을 출시했지만, Rodi는 iOS로 처음 출시한 앱입니다. 평소 관심이 있던 도메인을 직접 다루며, 화면 구현을 넘어 앱이 백그라운드로 전환된 뒤에도 어떤 상태를 유지해야 하는지, 예외 상황에서 어떤 데이터를 정리해야 하는지를 고민했습니다. 익숙하지 않은 플랫폼이었던 만큼 놓치고 있던 고려 사항을 마주했고, 그 과정을 통해 iOS 개발의 책임 범위를 더 넓게 바라볼 수 있었습니다. 앞으로도 실제 사용 환경에서 부족한 부분을 꾸준히 개선해 나가겠습니다. Rodi가 궁금해지셨다면 많은 이용 부탁드립니다.

다운로드 링크: 앱스토어 | 플레이스토어

이번 글이 Core Location과 Live Activity를 활용해 앱 밖의 사용자 흐름을 다루는 분들께 작은 참고가 되기를 바랍니다.
긴 글 읽어주셔서 감사합니다!

profile
Mobile Developer

0개의 댓글