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("이 코드는 영원히 실행되지 않습니다.") } }원인:
viewDidLoad는 메인 스레드에서 실행 중입니다. (할일이 아직 안 끝남)sync를 호출하여 메인 큐에 작업을 넣고, 그 작업이 끝날 때까지 메인 스레드를 멈추고 기다립니다.- 메인 큐는 Serial Queue이므로, 현재 실행 중인 작업(
viewDidLoad)이 끝나야 다음 작업(sync블록)을 시작할 수 있습니다.- 서로가 서로의 종료를 기다리며 영원히 멈춥니다.
DispatchWorkItem
클로저 대신 객체 형태로 작업을 캡슐화하여 큐에 제출할 수 있습니다.
- 취소 가능 (Cancel): 실행 전이라면
cancel()을 통해 작업을 취소할 수 있습니다. - 순서 제어 (Notify): 작업 완료 후 실행할 동작을 지정할 수 있습니다.
let workItem = DispatchWorkItem {
print("작업 수행")
}
DispatchQueue.global().async(execute: workItem)
// 실행 전 취소 시도
workItem.cancel()DispatchGroup
여러 비동기 작업들을 그룹으로 묶어, 모든 작업이 끝나는 시점을 알고 싶을 때 사용합니다.
사용 방법
- enter(): 작업 시작 전 호출 (Reference Count +1)
- leave(): 작업 완료 후 호출 (Reference Count -1)
- 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). 대기보던 스레드를 깨움.
활용 예시:
- Mutex (상호 배제):
value: 1로 설정하여 한 번에 하나의 스레드만 접근하도록 Lock 역할 수행. - 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플래그를 사용하여 작업을 보내면,- 이전에 큐에 있던 작업들이 모두 끝날 때까지 기다립니다.
- Barrier 작업은 혼자(Serial하게) 실행됩니다.
- 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)
}
}