현대의 앱은 사용자 인터페이스가 멈추지 않고 부드럽게 작동하면서도, 동시에 네트워크 통신, 파일 입출력, 복잡한 계산 등 시간이 오래 걸리는 작업을 처리해야 합니다. 이러한 작업들을 메인 스레드(UI 스레드)에서 직접 실행하면 앱이 멈추거나 '버벅이는' 현상이 발생하여 사용자 경험을 심각하게 저해합니다. 비동기 프로그래밍은 이처럼 시간을 소모하는 작업을 백그라운드 스레드에서 실행하고, 작업이 완료되면 메인 스레드에 결과를 알려 UI를 업데이트하는 방식으로 앱의 반응성을 유지하는 기법입니다.
Swift 5.5부터 async/await가 도입되어 비동기 코드를 훨씬 간결하게 작성할 수 있게 되었지만, 여전히 많은 기존 코드베이스에서 GCD와 OperationQueue가 활발하게 사용되고 있으며, 이 두 기술은 async/await의 저수준 기반이 되기도 합니다.
1. 왜 비동기 프로그래밍이 필요한가요?
- UI 응답성 유지: 앱이 네트워크 요청이나 데이터베이스 쿼리와 같은 긴 작업을 수행하는 동안에도 사용자가 UI와 계속 상호작용할 수 있도록 합니다.
- 성능 최적화: 여러 작업을 동시에 실행하여 전체 작업 완료 시간을 단축하고, 멀티 코어 CPU의 이점을 활용합니다.
- 리소스 효율성: 불필요한 스레드 생성 및 관리를 줄여 시스템 자원을 효율적으로 사용합니다.
2. Grand Central Dispatch (GCD): 저수준 동시성 제어
Grand Central Dispatch (GCD)는 Apple이 개발한 저수준(low-level) 동시성 프레임워크로, 멀티코어 하드웨어에서 작업을 효율적으로 실행할 수 있도록 지원합니다. GCD는 큐(Queue) 기반으로 동작하며, 작업을 디스패치 큐(Dispatch Queue)에 추가하면 GCD가 알아서 작업을 실행할 스레드를 관리합니다. 개발자가 직접 스레드를 생성하고 관리할 필요가 없어 스레드 프로그래밍의 복잡성을 크게 줄여줍니다.
2.1. 디스패치 큐 (Dispatch Queue)
디스패치 큐는 작업을 선입선출(FIFO) 방식으로 관리합니다. GCD는 두 가지 종류의 디스패치 큐를 제공합니다:
- 직렬 큐 (Serial Queue): 큐에 추가된 작업을 하나씩 순서대로 실행합니다. 동기화(Synchronization)가 필요할 때, 즉 특정 자원에 대한 접근을 한 번에 하나의 작업만 허용하여 데이터 경쟁(Data Race)을 방지할 때 유용합니다.
- 메인 큐 (Main Queue): 특별한 직렬 큐입니다. 모든 UI 관련 작업은 메인 스레드에서 이루어져야 하므로, 메인 큐를 사용하여 UI 업데이트 작업을 예약합니다.
DispatchQueue.main.async { // UI 업데이트 코드 (메인 스레드에서 실행됨) myLabel.text = "데이터 로드 완료!" }
- 커스텀 직렬 큐: 개발자가 직접 생성하여 특정 자원에 대한 접근을 직렬화할 때 사용합니다.
let mySerialQueue = DispatchQueue(label: "com.myapp.mySerialQueue") mySerialQueue.async { // 이 블록은 mySerialQueue에서 다른 작업이 완료될 때까지 기다렸다가 실행됨 print("첫 번째 직렬 작업 실행") } mySerialQueue.async { print("두 번째 직렬 작업 실행") // 첫 번째 작업 이후에 실행 보장 }
- 동시 큐 (Concurrent Queue): 큐에 추가된 작업을 동시에 여러 개 실행할 수 있습니다. 작업의 시작 순서는 보장되지만, 완료 순서는 보장되지 않습니다. 오랜 시간이 걸리는 백그라운드 작업을 병렬적으로 처리할 때 주로 사용됩니다.
- 글로벌 큐 (Global Queue): 시스템이 제공하는 동시 큐입니다. QoS(Quality of Service) 레벨을 지정하여 작업의 우선순위를 조절할 수 있습니다.
- .userInteractive: UI에 즉각적인 영향을 미치는 작업 (가장 높음)
- .userInitiated: 사용자가 시작하고 즉각적인 결과를 기대하는 작업
- .default: 기본 우선순위
- .utility: 시간이 오래 걸리지만 사용자에게 즉각적인 응답이 필요 없는 작업 (예: 다운로드)
- .background: 사용자에게 보이지 않는 백그라운드 작업 (가장 낮음)
DispatchQueue.global(qos: .userInitiated).async { // 시간이 오래 걸리는 백그라운드 작업 (예: 이미지 처리) let processedImage = self.processHeavyImage() DispatchQueue.main.async { // UI 업데이트는 반드시 메인 큐에서! self.imageView.image = processedImage print("이미지 처리 및 UI 업데이트 완료") } }
- 커스텀 동시 큐: 개발자가 직접 생성할 수 있지만, 일반적으로 글로벌 큐를 사용하는 것이 권장됩니다.
2.2. GCD의 동기/비동기 실행 메서드
- async (비동기): 작업을 큐에 추가하고 즉시 제어권을 반환합니다. 작업은 백그라운드에서 비동기적으로 실행됩니다.
- sync (동기): 작업을 큐에 추가하고 해당 작업이 완료될 때까지 현재 스레드를 차단합니다. 교착 상태(Deadlock)를 유발할 수 있으므로 사용에 주의해야 합니다. 특히 메인 큐에서 sync를 사용하면 앱이 멈춥니다.
// 메인 스레드에서 실행 중
print("1. 작업 시작")
// 백그라운드 작업
DispatchQueue.global().async {
print("2. 비동기 작업 시작 (글로벌 큐)")
Thread.sleep(forTimeInterval: 1.0) // 1초 대기
print("3. 비동기 작업 완료 (글로벌 큐)")
}
// 직렬 큐에서 동기 작업 (주의: 교착 상태 가능성)
let mySyncQueue = DispatchQueue(label: "com.myapp.mySyncQueue")
mySyncQueue.sync {
print("4. 동기 작업 시작 (직렬 큐)")
}
print("5. 동기 작업 완료 (현재 스레드 이어서 실행)")
// 출력 순서는 동기/비동기 특성상 1, 5, 4, 2, 3 또는 1, 4, 5, 2, 3 등 다양하게 나올 수 있음
// 4, 5는 거의 붙어 나옴. 2, 3은 1초 뒤에 나옴.
2.3. DispatchGroup 및 DispatchWorkItem
- DispatchGroup: 여러 비동기 작업이 모두 완료될 때까지 기다리거나, 모든 작업이 완료된 후에 한 번에 알림을 받을 때 사용합니다.
let group = DispatchGroup() let globalQueue = DispatchQueue.global() globalQueue.async(group: group) { print("첫 번째 비동기 작업 시작") Thread.sleep(forTimeInterval: 0.5) print("첫 번째 비동기 작업 완료") } globalQueue.async(group: group) { print("두 번째 비동기 작업 시작") Thread.sleep(forTimeInterval: 1.0) print("두 번째 비동기 작업 완료") } group.notify(queue: .main) { // 모든 그룹 작업이 완료되면 메인 큐에서 실행 print("모든 그룹 작업이 완료되었습니다! UI 업데이트 가능.") } print("그룹 작업 추가 완료 (비동기적으로 계속 진행)")
- DispatchWorkItem: 작업 블록을 캡슐화하고, 취소, 실행, 대기 등의 작업을 수행할 수 있도록 해줍니다.
3. OperationQueue: 고수준 동시성 제어
OperationQueue는 GCD 위에 구축된 Objective-C 프레임워크로, GCD보다 더 고수준(high-level)의 동시성 제공합니다. GCD가 큐에 작업을 넣는 기본적인 방식이라면, OperationQueue는 Operation 객체를 사용하여 작업 간의 의존성 설정, 작업 취소, 작업 상태 관리 등 더 정교한 기능을 제공합니다.
3.1. Operation과 OperationQueue의 관계
- Operation: 비동기적으로 수행될 단일 작업을 나타내는 추상 클래스입니다. BlockOperation이나 \다.
3.2. OperationQueue의 특징 및 장점
- 의존성(Dependency): 특정 Operation이 다른 Operation이 완료된 후에만 실행되도록 의존성을 설정할 수 있습니다.
let operation1 = BlockOperation { print("Operation 1 완료") } let operation2 = BlockOperation { print("Operation 2 완료") } let operation3 = BlockOperation { print("Operation 3 완료 (Operation 1과 2 완료 후)") } operation3.addDependency(operation1) operation3.addDependency(operation2) let queue = OperationQueue() queue.addOperation(operation1) queue.addOperation(operation2) queue.addOperation(operation3) // 출력: Operation 1 완료, Operation 2 완료 (순서 상관 없음), Operation 3 완료 (항상 마지막)
- 취소(Cancellation): 실행 중인 Operation을 취소할 수 있습니다. 장기 실행 작업에 유용합니다.
- 일시 중지/재개: 큐 전체를 일시 중지하거나 재개할 수 있습니다.
- 작업 개수 제한(maxConcurrentOperationCount): 동시에 실행될 수 있는 작업의 최대 개수를 설정하여 시스템 리소스 사용량을 제어할 수 있습니다.
- 1로 설정하면 직렬 큐처럼 작동합니다.
- OperationQueue.defaultMaxConcurrentOperationCount (시스템 권장 값)
- KVO(Key-Value Observing) 지원: Operation의 isFinished, isCancelled, isExecuting 등 상태 변화를 관찰할 수 있습니다.
3.3. OperationQueue 사용 예시
let imageDownloadQueue = OperationQueue()
imageDownloadQueue.name = "Image Download Queue"
imageDownloadQueue.maxConcurrentOperationCount = 3 // 최대 3개의 이미지 동시 다운로드
// 이미지 다운로드 Operation 정의
class ImageDownloadOperation: Operation {
let imageUrl: URL
var downloadedImage: UIImage?
init(url: URL) {
self.imageUrl = url
}
override func main() {
if isCancelled { return } // 취소되었는지 확인
print("이미지 다운로드 시작: \(imageUrl.lastPathComponent)")
// 실제 네트워크 다운로드 로직 (간단히 시뮬레이션)
Thread.sleep(forTimeInterval: 2.0)
if isCancelled { return }
// 다운로드 성공 가정
self.downloadedImage = UIImage(named: "placeholder") // 실제 이미지 데이터 대신 placeholder 사용
print("이미지 다운로드 완료: \(imageUrl.lastPathComponent)")
}
}
let urls = [
URL(string: "https://example.com/image1.jpg")!,
URL(string: "https://example.com/image2.jpg")!,
URL(string: "https://example.com/image3.jpg")!,
URL(string: "https://example.com/image4.jpg")!, // 4번째 이미지는 3개가 완료될 때까지 기다림
]
var downloadOperations: [ImageDownloadOperation] = []
for url in urls {
let op = ImageDownloadOperation(url: url)
downloadOperations.append(op)
imageDownloadQueue.addOperation(op)
}
// 모든 다운로드 작업이 완료된 후 UI 업데이트
imageDownloadQueue.addBarrierBlock {
print("모든 이미지 다운로드가 완료되었습니다. UI 업데이트를 시작합니다.")
// 메인 큐에서 UI 업데이트
DispatchQueue.main.async {
// self.updateUIWithImages(downloadOperations.compactMap { $0.downloadedImage })
print("UI 업데이트 완료!")
}
}
이 예시에서 OperationQueue는 최대 3개의 이미지 다운로드를 동시에 처리하고, 모든 다운로드가 완료된 후에 UI를 업데이트하는 addBarrierBlock을 사용합니다.
4. GCD vs OperationQueue: 언제 무엇을 사용할까?
일반적인 선택 기준:
- GCD: 간단한 비동기 작업, 특정 작업의 직렬 실행, 가볍고 빠른 처리가 필요할 때 (예: UI 업데이트를 위한 메인 큐 작업, 백그라운드 데이터 파싱).
- OperationQueue: 작업 간의 복잡한 의존성이 있거나, 작업 취소/일시 중지/재개가 필요한 경우, 또는 동시 실행 작업의 개수를 세밀하게 제어해야 할 때 (예: 이미지 다운로드 파이프라인, 비동기 데이터 처리 체인).
최근에는 Swift의 async/await가 도입되면서 대부분의 새로운 비동기 코드는 이를 활용하는 방향으로 가고 있지만, GCD와 OperationQueue는 여전히 중요한 기반 기술이며, 특정 요구사항을 충족시키거나 기존 코드를 이해하고 유지보수하는 데 필수적인 지식입니다.
정리하며
오늘은 Swift 비동기 프로그래밍의 핵심 도구인 Grand Central Dispatch (GCD)와 OperationQueue에 대해 자세히 알아보았습니다.
- GCD: 큐 기반의 저수준 동시성 프레임워크로, 직렬/동시 큐를 통해 작업을 효율적으로 관리합니다. 간단하고 빠른 비동기 처리에 강점이 있습니다.
- OperationQueue: GCD 위에 구축된 고수준 프레임워크로, Operation 객체를 통해 작업 간의 의존성, 취소, 동시성 개수 제한 등 더 정교한 제어 기능을 제공합니다.
두 기술 모두 Swift 앱의 반응성과 성능을 높이는 데 기여하지만, 각각의 특징과 장단점을 이해하고 프로젝트의 요구사항에 맞춰 적절히 선택하는 것이 중요합니다. 현대 Swift 개발에서는 async/await가 비동기 코드의 가독성을 크게 높였지만, GCD와 OperationQueue는 여전히 강력한 도구로 남아있습니다.
궁금한 점이 있다면 언제든지 댓글로 남겨주세요. 감사합니다.
0 comments:
댓글 쓰기