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

거짓말 못 하는 오프라인 사본

~14 min · offline, concurrency, caching, swift

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

규칙이 정반대인 저장소 둘

폰엔 데이터가 두 종류 있고, 둘은 정반대 규칙을 따라. 레슨 3의 outbox는 직접 만든 데이터야. 아빠가 만들었는데 엔진이 아직 받았다고 확인 안 한 접시들. 그건 아무도 몰래 버리면 안 돼. 오프라인 선반엔 엔진이 이미 답한 내용을 담은, 버려도 되는 사본이 들어 있어. 접시 목록, 접시마다 상세, 사진 바이트, 트리, 클래스, 스레드. 선반 자기 문서 주석이 둘 사이 순서를 정해 둬. 사본은 버려도 되고, outbox는 직접 만든 데이터로 남고, 엔진이 확인한 바이트를 outbox가 내려놓으려면 사본 저장 영수증이 먼저 있어야 해.

사본 하나는 파일 하나야. 응답 본문과 저장한 순간이 같이 들어 있어서, 사본을 보여주는 화면은 그 사실을 밝힐 수 있어. Saved on device(기기에 저장됨)라고 쓰고 날짜와 시각을 붙이는 식이야. 깨진 파일은 없는 걸로 읽히고, 다음에 제대로 읽으면 그걸로 바뀌어.

엔진 하나에 묶여

선반 디렉터리 이름은 엔진의 기본 URL에서 만들어. 그래서 한 엔진에서 받은 사본은 폰이 다른 엔진이랑 말할 땐 절대 안 떠. 아빠가 설정에서 엔진 주소를 바꾸면, 앱은 새 주소를 저장하기 전에 옛 선반부터 은퇴시켜. 그리고 새 엔진용 선반을 열고, 이미지 캐시를 비우고, 그 선반이 이미 가진 걸로 접시와 트리를 되살려. 한 번도 안 붙어 본 엔진이면 선반은 비어 있어. 호스티드 테스트 하나가 두 번째 엔진으로 바꿔서 빈 화면을 확인하고, 다시 돌아와서 첫 엔진의 접시를 다시 찾아.

티켓과 세대

진짜 어려운 건 시간이야. 읽기는 느릴 수 있고, 그게 가는 동안 아빠가 바로 그 기록을 바꿀 수 있어. 늦은 답을 도착하는 대로 써 버리면, 기기 사본이 아빠의 수정을 조용히 되돌려 놓겠지. 선반은 카운터 둘과 잠금 하나로 그걸 막아.

  • begin(path)는 그 경로의 최신 읽기로 새 id를 기록하고, 그 id와 선반의 지금 세대 번호를 담은 티켓을 돌려줘.
  • didMutate()는 세대 번호를 올려. retire()는 선반을 은퇴 상태로 표시하고 번호도 올려.
  • isCurrent는 선반이 은퇴하지 않았고, 세대가 안 바뀌었고, 이 티켓이 아직 그 경로의 최신 읽기일 때만 참이야.
  • 티켓을 든 쓰기는 그 티켓이 유효할 때만 들어가.

EngineClient는 이 규칙을 두 군데에 써. 미러링하는 경로(접시 목록, 접시, 트리, 클래스, 스레드)를 읽을 땐 요청이 나가기 전에 티켓부터 받아. 답이 와서 디코딩되면 티켓부터 확인해. 낡았으면 취소를 던져서 화면이 원래 보여주던 걸 지키고, 유효하면 사본을 써. 변경은 반대야. 수정이든 채택이든 질문이든 엔진이 답하면, 클라이언트는 세대 번호를 올리고 변경 자기 답을 선반에 바로 써. 바뀐 접시는 저장된 목록 안의 자기 줄도 고쳐.

