GCD 사용법 (Deep Dive)

GCD (Grand Central Dispatch)

GCD는 멀티코어 하드웨어에서 동시에 실행되는 코드를 쉽게 작성할 수 있도록 Apple이 제공하는 저수준(C 기반) API입니다.
개발자가 직접 스레드를 관리하는 대신 **작업(Task)**을 정의하여 **큐(Queue)**에 넣으면, 시스템이 적절한 스레드를 생성하여 실행해줍니다.

DispatchQueue

GCD에서 작업을 순서대로 처리하기 위해 제공하는 Queue 객체입니다. 작업을 할당하면 시스템이 알아서 스레드 풀에서 적절한 스레드를 가져와 작업을 분배합니다.

1. Main Queue

  • Serial Queue (= 직렬 큐)
  • 단 하나만 존재하며, 메인 스레드에 바인딩되어 있어 할당된 작업들은 모두 메인 스레드에서 실행됩니다.
  • UI 업데이트 동작은 반드시 메인 스레드에서 실행되어야 하기 때문에 메인 큐에 할당해야 합니다.
DispatchQueue.main.async {
    // UI 업데이트
    self.label.text = "Hello"
}

2. Global Queue

  • Concurrent Queue (= 동시 큐)
  • 할당된 작업들을 시스템이 관리하는 스레드 풀의 여러 스레드에 분산시켜 병렬로 처리합니다.
  • 동시에 실행되기 때문에 작업의 시작 순서는 큐 순서대로지만, 완료 순서는 보장되지 않습니다.
  • QoS (Quality of Service) 속성을 통해 작업의 중요도를 설정할 수 있으며, 각 QoS 별로 독립된 큐가 존재합니다.
DispatchQueue.global(qos: .userInitiated).async {
    // 무거운 작업 수행
}

3. Custom Queue (Private Queue)

  • 기본값은 Serial Queue이지만, attributes: .concurrent를 설정하여 Concurrent Queue로 만들 수 있습니다.
  • 디버깅 시 식별하기 쉽도록 고유의 label을 붙여 생성합니다.
  • 순서가 중요한 작업이나 특정 데이터의 Race Condition을 방지하기 위한 용도로 (Serial Queue로서) 자주 사용됩니다.
let myQueue = DispatchQueue(label: "com.example.myQueue") // 기본 Serial
let myConcurrentQueue = DispatchQueue(label: "com.example.myConcurrentQueue", attributes: .concurrent)

Serial vs Concurrent

Serial Queue (직렬)

  • 큐에 추가된 작업을 하나씩 순서대로 실행합니다.
  • 앞선 작업이 끝나야 다음 작업이 시작되므로 실행 순서가 보장됩니다.
  • 데이터 동기화나 순서가 중요한 작업에 적합합니다.

Concurrent Queue (동시)

  • 큐에 추가된 작업들을 동시에 실행할 수 있습니다. (물리적 코어 수 등에 따라 병렬 처리)
  • 앞선 작업이 끝나지 않아도 다음 작업을 시작할 수 있습니다.
  • 네트워크 요청, 이미지 처리 등 서로 의존성이 없는 독립적인 작업들에 적합합니다.

QoS (Quality of Service)

시스템에게 작업의 우선순위와 성격을 알려주어, 에너지 효율과 반응성을 최적화하는 속성입니다.

QoS설명사용 예시실행 시간
userInteractive즉각적인 반응이 필요함. Main Thread 작업과 동급.애니메이션, UI 이벤트 처리즉시
userInitiated유저가 결과를 기다리고 있음. 빠른 처리 필요.저장된 문서 열기, 버튼 클릭 후 데이터 로드수 초
default기본값.일반적인 작업-
utility사용자가 진행 상황을 알고 있는 긴 작업.대용량 데이터 다운로드, 인덱싱수 초 ~ 수 분
background유저가 인지하지 못하는 백그라운드 작업.동기화, 백업, 프리패칭수 분 ~ 수 시간

Async vs Sync

작업을 큐에 보낼 때(Dispatch), 호출한 쪽(Caller)에서 기다릴지 말지를 결정합니다.

Async (비동기)

  • 작업을 큐에 보내고 즉시 반환됩니다.
  • 호출한 스레드는 멈루지 않고 다음 코드를 계속 실행합니다.
  • 대부분의 경우 사용됩니다.

