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

RouteProvider, 그리고 자식이 스스로 밝히는 성질

~13 min · swift, protocols, adapter-pattern, architecture, maps

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

뿌리 하나, 자식 셋

Pathfinder가 태어난 첫날, 아직 길 한 번 달려보기도 전에 아빠가 규칙 하나를 정했어. 지도는 어댑터로 설계해. 그 뒤의 모든 게 이 한마디에서 나왔지. Pathfinder는 Apple Maps, Kakao Maps, Google Navigation SDK랑 이야기해. 셋 다 장소가 뭔지, 경로가 뭔지, 턴 안내를 누가 소리 내 말하는지 생각이 제각각이야. 앱의 답은 프로토콜 하나, RouteProvider야. 지도 스택은 전부 이 뿌리의 자식이고. 제일 먼저 나온 Apple 자식이 정본이고, Kakao는 한국을, Google은 나머지 세계를 맡아.

뿌리가 요구하는 건 얼마 안 돼. 변하지 않는 id, 아빠한테 보여줄 displayName, traits 값 하나, 그리고 동사 셋. search(_:near:limit:)는 장소를 찾고, routes(from:heading:to:options:)는 좋은 순서대로 경로를 찾아. travelTime(from:to:options:)는 달리는 도중에 도착 시각을 새로 고치는 거고. 이 마지막 동사는 일부러 그 주행의 옵션을 그대로 받아. 주석 말대로 옵션 없이 물은 시간은 "다른 길의 시간"이니까. 돌아오는 건 전부 중립적인 값, Place랑 NavRoute야. 안내 엔진, 카메라, 목소리, 피파한테 가는 보고, 모든 화면은 이 프로토콜이랑 이 값들만 알아. 그 밖엔 아무것도 몰라.

어댑터를 어댑터로 지키는 불변식

  • 뿌리는 자식 하나에 맞추려고 고치지 않아. 새 제공자는 번역 비용을 제가 내. Apple의 단계 문구는 그 단계 끝에서 하는 동작을 말해. 그래서 Apple 자식이 그 지점을 NavStep.maneuverAlong에 박아 두고, 안내 엔진은 Apple식 관례를 배울 일이 없어.
  • 차이는 스스로 밝히지, 이름으로 묻지 않아. 자식이 ProviderTraits에 할 수 있는 걸 적고, 쓰는 쪽은 그것만 읽어. 코드엔 예외가 딱 하나, 대놓고 있어. 검색이 cwkPippa의 Apple 문을 먼저 두드리려고 selected.id == "apple"을 물어. 왜 그런지는 레슨 2에서 봐.
  • 스택이 그려낸 건 그 스택에 남아. 목적지는 안 남아. 검색 결과 목록, 경로의 선과 문구는 그걸 만든 스택 거야. 아빠가 가고 싶은 곳은 이름 하나에 좌표 하나고, 어느 스택이든 거기로 길을 찾아.

성질 넷

ProviderTraits엔 필드가 넷이야. 하나하나가 어느 소비자한테 꼭 필요해서 생긴 거야.

성질답하는 질문AppleKakaoGoogle (Navigation SDK)
guide여정은 누구 엔진이 안내해?PathfinderPathfinderGoogle 자기 엔진 (턴은 여전히 피파가 피드를 받아 말해)
routeIsPortable선을 다른 지도에 그려도 돼?돼안 돼안 돼
regions어디서 길을 찾아?한국, 해외한국해외
modes차로? 걸어서?둘 다둘 다둘 다

뿌리 확장에 들어 있는 기본값이 Apple의 모양이야. "어디서나, 차로도 걸어서도, 안내는 Pathfinder가." Apple 자식은 아무것도 안 밝히고 그대로 물려받아. Kakao는 지역을 좁히고 선을 자기 지도에 묶어 둬. Google이 제일 많이 밝혀. 안내는 자기 엔진이 하고, 경로는 자기 지도 밖으로 안 나가.

이제 쓰는 쪽을 봐. 워치는 Apple 지도 위에 그려. 그래서 폰은 routeIsPortable이 참일 때만 경로 선을 손목에 보내. 턴 정보는 어느 스택이든 다 보내고. 이동 수단 선택기는 traits.modes(in: region)를 보여주는데, 스택이 안 다루는 지역이면 빈 목록이야. guide == .provider라고 밝히는 건 약속이고, 그 약속을 지키는 건 또 다른 일이야. 자기 엔진으로 안내하는 자식은 SelfGuidingProvider도 따르고, 여정을 직접 만들어. 안내를 시작할 땐 둘 다 읽어. 먼저 성질. .provider라고 밝혔는데 그 프로토콜은 안 따르는 스택, 예를 들어 cwkPippa 문으로 오는 Google 도보 미리보기는 이유를 화면에 띄우고 거절당해. 그 경로는 미리보기일 뿐이거든. CarPlay는 .provider 스택이면 무조건 거절하고. 그다음 프로토콜. SelfGuidingProvider면 여정을 직접 만들고(provider as? any SelfGuidingProvider), 나머지 경로는 전부 Pathfinder의 NavigationSession한테 가. 이 중 어느 줄도 제공자 이름을 안 불러. 내일 네 번째 스택이 붙어도 워치, 선택기, 안내 스위치는 손댈 게 없어. 새 자식은 필드 넷을 채우고, 자기 엔진으로 안내한다면 SelfGuidingProvider까지 들고 오면 돼.

