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

솔직한 업로드

~14 min · 업로드, urlsession, 느린 회선, 상태 줄

Level 0첫 단어
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

조용한 게 버그야

아빠의 불만은 해외의 느린 모바일 데이터에서 나왔어. 사진이 올라가는 동안 가족 앱들이 멈춘 것처럼 굴면서 아무 말도 안 했거든. Spark에선 캡처가 순간 화면을 연 다음 Kept on the phone.이나 Sending...만 몇 분씩 띄워 놨어. 바이트도, 속도도, 초도 없이. 느린 회선에서 아무 말 없는 화면은 고장 난 화면이랑 똑같아 보여. 그러니 아빠는 기다려야 할지, 다시 해야 할지, 포기해야 할지 알 수가 없었지.

고치는 건 측정부터 시작했어. 임시 엔진을 킷의 속도 조절 프록시 뒤에 세우고, 전용 테스트 시뮬레이터가 심어 둔 카메라로 사진을 찍고, 상태 줄을 전부 기록했어. 그랬더니 침묵 뒤에 숨은 결함이 두 개 나왔어. 하나는 큐야. 드레인 패스가 도는 중에 간직한 사진은 다음 60초 동기화 패스까지 기다렸어. 드레인이 이제 호출을 합치는 게 그래서야(지난 레슨). 다른 하나는 더 나빴고, 이번 레슨의 주제야.

엉뚱한 걸 세던 타이머

20 KB/s에서 피파가 읽을 1.65 MB 사본은 끝내 안 올라갔어. 요청은 본문 전체를 1초쯤 만에 전송 버퍼에 넘겼고, 버퍼가 회선 속도로 비는 동안 URLSession은 바이트를 하나도 안 셌어. 유휴 제한이 60초였으니 패스마다 멀쩡한 업로드를 죽은 회선으로 읽고, 끊고, 처음부터 다시 보냈지. 7번 시도에 9.93 MB가 회선을 탔는데, 900초가 지나도 못 보냈어. 그다음엔 180초 자원 상한이 4 MB 원본을 끊었을 거야. 4 MB를 20 KB/s로 보내면 200초가 걸리거든.

그래서 업로드에만 쓰는 전송과 정책이 따로 생겼어(첫 번째 코드 블록). 유휴 제한은 요청마다 본문 크기로 정해. 본문 전체를 15 KB/s로 보내는 시간이고, 120초 밑으로는 안 내려가. 1.65 MB 사본은 최솟값 120초를 받고, 4.31 MB 원본은 287초쯤 받아. 자원 상한은 1800초라서 12 MB 원본도 7 KB/s로 버텨. 코드 주석은 움직이는 업로드를 상한이 결정하는 일은 없다고 하는데, 벤치에서 잰 속도의 카메라 파일이라면 맞는 말이야. 그래도 상한은 상한이야. 회선에서 30분 넘게 걸릴 파일, 예를 들어 7 KB/s에 13 MB짜리는 끊기고, 다음 패스가 처음부터 다시 보내.

요청유휴 제한상한
JSON 호출15초30초
내려받는 미디어60초180초
올라가는 캡처 파일본문을 15 KB/s로 보내는 시간, 최소 120초1800초
라이브 레인의 짧은 호출 하나60초75초
순간의 이벤트 스트림120초900초

정책 하나로 다 묶으면 대부분이 틀려. 15초 동안 말이 없는 JSON 호출은 아마 길을 잃은 거야. 15초 동안 말이 없는 사진은 그냥 버퍼에서 기다리는 중일 수 있어.

지금 뭐가 건너가는지 말하는 줄