그래서 가장 새로운 진실은 늘 변경에 대한 답이고, 그보다 먼저 시작한 읽기는 다 거절돼. 같은 경로를 나중에 읽으면 먼저 읽은 것도 밀려나. begin마다 그 경로의 읽기 id를 갈아 끼우니까. 호스티드 테스트 둘이 두 경우를 다 박아 둬. 쓰기 뒤에 끝난 느린 목록 읽기는 기기 기록을 못 지우고, 옛 엔진에서 늦게 온 답은 새 엔진 화면을 못 채워.

동기화는 읽기만 하고 묻지 않아

앱이 활성이면 루프가 60초마다 syncOffline을 돌려. 먼저 outbox를 비워. 접시 목록 읽기가 성공했으면 트리를 읽고, 목록의 접시마다 상세를, 사진마다 썸네일과 화면용 바이트를 읽어. 이어서 트리와 접시 링크에서 닿는 클래스를 부모와 자식을 따라 새 게 없을 때까지 읽고, 마지막으로 스레드를 읽어. 키친, 모든 접시의 스레드, 다녀온 모든 클래스의 스레드. 도착한 건 뒤에서 실패가 나도 하나하나 남아.

그 호출이 전부 GET이야. 오프라인 콜드 스타트를 준비하는 테스트가 바로 그걸 확인해. 동기화가 피파한테 새 읽기나 새 답을 부탁하면 절대 안 되니까. 사진은 한번 저장되면 안 바뀌니까, 저장된 이미지는 바이트가 이미지로 읽히는 한 요청 없이 그대로 돌려줘.

페이지도 거짓말 못 해

접시 목록은 500개씩 페이지로 와. 클라이언트는 짧은 페이지가 올 때까지 계속 넘겨. 뒤 페이지가 이미 가진 id만 되풀이하면, 저장된 사본은 그대로 뒀다는 메시지를 남기고 멈춰. 목록 전체는 모든 페이지가 도착한 뒤에, 그것도 유효한 티켓으로만 선반에 써. 두 번째 페이지가 중간에 실패하면 이전의 완전한 사본이 그대로 남아. 테스트는 접시 501개로 그걸 증명해.

outbox는 사본이 생긴 뒤에 물러나

엔진이 새 접시를 받았다고 확인하면, drain의 보내기는 세대 번호를 올리고, 접시 상세를 저장하고, 올린 사진을 전부 선반에 써. 사진은 캡처 자기 경로에도 들어가고, 엔진 사진 id 아래 썸네일과 화면용 사본으로도 들어가. 그건 폰이 직접 줄인 바이트라서, 새 접시는 바로 오프라인에서도 보여. 그중 하나라도 못 쓰면, 엔진은 접시를 받았는데 기기 사본을 못 저장했다는 일시 실패로 보고해. 접시는 큐에 남고, 다음 패스는 엔진이 저장된 접시로 답하는 재전송이 돼. 웹엔 이런 게 하나도 없어. 살아 있는 답만 보여주고, 로컬 저장소엔 Sidekick 설정만 들어 있어. 열려 있었는지, 어떤 모드였는지, 그리고 프레임 크기 셋(옆 패널 폭, 아래 독 높이, 떠 있는 창 크기). 접시나 읽기는 하나도 안 들어가.

원칙: 캐시는 낡을 수는 있어도, 진실보다 새것처럼 보이면 절대 안 돼. 변경에 번호를 매기고, 읽기마다 시작할 때의 번호를 찍고, 번호가 바뀐 쓰기는 거절해.

Code

cwkSizzle, clients/apple/SizzleMobile/Sources/OfflineShelf.swift: 엔진마다 선반 하나, 티켓, 세대, 은퇴 (원문 그대로, 14-34행)·swift
    init(root: URL, endpoint: EngineEndpoint) {
        directory = root.appendingPathComponent(OfflineKey.filename((try? endpoint.baseURL().absoluteString) ?? "invalid"), isDirectory: true)
    }

    struct Ticket { let generation: Int; let id: UUID }

    func begin(_ path: String) -> Ticket {
        lock.withLock {
            let id = UUID()
            reads[path] = id
            return Ticket(generation: generation, id: id)
        }
    }

    func isCurrent(_ ticket: Ticket, path: String) -> Bool {
        lock.withLock { !retired && ticket.generation == generation && reads[path] == ticket.id }
    }

    func didMutate() { lock.withLock { generation += 1 } }
    func retire() { lock.withLock { retired = true; generation += 1 } }
    var hasWriteFailure: Bool { lock.withLock { !failedPaths.isEmpty } }
