Apple의 libdispatch(GCD) 효율적으로 사용하는 법

실전 가이드

🔍 서론: GCD의 이면 이해하기

Apple의 libdispatch(일명 Grand Central Dispatch, GCD)는 macOS 및 iOS에서 멀티스레딩을 우아하게 구현할 수 있게 도와주는 강력한 도구입니다. 2009년 처음 공개된 이후, 많은 Apple 플랫폼 개발자들에게 비동기 프로그래밍의 기본이 되었습니다.

그러나 강력한 만큼 오용되기 쉬운 API이기도 합니다. 많은 개발자들이 DispatchQueue.global() 호출이나 async 블록을 무분별하게 사용하는 실수를 저지르고, 그로 인해 오히려 성능이 나빠지거나 디버깅이 매우 어려운 상황을 마주하게 됩니다.

이 글에서는 libdispatch의 설계 이면에 있는 철학과 함께, 실제 고성능 앱을 개발할 때 도움이 되는 GCD 사용 팁을 정리했습니다. 특히 libdispatch의 설계자이자 Apple의 내부 개발자인 Pierre Habouzit와 GCD 관련 실무 경험이 풍부한 개발자들의 조언을 바탕으로 작성했습니다.

🎯 1. 큐는 최소한으로, 오래 유지되게

핵심 원칙

가장 중요한 원칙은 다음과 같습니다:

“가능한 한 적은 수의, 명확한 용도의 큐를 오래 유지하면서 사용하라.”

왜 그런가?

libdispatch는 비동기 실행을 위해 내부적으로 스레드 풀을 관리합니다. 여러 큐가 동시에 활성화되면, 시스템은 각각을 실행하기 위해 스레드를 할당해야 합니다. 따라서 큐가 많을수록 스레드 수가 폭증할 수 있습니다.

스레드가 너무 많아지면 다음과 같은 문제가 발생합니다:

  • 컨텍스트 스위칭 오버헤드 증가: CPU 코어는 스레드 간 전환할 때마다 비용이 발생합니다.
  • 메모리 사용량 증가: 각 스레드는 스택 메모리를 차지합니다(기본적으로 약 512KB).
  • 캐시 효율성 저하: 스레드가 너무 많으면 CPU 캐시 적중률이 떨어집니다.
  • 스케줄링 복잡성 증가: 운영체제가 많은 스레드 간에 CPU 시간을 분배하는 것은 점점 더 비효율적이 됩니다.

실제 사용 사례

대부분의 앱은 다음과 같은 큐만 있어도 충분합니다:

  • UI 관련 작업 (main queue)
  • 백그라운드 데이터 처리 (앱 수준의 백그라운드 큐 1개)
  • 파일/네트워크 IO (IO 전용 큐 1개)
  • 미디어 처리 (오디오/비디오 처리 큐 1개)

즉, 3~4개 정도의 큐면 대부분의 앱에 충분합니다.

코드 예시: 좋은 방식

// 앱 시작 시 전역 큐 몇 개만 정의
class AppQueues {
    static let shared = AppQueues()

    let mainQueue = DispatchQueue.main
    let backgroundQueue = DispatchQueue(label: "com.myapp.background", qos: .utility)
    let ioQueue = DispatchQueue(label: "com.myapp.io", qos: .utility)
    let mediaProcessingQueue = DispatchQueue(label: "com.myapp.media", qos: .userInitiated)
}

// 사용 예시
func processImage(_ image: UIImage, completion: @escaping (UIImage?) -> Void) {
    AppQueues.shared.mediaProcessingQueue.async {
        let processedImage = // 이미지 처리 작업
        DispatchQueue.main.async {
            completion(processedImage)
        }
    }
}

코드 예시: 피해야 할 방식

// ❌ 함수마다 새 큐를 생성
func processImage(_ image: UIImage, completion: @escaping (UIImage?) -> Void) {
    // 매번 새로운 큐를 생성 - 이렇게 하지 마세요!
    let processingQueue = DispatchQueue(label: "com.myapp.image.processing.\(UUID().uuidString)")

    processingQueue.async {
        let processedImage = // 이미지 처리 작업
        DispatchQueue.main.async {
            completion(processedImage)
        }
    }
}

