요약
앱의 네트워크 레이어는 단순한 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) 동시도 조절을 도입한다.