네트워크 레이어 최적화: async/await + HTTP/2/3 관점에서 (Swift 6 · iOS 18 · Xcode 26)

이경규·2025년 9월 19일

네트워크 레이어 최적화: async/await + HTTP/2/3 관점에서 (Swift 6 · iOS 18 · Xcode 26)

요약
앱의 네트워크 레이어는 단순한 API 호출 집합이 아니라 앱 성능과 사용자 경험을 좌우하는 핵심 인프라입니다. Swift의 async/await를 사용해 깔끔하게 비동기 흐름을 설계하면서, URLSession 설정(커넥션 풀·HTTP/2·HTTP/3 친화적), 요청 중복 제거(in-flight dedupe), 요청 우선순위·취소 처리, 로컬/네트워크 캐시, 안정적인 재시도(backoff) 전략을 조합하면 대역폭·CPU·메모리 사용을 줄이고 응답성을 크게 향상시킬 수 있습니다. 아래는 개념 설명과 실전 코드(복사해서 바로 붙여넣어 쓸 수 있음)입니다.

1) 설계 목표(무엇을 개선하려는가)
• 불필요한 동시 연결 수를 줄여 서버/클라이언트 부하를 완화.
• 동일한 리소스에 대해 중복 요청을 막아 대역폭 절약.
• HTTP/2의 멀티플렉싱과 HTTP/3(QUIC)의 장점을 살리되, 서버·네트워크 조건에 유연하게 대응.
• 네트워크 상태(셀룰러/와이파이/제한 모드)에 따른 정책(해상도·동시도) 적용.
• async/await 기반으로 취소·에러·재시도를 명확하게 처리.

2) URLSession 구성 포인트 (핵심 속성과 이유)
• URLSessionConfiguration.httpMaximumConnectionsPerHost
한 호스트당 동시 연결 수 제한. HTTP/2의 경우 멀티플렉싱으로 많은 스트림을 하나의 연결에서 처리하므로 너무 높게 설정할 필요 없음(대개 6~10 권장, 앱·서버 특성에 따라 조정).
• URLSessionConfiguration.requestCachePolicy 및 URLCache 설정
적절한 메모리/디스크 캐시를 두면 동일 요청 재시작 시 네트워크 호출을 줄일 수 있음. (특히 이미지·정적 리소스)
• configuration.waitsForConnectivity = true
네트워크가 없을 때 자동 재시도/대기 로직을 URLSession에게 위임해 UX를 개선.
• configuration.allowsExpensiveNetworkAccess / allowsConstrainedNetworkAccess
사용자의 네트워크 조건이나 전원 정책에 따라 요청을 제한하거나 허용할 수 있음(iOS에서 제공).
• TLS 및 보안 관련 설정
ATS(앱 전송 보안)를 지키되, 서버 측에서 HTTP/3(QUIC) 지원 시 프로토콜 우선순위를 확인. iOS는 네이티브로 QUIC을 지원하면 HTTP/3를 사용함(서버·OS 지원 필요).

3) 요청 중복 방지(in-flight dedupe)와 응답 재사용

동일 URL(또는 동일한 리소스 식별자)에 대해 여러 UI 요소가 동시에 요청을 발생시키면 네트워크 낭비가 발생한다. 이를 방지하려면 “in-flight map”을 두어 이미 진행 중인 Task를 재사용한다.

아래 NetworkClient 예제는 async/await와 Task를 사용해 in-flight dedupe, 기본 재시도 로직, 취소 처리를 포함합니다.

import Foundation

enum NetworkError: Error {
    case invalidResponse
    case httpError(status: Int, data: Data?)
}

final class NetworkClient {
    static let shared = NetworkClient()

    private let session: URLSession
    // in-flight dedupe: URLRequest key -> Task<Data, Error>
    private var inFlight = [String: Task<Data, Error>]()
    private let lock = NSLock() // 간단 안전성 보장 (actor 대체 가능)