🧱 2. 무조건 직렬(Serial) 큐부터 시작하라

직렬 큐의 장점

병렬 처리는 멋져 보이지만, 실제로는 대부분의 경우에서 오히려 성능 저하디버깅 지옥을 초래합니다.

직렬 큐를 사용할 때의 이점:

  • 데이터 경쟁 방지: 동일한 큐 내에서 모든 작업이 순차적으로 실행됩니다.
  • 예측 가능한 동작: 작업 실행 순서가 항상 일정합니다.
  • 리소스 사용 효율: 병렬 큐보다 적은 스레드를 사용합니다.
  • 디버깅 용이성: 문제 발생 시 추적이 쉽습니다.

병렬화가 정말 필요한 경우

병렬 처리는 다음 조건이 모두 충족될 때만 고려해야 합니다:

  1. 확실한 병목 지점이 있는지 측정하고
  2. 병렬 처리가 실제로 도움이 되는지를 검증한 후
  3. 그때서야 비동기 처리를 적용해야 합니다. 병렬 처리가 실제로 도움이 되는 작업의 특징:
  • 서로 독립적인 계산 작업들 (상호작용 없음)
  • 계산이 복잡하고 시간이 많이 소요되는 작업
  • 공유 자원에 접근이 적은 작업
  • 각 작업의 실행 시간이 최소 수십 밀리초 이상

코드 예시: 좋은 직렬 큐 사용

let processQueue = DispatchQueue(label: "com.myapp.image.processing")

func processImages(_ images: [UIImage], completion: @escaping ([UIImage]) -> Void) {
    // 먼저 직렬 큐에서 처리해보고, 성능 문제가 있을 때만 병렬화 고려
    processQueue.async {
        let processed = images.map { self.applyFilter($0) }
        DispatchQueue.main.async {
            completion(processed)
        }
    }
}

코드 예시: 측정 후 병렬화 적용 (필요한 경우만)

func processImagesInParallel(_ images: [UIImage], completion: @escaping ([UIImage]) -> Void) {
    // 성능 측정 후 병렬화가 확실히 이점이 있다고 판단된 경우
    let group = DispatchGroup()
    let serialQueue = DispatchQueue(label: "com.myapp.processing.results")
    var results = [Int: UIImage]() // 결과를 저장할 딕셔너리

    // 이미지를 처리할 작업자 큐 - 적절한 max_concurrent 값 설정
    let workerQueue = DispatchQueue(label: "com.myapp.processing.worker", 
                                  attributes: .concurrent)

    for (index, image) in images.enumerated() {
        group.enter()
        workerQueue.async {
            let processed = self.applyFilter(image)

            // 결과를 안전하게 저장
            serialQueue.async {
                results[index] = processed
                group.leave()
            }
        }
    }

    group.notify(queue: DispatchQueue.main) {
        // 순서 유지하여 결과 전달
        let orderedResults = images.indices.map { results[$0]! }
        completion(orderedResults)
    }
}

🧨 3. DispatchQueue.global()은 절대 쓰지 마라

global() 사용의 위험성

많은 예제에서 무심코 DispatchQueue.global().async를 사용하는 걸 보셨을 겁니다. 하지만 libdispatch의 설계자 중 한 명인 Pierre Habouzit는 이를 다음과 같이 표현했습니다:

“libdispatch API에서 가장 나쁜 기능이다.”

왜일까요?

  • 과도한 스레드 생성(overcommit): global queue는 시스템 전체에서 공유되어, 과도한 스레드 생성을 초래할 수 있습니다.
  • 블로킹 작업 처리: 블로킹(sleep, lock 등)이 있으면, libdispatch는 이를 비활성 스레드로 간주하고 새로운 스레드를 생성합니다.
  • 스레드 제어 불가: 이런 동작은 앱 전체의 스레드 개수를 통제할 수 없게 만듭니다.
  • 예측 불가능한 QoS: global queue는 QoS(Quality of Service) 우선순위도 제대로 반영하지 못합니다. 따라서 복잡한 비동기 작업이 섞이면 우선순위 역전이 발생할 수 있습니다.