이제 파일마다 킷의 FileUploadRun을 거치고, 순간 화면과 큐의 그 줄엔 킷의 UploadStatusLine이 떠. 줄은 파일 이름을 대고, 몇 MB 중 몇 MB인지 세고, 속도와 초를 보여주고, 바이트 다음에 오는 요청으로 끝나. 사본 뒤엔 피파한테 묻는다는 뜻의 Asking Pippa, 원본 뒤엔 원본 크기로 간직한다는 뜻의 Keeping it at full size. 바이트가 멈춰도 줄은 카운트다운을 시작하지 않아. 확인해서 찾은 걸 말해. 먼저 이 폰의 회선, 그다음 엔진의 헬스 경로. 줄은 순간 기록이 가기도 전에 떠서 첫 초부터 움직여. 다른 캡처 뒤에 선 캡처는 다음 차례라는 뜻으로 Kept · next in line이라고 해.

실패도 똑같이 솔직해. 엔진이 파일 자체를 거절하면, 예를 들어 받을 수 없는 크기라고 하면, 줄은 다시 시도 없이 멈춰. 다시 해 봐야 소용없으니까. 회선이 끊겼거나, 엔진이 503을 줬거나, 킷이 일시 실패로 치는 4xx(401, 403, 408, 425, 429, 앞 레슨에서 본 다섯 개)면 사진은 폰에 남고 다시 시도가 떠. 벤치 숫자를 보자. 61.4 KB/s에 사진 세 장. 전엔 고정 라벨 4개뿐이었고 바이트 수는 한 번도 안 나왔어. 고친 뒤엔 첫 줄이 3.5초에 떴고, 서로 다른 줄 상태가 299개였고, 두 번 간 건 하나도 없었어. 20 KB/s에선 끝내 못 올라가던 사본이 한 번에 올라갔고, 원본까지 305.9초에 끝났어.

원칙: 상태 줄은 세상에 대한 주장이야. 거기 든 단어는 하나하나 참인 걸 가리켜야 해. 파일, 크기, 속도, 확인 결과. 짐작으로 움직이는 진행 막대는 사용자가 그걸 무시하게 길들여.

사진 한 장, 크기 둘

숫자가 정직해졌다고 끝난 게 아니었어. 처음 제목은 1 of 2 · the copy Pippa reads, 그다음 2 of 2 · the full-resolution original이었어. 아빠는 카메라로 한 장 찍었는데, 이 줄을 사진 두 장이 올라가는 걸로 읽었어. 이미 있던 사진 하나에 새로 찍은 사진 하나. 엔진의 기록도, 로그도, 턴도 사진은 딱 한 장이었어. 둘로 보인 건 같은 사진을 두 번에 나눠 보낸 거였고, 제목의 숫자가 다른 말을 한 거지.

이제 제목은 아무것도 세지 않아(두 번째 코드 블록). 이 사진을 먼저 작게 보내서 피파가 읽게 한다는 줄, 그다음 같은 사진을 이제 원래 크기로 간직한다는 줄이야. 제목이 다시 개수처럼 보이는 패턴에 걸리면 테스트가 실패해(세 번째 코드 블록). 짝이 되는 테스트는 캡처 두 개를 연달아 찍어. 그리고 캡처마다 순간을 하나씩 만들고, 자기 바이트로 사본 하나와 원본 하나만 보내는지 확인해.

웹 클라이언트도 같은 업로드 줄을 킷에서 받았어. 문제가 가족 전체의 문제였으니 고치는 것도 가족 전체로 갔지. 두 번째 앱이 같은 조각을 필요로 하면 그때 그 조각을 킷으로 옮긴다는 규칙은 Sibling Kit Quest에서 가르쳐.

Code

