본문 바로가기
C.W.K.
Stream
Lesson 04 of 05 · published

Google은 스스로 안내해

~17 min · swift, navigation-sdk, licensing, sentinel-values, maps

Level 0길 잃음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

약관이 아키텍처를 정해

해외에선 Google이 주력 지도야. 그러자 코드를 아무리 깔끔하게 짜도 답이 안 나오는 질문이 생겼어. Pathfinder가 Google 경로를 피파 목소리로 안내해도 돼? Google Maps Platform 약관 §3.2.3("Restrictions Against Misusing the Services")은 세 군데서 안 된다고 해. "use Google Maps Content with text-to-speech services" 금지. "convert text-based driving times into synthesized speech results" 금지. 핵심 서비스로 "a substitute of the Google Maps Core Services, Google Maps, or Google Maps mobile apps, or their features"를 만드는 것도 금지. 피파 목소리는 바로 합성 음성이야. Pathfinder 안내 엔진이 Google 경로를 따라 걸으면, Google 길안내로 만든 대체 내비가 되는 거고.

그래서 아빠가 2026-10-03에 정했어. "공식 Navigation SDK 로 가." Google 자기 엔진이 앱 안에서 경로를 찾고, 지도에 그리고, 안내해. Pathfinder는 따라가고. Google 자식이 밝히는 것도 딱 그거야. ProviderTraits(guide: .provider, routeIsPortable: false, regions: [.overseas]). 아래 내용은 Pathfinder가 그 약관을 읽은 방식이고, 아키텍처 문서에 불변식 19번으로 적혀 있어. 법률 자문이 아니라 설계 결정이야. 그리고 그 결정이 아래 모든 줄을 빚었어.

선을 넘는 것, 절대 안 넘는 것

앱에서 Google 경로를 찾는 것도, 그리는 것도, 안내하는 것도 GoogleEngine 한 곳에서만 해. 거기서 나오는 건 중립적인 값이야. RouteFacts는 길이, 시간, 선의 점들을 담는데, 점들은 Google 자기 지도에서 카메라 구도 잡는 데만 써. FeedReading은 턴 피드를 담아. 상태 하나, 바로 앞에 올 동작이랑 그다음 동작(단계 번호, ManeuverKind, 회전교차로 출구, 목적지가 어느 쪽인지, 거리), 그리고 남은 거리랑 시간.

Google 여정에서 Pathfinder가 쥐는 NavRoute는 steps: []에 name: ""이야. Google 도로 이름을 담을 수 있는 게 Pathfinder 쪽엔 아예 없어. 그러니 나중에 누가 화면을 어떻게 짜든, 도로 이름이 피파 목소리나 보고나 주행 기록에 닿을 길이 없어. 이게 규칙의 강한 형태야. "문구를 읽지 않게 조심해"가 아니라 "읽을 문구가 없어".

GoogleManeuver는 GMSNavigationManeuver를 하나도 빠짐없이 Pathfinder 종류로 옮겨. depart랑 nameChange는 지나가는 단계로 표시해(할 것도, 말할 것도 없으니까). 엔진은 그 길이를 다음 진짜 동작까지의 거리에 얹어. roundaboutExit는 출구야. 이미 회전교차로 안에 있으니까. 그다음 ManeuverWords가 종류를 Pathfinder 자기 닫힌 어휘로 바꿔. 좌회전, 회전교차로에서 두 번째 출구로 나가기, 오른쪽에 목적지가 있음. 언제 말할지는 FeedTrip이 Pathfinder 자체 엔진이랑 똑같은 단계 거리로 정해. 그래서 누가 안내하든 주행이 똑같이 들려. Google 지도 위엔 Google 헤더가 동작, 도로, 차선을 보여줘. Google 지도에서 Google이 Google 콘텐츠를 보여주는 거니까 괜찮아. 하단 바랑 재중심 버튼은 끄고, 제한 속도랑 속도계는 켜. SDK 자체 음성은 .silent로 시작해. 아빠가 Google 안내를 통째로 듣고 싶은 날을 위한 설정이야.

그 매핑에 작은 Swift 덫이 하나 숨어 있어. Google enum이랑 Pathfinder enum이 case 이름을 같이 써. 그래서 매핑 안에서 레이블 없는 GoogleManeuver(.turnLeft)가 Google의 .turnLeft로 풀려서 매핑을 다시 불렀어. 끝없이. 컴파일러는 경고만 했고. 매핑이 쓰는 이니셜라이저에 GoogleManeuver(kind:)처럼 레이블이 붙은 게 바로 그 때문이야.