libdispatch 설계자의 조언

[참고] Pierre Habouzit는 다음과 같이 언급했습니다:

“사실 우리(Apple)는 전역 동시 큐(global concurrent queue)를 제공한 것을 후회하고 있습니다. 이 API는 너무 많이 남용되고 있고, 오히려 성능 문제를 만들어내거나 감추고 있습니다.”

대안: 커스텀 큐 사용

global() 대신 다음과 같이 직접 만든 커스텀 큐를 사용하세요:

// 앱 수준에서 재사용 가능한 큐 생성
let backgroundQueue = DispatchQueue(label: "com.myapp.background", 
                                  qos: .utility)

// 특수한 경우, 필요하면 target을 지정한 커스텀 큐 사용
let processingQueue = DispatchQueue(
    label: "com.myapp.processing",
    qos: .userInitiated, 
    target: backgroundQueue
)

코드 예시: 피해야 할 global() 사용

// ❌ 이렇게 하지 마세요!
func processImage(_ image: UIImage, completion: @escaping (UIImage?) -> Void) {
    DispatchQueue.global().async {  // 위험한 전역 큐 사용
        let processedImage = // 이미지 처리 작업
        DispatchQueue.main.async {
            completion(processedImage)
        }
    }
}

코드 예시: 올바른 접근 방식

// ✅ 이렇게 하세요
// 앱 수준에서 정의된 백그라운드 큐 사용
func processImage(_ image: UIImage, completion: @escaping (UIImage?) -> Void) {
    AppQueues.shared.backgroundQueue.async {
        let processedImage = // 이미지 처리 작업
        DispatchQueue.main.async {
            completion(processedImage)
        }
    }
}

🔁 4. 비동기 작업이라고 항상 빠르지 않다

비동기 처리의 오버헤드

비동기로 코드를 실행시키면 무조건 빨라질까요? 절대 아닙니다.

특히 실행 시간이 1ms 이하인 작은 작업들queue.async로 실행하면 오히려 더 느려집니다. 이유는 다음과 같습니다:

  • 스레드 전환 비용: libdispatch는 작업을 스레드 풀의 스레드로 디스패치하는 과정에서 컨텍스트 스위칭 비용이 발생합니다.
  • 큐 관리 오버헤드: 비동기 작업을 큐에 넣고 실행 순서를 관리하는 데 드는 오버헤드가 있습니다.
  • 메모리 및 캐시 비효율: 스레드 간 메모리 이동은 CPU 캐시 효율성을 감소시킵니다.
  • 스케줄링 지연: 작업이 실제로 실행되기까지 스케줄링 지연이 발생할 수 있습니다.

성능 비교: 동기 vs 비동기

[참고] Apple 내부 개발자의 측정에 따르면:

  • 1ms 미만 작업: 직접 실행이 비동기 실행보다 10배 이상 빠름
  • 1-10ms 작업: 직접 실행이 여전히 효율적일 수 있음
  • 10ms 이상 작업: 비동기 처리가 효율적일 가능성이 높아짐

코드 예시: 간단한 작업은 동기적으로

// 짧은 작업은 비동기로 처리할 필요 없음
func calculateTip(for amount: Double, percentage: Double) -> Double {
    // 이런 간단한 계산은 그냥 직접 실행하세요
    return amount * (percentage / 100.0)
}

// 사용 예
let tip = calculateTip(for: 100.0, percentage: 15.0)

코드 예시: 실행 시간이 긴 작업만 비동기로

