요약
무제한 병렬화는 직관적으로 성능을 올릴 것 같지만, 실제로는 네트워크 커넥션 제한, 디코딩 CPU, 메모리 점유, 디스크 I/O 등 실자원(physical resource)을 포화시켜 전체 처리량·응답성을 떨어뜨립니다. TaskGroup으로 작업을 묶되, 동시 실행 수(동시도, concurrency)를 제어하는 경량 동기화(예: AsyncSemaphore), 우선순위 분리, 리트라이·백오프, 취소 처리 전략을 결합하면 안정적이고 예측 가능한 병렬 처리 시스템을 만들 수 있습니다. 아래는 개념 설명, 설계 고려사항, 구체적 코드, 튜닝·테스트 방법, 그리고 운영에서 관찰해야 할 지표를 포함한 실무 가이드입니다.
⸻
문제의 본질 — 왜 무제한 병렬화가 역효과를 내는가?
동시에 더 많은 작업을 실행하면 이상적으로 처리량이 늘어납니다. 하지만 현실은 다음과 같은 병목이 존재합니다.
• 네트워크 제한: 모바일 기기와 서버는 동시 연결/스레드 제한을 가지고 있습니다. 동시 요청이 많아지면 TCP 연결 재사용 비용, 큐잉, 패킷 손실로 전체 지연이 커집니다.
• CPU 바운드 작업: 이미지 디코딩, 압축 해제, 암호화/복호화 같은 작업은 CPU를 집중적으로 사용합니다. 디코딩을 병렬로 너무 많이 돌리면 컨텍스트 스위칭과 캐시 미스가 증가합니다.
• 메모리 압박: 각 작업이 큰 버퍼(예: 이미지 바이트)를 할당하면 OOM(Out Of Memory) 위험이 커집니다.
• 디스크 I/O 병목: 로컬에 쓰기/읽기를 많이 하면 디스크 큐가 늘어나고 응답성이 떨어집니다.
즉, 병렬 작업 수를 제어하지 않으면 자원 경쟁이 발생해 처리량과 응답성 모두 악화됩니다. 따라서 동시도 제한(스로틀링)은 안정성 향상과 전체 처리량 최적화에 필수입니다.
⸻
설계 아이디어 요약
1. 경량 동시성 제어: actor 기반 AsyncSemaphore 등으로 동시 슬롯을 관리해 작업 시작 수를 제한한다.
2. 구조화된 실행: withThrowingTaskGroup 또는 withTaskGroup을 사용하되, 실제 자원 사용 시점(예: 네트워크 호출, 디코딩 시작)에 세마포어를 획득한다.
3. 우선순위 분리: 긴급(로딩 화면) vs 백그라운드(사전 페칭) 작업을 다른 세마포어나 풀로 분리한다.
4. 취소 일관성: 부모 Task 취소 시 자식 Task가 빠르게 반응하도록 Task.checkCancellation()과 defer 리소스 정리를 활용한다.
5. 리트라이와 백오프: 네트워크 오류에 대해 지수 백오프 + jitter를 사용해 재시도한다.
6. 적응형 동시도: 실패율/지연을 관측해 동시도를 동적으로 조정(증가/감소)하는 적응형 알고리즘을 적용 가능.
⸻
AsyncSemaphore (actor 기반) — 구현과 설명
actor를 이용하면 동시성 안전하게 대기자 목록을 관리할 수 있습니다. 아래 코드는 간단명료하며 Task 취소도 자연스럽게 작동합니다.
import Foundation
actor AsyncSemaphore {
private var value: Int
private var waiters: [CheckedContinuation<Void, Never>] = []
init(value: Int) {
precondition(value >= 0)
self.value = value
}
func wait() async {
if value > 0 {
value -= 1
return
}
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
waiters.append(continuation)
}
}
func signal() {
if let first = waiters.first {
waiters.removeFirst()
first.resume()
} else {
value += 1
}
}
func availablePermits() -> Int { value }
}
⸻
TaskGroup + Semaphore 조합 예제
TaskGroup 안에서 무작정 addTask만 하고 내부에서 기다리지 않으면 생성만 되고 자원 사용 시점은 통제되지 않습니다. 그래서 네트워크 호출 직전에 await semaphore.wait()를 호출하도록 설계합니다.
import Foundation
func fetchURLsLimited(_ urls: [URL], concurrency: Int) async -> [Result<Data, Error>] {
let semaphore = AsyncSemaphore(value: concurrency)
var results = Array<Result<Data, Error>?>(repeating: nil, count: urls.count)
await withTaskGroup(of: Void.self) { group in
for (index, url) in urls.enumerated() {
if Task.isCancelled { break }
group.addTask {
await semaphore.wait()
defer { semaphore.signal() }
if Task.isCancelled {
results[index] = .failure(CancellationError())
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
results[index] = .success(data)
} catch {
results[index] = .failure(error)
}
}
}
// 그룹이 끝날 때까지 대기
await group.waitForAll()
}
return results.compactMap { $0 }
}
포인트
• 세마포어 획득은 네트워크 시작 직전에 하므로, 그룹이 많이 생성되어도 실제 자원 사용 수는 제한됩니다.
• defer로 항상 signal()을 호출해 슬롯 반환을 보장합니다.
• 결과는 인덱스 기반 배열에 기록해 원래 순서를 유지하거나 필요하면 순서 무관으로 처리할 수 있습니다.
⸻
우선순위 처리 — 현실적인 전략
우선순위를 단순히 Task 우선순위(priority:)로만 맡기기보다는, 카테고리별 세마포어 풀을 두는 것이 효과적입니다.
• highPrioritySemaphore (ex: value = 2) — 화면 표시용, 사용자 눈앞 작업
• defaultSemaphore (ex: value = 4) — 일반 페칭, 리스트 항목
• backgroundSemaphore (ex: value = 1~2) — 사전 페칭, 백그라운드 작업
요청을 분류하여 적절한 세마포어를 선택하면 급한 작업이 느릿느릿 처리되는 상황을 피할 수 있습니다. 더 정교하게는 가중치 기반 할당(dynamic weight)이나 토큰 버킷(token bucket) 방식으로 조절할 수 있습니다.
⸻
리트라이와 백오프 — 안정적인 네트워크 복구
지수 백오프 + jitter(무작위 소량 추가)는 네트워크 폭주 시 재시도를 완화하는 표준 패턴입니다.
func retrying<T>(
attempts: Int = 3,
initialDelay: TimeInterval = 0.2,
maxDelay: TimeInterval = 5.0,
operation: @escaping () async throws -> T
) async throws -> T {
var currentDelay = initialDelay
var lastError: Error?
for i in 0..<attempts {
if Task.isCancelled { throw CancellationError() }
do {
return try await operation()
} catch {
lastError = error
if i == attempts - 1 { break }
// jitter 추가: 랜덤 ±10% 정도
let jitter = Double.random(in: -0.1...0.1) * currentDelay
let wait = max(0, currentDelay + jitter)
try await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
currentDelay = min(currentDelay * 2, maxDelay)
}
}
throw lastError!
}
⸻
CPU 바운드 작업(디코딩 등)과 I/O 바운드 작업(네트워크/디스크)의 분리
동일한 동시도 설정으로 CPU 바운드와 I/O 바운드를 동시에 제어하면 최적화가 어렵습니다. 권장 설계:
• 네트워크 다운로드: 비교적 경량 작업. 동시도는 네트워크 상황과 서버 제한에 맞춰 4~8 범위를 자주 실험.
• 이미지 디코딩 / 변환: CPU 바운드. 디코더 풀(decoder pool)이나 DispatchQueue.concurrentPerform 대신 Task.detached + 세마포어로 CPU 슬롯을 제한(예: 기기 코어 수 기반: max(1, physicalCores - 1)).
• 디스크 쓰기: 디스크 쓰기는 I/O 큐잉이 발생하므로 디스크 전용 쓰기 큐를 두고 일괄 쓰기(batch) 또는 백그라운드 스로틀링 적용.
구현 예: 다운로드는 downloadSemaphore(value: 6), 디코딩은 decodeSemaphore(value: 2)처럼 분리해서 사용.
⸻
적응형(Adaptive) 동시도 전략
정적 숫자 대신 런타임 관찰값을 보고 동시도를 조정:
• 성공기반 증감: 최근 N건의 평균 응답시간이 낮으면 동시도 증가, 높으면 감소.
• 실패율 기반: 실패율이 높아지면 동시도 감소.
• 기기 상태 고려: 배터리 Saver 모드 / low power mode / 네트워크 유형(4G vs Wi-Fi)에 따라 동시도 조정.
• 스타트업 단계: 앱 시작 시 동시도를 낮게 시작해 점진적으로 올리는 방식(warm-up).
이런 로직은 actor로 상태를 관리하고, 주기적으로(예: 1초 단위) 지표를 집계해 결정합니다.
⸻
취소 전파와 리소스 정리
부모 Task가 취소되면 자식들은 Task.isCancelled 또는 try Task.checkCancellation()으로 응답해야 합니다. 중요한 점:
• 네트워크 요청 중 취소: URLSession의 dataTask는 Task 취소 시 자동으로 취소되므로 try await 호출 직후 취소 예외가 발생할 수 있습니다.
• 디코딩 중 취소: 디코딩 함수 내부에서 Task.checkCancellation()을 적절히 호출하거나, 긴 루프(예: 스트리밍 디코딩) 도중 취소를 확인한다.
• 리소스 정리: 파일 핸들, 임시 버퍼 등은 defer로 반드시 닫고 제거한다.
⸻
모니터링·프로파일링: 무엇을 측정해야 하는가?
• 응답 시간(P95, P99): 단순 평균이 아닌 퍼센타일을 본다.
• 동시 다운로드/디코딩 수: 실제 동시 자원 사용량.
• 메모리 사용량(peak): 각 동시도 별 peak 메모리 관찰.
• 오류율 & 재시도 횟수: 네트워크/서버 문제인지 클라이언트 스로틀링 문제인지 구분.
• 스크롤 프레임 드랍: UI 경험을 직접적으로 해치는 지표.
툴: Xcode Instruments(Allocations, Time Profiler, Network)와 자체 메트릭(로그, Firebase/Datadog 등) 결합.
⸻
실제 튜닝 워크플로
1. 수치 가설 설정: 예: 이미지 다운로드 concurrency = 6, decode concurrency = 2.
2. 시나리오 테스트: 와이파이/셀룰러/저전력 기기에서 스크롤 스트레스 테스트(대량 이미지).
3. 측정: P95 응답시간, 메모리 peak, 재시도 비율, 프레임 드랍 수집.
4. 조정: 동시도 감소 → 메모리/CPU 안정성 개선? 증가 → 처리량 향상? 의사결정.
5. 적응형 적용: 단일 수치로 고정하지 말고 런타임 지표에 따라 조정하는 로직 추가.
6. 회귀 테스트: 정책 변경 시 기존 시나리오 재검증.
⸻
운영 고려사항
• 서버 협력: API에서 썸네일 제공 또는 Accept 헤더를 통한 최적 해상도 전달을 협의하면 클라이언트 부담이 크게 줄어든다.
• 계측 포인트: 각 작업의 시작·종료 시각, 실패 원인 분류를 로그로 남겨 문제 원인을 빠르게 파악한다.
• 피드백 루프: 실사용 지표로 동시도 정책을 개선(예: 특정 지역·네트워크에서 낮은 동시도를 권장)한다.
⸻
마무리
• 동시성은 무조건 많이 돌린다고 좋은 것이 아니라, 시스템 자원과 작업 특성에 맞춰 제어해야 효율적입니다.
• TaskGroup + AsyncSemaphore(또는 토큰 버킷) 조합은 실무에서 직관적이고 안전하게 동시도를 제어하는 방법입니다.
• 우선순위 분리, CPU/I/O 작업 분리, 리트라이/백오프, 취소 일관성, 측정과 적응형 정책이 함께할 때 진정으로 견고한 병렬 처리 파이프라인이 됩니다.