약관 창은 화면을 혼자 써야 해

내비 세션은 아빠가 Google 자체 창에서 약관을 확인한 뒤에야 만들어져(게시자는 "CWK"로 떠). coop 학습 기록 #754가 함정을 적어 뒀어. SwiftUI 시트가 지도를 덮고 있거나 아직 닫히는 중에 띄우면, 창이 안 뜨고 콜백도 영영 안 올 수 있어. 그 콜백을 기다리는 continuation은 그 뒤 경로 요청을 전부 붙잡아 두고. 그래서 엔진은 경로 요청 한복판이 아니라 Google 지도가 처음 화면에 올 때 물어. 키 윈도의 루트가 아무것도 안 띄울 때까지 기다리는데, 250 ms마다 최대 1,200번(5분) 확인해. 답 자체도 120초로 묶고. 그 뒤에 아빠가 수락하면 그때 세션을 만들고 띄워 둔 경고를 거둬.

Int()를 터뜨리는 보초값

같은 기록이 더 고약한 버그도 찾아냈어. 경로가 없을 때 SDK는 distanceToNextDestination을 CLLocationDistanceMax로, timeToNextDestination을 CLTimeIntervalMax로 줘. 둘 다 유한한 Double 중 제일 큰 값이야. isFinite는 통과시키고, Int(_:)는 거기서 터져. "남은 거리"를 찍으려다 앱이 죽는 거지. 고친 방법은 경계마다 숫자를 묶는 거야. FeedTrip.plausible은 거리는 10만 km 미만, 시간은 천만 초 미만, 속도는 200 m/s 미만일 때만 받아. 나머지는 버리고 마지막 멀쩡한 값을 유지해. SpokenDistance는 변환하는 대신 "잠시 후"라고 하고, 화면의 Format은 대시를 찍어. 세 번째 코드 블록이 이 덫을 열두 줄 남짓으로 재현해.

피드가 끝내 안 오면

피파는 피드가 알려준 것만 말할 수 있어. 안내를 시작하고 25초 안에 읽을 게 하나도 안 오면 엔진이 여정한테 알려. 그럼 Google 자기 목소리가 턴 안내를 넘겨받고, 화면이랑 주행 기록엔 둘 다 그 이유가 남아. 한 여정을 두 목소리가 안내하는 일은 없어. 아무도 안내하지 않은 채 조용히 지나가는 일도 없고.

뒤처진 문서

아키텍처 문서의 상태 절을 읽으면 앱 안 Google 안내가 "remains unenabled"라고 나와. 제공자 문서도 Google 맞춤 안내가 "is not enabled"라고 하고. 그 줄들은 결정 이전의 활성화 기록이야. 코드, 불변식 19번, 커밋 6782c3e는 다르게 말해. 그 뒤 문서 커밋 하나가 폰 키에 Navigation SDK를 켠 콘솔 변경도 기록했고. 엔진은 SDK 헤더를 보고 짰고, 테스트는 대본대로 흘려보낸 피드로 증명했어. Google 지도랑 그 안내는 그 뒤로 해외에서 아빠 폰으로 실제로 쓰였고. Mac 테스트는 여전히 그걸 못 돌려. 키가 아빠 폰에만 있거든.

금지된 걸 아예 표현할 수 없게 만들어. "Google 문구는 절대 말하지 마"라는 규칙은 앞으로의 모든 변경이 기억해야 해. 단계 배열이 비었고 이름이 빈칸인 경로는, 아무리 덜렁대는 사람이 와도 말할 수가 없어.

Code

PathfinderMobile, Providers/Google/GoogleEngine.swift: 모양과 거리만 남긴 턴 피드 (원문 그대로 발췌)·swift
  /// The maneuver ahead and the one after it. A step that is no maneuver (the
  /// departure, a road that only changes its name) is passed over, and its
  /// length joins the way to the next real one.
  static func maneuvers(in navInfo: GMSNavigationNavInfo) -> (
    next: FeedReading.Step?, following: FeedReading.Step?
  ) {
    guard let current = navInfo.currentStep else { return (nil, nil) }
    var next: FeedReading.Step?
    var metres = navInfo.distanceToCurrentStepMeters
    for (index, step) in ([current] + navInfo.remainingSteps.prefix(8)).enumerated() {
      if index > 0 { metres += step.distanceFromPrevStepMeters }
      if GoogleManeuver(step.maneuver).isPassing { continue }
      if next == nil {
        next = FeedReading.Step(step, distance: metres)
        metres = 0
      } else {
        return (next, FeedReading.Step(step, distance: metres))
      }
    }
    return (next, nil)
  }