func processLargeDataSet(_ data: [Double], completion: @escaping ([Double]) -> Void) {
    // 실행 시간이 긴 작업만 비동기로 처리
    if data.count < 1000 {
        // 작은 데이터셋은 동기적으로 처리 (오버헤드 방지)
        let result = performHeavyCalculation(data)
        completion(result)
    } else {
        // 큰 데이터셋만 비동기로 처리
        AppQueues.shared.backgroundQueue.async {
            let result = self.performHeavyCalculation(data)
            DispatchQueue.main.async {
                completion(result)
            }
        }
    }
}

🔒 5. 락(Lock)은 결코 느리지 않다

락의 효율성

비동기 처리만이 정답이 아닙니다. 오히려 전통적인 락을 사용한 동기 처리가 더 안전하고 빠를 때도 많습니다.

현대 운영체제의 락 구현은 매우 효율적이며, 특히 다음과 같은 장점이 있습니다:

  • 예측 가능성: 락은 결정론적인 동작을 제공합니다.
  • 간결한 코드: 비동기 콜백 지옥을 피할 수 있습니다.
  • 데이터 일관성: 공유 자원에 대한 액세스를 명확하게 제어합니다.
  • 낮은 오버헤드: 현대적인 락은 매우 효율적으로 구현되어 있습니다.

효율적인 락 종류

Swift/Objective-C에서 사용할 수 있는 효율적인 락:

  • os_unfair_lock: 현대적이고 경량화된 상호 배제 락 (iOS 10 이상)
  • NSLock: Objective-C 기반의 상호 배제 락
  • Swift 5.7+의 Mutex: Apple 플랫폼에서 자체 지원되는 Swift의 뮤텍스
  • pthread_mutex_t: 저수준 POSIX 스레드 뮤텍스

락 성능에 대한 오해

[참고] libdispatch의 원 개발자는 다음과 같이 말했습니다:

“락은 무조건 피해야 하는 도구가 아닙니다. 아주 잘 설계된 락은 매우 빠르며, GCD의 모든 큐도 내부적으로는 락을 사용하고 있습니다.”

실제로 잘 구현된 os_unfair_lock은 매우 효율적이며, 특히 경합이 적은 환경에서는 거의 오버헤드가 없습니다.

코드 예시: 효율적인 락 사용

class ThreadSafeCache<Key: Hashable, Value> {
    private var cache = [Key: Value]()
    private let lock = NSLock()

    func setValue(_ value: Value, forKey key: Key) {
        lock.lock()
        defer { lock.unlock() }
        cache[key] = value
    }

    func getValue(forKey key: Key) -> Value? {
        lock.lock()
        defer { lock.unlock() }
        return cache[key]
    }
}

코드 예시: Swift 동시성 환경에서의 락 사용 (Swift 5.7+)

import Foundation

actor SafeCounter {
    private var count = 0

    func increment() -> Int {
        count += 1
        return count
    }

    func getCount() -> Int {
        return count
    }
}

// 사용 예시
func incrementExample() async {
    let counter = SafeCounter()
    let newValue = await counter.increment()
    print("New count: \(newValue)")
}

🚫 6. 세마포어 기다리기는 비효율적이다

세마포어 대기의 문제점

다음과 같은 코드는 절대 피해야 합니다:

let group = DispatchGroup()
queue.async(group: group) {
    doSomeWork()
}
group.wait() // ❌❌❌

이런 코드는 다음과 같은 심각한 문제를 야기합니다:

  • 리소스 낭비: 대기 중인 스레드는 CPU 사이클을 낭비합니다.
  • 스케줄링 불확실성: 커널 스케줄러에게 어떤 스레드가 깨어날지 예측 불가능하게 만듭니다.
  • 데드락 위험: 비동기 작업이 현재 스레드에 의존하는 경우 데드락이 발생할 수 있습니다.
  • 우선순위 역전: 우선순위가 높은 스레드가 우선순위가 낮은 스레드를 기다릴 수 있습니다.

가장 큰 문제: 스레드 풀 고갈

특히 DispatchQueue.global()에서 .wait()를 호출하면, 시스템 스레드 풀 고갈로 이어질 수 있습니다:

  1. 스레드가 세마포어를 기다리며 차단됨
  2. libdispatch는 이를 “사용 불가능한” 스레드로 간주
  3. 새 스레드를 생성하여 다른 작업 처리
  4. 이 과정이 반복되면 시스템 스레드 풀이 고갈될 수 있음

