~18 min · engine-at-home, outbox, offline, idempotency, sync
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"아무것도 가리키지 않는 곳을 들여다봐."
로컬에 쓰는 순간이 곧 담기야
산길에서 말로 남긴 메모, 터널 안에서 찍은 사진, 지하 헬스장에서 적은 세트. 가족 폰 앱은 네트워크가 없는 곳에서도 담으니까, 담기가 엔진에 기대면 안 돼. 아웃박스는 로컬에 쓰는 순간을 담기로 치고, 엔진에 보내는 일은 실패할 수도 있는 별개의 동작으로 떼어놔. 항목마다 기기가 만든 id를 달고, 엔진은 그 id가 다시 오면 기록을 하나 더 붙이지 않고 이미 가진 걸 돌려줘. 이 규칙 하나 덕분에 나머지 어디서든 마음 놓고 다시 시도할 수 있어.
항목이 거치는 중요한 상태는 넷이야. QUEUED, SYNCING(지금 전송 중인 것), SYNCED, FAILED_NEEDS_ATTENTION. 보내다가 죽은 앱은 다시 켜질 때 SYNCING에 멈춘 항목을 찾아서 그냥 줄에 다시 세워. 엔진이 중복을 걸러주니까 두 번 보내도 탈이 없어.
줄을 비울 때의 판단
줄을 비울 땐 항목을 순서대로 하나씩 처리해. 영구 실패는 엔진이 이 내용물 자체를 거절했다는 뜻이야. 그 실패는 뒤에 있는 항목들에 대해선 아무것도 알려주지 않으니까, 그 항목만 사용자가 볼 수 있는 곳에 세워두고 계속 진행해. 일시 실패라면 나머지도 똑같이 실패할 테니까, 항목을 줄에 돌려놓고 멈춰. 공유 버전의 바탕이 된 Swift 앱 두 개는 둘 다 이 경계를 잘못 그었어. 4xx를 전부 영구 실패로 쳤거든. 근데 401과 403은 내용물이 아니라 세션이 잘못됐다는 뜻이고, 408, 425, 429는 "지금은 말고"라는 뜻이야. 자기 엔진을 잘 아는 앱은 더 세밀하게 나눌 수 있어. 여행 일지 앱이 엔진의 405를 "엔진 재시작하고 다시 시도"로 다루는 것처럼.
양쪽 끝 모두 멱등하게
훈련 앱의 워치는 연결이 돌아올 때마다 담은 걸 폰에 다시 내밀어. 일부러 그렇게 만든 거야. 근데 폰의 저장소는 내밀 때마다 항목을 새로 붙였어. 확인 응답 하나로 한 벌은 정리됐지만, 다른 한 벌은 빌드 여덟 개가 나가는 동안 줄 맨 앞을 차지하고 있었어. POST를 774번 되풀이한 루프의 나머지 반쪽은 완료 단계였어. 저장소에 더는 없는 id를 받고도 아무 말 없이 돌아왔거든. 고친 건 세 군데야. 넣기는 id 기준으로 멱등하게 만들고, 이미 디스크에 있는 중복은 불러올 때 바로잡고(정리된 쪽이 이겨), 모르는 id를 완료로 표시하려 하면 에러를 던지게 했어.
목록 둘, 그리고 빠뜨리면 안 되는 개수
여행 일지 앱에선 항목이 동기화된 뒤에도 사진이 폰에 계속 남아 있었어. 목록 하나가 두 가지 역할을 맡았거든. 아직 올릴 파일 목록이면서, 항목이 은퇴할 때 지울 파일 목록이기도 했어. 줄을 비우는 코드는 업로드가 끝날 때마다 올릴 파일 목록에서 해당 파일을 제대로 뺐어. 그러니 확인 응답이 올 즈음엔 정리 반복문이 돌 대상이 남아 있지 않았지. 어떤 파일을 소유하는지는 절대 소비되지 않는 별도 필드에 둬야 해. 테스트도 은퇴시키기 전에 대기 목록을 다 비워야 하고. 안 그러면 버그가 있어도 통과하거든.
마지막 함정은 제일 조용해. 항목 목록을 try? decode와 continue로 읽는 저장소는 필드 하나만 늘어도 줄이 텅 비어버려. 이전 빌드가 쓴 매니페스트가 한꺼번에 해독에 실패하고, 앱은 보낼 게 없다고 표시하지. 건너뛰는 것 자체는 맞아. 버그는 개수를 빠뜨리는 거야. 읽을 수 있는 항목과 함께 못 읽은 항목도 돌려주고, "이 앱이 못 읽는 담기 N개, 이 기기에 그대로 있음"이라고 보여줘. 옛 항목은 아무것도 잃지 않도록 고른 기본값으로 해독해. 상태는 QUEUED로, 만든 날짜가 없으면 epoch로 둬. 그래야 되살린 담기가 가장 먼저 비워져.
Code
아웃박스: 멱등한 넣기, 켜질 때 복구, 멈출 때를 아는 비우기·swift
import Foundation
enum OutboxState: String, Codable, Sendable {
case queued = "QUEUED"
case syncing = "SYNCING"
case synced = "SYNCED"
case needsAttention = "FAILED_NEEDS_ATTENTION"
}
struct OutboxEntry: Codable, Sendable, Equatable {
let id: String // minted on the device; the engine answers a replay of it idempotently
var state: OutboxState
var pendingMedia: [String] // still to upload, consumed as each upload lands
let ownedMedia: [String] // what to delete on every exit path, never consumed
var lastError: String?
}
enum SendFailure: Error, Equatable {
case transient(String)
case permanent(String)
}
/// A wrong session or a busy engine is a wait. Another 4xx refuses THIS payload.
func classify(status: Int) -> SendFailure? {
switch status {
case 200..<300: nil
case 401, 403, 408, 425, 429: .transient("HTTP \(status)")
case 400..<500: .permanent("HTTP \(status)")
default: .transient("HTTP \(status)")
}
}
struct Outbox {
private(set) var entries: [OutboxEntry] = []
/// Idempotent on id: a payload offered twice stays one entry.
mutating func enqueue(_ entry: OutboxEntry) {
guard !entries.contains(where: { $0.id == entry.id }) else { return }
entries.append(entry)
}
/// At launch. A send killed mid-flight goes back in line; the engine dedupes the replay.
mutating func recoverInterruptedSends() {
for index in entries.indices where entries[index].state == .syncing {
entries[index].state = .queued
}
}
/// One pass in order. Permanent: park that entry and continue. Transient: requeue and stop.
mutating func drain(send: @Sendable (OutboxEntry) async -> Int) async -> (synced: Int, haltedBecause: String?) {
var synced = 0
for index in entries.indices where entries[index].state == .queued {
entries[index].state = .syncing
switch classify(status: await send(entries[index])) {
case nil:
entries[index].state = .synced
synced += 1
case .permanent(let reason)?:
entries[index].state = .needsAttention
entries[index].lastError = reason
case .transient(let reason)?:
entries[index].state = .queued
return (synced, reason)
}
}
return (synced, nil)
}
}
확인 실행: 같은 id 두 번 내밀기, 보내다 끊긴 하나, 그리고 200, 422, 503, 200·text
entries after double offer: 4
states after recovery: ["QUEUED", "QUEUED", "QUEUED", "QUEUED"]
outcome: (synced: 1, haltedBecause: Optional("HTTP 503"))
states after drain: ["01A=SYNCED", "01B=FAILED_NEEDS_ATTENTION", "01C=QUEUED", "01D=QUEUED"]
SparkKit에 아웃박스를 넣고 확인 실행을 셀프 테스트로 재현해. 이어서 빠진 동작 두 개를 추가해. 하나는 저장소에 없는 id면 에러를 던지는 markSynced(id:), 다른 하나는 임시 디렉터리에서 ownedMedia 파일을 지우는 retire(id:)야. 은퇴 테스트는 속임수 없이 써. 대기 파일을 먼저 전부 올려서 pendingMedia를 비운 뒤에 은퇴시키고, 파일이 사라졌는지 확인해. 마지막으로 항목 하나에 state 키가 없는 JSON 문서를 불러오게 해서, 그 항목을 버리지 말고 보고하게 만들어.
Hint
키가 빠진 경우엔 OutboxEntry에 직접 작성한 init(from:)을 두고, decodeIfPresent에 기본값 .queued를 줘. id가 없는 경우처럼 기본값으로 메울 수 없는 이유로 실패한 항목은 따로 목록에 모아둬.
Progress
Progress is local-only — sign in to sync across devices.