Daeseon Yoo
Back to project
·Tech retro·4 min

URLSession Cold-Start Optimization — Dispatcher.prewarm() Pattern

URLSession의 첫 호출 cold start (TLS 핸드셰이크 + DNS = 1-2초) 최적화. 앱 launch 시 dispatcher.prewarm()으로 경량 GET 요청 (응답 1KB, cost 0) 미리 실행 — 실제 analyze 호출 시 1-2초 단축.

문제

"첫 ⌥+Space는 왜 ~10초 걸려?" 🔍

사용자가 처음 hotkey를 눌렀을 때 analyze 응답이 ~10초:

실측: HTTPSession이 keep-alive로 두 번째 호출은 이미 연결된 소켓 재사용하므로 빠름. 하지만 첫 호출은 TLS 협상 + DNS 리졸브를 처음부터.

결정 분기

3 옵션:

A. DNS prefetch + 더 큰 URLSession 풀링

B. 더 빠른 API endpoint로 swap

C. ⭐ 앱 launch 시 prewarm() 호출

선택 C.

박힌 거

1. LLMDispatcher protocol에 prewarm() 추가

protocol LLMDispatcher: Sendable {
    func analyze(
        imageData: Data,
        imageSize: CGSize,
        instruction: String
    ) async throws -> AnalysisResult
 
    /// 앱 launch 시 TLS handshake + DNS resolve 미리. default noop — Gemini만 override.
    func prewarm() async
}
 
extension LLMDispatcher {
    func prewarm() async { /* noop */ }
}

2. GeminiDispatcher.prewarm 구현

func prewarm() async {
    let endpoint = "https://generativelanguage.googleapis.com/v1beta/models?key=\(apiKey)"
    guard let url = URL(string: endpoint) else { return }
    var request = URLRequest(url: url)
    request.httpMethod = "GET"
    request.timeoutInterval = 5
    let started = Date()
    do {
        _ = try await session.data(for: request)
        let elapsed = Date().timeIntervalSince(started)
        Log.dispatcher.info("[gemini] prewarm ok \(String(format: "%.2f", elapsed), privacy: .public)s")
    } catch {
        Log.dispatcher.notice("[gemini] prewarm failed (non-fatal): \(error.localizedDescription, privacy: .public)")
    }
}

3. AppDelegate.applicationDidFinishLaunching에서 Task.detached로 실행

if let dispatcher {
    Task.detached {
        await dispatcher.prewarm()
    }
}

비용

실측

패턴

"첫 호출 cold의 정체를 network 계층 깊이까지 파고들기"

Commit

c15578e (2026-05-30 17:15 -0400)