cwkSpark, clients/apple/SparkMobile/Sources/EngineClient.swift: 요청 종류마다 다른 세션 정책과 업로드 유휴 제한 (원문 그대로, 18-38행)·swift
    static let jsonPolicy = HTTPSessionPolicy(requestTimeout: 15, resourceTimeout: 30, byteBudget: 8 << 20)
    static let mediaPolicy = HTTPSessionPolicy(requestTimeout: 60, resourceTimeout: 180, byteBudget: 32 << 20)
    /// One file of a capture (cwkSiblingKit todo #545). The resource ceiling
    /// never decides a moving upload: a 4 MB camera original at 20 KB/s is
    /// 200 s, past the 180 s that used to cut it and send it again from the
    /// start; 1800 s holds a 12 MB original at 7 KB/s. The idle limit is
    /// `uploadIdle`, per request.
    static let uploadPolicy = HTTPSessionPolicy(requestTimeout: 120, resourceTimeout: 1800, byteBudget: 1 << 20)

    /// Seconds without a byte counted before a file's request is a dead link.
    /// The body can go into the send buffer at once, and URLSession counts
    /// nothing while the buffer drains: on the throttled bench at 20 KB/s the
    /// 1.65 MB copy was handed over in a second, then cut by the 60 s idle limit
    /// every pass and sent again, and never landed. So the limit is the whole
    /// body's time at 15 KB/s, never under the policy's 120 s.
    static func uploadIdle(bytes: Int) -> TimeInterval {
        max(uploadPolicy.requestTimeout, Double(bytes) / 15_000)
    }
    /// The live lane waits for Pippa's one short call (measured 3-6 s).
    static let livePolicy = HTTPSessionPolicy(requestTimeout: 60, resourceTimeout: 75, byteBudget: 1 << 20)
    static let streamPolicy = HTTPSessionPolicy(requestTimeout: 120, resourceTimeout: 900, byteBudget: 8 << 20)
cwkSpark, clients/apple/SparkMobile/Sources/Outbox.swift: 파일마다의 제목과 마무리 말 (원문 그대로, 154-181행)·swift
struct MediaUpload: Equatable, Sendable {
    enum Step: String, Sendable {
        case copy, original, document

        /// Which file this is, over its line.
        var title: String {
            switch self {
            case .copy: "This photo · a smaller size first, for Pippa to read"
            case .original: "The same photo · now at full size, to keep"
            case .document: "The document"
            }
        }

        /// The request that follows the bytes (the line's finishing word).
        var finishing: String {
            switch self {
            case .copy: "Asking Pippa"
            case .original: "Keeping it at full size"
            case .document: "Reading the document"
            }
        }
    }

    var entryId: String
    var momentId: String
    var step: Step
    var status: UploadStatus
}
cwkSpark, clients/apple/SparkMobile/Tests/TwoCapturesTests.swift: 업로드 제목은 사진 개수처럼 읽히면 안 돼 (원문 그대로, 93-100행)·swift
    func testNoUploadLineReadsAsAPhotoCount() {
        for step in [MediaUpload.Step.copy, .original] {
            XCTAssertNil(step.title.range(of: #"\d+\s+of\s+\d+"#, options: .regularExpression),
                         "\(step.title) reads as a count of photos")
        }
        XCTAssertTrue(MediaUpload.Step.original.title.contains("same photo"),
                      "the full-size pass names the photo as the one already going up")
    }

External links

Exercise

파일을 올리는 앱 하나를 골라서 20 KB/s쯤으로 조인 프록시 뒤에서 돌려 봐. 파일당 시도 횟수, 보낸 바이트 합계, 사용자가 보는 서로 다른 상태 문구를 전부 기록해. 이번엔 업로드에 본문 크기와 최솟값으로 계산한 유휴 제한을 따로 주고, 개수처럼 읽히는 제목은 파일을 가리키는 말로 바꿔. 같은 측정을 다시 해서 비교해 봐.
Hint
유휴 제한은 바이트와 바이트 사이의 시간이고, 요청 본문은 아무것도 안 세어진 채 전송 버퍼에 앉아 있을 수 있어. Spark 공식은 느리게 잡은 최저 속도로 본문을 보내는 시간이고, 정해 둔 최솟값 밑으로는 안 내려가. 제목은 나쁜 회선 앞에서 지친 사람이 그 말을 뭐라고 읽을지 물어보면 돼.

Progress

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

댓글 0

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

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