묻지 말고 밝히게 해. 소비자가 제공자에 대해 뭔가 알아야 하면, 그건 뿌리의 traits에 필드로 올라가. 화면이 제공자 이름으로 갈라지는 날, 다음 제공자는 화면 수정을 몰고 와. 어댑터가 슬그머니 switch 문이 돼버리는 거지.

목적지는 값이야

Place엔 providerID가 붙어. 어느 스택이 찾았는지, 아니면 피파가 넘겨준 목적지면 pippa. 이 필드는 출처 기록이지 관문이 아냐. 예전엔 관문이었는데, 그 대가가 뭐였는지는 레슨 3에서 나와. 지금은 저장된 장소, 피파의 핸드오프, pathfinder:// 링크, 아빠가 길게 눌러 떨군 핀이 전부 이름 하나에 좌표 하나야. 저장된 장소는 찾은 쪽의 고유 id를 placeRef로 들고 있을 수도 있어. 그 id를 아는 스택(Google)은 좌표 말고 그 장소 자체로 길을 찾으라고. 덤으로 아는 건 따라다닐 뿐, 누가 길을 찾아도 되는지를 정하진 않아.

뇌엔 손이 없어

프로토콜은 PathfinderKit에 살아. 이 Swift 패키지의 주 타깃이 내비의 뇌야. 기하, 경로 모델, 안내 엔진, 단속 카메라, 음성 조각내기, 피파 보고의 모양, 주행 기록. 가져오는 건 Foundation뿐이야(음성 조각 캐시 이름 짓느라 CryptoKit도). MapKit도, Core Location도, 오디오도, 네트워크도 없어. Apple 자식은 옆 타깃 PathfinderAppleMaps에, cwkPippa 문을 두드리는 자식은 PathfinderRemoteMaps에 살아. Google 자식은 앱 안에 살아. Navigation SDK를 링크할 수 있는 패키지 테스트가 없거든. 이렇게 나눠 둔 덕에 아빠가 뭘 듣게 될지 정하는 규칙이 전부 Mac에서 swift test로 몇 초 만에 돌아. 폰도, 지도 키도 없이.

Code

PathfinderKit, Route/RouteProvider.swift: 뿌리와 기본 traits (원문 그대로 발췌)·swift
public protocol RouteProvider: Sendable {
  /// Stable id recorded on every place and route it produces (`apple`).
  var id: String { get }
  /// The name shown to Dad when he picks or is told about a provider.
  var displayName: String { get }
  /// Where it routes, by which modes, and who guides.
  var traits: ProviderTraits { get }

  /// Places matching `query`, nearest to `near` first when given.
  func search(_ query: String, near: Coordinate?, limit: Int) async throws -> [Place]

  /// Driving routes from `origin` to `destination`, best first. `heading` is
  /// the car's current course, when known, so a reroute does not begin with a
  /// U-turn the car cannot make.
  func routes(from origin: Coordinate, heading: Double?, to destination: Place, options: RouteOptions)
    async throws -> [NavRoute]

  /// A fresh travel time from `origin` to `destination` (traffic-aware when
  /// the provider has traffic), for keeping the ETA honest mid-drive. It is
  /// asked with the drive's own `options` (its mode, the roads Dad told it to
  /// stay off): a time asked without them is the time of another road.
  func travelTime(from origin: Coordinate, to destination: Place, options: RouteOptions) async throws
    -> TimeInterval?
}

extension RouteProvider {
  /// Apple's shape: everywhere, by car and on foot, guided by Pathfinder.
  public var traits: ProviderTraits { ProviderTraits() }
}
PathfinderKit, Guidance/TripStatus.swift: ProviderTraits (원문 그대로 발췌)·swift
/// What a stack can do and how it guides. Each child of `RouteProvider`
/// declares its own; consumers ask the root and never name a provider.
public struct ProviderTraits: Equatable, Sendable {
  public enum Guide: Equatable, Sendable {
    /// Pathfinder's engine guides along the provider's route.
    case pathfinder
    /// The provider's own engine guides and draws (Google's Navigation SDK):
    /// its route has no steps for Pathfinder to read, and what Pippa says comes
    /// from the provider's turn feed.
    case provider
  }

  public var guide: Guide
  /// The route's geometry may be drawn on a map that is not the provider's
  /// (the watch draws on Apple's).
  public var routeIsPortable: Bool
  public var regions: Set<MapRegion>
  public var modes: Set<TravelMode>