PathfinderKit, Guidance/FeedTrip.swift: 피드에선 그럴듯한 숫자만 받아 (원문 그대로 발췌)·swift
  /// A distance or a time the trip can show. A provider's engine reports "no
  /// route" as the largest number it has, and that is not what is left.
  static func plausible(_ value: Double, below limit: Double) -> Bool {
    value.isFinite && value >= 0 && value < limit
  }

  public mutating func read(_ reading: FeedReading) -> [GuidanceEvent] {
    guard status.phase != .arrived else { return [] }
    var reading = reading
    var events: [GuidanceEvent] = []
    // What is left is taken only while the provider is on a route, and only
    // when it is a number of this world; otherwise the last one stands.
    if reading.state == .enroute {
      if Self.plausible(reading.remainingDistance, below: 100_000_000) {
        status.remainingDistance = reading.remainingDistance
      }
      if Self.plausible(reading.remainingTime, below: 10_000_000) { status.remainingTime = reading.remainingTime }
    }
    if let speed = reading.speed, !Self.plausible(speed, below: 200) { reading.speed = nil }
    if let next = reading.next, !Self.plausible(next.distance, below: 100_000_000) { reading.next = nil }
    if let following = reading.following, !Self.plausible(following.distance, below: 100_000_000) {
      reading.following = nil
    }
최소 스케치: isFinite가 통과시키는 보초값과, 그걸 Int()에서 막는 가드 (swiftc로 컴파일해서 돌렸어)·swift
import Foundation

// A minimal sketch of a "no route" sentinel and the guard that keeps it out
// of an Int conversion. The stand-in is the largest finite Double: Core
// Location's CLLocationDistanceMax and CLTimeIntervalMax both equal it.
let noRoute = Double.greatestFiniteMagnitude

print("isFinite passes it:", noRoute.isFinite)
print("Int(exactly:) refuses it:", Int(exactly: noRoute) as Any)
// Int(noRoute) here would trap at run time: the value is greater than Int.max.

/// A distance or a time this world can have.
func plausible(_ value: Double, below limit: Double) -> Bool {
  value.isFinite && value >= 0 && value < limit
}

/// What the screen prints: a dash for anything that cannot be a distance.
func distance(_ metres: Double) -> String {
  guard plausible(metres, below: 100_000_000) else { return "–" }
  if metres < 1000 { return "\(Int((metres / 10).rounded()) * 10) m" }
  return String(format: metres < 10_000 ? "%.1f km" : "%.0f km", metres / 1000)
}

for metres in [287.0, 4_380.0, noRoute, .nan, -1] {
  print(metres, "->", distance(metres))
}
// isFinite passes it: true
// Int(exactly:) refuses it: nil
// 287.0 -> 290 m
// 4380.0 -> 4.4 km
// 1.7976931348623157e+308 -> –
// nan -> –
// -1.0 -> –

External links

Exercise

Swift(아니면 타입 있는 아무 언어) 프로젝트에서 바깥에서 온 Double을 Int로 바꾸거나 화면용으로 포맷하는 곳을 전부 찾아봐. Int(x), Int(x.rounded()), String(format:) 같은 거. 하나하나 그 숫자가 어디서 왔는지, 그럴듯한 최댓값이 뭔지 적어. plausible(_:below:) 도우미 하나를 만들고 변환을 전부 그걸 거치게 해. 그다음 포맷터마다 Double.greatestFiniteMagnitude, .nan, .infinity, -1을 넣는 테스트를 짜.
Hint
Int(Double.greatestFiniteMagnitude)를 부르는 테스트는 실패하는 게 아니라 테스트 프로세스를 죽여. 그러니 변환 자체 말고 포맷터의 출력("–")을 테스트해. 테스트에서 값을 찔러봐야 하면 Int(exactly:)가 안전한 방법이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.