    init() {
        let config = URLSessionConfiguration.ephemeral
        config.httpMaximumConnectionsPerHost = 8
        config.waitsForConnectivity = true
        config.requestCachePolicy = .useProtocolCachePolicy
        // URLCache 커스터마이징(메모리/디스크)
        config.urlCache = URLCache(memoryCapacity: 50 * 1024 * 1024,
                                  diskCapacity: 200 * 1024 * 1024,
                                  diskPath: "network-cache")

        session = URLSession(configuration: config)
    }

    // requestKey: 요청을 고유하게 식별하는 문자열 (URL + method + bodyHash 등)
    private func requestKey(for request: URLRequest) -> String {
        var s = request.url?.absoluteString ?? ""
        s += "|\(request.httpMethod ?? "GET")"
        if let body = request.httpBody {
            s += "|\(body.hashValue)"
        }
        return s
    }

    func data(for request: URLRequest, retries: Int = 2) async throws -> Data {
        let key = requestKey(for: request)

        lock.lock()
        if let t = inFlight[key] {
            lock.unlock()
            return try await t.value
        }

        let task = Task<Data, Error> {
            defer {
                self.lock.lock()
                self.inFlight.removeValue(forKey: key)
                self.lock.unlock()
            }
            return try await self.performRequestWithRetry(request: request, retries: retries)
        }

        inFlight[key] = task
        lock.unlock()

        return try await task.value
    }

    private func performRequestWithRetry(request: URLRequest, retries: Int) async throws -> Data {
        var attempt = 0
        var delay: TimeInterval = 0.2

        while true {
            if Task.isCancelled { throw CancellationError() }
            do {
                let (data, response) = try await session.data(for: request)
                guard let http = response as? HTTPURLResponse else { throw NetworkError.invalidResponse }
                if 200..<300 ~= http.statusCode {
                    return data
                } else {
                    throw NetworkError.httpError(status: http.statusCode, data: data)
                }
            } catch {
                attempt += 1
                // 특정 에러 타입은 재시도하지 않음(예: 4xx)
                if attempt > retries || isNonRetriable(error: error) {
                    throw error
                }
                // 지수 백오프 + jitter
                let jitter = Double.random(in: 0...(delay * 0.1))
                try await Task.sleep(nanoseconds: UInt64((delay + jitter) * 1_000_000_000))
                delay = min(delay * 2, 5.0)
            }
        }
    }

    private func isNonRetriable(error: Error) -> Bool {
        if let netErr = error as? URLError {
            // 타임아웃이나 네트워크 연결 불가 등은 재시도 가능
            // 인증 실패, bad request 등은 재시도 불가
            switch netErr.code {
            case .userAuthenticationRequired, .userCancelledAuthentication, .cannotFindHost:
                return true
            default:
                return false
            }
        }
        if case NetworkError.httpError(let status, _) = error {
            return (400..<500).contains(status)
        }
        return false
    }
}

포인트 설명
• inFlight 맵은 동일한 요청키에 대해 이미 생성된 Task를 다른 호출자들이 재사용하도록 한다.
• NSLock으로 간단한 스레드 안전을 보장했지만, 대규모 동시성 코드에서는 actor로 대체하는 편이 더 안전하다.
• performRequestWithRetry는 재시도 횟수와 지수적 백오프(jitter 포함)를 적용한다. 4xx 계열의 클라이언트 에러는 재시도하지 않도록 판단한다.

4) HTTP/2와 HTTP/3 고려사항
• HTTP/2 장점: 단일 TCP 연결 위에서 스트림을 멀티플렉싱하므로 연결 수를 줄이고 레이턴시를 개선한다. 하지만 서버가 연결당 허용하는 동시 스트림 수(limit)를 두므로, 과도한 동시 스트림 생성은 서버에서 거부되거나 지연을 초래할 수 있다. 따라서 httpMaximumConnectionsPerHost를 무작정 크게 하지 말고 서버 한도와 실측을 기준으로 튜닝한다.
• HTTP/3(QUIC) 장점: UDP 기반의 QUIC는 연결/핸드쉐이크 지연이 작고 패킷 손실에 덜 민감해 모바일 환경에서 유리할 수 있다. iOS 네트워크 스택이 서버와 협의해 자동으로 HTTP/3를 사용할 수 있으니 클라이언트에서 별도 처리 없이 이점을 누릴 수 있다. 다만 서버(및 CDN)가 HTTP/3을 지원해야 한다.
• 멀티플렉싱 주의: 이미지 같은 큰 스트리밍 리소스는 멀티플렉싱으로 인해 작은 요청의 응답이 지연될 수 있다. 이런 경우에는 큰 파일은 별도 전용 연결(또는 범위를 나눠 다운샘플링)로 처리하는 전략을 고려한다.