cwkSizzle, clients/apple/SizzleMobile/Sources/EngineClient.swift: 읽기는 티켓으로 쓰고, 변경은 세대를 올리고 자기 답을 저장해 (원문 그대로, 210-236행)·swift
    private func get<T: Decodable>(_ path: String) async throws -> T {
        let mirrored = path == "/api/encounters" || path == "/api/tree"
            || path.hasPrefix("/api/encounters/") || path.hasPrefix("/api/classes/") || path.hasPrefix("/api/threads/")
        let ticket = mirrored ? shelf?.begin(path) : nil
        let reply = try await transport(try request("GET", path))
        let value: T = try decode(reply)
        try Task.checkCancellation()
        if let ticket, let shelf {
            guard shelf.isCurrent(ticket, path: path) else { throw CancellationError() }
            shelf.write(reply.data, path: path, ticket: ticket)
        }
        return value
    }

    private func send<T: Decodable>(_ method: String, _ path: String, json: [String: String]? = nil) async throws -> T {
        var request = try request(method, path)
        if let json {
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try JSONSerialization.data(withJSONObject: json)
        }
        let value: T = try decode(try await transport(request))
        shelf?.didMutate()
        if let detail = value as? EncounterDetail { shelf?.remember(detail) }
        if let detail = value as? ClassDetail { shelf?.save(detail, path: "/api/classes/\(detail.class.id)") }
        if let reply = value as? AskReply { shelf?.save(reply.thread, path: "/api/threads/\(Self.escape(reply.thread.key))") }
        return value
    }
cwkSizzle, clients/apple/SizzleMobile/Tests/OfflineTests.swift: 변경 전에 시작한 읽기는 그 위에 못 써 (원문 그대로, 158-167행)·swift
    func testOldReadsCannotReplaceACompletedMutation() async throws {
        let app = model(online().transport)
        let shelf = try XCTUnwrap(app.client.shelf)
        let before = shelf.begin("/api/encounters")
        shelf.didMutate()
        let detail = try JSONDecoder().decode(EncounterDetail.self, from: Data(Self.detail.utf8))
        XCTAssertTrue(shelf.remember(detail))
        XCTAssertFalse(shelf.write(Data("{\"encounters\":[]}".utf8), path: "/api/encounters", ticket: before))
        XCTAssertEqual(model(online().transport).plates.count, 1)
    }

External links

Exercise

기록을 읽기도 하고 바꾸기도 하는 네 클라이언트에 기기 사본을 붙여 봐. 서버 주소마다 디렉터리를 하나씩 둬. 캐시엔 세대 카운터와 경로마다 최신 읽기 id를 둬. 읽기는 요청 전에 티켓을 받고, 티켓이 아직 유효할 때만 사본을 써. 변경이 성공할 때마다 세대를 올리고 변경 자기 답을 캐시에 써. 그러고 나서 제일 중요한 테스트를 써. 읽기를 시작하고, 답을 붙잡아 두고, 변경을 하고, 답을 풀어 주고, 캐시의 기록에 변경이 아직 보이는지 확인해.
Hint
손으로 풀어 주는 액터나 continuation이 있으면 답을 원하는 순간까지 기다리게 할 수 있어. 이 스위트의 지연 답이 그렇게 해. 두 번째 테스트에선 답을 붙잡아 둔 채로 서버를 바꾸고, 새 서버 화면이 비어 있는지 확인해. 사용자가 아직 안 보낸 데이터는 캐시보다 엄한 규칙을 가진 별도 저장소에 둬.

Progress

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

댓글 0

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

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