  public init(
    guide: Guide = .pathfinder, routeIsPortable: Bool = true, regions: Set<MapRegion> = [.korea, .overseas],
    modes: Set<TravelMode> = [.driving, .walking]
  ) {
    self.guide = guide
    self.routeIsPortable = routeIsPortable
    self.regions = regions
    self.modes = modes
  }

  /// The travel modes offered in `region`, car first.
  public func modes(in region: MapRegion) -> [TravelMode] {
    guard regions.contains(region) else { return [] }
    return TravelMode.allCases.filter(modes.contains)
  }
}
최소 스케치: 자식 셋, traits만 읽는 소비자, 옮겨 다니는 목적지 하나 (swiftc로 컴파일해서 돌렸어)·swift
// A minimal sketch of "the map is an adapter": one root protocol, children
// that declare what they can do, and consumers that only ever ask the root.
struct Coordinate: Hashable { var lat: Double; var lon: Double }
enum Region: Hashable { case korea, overseas }
enum Mode: String, CaseIterable { case driving, walking }

struct Traits {
  enum Guide { case pathfinder, provider }
  var guide: Guide = .pathfinder
  var routeIsPortable = true
  var regions: Set<Region> = [.korea, .overseas]
  var modes: Set<Mode> = [.driving, .walking]
  func modes(in region: Region) -> [Mode] {
    guard regions.contains(region) else { return [] }
    return Mode.allCases.filter(modes.contains)
  }
}

/// Where Dad wants to go: a name and a coordinate. No provider owns it.
struct Destination { var name: String; var coordinate: Coordinate; var foundBy: String }
/// What a provider rendered: it stays with that provider.
struct Route { var providerID: String; var to: Destination; var line: [Coordinate] }

protocol RouteProvider {
  var id: String { get }
  var traits: Traits { get }
  func route(to destination: Destination) -> Route
}
extension RouteProvider {
  var traits: Traits { Traits() }  // the root's default is Apple's shape
  func route(to destination: Destination) -> Route {
    Route(providerID: id, to: destination, line: [destination.coordinate])
  }
}

struct Apple: RouteProvider { let id = "apple" }
struct Kakao: RouteProvider {
  let id = "kakao"
  var traits: Traits { Traits(routeIsPortable: false, regions: [.korea]) }
}
struct Google: RouteProvider {
  let id = "google"
  var traits: Traits { Traits(guide: .provider, routeIsPortable: false, regions: [.overseas]) }
}

// Consumers: none of them names a provider.
func lineForTheWatch(_ route: Route, from provider: any RouteProvider) -> [Coordinate]? {
  provider.traits.routeIsPortable ? route.line : nil
}
func whoGuides(_ provider: any RouteProvider) -> String {
  provider.traits.guide == .pathfinder ? "Pathfinder's engine" : "its own engine"
}

// A public landmark (Seoul City Hall), saved after Apple found it.
let home = Destination(name: "Home", coordinate: Coordinate(lat: 37.5665, lon: 126.9780), foundBy: "apple")
for provider in [Apple(), Kakao(), Google()] as [any RouteProvider] {
  let route = provider.route(to: home)
  let korea = provider.traits.modes(in: .korea).map(\.rawValue)
  let abroad = provider.traits.modes(in: .overseas).map(\.rawValue)
  print(provider.id, "| Korea:", korea, "| abroad:", abroad, "| guided by", whoGuides(provider),
        "| watch line:", lineForTheWatch(route, from: provider) != nil, "| routes to", route.to.name)
}
// apple | Korea: ["driving", "walking"] | abroad: ["driving", "walking"] | guided by Pathfinder's engine | watch line: true | routes to Home
// kakao | Korea: ["driving", "walking"] | abroad: [] | guided by Pathfinder's engine | watch line: false | routes to Home
// google | Korea: [] | abroad: ["driving", "walking"] | guided by its own engine | watch line: false | routes to Home

External links

Exercise

같은 종류의 백엔드를 둘 이상 쓰는 프로젝트를 하나 골라봐. 결제 처리기 둘, 저장소 서비스 둘, LLM 업체 둘 같은 거. 코드에서 백엔드 이름이나 타입으로 갈라지는 곳을 전부 찾아. 갈림길마다 그게 진짜로 묻는 질문을 업체 이름 없이 한 줄로 적어. 그다음 그 질문들에 다 답하는 작은 traits 구조체를 그려봐. 백엔드마다 traits를 밝히게 하면 갈림길이 몇 개나 사라지는지 세봐.
Hint
업체 이름 문자열 리터럴이랑 is SomeClient / as? SomeClient 캐스트를 grep 해봐. 갈림길 대부분은 예/아니오 질문 서너 개로 줄어들어. X를 지원해? 결과를 딴 데서 다시 써도 돼? 뒷일은 누가 맡아? 어디서 동작해?

Progress

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

댓글 0

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

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