~14 min · logging, field-testing, evidence, testflight
Level 0길 잃음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
살아 있는 실험엔 실험 노트가 필요해
첫날 아빠가 조건을 걸었어. 이 앱은 살아 있는 실험으로 다뤄라. 되는 것도 안 되는 것도 기록해라. 하우스 사인은 그걸 규칙으로 박아뒀어. 진짜 주행이 유일하게 쳐주는 테스트야. 시뮬레이터 재생은 배선이 맞는지를 보여줄 뿐이고, 증거는 실제 주행의 기록이야.
이 구분이 중요해. 시뮬레이터는 Apple이 준 경로 모양 그대로 주행을 다시 돌리면서 안내 루프 전체를 밟을 수 있어. Pathfinder도 정확히 그렇게 해. 근데 GPS를 삼키는 터널, 주차장에서 시작하는 출발, 회전 직전에 걸려 온 전화, 조용히 귀를 닫아버린 워치는 못 줘. 그런 건 길에 있어. 주행 기록은 길이 보고를 올려 보내는 통로야.
한 번의 주행이 남기는 것
DriveLog는 PathfinderKit 안에 있어. 나머지 두뇌처럼 테스트에 묶여 있지. 일이 생기는 순간 JSON 객체 하나를 한 줄로 덧붙여. 깔끔한 종료를 기다리는 메모리 버퍼 같은 건 없어. 그 종료는 안 올 수도 있으니까. 줄은 만들어지자마자 운영체제로 넘어가. 그래서 주행 중에 앱이 죽거나 강제로 꺼져도 그 순간까지 쓴 줄은 다 남아.
소스 주석은 한 발 더 나가. "a crash or a dead battery", 그러니까 배터리가 나가도 남는다고 해. 배터리 쪽은 증명된 적이 없어. append는 synchronize()를 안 불러. 그걸 부르는 건 close()뿐이야. 시스템이 받아만 두고 아직 저장 장치에 안 내린 줄은 전원이 한 번에 나가면 날아갈 수 있어. Mac 위의 테스트로는 전원을 뽑아 볼 수도 없고. 주행이 직접 보여주기 전까진 그 절반은 성질이 아니라 바람이야.
주행 하나에 파일 하나. 폰의 drives 폴더에 drive-<서울 기준 시작 시각>.jsonl로 쌓여. 어디로도 안 올라가. 설정 화면이 주행마다 공유 링크를 달아서, 읽어볼 주행이 있으면 아빠가 꺼내 보낼 수 있어.
남기는 건 Pathfinder 자기 것뿐이야. 측위는 자기가 잰 값이고, 경로 문장은 아빠한테 실제로 말한 것만 남아. 대화 기록처럼. Google이 안내한 여정은 google:guided by the provider라고만 쓰고 길에 대해선 한 글자도 안 남겨. Google의 말은 Google 지도 위에 머물러야 하니까.
주행이 아닌 건 파일도 없어. 시작조차 못 한 여정은 그 자리에서 지워. 시뮬레이션은 아예 아무것도 안 써.
길은 계속 뭔가를 찾아내
습관은 단순해. 빌드하고, 들고 나가고, 무슨 일이 있었는지 가져오고, 고치고, 다시 빌드해. 길이 찾아낸 것 몇 개만 보자.
첫 TestFlight 테스트: 기기 안 MapKit이 한글 검색마다 "not found"(MKErrorDomain 4)로 답했어. 일산 근처에서 "hufs"를 찾았더니 텍사스 헤이스가 나왔고. 그래서 검색에 중심점이 생기고 피파의 Apple Maps 문이 붙었어(레슨 4).
첫 주행들: 아빠가 원한 건 셋이었어. 자기 장소 목록, 진행 방향 기준 평면 지도(자기 위치는 화면 아래쪽), 그리고 도보 모드. 셋 다 커밋 하나로 들어갔어.
빌드 7이 길에 나갔어. 돌아온 아빠 판정은 "테스트했어. 잘 돼." 할 일 #290은 초록색 테스트가 아니라 아빠 말 한마디로 닫혔어.
마드리드 아토차역까지 걸어가는 길, 아빠는 다음 회전이랑 도착 시각이랑 거리는 봤는데 어디로 가는 중인지는 끝내 못 봤어(#367). 지금은 안내 바가 모든 스택에서 목적지 이름을 달고 다녀.
세비야에선 워치가 아무것도 안 따라왔어(#391). 왜 그랬는지 남은 기록이 하나도 없었고.
기록이 침묵하면, 그것도 발견이야
곱씹을 만한 건 세비야 건이야. 워치는 고쳤어. 근데 더 오래 남는 쪽은 기록에 들어간 수정이야. 이제 폰은 워치가 안내를 못 듣게 되는 순간 note를 하나 쓰고, 다시 듣게 되면 또 하나를 써. 워치가 붙은 채로 시작한 주행은 아무 말도 안 해서 기록이 잡음으로 차지도 않아.
실패가 흔적 없이 사라질 수 있는 자리마다 같은 본능이 보여. Google의 회전 정보가 끝내 안 오면 Pathfinder는 회전 안내를 Google 자체 음성에 넘기고 google: no turn feed; its own voice guides라고 남겨. 문장 하나가 시스템 음성으로 넘어가야 했으면 voice_fallback이 그걸 말해. 잘 풀린 경우만 담는 기록은 일기지, 증거가 아니야.
버그를 고치고, 그 버그를 놓친 기록도 고쳐. 길에서 난 실패가 흔적을 안 남겼다면, 그걸 고치는 변경에 그 실패를 보여줬을 이벤트도 같이 넣어. 그러면 다음 침묵은 타임스탬프를 달고 도착해.
빌드가 심장 박동이야
이 고리는 새 빌드가 아빠 폰까지 빨리 닿아야 돌아가. Pathfinder의 파이프라인 커밋은 처음 열이틀 동안 TestFlight 빌드 2부터 빌드 30까지 이어져. 전부 TestFlight로 갔어. 우리 집에선 iOS 앱이 아빠 기기에 닿는 길이 그거 하나야. 케이블 설치는 빌드 확인이지 배달이 아니야.
파이프라인 커밋이 곧 "아빠 폰에 있음"도 아니야. 업로드, Apple 처리, 내부 테스트 그룹 배정, 아빠 폰에 설치. 이 넷은 따로따로인 사실이야. 앞의 셋은 Mac에서 확인할 수 있고, 넷째는 아빠 폰만 증언할 수 있어. 1.0 출시 관문은 이걸 콕 집어 요구해. 진짜 주행 한 번도 같이 요구하고. 검색에서 도착까지 가는 동안 재탐색 한 번, 카메라 경고 한 번, 그리고 내비 보고로 답하는 피파까지 다 들어간 주행이야.
public func append(_ event: DriveEvent) {
if event.kind == .fix, let at = ISO8601DateFormatter.fractional.date(from: event.at) {
if let lastFixAt, at.timeIntervalSince(lastFixAt) < fixInterval { return }
lastFixAt = at
}
guard var data = try? encoder.encode(event) else { return }
data.append(0x0A)
try? handle?.write(contentsOf: data)
}
같은 생각의 최소 스케치: 덧붙이기만 하는 JSONL, 측위 솎기, close 없이 다시 읽기 (swiftc로 컴파일해서 돌렸어. 출력은 맨 끝)·swift
import Foundation
// One JSON line per event, written the moment it happens. Fixes are thinned to
// one a second; every other event is always kept. Each line goes to the OS as
// soon as it is encoded, with no buffer in the app, so a crash keeps every line
// written so far. Nothing calls synchronize(), so a sudden power cut is a
// different question that this sketch does not answer.
struct Event: Codable {
var at: Date
var kind: String
var text: String?
}
final class DriveLog {
let url: URL
private let handle: FileHandle
private let encoder = JSONEncoder()
private var lastFixAt: Date?
private let fixInterval: TimeInterval = 1
init(url: URL) throws {
FileManager.default.createFile(atPath: url.path, contents: nil)
handle = try FileHandle(forWritingTo: url)
encoder.dateEncodingStrategy = .secondsSince1970
encoder.outputFormatting = [.sortedKeys]
self.url = url
}
func append(_ event: Event) {
if event.kind == "fix" {
if let lastFixAt, event.at.timeIntervalSince(lastFixAt) < fixInterval { return }
lastFixAt = event.at
}
guard var data = try? encoder.encode(event) else { return }
data.append(0x0A)
try? handle.write(contentsOf: data)
}
}
let start = Date(timeIntervalSince1970: 0)
func at(_ s: Double) -> Date { start.addingTimeInterval(s) }
let url = FileManager.default.temporaryDirectory.appendingPathComponent("drive-sketch.jsonl")
let log = try DriveLog(url: url)
log.append(Event(at: at(0), kind: "start", text: "to the station"))
for s in stride(from: 0.0, through: 6.0, by: 0.4) { log.append(Event(at: at(s), kind: "fix")) }
log.append(Event(at: at(2.0), kind: "spoke", text: "300미터 앞에서 우회전해"))
log.append(Event(at: at(4.4), kind: "off_route", text: "lateral 62 m"))
log.append(Event(at: at(5.1), kind: "reroute"))
log.append(Event(at: at(9.8), kind: "back_on_route"))
// No close() and no synchronize(): read back what the file already holds.
// This shows that nothing waits for a clean close. It is not a crash or a
// power cut; the exercise below kills a real process.
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let lines = try String(contentsOf: url, encoding: .utf8).split(separator: "\n")
let events = lines.compactMap { try? decoder.decode(Event.self, from: Data($0.utf8)) }
let fixes = events.filter { $0.kind == "fix" }.count
print("lines on disk: \(events.count) (fixes kept: \(fixes) of 16 offered)")
if let off = events.first(where: { $0.kind == "off_route" }),
let back = events.first(where: { $0.kind == "back_on_route" })
{
print("off the route for", String(format: "%.1f s", back.at.timeIntervalSince(off.at)))
}
// Output:
// lines on disk: 11 (fixes kept: 6 of 16 offered)
// off the route for 5.4 s
cwkPathfinder, clients/apple/PathfinderMobile/Sources/App/AppModel.swift: 워치의 침묵을 주행 기록에 남기기, #391 (원문 그대로)·swift
/// A drive's log says when the watch stops hearing the guidance and when
/// it hears it again (todo #391: in Seville the watch followed nothing and
/// no record said why). A drive that begins with the watch heard says
/// nothing.
private func noteWristTrouble(_ trouble: String?) {
guard !isSimulating, let log = driveLog else { return }
let id = ObjectIdentifier(log)
if let note = wristNote, note.log == id, note.trouble == trouble { return }
let first = wristNote?.log != id
wristNote = (id, trouble)
if first && trouble == nil { return }
let event = DriveEvent(at: Date(), kind: .note, text: "watch: \(trouble ?? "hears the guidance again")")
Task { await log.append(event) }
}
오래 도는 프로그램 하나(서버, 배치 작업, 게임 루프 뭐든)에 덧붙이기만 하는 JSONL 이벤트 기록을 붙여봐. 이벤트는 종료할 때 말고 일어나는 순간에 써. 한창 돌 때 kill -9로 프로세스를 죽이고, 죽기 전 줄이 전부 읽히는지 확인해. 그다음 예전에 아무 단서 없이 디버깅했던 실패 하나를 떠올리고, 그걸 보여줬을 이벤트 하나를 추가해.
Hint
자주 나오는 이벤트(위치, 틱, 프레임)는 시간으로 솎고, 드문 이벤트(에러, 상태 변화)는 절대 솎지 마. Pathfinder가 측위랑 나머지를 가르는 방식이 정확히 그거야. 쓰는 언어가 파일 쓰기를 버퍼링한다면 강제 종료 때 버퍼가 날아가는지 봐. 파일은 덧붙이기 모드로 열고, 줄 단위로 통째로 써. kill -9는 크래시를 시험하는 거지 정전을 시험하는 게 아니야. 쓴 건 아직 커널이 쥐고 있거든. 정전까지 버티려면 명시적으로 동기화해야 해(fsync, Swift라면 FileHandle.synchronize()). 그건 부를 때마다 저장 장치까지 한 번씩 다녀오는 값을 치러.
Progress
Progress is local-only — sign in to sync across devices.