대안: 컴플리션 핸들러 또는 Swift 비동기/어웨이트

더 좋은 방법:

// 컴플리션 핸들러 방식
func doAsyncWork(completion: @escaping (Result) -> Void) {
    queue.async {
        let result = performWork()
        DispatchQueue.main.async {
            completion(result)
        }
    }
}

// 사용 예시
doAsyncWork { result in
    // 결과 처리
}

Swift 5.5 이상에서는 async/await를 활용하는 것이 훨씬 더 깔끔합니다:

// Swift 동시성 방식
func doAsyncWork() async -> Result {
    return await withCheckedContinuation { continuation in
        queue.async {
            let result = performWork()
            continuation.resume(returning: result)
        }
    }
}

// 사용 예시
Task {
    let result = await doAsyncWork()
    // 결과 처리
}

🚷 7. DispatchQueue.main은 GUI 앱 전용

main 큐의 특수성

DispatchQueue.main은 GUI 이벤트 루프와 연동되는 특수한 큐입니다.

이 큐는 다음과 같은 특성을 갖습니다:

  • UIKit/AppKit의 이벤트 루프와 연결되어 있습니다.
  • UI 관련 작업은 반드시 이 큐에서 실행해야 합니다.
  • 내부적으로 특별한 처리를 통해 UI 이벤트 처리와 통합됩니다.

main 큐를 사용하지 말아야 할 경우

다음과 같은 경우엔 main 큐 사용을 피해야 합니다:

  • CLI 도구: 명령줄 애플리케이션에는 UI 루프가 없습니다.
  • 백그라운드 데몬: 백그라운드에서 실행되는 서비스 프로세스.
  • 서버 애플리케이션: 특히 macOS에서 실행되는 서버 소프트웨어.
  • 확장 프로그램: 일부 iOS/macOS 확장 프로그램은 제한된 UI 컨텍스트만 가집니다.

대안: 커스텀 직렬 큐

이런 경우엔 일반 직렬 큐를 대신 사용하세요:

// UI가 없는 애플리케이션에서의 메인 큐 대안
let applicationQueue = DispatchQueue(label: "com.myapp.main", qos: .userInteractive)

// 사용 예시
func processCommand() {
    applicationQueue.async {
        // 애플리케이션의 주요 로직 실행
    }
}

main 큐에 대한 중요 참고사항

[참고] main 큐는 RecursiveLock 대신 NSLock 기반으로 구현되어 있어서, 재진입(reentrancy)이 허용되지 않습니다. main 큐에서 다시 main 큐로 동기 호출(.sync)을 하면 데드락이 발생합니다.

// ❌ 절대 이렇게 하지 마세요! 데드락 발생
DispatchQueue.main.async {
    // 메인 큐에서 실행 중
    DispatchQueue.main.sync {  // 데드락!
        // 이 코드는 절대 실행되지 않습니다
    }
}

🔁 8. 병렬 처리 시 공유 자원은 성능 저하의 주범

공유 자원의 병목 현상

비동기 처리로 성능을 올리고자 할 때, 다음 항목에 주의해야 합니다:

  • 메모리 할당: malloc이나 shared memory에 접근하는 코드는 내부적으로 락을 사용합니다.
  • 파일 시스템: 파일 입출력은 보통 직렬화된 액세스를 요구합니다.
  • 네트워크 스택: 네트워크 요청은 종종 시스템 수준에서 제한됩니다.
  • 데이터베이스: 대부분의 데이터베이스는 동시 액세스에 제한이 있습니다.
  • 글로벌 객체: NotificationCenter, UserDefaults와 같은 글로벌 객체는 내부적으로 동기화됩니다.

이러한 공유 자원에 여러 스레드가 접근하면 병목 지점이 생기고 성능은 급락하게 됩니다.

경쟁 상태의 이해

