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

손목 아웃박스: 폰이 받았다고 할 때까진 유일한 사본

~17 min · on-the-wrist, outbox, idempotency, watchconnectivity, data-safety

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"페이로드는 기대만 믿고 기기를 떠나지 않아."

워치가 자기 줄을 따로 두는 이유

transferUserInfo는 시스템이 알아서 줄 세우고 순서도 지키고 전달도 보장해주니까, 워치에 사본을 하나 더 두는 건 중복처럼 보일 거야. 그렇지 않아. 이유는 둘이야. 첫째, 담기는 로컬에 쓰는 순간 끝나고, 전송은 실패하거나 몇 시간씩 걸릴 수 있는 별개의 일이야. 그러니 뭘 보내기 전에 말부터 손목에 안전하게 남아 있어야 해. 둘째, 눈에 보이는 줄은 정직해. 워치를 찬 사람은 "3개 기다리는 중"을 직접 보게 되지, 다 처리됐다는 말만 듣는 게 아니야. 워치의 줄은 폰 아웃박스와 같은 규칙을 한 단계 바깥에서 따라. 항목은 전송의 didFinish에서 빼고, 보낼 때는 절대 안 빼. 받는 쪽이 id로 중복을 거르니까, 세션이 활성화될 때마다 아직 기다리는 걸 전부 다시 내밀어. 그리고 담기마다 말한 순간을 시간대와 함께 손목에서 찍고, 폰은 그걸 손대지 않고 넘겨. 몇 시간 꺼져 있던 폰이 도착 시각을 다시 찍으면 거짓을 기록하는 거거든.

못 읽는 줄은 갈아치우지 말고 남겨

줄의 첫 버전은 try? decode … else pending = []로 불러왔어. 파일을 못 읽으면 빈 채로 시작하고, 다음 담기를 저장할 때 그 빈 목록이 기다리던 걸 덮어써. 워치에선 그 항목들이 유일한 사본이야. 새 빌드에 필드 하나만 늘어도 옛 파일은 못 읽게 돼. 지금 키트 줄은 못 읽는 파일을 바이트 그대로 옆으로 옮겨두고, 복원 실패 메시지에 그 파일 이름을 적어. 앱은 그 메시지를 화면에 보여줄 수 있어.

멱등성은 정말 중요한 곳, 저장소에

문에서 중복을 거르는 건 문이 아직 기억하는 id까지만 막아줘. 훈련 앱의 폰 저장소는 내밀 때마다 뒤에 붙이기만 했어. 워치는 설계대로 활성화될 때마다 담기를 다시 내밀었으니, 두 번 내민 페이로드는 id 하나에 항목 둘이 됐지. 확인 응답 하나로 한쪽은 정리됐지만, 다른 하나는 TestFlight 빌드 여덟 개가 나가는 동안 폰 줄 맨 앞에 버티고 있었어. 저장소 자체가 담기 id로 멱등해야 해.

폰의 문엔 자기만의 순서 규칙이 있어. 담기를 먼저 반영하고, 본 id 장부엔 그 뒤에 적어. 순서를 거꾸로 하면 반영이 에러를 던져도 id는 본 걸로 남아. 그러면 워치가 다음에 다시 내밀어도 문에서 버려지고, 워치가 바로 이런 때를 대비해 들고 있던 하나뿐인 다른 사본도, 놓아주는 순간 사라져. 이 순서를 지키면 반영과 기록 사이에 죽어도 반영이 한 번 더 될 뿐이고, 그건 멱등한 저장소가 흡수해. 장부는 정상적으로 생기는 메아리를 일찍 걸러내는 크기 제한 캐시일 뿐이고, 결코 유일한 방어선이 아니야.

개수는 적힌 그대로를 뜻해야 해

워치 줄만 보고 센 대기 개수는 폰의 상태가 아니라 전송 데몬의 상태야. "3개 기다리는 중"은 전송만 놓고 보면 맞았지만, 폰이 이미 받은 담기에 대해선 오해를 불렀어. 그래서 폰이 초인종에 영수증으로 대답하면, 전송이 끝날 때까지 그 id들은 넘겨준 걸로 치고 상태 줄에도 그렇게 보여줘.

Code

못 읽는 파일을 지키는 워치 줄, 기억하기 전에 반영하는 폰 문·swift
import Foundation

struct WristCapture: Codable, Equatable, Sendable {
    let id: String
    let text: String
    let spokenAt: Date          // stamped on the wrist when spoken; the phone carries it untouched
    let timeZone: String
}

/// The watch's own queue. On the wrist this file IS the captures until the phone has them.
final class WristQueue {
    private let file: URL
    private(set) var pending: [WristCapture] = []
    private(set) var restoreFailure: String?

    init(file: URL) {
        self.file = file
        guard let data = try? Data(contentsOf: file) else { return }   // no file yet: a fresh install
        do {
            pending = try JSONDecoder().decode([WristCapture].self, from: data)
        } catch {
            // Never start empty over an unreadable queue: the next write would erase what was spoken.
            let aside = file.deletingLastPathComponent().appending(path: "queue-unreadable-\(Int(Date().timeIntervalSince1970)).json")
            try? FileManager.default.moveItem(at: file, to: aside)
            restoreFailure = "The waiting captures could not be read and were kept at \(aside.lastPathComponent)."
        }
    }

    /// Idempotent on id: the same capture offered twice stays one entry.
    func enqueue(_ capture: WristCapture) throws {
        guard !pending.contains(where: { $0.id == capture.id }) else { return }
        pending.append(capture)
        try save()
    }

    /// Called from the transfer's didFinish, and nowhere else.
    func acknowledge(_ id: String) throws {
        pending.removeAll { $0.id == id }
        try save()
    }

    private func save() throws {
        try JSONEncoder().encode(pending).write(to: file, options: .atomic)
    }
}

/// The phone's door. Commit first, remember second: a crash between them costs one redundant
/// commit, which an idempotent store absorbs. The other order could lose the only copy.
struct PhoneDoor {
    var seen: Set<String>
    let commit: (WristCapture) throws -> Void

    mutating func admit(_ capture: WristCapture) throws -> Bool {
        guard !seen.contains(capture.id) else { return false }   // the echo of a ring or a re-offer
        try commit(capture)
        seen.insert(capture.id)
        return true
    }
}
규칙을 돌려본 결과·text
A check run on macOS:

  pending after double offer: 1
  restored pending: 0 | failure: The waiting captures could not be read and were kept at
                                 queue-unreadable-1789393851.json      <- moved aside, bytes intact
  admit: true  echo: false  store: ["w1"]
  after a failed commit, seen contains id: false                        <- the next re-offer still gets in

External links

Exercise

Spark 워치 타깃에 WristQueue를, 폰 타깃에 PhoneDoor를 넣고 테스트 셋을 써. 첫째, 옛 형식으로 줄 파일을 써 두고, 불러올 때 그 파일을 옆으로 치우고 알려주는지 증명해. 둘째, 담기 하나를 두 번 내밀어도 항목이 하나뿐인지 증명해. 셋째, 처음엔 던지고 두 번째엔 성공하는 반영 클로저를 문에 넘기고, 다시 내밀었을 때 담기가 반영되는지 증명해. 마지막으로 문 순서를 뒤집어서 반영 전에 id를 적게 하고, 세 테스트 중 어느 게 실패하는지 보여줘.
Hint
파일 URL과 반영 클로저를 주입하면 WatchConnectivity 없이도 테스트할 수 있어. 클로저가 붙잡은 카운터 하나면 첫 호출에서만 던지게 할 수 있어.

Progress

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

댓글 0

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

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