Skip to content
C.W.K.
Stream
Lesson 03 of 05 · published

Kakao Through a Server Door

~16 min · swift, python, api-design, kakao, maps

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

A child whose data lives behind a door

Kakao is Korea's own stack, and it arrives in two halves. The map itself is drawn on the phone by the Kakao Maps SDK, with a native key that lives only in the phone's Keychain. Search, routes and travel times do not come from the phone. They come through cwkPippa: the Kakao child is RemoteMapsProvider, and it asks POST /api/location/maps/search, /routes or /eta. The upstream credentials stay on the server, the door is authenticated, and the upstream keys are restricted to the office machine's address. The phone never holds them, so no build, crash log or screenshot can leak them.

The client end is narrow on purpose. The app's request closure refuses any operation other than those three before a byte leaves the phone. Every failure becomes a MapsIssue: quota, permission, noRoute, mismatchedProvider and a few more, mapped from the server's status and code. An error carries no response body, request URL, credential or coordinate, so nothing private rides along into a log or onto the screen.

The wire is checked, not trusted

A door is a boundary, and the child treats the reply like input from a stranger. The reply must name the provider that was asked, or it is refused as mismatchedProvider. Every route needs at least two valid points, a positive length and a finite, non-negative time. Every step needs a finite, non-negative distance, and a maneuverAlong inside the line (with two metres of slack) that never goes backwards. Search results must all carry this provider's id and valid coordinates. One bad row fails the whole answer as invalidResponse, because guidance built on half a wire is worse than no guidance.

Rewriting Kakao into Pathfinder's grammar

The server does more than relay. pathfinder_maps.py rewrites Kakao's guides into the grammar PathfinderKit's chunker reads: place, direction, one fixed wording per guide type. Kakao gives a junction's name in name and its words in guidance ("청계1가 방면으로 우회전"); the server rebuilds the line as "청계광장에서 청계1가 방면으로 우회전".

The guide-type table, KAKAO_GUIDE_TYPES, gives each type an arrow kind and its one wording: 1 is 좌회전, 2 is 우회전, 7 is 고속도로 출구로 나가기. Types 18 to 29 are clock directions; 30 to 41 add "로터리에서", 70 to 81 add "회전교차로에서". Types 100, 101, 1000, 300 and 301 (origin, destination, waypoint, and the tollgates and rest areas a road merely passes) are not maneuvers at all. Type 29, "12시 방향", with no sign is dropped: a road that runs straight through a junction it only passes was being announced three times a junction, and "it drowned the real turns". A type newer than the table keeps Kakao's own words, so a Kakao update degrades gracefully instead of going silent. Finally, route_wire pins every maneuver at the point where it happens and appends "목적지에 도착" itself.

Why go to this trouble? Pippa's voice is cached in chunks. If every Kakao maneuver ends in one fixed phrase from a closed vocabulary, that phrase is synthesized once and replayed forever, and only the place names are new. Kakao's free-form words would be fresh audio on every junction.

A twin with no import

Here is the fragile part, named out loud. The wordings are composed in Python on cwkPippa, and answered by phrases.ko.json in Swift on the phone. Neither repository imports the other. So the kit carries a twin test, KakaoWordingTests.everyKakaoWordingEndsInPippasPhrase: it lists every wording the server can compose, puts each after a test place and a test direction, and requires exactly three chunks, the last one a phrase from the closed vocabulary. The repo's house file states the rule: "a wording changes in both or it is spoken a word at a time". A test cannot reach across two repositories, but it can pin one side to a list that a reviewer compares against the other.

Kakao's camera turns the other way

The map half had its own surprise, written up as coop learning #753 from the SDK's own headers. KakaoMap.rotationAngle reads the camera's rotation counter-clockwise, in radians; CameraUpdate.make takes rotation clockwise. Hand the getter's value back while changing only the tilt, and a turned map flips to its mirror image. The fix does not read the rotation at all: a tilt change goes out as a CameraTransform with deltaRotation: 0 and deltaTilt set to the difference. The same learning caught a second trap: CameraAnimationOptions(consecutive: true) queues animations, and on a walk the compass turns the map several times a second, so a queue of 0.7 s moves fell further and further behind Dad. The follow camera now uses consecutive: false, 500 ms moves, and never asks again for the same target, heading, zoom and tilt. Honest footnote: those rules were read from headers, because Kakao's native key lives only on Dad's phone and nothing on Kakao's map runs on a Mac.

The gate that refused Dad's own Home

Before the review checkpoint of 2026-10-03 (commit 0066b86), each provider had an accepts check, shown in the second code block. It looked tidy. Then Kakao became Korea's default. Dad's saved Home had been found on Apple, so its providerID was apple, and Kakao refused it as "another provider's place". Pippa's handoffs, tagged pippa, were accepted only by Apple. The rule was protecting something real (a route's line and words really do belong to their stack) but it drew the line in the wrong place.