5) 우선순위·취소·부하 제어(광고·미리 로드 등)
• UI가 즉시 필요로 하는 요청(예: 현재 화면의 프로필 이미지)과 백그라운드 사전 페칭을 구분한다. 우선순위 높은 요청은 in-flight 우선순위 큐에 넣거나 별도 URLRequest의 networkServiceType/priority 속성을 사용해 OS에게 힌트를 준다.
• Task 취소를 적극적으로 사용해 사용자가 화면을 벗어나면 관련 요청을 중단하고 리소스를 해제한다. URLSession의 data(for:) 호출은 Task 취소와 연동되어 요청을 취소한다.
• 과도한 동시 요청은 클라이언트에서 페이싱(pacing)으로 제어(예: per-host 세마포어)한다. per-host 세마포어는 httpMaximumConnectionsPerHost와는 별개로 클라이언트가 조정 가능한 안전장치다.

6) 캐시 전략(요청/응답 캐시 + 로컬 최적화)
• 서버가 적절한 Cache-Control, ETag 헤더를 제공하면 클라이언트는 URLCache를 통해 네트워크 호출을 줄일 수 있다. 중요한 것은 서버와 협업해 TTL과 revalidation 정책을 설계하는 것.
• 정적 리소스(이미지, 폰트 등)는 디스크 캐시를 넉넉히 잡아두고 만료·정리 정책을 서버 정책과 맞춘다.
• 민감 데이터(인증이 필요한 리소스)는 캐시하지 않거나, 캐시 보안(암호화) 정책을 별도 둔다.

7) 예외 처리와 관찰(모니터링)
• 각 요청의 시작/종료/에러 타입(타임아웃, DNS 실패, TLS 실패 등)을 로깅해 지표로 수집한다. P95, P99 레이턴시, 재시도율, 취소율 같은 메트릭은 네트워크 정책을 튜닝하는 근거가 된다.
• 네트워크 레이어에서 중요한 이벤트(예: 서버의 5xx 비율 상승)는 상위 레이어(서버 팀)로 알리고, 클라이언트에서는 자동 페이싱/기능 축소(저해상도 모드 등)를 고려한다.

8) SwiftUI/앱 코드와의 통합(사용 예)

SwiftUI 뷰에서 NetworkClient를 쉽게 쓰는 방법 예시:

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published var avatar: UIImage?

    func loadAvatar(url: URL) {
        Task {
            var req = URLRequest(url: url)
            req.httpMethod = "GET"
            // 필요 시 priority/headers 설정
            do {
                let data = try await NetworkClient.shared.data(for: req)
                if Task.isCancelled { return }
                avatar = UIImage(data: data)
            } catch {
                // 에러 처리(placeholder 등)
                print("avatar load failed:", error)
            }
        }
    }
}

뷰가 사라질 때 Task를 취소하면 URLSession 호출도 즉시 취소되어 불필요한 네트워크 사용을 막는다.

마무리(권장 단계)
1. 기본 URLSessionConfiguration을 적절히 세팅(connectionPerHost, cache 등).
2. in-flight dedupe와 재시도 정책을 적용해 동일 요청·비정상 네트워크 상황에서의 낭비를 줄인다.
3. HTTP/2·HTTP/3 특성을 이해하고 서버·CDN과 협의해 최적화(썸네일 서비스, Accept 헤더 등)를 진행한다.
4. 실제 기기에서 다양한 네트워크(와이파이/4G/3G/로우파워) 시나리오로 측정하고 동시도·재시도 정책을 조정한다.
5. 메트릭을 수집해 동작을 관찰하고, 필요한 경우 적응형(policies) 동시도 조절을 도입한다.

profile
iOS 앱 개발자

0개의 댓글