Amdahl의 법칙에 따르면, 프로그램의 성능 향상은 병렬화할 수 없는 부분에 의해 제한됩니다. 즉, 공유 자원에 대한 액세스가 많을수록 병렬화의 이점은 줄어듭니다.

공유 자원에 동시에 접근할 때 나타나는 현상:

  • 락 경합(Lock Contention): 여러 스레드가 같은 락을 얻기 위해 경쟁합니다.
  • 캐시 라인 핑퐁(Cache Line Ping-Pong): CPU 코어 간에 데이터가 계속 이동합니다.
  • 메모리 일관성 오버헤드: CPU는 코어 간 메모리 상태를 일관되게 유지하기 위해 추가 작업을 합니다.

효율적인 병렬 처리 전략

병렬 처리를 효율적으로 하기 위한 전략:

  1. 파티셔닝: 데이터를 독립적인 청크로 분할하여 각 스레드가 자체 데이터만 처리하도록 합니다.
  2. 지역성 최대화: 각 스레드가 로컬 데이터로 최대한 작업하고 공유 상태 접근을 최소화합니다.
  3. 락 단위 최적화: 필요한 경우 더 세밀한 락을 사용하여 경합을 줄입니다.
  4. 워크스티일링(Work Stealing): 균형 잡힌 작업 분배를 위해 작업 훔치기 알고리즘을 구현합니다.

코드 예시: 공유 자원 최소화한 병렬 처리

func processImages(_ images: [UIImage]) -> [UIImage] {
    let numberOfCores = ProcessInfo.processInfo.activeProcessorCount
    let chunkSize = max(1, images.count / numberOfCores)

    // 이미지를 코어 수에 맞게 청크로 분할
    let chunks = stride(from: 0, to: images.count, by: chunkSize).map { i in
        let end = min(i + chunkSize, images.count)
        return Array(images[i..<end])
    }

    let group = DispatchGroup()
    let resultQueue = DispatchQueue(label: "com.myapp.results")
    var results = [Int: [UIImage]]() // 결과를 저장할 딕셔너리

    // 각 청크를 병렬로 처리
    for (chunkIndex, chunk) in chunks.enumerated() {
        group.enter()
        DispatchQueue(label: "com.myapp.worker.\(chunkIndex)").async {
            // 각 워커는 자체 메모리와 자원으로 작업
            let processedChunk = chunk.map { self.processImage($0) }

            // 결과만 공유 자원에 저장
            resultQueue.async {
                results[chunkIndex] = processedChunk
                group.leave()
            }
        }
    }

    // 모든 작업이 완료될 때까지 기다림 (여기서는 간단히 하기 위해 wait 사용)
    group.wait()

    // 결과 재조립
    return chunks.indices.flatMap { results[$0] ?? [] }
}

📉 9. 3~4개 코어 이상은 오히려 효율이 떨어질 수 있다

코어 수와 성능의 관계

Apple 디바이스(특히 모바일 기기)는 동시에 모든 코어를 사용하면 오히려 성능이 낮아질 수 있습니다.

이런 현상이 발생하는 이유:

  • 발열 제한: 모든 코어를 100% 사용하면 기기가 빠르게 열을 발생시킵니다.
  • 전력 소모: 배터리 기기에서는 전력 소모가 성능 제한으로 이어집니다.
  • 동적 클럭 조절: 많은 코어가 활성화되면 코어당 클럭 속도가 낮아질 수 있습니다.
  • 터보 부스트 해제: 모든 코어가 활성화되면 터보 부스트 기능이 비활성화됩니다.

실제 사례

[참고] iOS 12에서 성능 향상을 이룬 이유 중 하나는, 많은 시스템 데몬들이 단일 스레드 처리로 바뀐 덕분입니다. Apple은 내부적으로 “코어 낭비를 방지하고 단일 코어 성능을 극대화하는” 방향으로 전환했습니다.

최적의 스레드 수 결정

병렬 처리를 위한 최적의 스레드 수는 일반적으로:

