~17 min · engine-at-home, urlsession, networking, performance, swift-concurrency
Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"느리지만 맞는 루프는, 그 루프를 한 번도 안 돌리는 테스트 묶음 눈엔 안 보여."
클라이언트 세션이 거절해야 할 것
얇은 클라이언트는 엔진 딱 하나와 이야기하니까, URLSession도 자기가 요청하지 않은 건 아무것도 들고 있으면 안 돼. 출발점은 쿠키, 캐시, 자격 증명을 메모리에만 두는 URLSessionConfiguration.ephemeral이야. 여기에 httpCookieStorage와 urlCache까지 nil로 둬서 남길 게 아예 없게 해. 마감 두 개도 신경 써서 정해. timeoutIntervalForRequest는 바이트 사이에 허용하는 가장 긴 침묵이고, timeoutIntervalForResource는 요청과 응답이 오가는 전체 과정의 한도야.
리다이렉트 거절은 설정으로는 표현할 수 없어. 엔진이 일부러 3xx로 대답할 일은 없으니까, 리다이렉트가 왔다면 클라이언트와 엔진 사이에 있는 무언가가 겉으로 내세운 정체와 다르다는 뜻이야. 가족 공유 전송 계층은 델리게이트 메서드 urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)에서 nil을 넘겨서 거절하고, 그러면 3xx 응답 자체가 돌아와. 키트에서 직접 확인해봤어. 그 델리게이트 없이 단단히 조인 설정만 쓴 호출자는 리다이렉트를 따라가서 목적지의 200과 본문을 받았어. 그리고 델리게이트를 달고 만든 세션은 무효화될 때까지 그 델리게이트를 강하게 붙잡고 있어. 그래서 요청마다 세션을 만든다면 finishTasksAndInvalidate()를 꼭 불러야 해.
읽는 도중에 지키는 예산
엔진 응답은 몇 킬로바이트짜리 JSON일 수도, 생성된 이미지일 수도 있어. 클라이언트는 이번 호출에서 받을 바이트 수를 미리 선언해. 그리고 다 받은 뒤에 확인하는 게 아니라, 본문이 그걸 넘으려는 순간 읽기를 멈춰. 공유 전송 계층의 첫 버전은 이걸 URLSession.AsyncBytes로 구현했어. await 한 번에 UInt8를 하나씩 붙이는 방식이었지. 결과는 맞았고 테스트도 다 통과했어. 쓰는 앱들이 전부 작은 JSON만 받았고, 그 루프를 제대로 돌려보는 테스트가 없었거든. 이미지를 받은 첫 앱에서 비용이 드러났어.
이 레슨을 쓰면서 1 MiB 본문으로 다시 재봤어. 바이트 단위 루프가 @concurrent 함수에선 초당 약 24 MiB였고, 똑같은 루프를 메인 액터에서 돌리니 초당 약 0.1 MiB였어. 달라진 건 루프가 어디서 돌았느냐 하나뿐이야. 그 루프에선 원소 하나하나가 await이거든. URLSessionDataDelegate는 본문을 덩어리로 받아. 덩어리마다 한 번 붙이고, 매번 누적 합을 확인하고, 넘으면 작업을 취소해. 이 방식으로는 루프백에서 8 MiB를 초당 약 1,500 MiB 속도로 옮겼어.
키트 테스트가 실패하면서 하나를 더 알게 됐어. Content-Length를 실제보다 작게 알려주고 계속 보내는 서버로는 예산을 못 넘겨. URLSession이 선언된 길이에서 전달을 멈추거든. 누적 합이 정말 필요한 건 길이 정보가 아예 없고 연결이 닫혀야 끝나는 응답이야. 지금 테스트는 바로 그런 응답을 내줘.
취소가 실제 전송까지 닿게
Swift 작업 취소는 그것만으로 URLSession 작업을 취소하지 않아. continuation을 withTaskCancellationHandler로 감싸고 핸들러에서 작업을 취소해. 그래야 뷰가 사라질 때 다운로드도 같이 멈춰. continuation은 완료 콜백에서 딱 한 번만 재개해.
Code
같은 바이트 단위 루프, 메인 액터 안과 밖·swift
import Foundation
// Reading a 1 MiB body one byte at a time. Measured with Swift 6.3.3, -O, on macOS 26:
@MainActor
func readOnMainActor(_ url: URL) async throws -> Data { // 0.1 MiB/s
var out = Data()
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes { out.append(byte) } // every element is an await
return out
}
@concurrent
func readOffTheMainActor(_ url: URL) async throws -> Data { // 23.8 MiB/s
var out = Data()
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes { out.append(byte) }
return out
}
// The chunked delegate below moved 8 MiB over loopback HTTP at about 1,500 MiB/s.
요청 하나: 덩어리로 읽기, 도중에 지키는 예산, 리다이렉트 거절, 취소 연결·swift
import Foundation
struct FetchReply: Sendable {
let status: Int
let body: Data
}
enum FetchFailure: Error, Equatable {
case overBudget(limit: Int)
case redirectRefused(status: Int)
case transport(URLError.Code)
}
/// One request, read in chunks, cut off the moment the body would exceed its budget.
final class BudgetedFetch: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private let budget: Int
private let lock = NSLock()
private var body = Data()
private var failure: FetchFailure?
private var continuation: CheckedContinuation<FetchReply, Error>?
init(budget: Int) { self.budget = budget }
func run(_ request: URLRequest) async throws -> FetchReply {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpCookieStorage = nil
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 15 // longest silence between bytes
configuration.timeoutIntervalForResource = 300 // the whole exchange
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
let task = session.dataTask(with: request)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
lock.withLock { self.continuation = continuation }
task.resume()
}
} onCancel: {
task.cancel()
}
}
// Passing nil stops at the 3xx instead of following it, and the fetch reports it as redirectRefused.
// A configuration alone cannot refuse a redirect.
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
lock.withLock { failure = .redirectRefused(status: response.statusCode) }
completionHandler(nil)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let over = lock.withLock {
body.append(data) // one append per chunk, never per byte
guard body.count > budget else { return false }
failure = .overBudget(limit: budget)
return true
}
if over { dataTask.cancel() }
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let (waiting, result) = lock.withLock { () -> (CheckedContinuation<FetchReply, Error>?, Result<FetchReply, Error>) in
defer { continuation = nil }
if let failure { return (continuation, .failure(failure)) }
if let error = error as? URLError { return (continuation, .failure(FetchFailure.transport(error.code))) }
if let error { return (continuation, .failure(error)) }
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
return (continuation, .success(FetchReply(status: status, body: body)))
}
waiting?.resume(with: result)
}
}
작은 로컬 서버를 하나 만들어. 파이썬 http.server면 충분해. 경로는 셋이야. 302 리다이렉트, 작은 JSON 응답, 그리고 Content-Length 없이 4 MiB를 보낸 뒤 연결을 닫는 응답. SparkKit에 BudgetedFetch를 넣고 예산 1 MiB로 세 경로의 결과를 전부 증명해. 그러고 나서 Task에서 큰 경로를 받다가 취소하고, 나오는 에러를 적어. 마지막으로 같은 경로를 두고 메인 액터 안과 밖에서 바이트 단위 루프 시간을 재고, 두 숫자를 타입 맨 위 주석에 적어.
Hint
파이썬에선 길이를 보내지 않은 채 end_headers()를 부르고, 그 뒤에 반복문으로 본문을 써. close_connection = True도 설정하고. Swift 쪽은 @main 실행 파일로 돌리는데, @main 타입의 static func main() async는 메인 액터에서 돈다는 걸 기억해.
Progress
Progress is local-only — sign in to sync across devices.