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

아웃박스: 담은 건 절대 안 잃어버려

~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? decodecontinue로 읽는 저장소는 필드 하나만 늘어도 줄이 텅 비어버려. 이전 빌드가 쓴 매니페스트가 한꺼번에 해독에 실패하고, 앱은 보낼 게 없다고 표시하지. 건너뛰는 것 자체는 맞아. 버그는 개수를 빠뜨리는 거야. 읽을 수 있는 항목과 함께 못 읽은 항목도 돌려주고, "이 앱이 못 읽는 담기 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"]

External links

Exercise

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.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

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

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