// 최적의 작업자 스레드 수는 보통 (코어 수 - 1) 정도로 유지
let optimalThreadCount = max(1, ProcessInfo.processInfo.activeProcessorCount - 1)

특히 장시간 실행되는 백그라운드 작업의 경우, 모든 코어를 100% 활용하는 것은 사용자 경험과 배터리 수명에 부정적인 영향을 미칠 수 있습니다.

코드 예시: 적절한 병렬 처리 수준

class BackgroundProcessor {
    // 최적의 병렬 처리 수준 결정
    private let concurrencyLevel: Int
    private let processingQueue: DispatchQueue

    init() {
        // 대부분의 경우 (코어 수 - 1)이 적절함
        // 코어가 많은 기기(맥북 프로 등)에서는 최대값 제한
        let cores = ProcessInfo.processInfo.activeProcessorCount
        self.concurrencyLevel = min(max(1, cores - 1), 4) // 최대 4개로 제한

        self.processingQueue = DispatchQueue(
            label: "com.myapp.processing",
            attributes: .concurrent
        )
    }

    func process<T, U>(items: [T], transform: @escaping (T) -> U, completion: @escaping ([U]) -> Void) {
        let semaphore = DispatchSemaphore(value: concurrencyLevel)
        let group = DispatchGroup()
        let resultQueue = DispatchQueue(label: "com.myapp.results")
        var results = [Int: U]()

        for (index, item) in items.enumerated() {
            // 세마포어로 최대 동시 실행 수준 제한
            semaphore.wait()
            group.enter()

            processingQueue.async {
                let result = transform(item)
                resultQueue.async {
                    results[index] = result
                    group.leave()
                    semaphore.signal()
                }
            }
        }

        group.notify(queue: DispatchQueue.main) {
            // 결과 재조립
            let orderedResults = items.indices.compactMap { results[$0] }
            completion(orderedResults)
        }
    }
}

🧪 10. 마이크로 벤치마크를 믿지 마라

마이크로 벤치마크의 함정

짧은 벤치마크 코드는 다음과 같은 문제를 가집니다:

  • CPU 캐시 효과: 짧은 반복적인 코드는 CPU 캐시가 계속 warm 상태를 유지하여 실제 성능과 다른 결과를 보여줍니다.
  • JIT 최적화: 반복적인 코드는 런타임 최적화로 인해 실제보다 빠르게 측정될 수 있습니다.
  • 스레드 풀 예열: 첫 몇 번의 실행 이후에는 스레드 풀이 이미 생성되어 있어 실제 시나리오와 다릅니다.
  • 시스템 부하 무시: 실제 앱에서는 여러 시스템 이벤트와 경합이 발생합니다.

올바른 성능 측정 방법

매크로 벤치마크 — 실제 시나리오에서 전체 워크플로우 기준으로 측정하세요:

  1. 전체 애플리케이션 흐름 측정: 개별 함수가 아니라 사용자 시나리오 전체를 측정합니다.
  2. 콜드 스타트 포함: 첫 번째 실행 성능도 중요합니다(캐시가 없는 상태).
  3. 다양한 부하 상태 테스트: 백그라운드에서 다른 앱이 실행 중일 때의 성능도 확인합니다.
  4. 실제 기기에서 테스트: 시뮬레이터가 아닌 실제 기기에서 측정합니다.
  5. 배터리 상태 고려: 저전력 모드와 일반 모드에서 모두 테스트합니다.

코드 예시: 성능 측정 헬퍼