Sync (동기)

  • 큐에 보낸 작업이 완전히 끝날 때까지 기다립니다.
  • 작업이 끝날 때까지 호출한 스레드는 Block(멈춤) 상태가 됩니다.
  • 결과가 바로 필요하거나, 반드시 순서를 맞춰야 할 때 제한적으로 사용됩니다.
WARNING

주의: Deadlock (교착 상태) Main Queue에서 Sync를 호출하면 Deadlock이 발생합니다.

override func viewDidLoad() {
    super.viewDidLoad() // 메인 스레드에서 실행 중
    
    // ☠️ Deadlock 발생!
    DispatchQueue.main.sync {
        print("이 코드는 영원히 실행되지 않습니다.")
    }
}

원인:

  1. viewDidLoad는 메인 스레드에서 실행 중입니다. (할일이 아직 안 끝남)
  2. sync를 호출하여 메인 큐에 작업을 넣고, 그 작업이 끝날 때까지 메인 스레드를 멈추고 기다립니다.
  3. 메인 큐는 Serial Queue이므로, 현재 실행 중인 작업(viewDidLoad)이 끝나야 다음 작업(sync 블록)을 시작할 수 있습니다.
  4. 서로가 서로의 종료를 기다리며 영원히 멈춥니다.

DispatchWorkItem

클로저 대신 객체 형태로 작업을 캡슐화하여 큐에 제출할 수 있습니다.

  • 취소 가능 (Cancel): 실행 전이라면 cancel()을 통해 작업을 취소할 수 있습니다.
  • 순서 제어 (Notify): 작업 완료 후 실행할 동작을 지정할 수 있습니다.
let workItem = DispatchWorkItem {
    print("작업 수행")
}

DispatchQueue.global().async(execute: workItem)

// 실행 전 취소 시도
workItem.cancel()

DispatchGroup

여러 비동기 작업들을 그룹으로 묶어, 모든 작업이 끝나는 시점을 알고 싶을 때 사용합니다.

사용 방법

  1. enter(): 작업 시작 전 호출 (Reference Count +1)
  2. leave(): 작업 완료 후 호출 (Reference Count -1)
  3. notify(): Count가 0이 되면 실행될 완료 핸들러
let group = DispatchGroup()

group.enter()
networkRequest1 {
    print("요청 1 완료")
    group.leave()
}

group.enter()
networkRequest2 {
    print("요청 2 완료")
    group.leave()
}

group.notify(queue: .main) {
    print("모든 요청 완료!")
}

동시성 문제 해결 도구

DispatchSemaphore

공유 자원에 접근할 수 있는 스레드의 개수를 제어하는 계수기(Counting Semaphore)입니다.

  • wait(): 자원 접근 요청 (Count -1). Count가 0보다 작으면 대기.
  • signal(): 자원 반납 (Count +1). 대기보던 스레드를 깨움.

활용 예시:

  1. Mutex (상호 배제): value: 1로 설정하여 한 번에 하나의 스레드만 접근하도록 Lock 역할 수행.
  2. Throttling: 동시 실행 작업 수 제한 (예: 동시에 최대 3개의 다운로드만 허용).
let semaphore = DispatchSemaphore(value: 1)

DispatchQueue.global().async {
    semaphore.wait() // Lock
    // 공유 자원 접근
    semaphore.signal() // Unlock
}
CAUTION

주의: Main Thread에서 wait()를 호출하면 화면이 멈출 수 있으므로 주의해야 합니다.

Dispatch Barrier

Concurrent Queue(동시 큐)에서 읽기(Read)는 동시에, 쓰기(Write)는 독점적으로 수행하고 싶을 때 사용합니다. (Reader-Writer Problem 해결)

  • .barrier 플래그를 사용하여 작업을 보내면,
    1. 이전에 큐에 있던 작업들이 모두 끝날 때까지 기다립니다.
    2. Barrier 작업은 혼자(Serial하게) 실행됩니다.
    3. Barrier 작업이 끝나면, 다시 이후 작업들이 동시에 실행됩니다.
let queue = DispatchQueue(label: "com.data.queue", attributes: .concurrent)
var data: [String] = []

// 읽기 (동시 접근 가능)
func read() {
    queue.async {
        print(self.data)
    }
}

// 쓰기 (독점 접근 - Barrier)
func write(value: String) {
    queue.async(flags: .barrier) {
        self.data.append(value)
    }
}