TaskGroup 성능 튜닝과 스로틀링 패턴 — 동시성 한계 관리( Swift 6 · iOS 18 · Xcode 26 )

이경규·2025년 9월 19일

TaskGroup 성능 튜닝과 스로틀링 패턴 — 동시성 한계 관리( Swift 6 · iOS 18 · Xcode 26 )

요약
무제한 병렬화는 직관적으로 성능을 올릴 것 같지만, 실제로는 네트워크 커넥션 제한, 디코딩 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 }
}
  • wait()는 사용 가능한 슬롯이 있으면 즉시 반환하고, 없으면 비동기적으로 대기열에 들어갑니다.
  • signal()은 대기자가 있으면 하나를 깨우고, 없으면 가용 슬롯을 증가시킵니다.
  • actor 내부에 상태가 있으므로 별도의 락 없이 안전합니다.

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!
}
  • 중요한 점: 취소 가능하도록 구현해야 하며, 재시도 루프 안에서도 Task.isCancelled 체크를 고려합니다.
  • 서버가 과부하 상태일 때는 재시도보다 실패를 상위 레이어에 전파해 사용자에게 즉시 피드백을 주는 것이 UX상 더 나을 수 있습니다.

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 작업 분리, 리트라이/백오프, 취소 일관성, 측정과 적응형 정책이 함께할 때 진정으로 견고한 병렬 처리 파이프라인이 됩니다.

profile
iOS 앱 개발자

0개의 댓글