class PerformanceTester {
    static func measure(iterations: Int = 10, setupAction: (() -> Void)? = nil, 
                      cleanupAction: (() -> Void)? = nil, action: @escaping () -> Void) -> TimeInterval {

        // 첫 번째 실행은 JIT 최적화와 캐시 예열을 위해 별도로 실행
        setupAction?()
        action()
        cleanupAction?()

        var totalTime: TimeInterval = 0
        var times: [TimeInterval] = []

        for _ in 0..<iterations {
            setupAction?()

            let start = CFAbsoluteTimeGetCurrent()
            action()
            let end = CFAbsoluteTimeGetCurrent()

            cleanupAction?()

            let executionTime = end - start
            times.append(executionTime)
            totalTime += executionTime
        }

        // 최대/최소값을 제외한 평균을 계산 (더 안정적인 결과)
        times.sort()
        let trimmedTimes = iterations > 3 ? 
            Array(times.dropFirst().dropLast()) : times

        let averageTime = trimmedTimes.reduce(0, +) / Double(trimmedTimes.count)

        print("Performance results over \(iterations) runs:")
        print("- Average time: \(averageTime * 1000) ms")
        print("- Best time: \(times.first! * 1000) ms")
        print("- Worst time: \(times.last! * 1000) ms")

        return averageTime
    }
}

// 사용 예시
PerformanceTester.measure(
    iterations: 20,
    setupAction: {
        // 테스트 전 설정 (예: 초기 데이터 준비)
    },
    cleanupAction: {
        // 테스트 후 정리 (예: 임시 파일 삭제)
    },
    action: {
        // 측정할 코드
        processLargeDataSet()
    }
)

🧠 결론: libdispatch는 마법이 아니다

핵심 교훈

GCD는 훌륭한 도구지만, 무분별하게 사용하면 재앙이 될 수 있습니다.

  • 스레드는 무한하지 않습니다: 시스템 리소스는 제한되어 있습니다.
  • 성능은 측정이 필요합니다: 추측이나 유행이 아닌 데이터에 기반한 최적화가 중요합니다.
  • 단순함이 복잡성보다 낫습니다: 이해하기 쉽고 예측 가능한 코드가 유지보수에 유리합니다.
  • 직렬 큐로 시작하세요: 문제가 증명된 경우에만 병렬화를 도입합니다.
  • 글로벌 큐를 피하세요: 명확한 목적을 가진 커스텀 큐를 사용합니다.
  • 락을 두려워하지 마세요: 적절한 락 사용은 효율적이고 예측 가능한 코드를 만듭니다.

실전 권장사항 요약

실제 앱에서 적용할 수 있는 최종 권장사항:

  1. 앱 시작 시 몇 개의 명확한 큐만 정의하고 재사용
  2. 기본적으로 직렬 큐로 시작하고, 꼭 필요한 경우에만 병렬 처리 도입
  3. DispatchQueue.global() 사용을 지양하고 커스텀 큐로 대체
  4. 짧은 작업은 비동기로 처리하지 않음
  5. 적절한 락 사용을 두려워하지 않음
  6. 세마포어 대기를 지양하고 비동기 콜백이나 async/await 사용
  7. 공유 자원 접근을 최소화하는 방식으로 병렬 처리 설계
  8. 모든 코어를 100% 활용하는 것은 피하고 적절한 수준의 병렬 처리 유지
  9. 마이크로 벤치마크 대신 실제 사용 시나리오에서 성능 측정

추가 학습 자료

[참고] 더 자세한 내용을 위한 참고 자료:


마무리 생각

[참고] 실제 iOS 앱 개발을 하다 보면 “성능을 위해 async!”를 외치고 싶을 때가 많습니다. 그러나 성능은 측정에 의해 증명돼야 하고, 그 전에 구조적인 설계가 먼저입니다.

대부분의 성능 문제는 비동기 처리보다 효율적인 알고리즘과 데이터 구조 선택으로 해결됩니다. 그리고 가장 빠른 코드는 실행되지 않는 코드입니다. 불필요한 작업을 줄이는 것이 항상 첫 번째 최적화 전략이어야 합니다.

마지막으로, 성능뿐만 아니라 코드의 가독성과 유지보수성도 중요한 가치입니다. 과도한 최적화로 인해 버그 가능성이 높아지거나 팀원들이 이해하기 어려운 코드가 된다면, 그것은 좋은 거래가 아닙니다.

이번 포스팅이 libdispatch를 더 정확하게 이해하고 실전에서 효율적으로 활용하는 데 도움이 되셨기를 바랍니다.

Posted in iOS

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다