The fix moved the line. A destination is a name and a coordinate, and every stack routes to it. Only what a stack rendered (a result list, a route's geometry and words) stays with that stack. The app's suite now checks both halves in one test: a Kakao search answered for Google is refused with mismatchedProvider, and a Home saved with providerID apple routes on Kakao.

Guard the rendering, not the intent. A provider's output can be wrong for another provider; a person's destination cannot. When a safety check fires on what the user wants rather than on what a system produced, it is guarding the wrong object.

Code

cwkPippa, services/pathfinder_maps.py: one Kakao guide rebuilt as place, direction and a fixed wording (verbatim excerpt)·python
def kakao_guide_words(guide: dict) -> tuple[str, str | None] | None:
    """One Kakao guide as (instruction, kind), or None when it is not a
    maneuver. Kakao gives the place as ``name`` and the words as ``guidance``
    ("청계1가 방면으로 우회전"); the instruction is rebuilt as place, direction
    and the type's fixed wording: "청계광장에서 청계1가 방면으로 우회전"."""
    said = " ".join(str(guide.get("guidance") or "").split())
    name = " ".join(str(guide.get("name") or "").split())
    kind_of = guide.get("type")
    if kind_of not in KAKAO_GUIDE_TYPES:
        # A type newer than this table keeps Kakao's own words; the phone
        # reads the arrow from them.
        return (said, None) if said else None
    entry = KAKAO_GUIDE_TYPES[kind_of]
    if entry is None:
        return None
    kind, where, wording = entry
    # A sign's places, which Kakao lists with spaces and commas alike
    # ("법원,검찰청 장항IC 방면으로").
    signs = [sign for sign in re.split(r"[,\s]+", said.split(" 방면으로", 1)[0]) if sign] if " 방면으로" in said else []
    if kind_of == _KAKAO_STRAIGHT_ON and not signs:
        # "자유로JC | 12시 방향": the road goes straight through a junction it
        # only passes. Said three times a junction, it drowned the real turns.
        return None
    if kind == "roundabout":
        if name and ("교차로" in name or "로터리" in name):
            where = name + "에서"
    elif name and kind_of not in _KAKAO_UNNAMED and name not in signs:
        structure = _KAKAO_STRUCTURES.get(kind_of)
        if structure is None or structure not in name:
            where = name + "에서"
    parts = [where] if where else []
    if signs:
        parts.append(", ".join(signs) + " 방면으로")
    parts.append(wording)
    return " ".join(parts), kind
PathfinderKit, Providers/MapProvider.swift before 0066b86: the gate that refused Dad's own destinations (verbatim, from the parent commit)·swift
  public func accepts(_ place: Place) -> Bool {
    place.providerID == rawValue || place.providerID == "device" || place.providerID == "user"
      || (self == .apple && place.providerID == "pippa")
  }
PathfinderRemoteMaps, RemoteMapsProvider.swift: the routes reply is validated before it becomes a NavRoute (verbatim excerpt)·swift
    let reply = try await call(
      "routes",
      body: Body(
        provider: id, origin: origin, destination: destination.coordinate, mode: options.mode,
        heading: heading, alternatives: options.alternatives, avoidTolls: options.avoidTolls,
        avoidHighways: options.avoidHighways), as: Answer.self)
    guard reply.provider == id else { throw MapsIssue.mismatchedProvider }
    guard !reply.routes.isEmpty else { throw MapsIssue.noRoute }
    return try reply.routes.map { row in
      let line = Polyline(row.points)
      guard row.points.count > 1, row.points.allSatisfy(\.isValid), line.length > 0,
        row.seconds.isFinite, row.seconds >= 0, !row.steps.isEmpty,
        row.steps.allSatisfy({
          $0.distance.isFinite && $0.distance >= 0 && $0.maneuverAlong.isFinite
            && $0.maneuverAlong >= 0 && $0.maneuverAlong <= line.length + 2
        }),
        zip(row.steps, row.steps.dropFirst()).allSatisfy({ $0.maneuverAlong <= $1.maneuverAlong })
      else { throw MapsIssue.invalidResponse }

External links

Exercise

Pick an API your app calls through a server you control (or a mock of one). Write a validator on the client that rejects a reply which names the wrong source, has an empty or non-finite numeric field, or has an ordered list that goes backwards. Then write three deliberately broken replies and a test that each one is refused with a specific, body-free error.
Hint
Validate the whole reply before converting any part of it, so a half-good reply never produces a half-built object. Keep the error type an enum of reasons with no payload: a reason tells the screen what to say, and a payload is how a coordinate or a token ends up in a log.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.