Swift Concurrency가 iOS 15에서 정식 지원되면서, async/await 기반의 코드가 GCD와 함께 한 프로젝트에 공존하는 경우가 많아졌습니다. 이 시점에서 Thread, DispatchQueue, Task 세 개념의 역할을 정확히 이해해두면, 코드를 설계하고 읽는 방식이 달라집니다.
세 개념은 모두 "동시성"과 관련이 있지만, 각각이 작동하는 추상화 레벨이 다릅니다. 스레드는 시스템 레벨의 물리적 실행 단위, DispatchQueue는 GCD가 제공하는 작업 배분 도구, Task는 Swift Concurrency 런타임이 관리하는 논리적 작업 단위입니다. 이 글에서는 세 개념이 각각 무엇을 담당하는지, 그리고 어떻게 연결되는지를 정리합니다.
스레드(Thread)는 CPU가 코드를 실행하는 물리적 작업자입니다. iOS 앱이 실행되면 시스템은 기본적으로 두 종류의 스레드를 제공합니다.
메인 스레드(Main Thread)는 앱당 하나만 존재합니다. UIKit과 SwiftUI의 UI 업데이트는 반드시 메인 스레드에서 이루어져야 하며, 메인 스레드가 장시간 블로킹되면 시스템이 앱을 강제 종료합니다.
백그라운드 스레드(Background Thread)는 필요에 따라 여러 개 생성됩니다. 생성 시 기본으로 512KB의 스택 메모리를 할당받습니다. 스레드를 대량으로 생성하면 메모리 사용량이 증가하고, CPU가 스레드 간 실행 순서를 전환하는 컨텍스트 스위칭 비용도 함께 증가합니다.
Thread 클래스를 이용해 스레드를 직접 생성하고 관리하는 방식은 iOS 개발에서 거의 사용하지 않습니다. 직접 관리할수록 스케줄링 최적화가 어렵고 실수하기 쉽기 때문에, 실제로는 GCD나 Swift Concurrency 같은 상위 추상화를 사용합니다.
DispatchQueue는 GCD(Grand Central Dispatch)의 핵심 API로, 개발자가 스레드를 직접 다루는 대신 작업을 대기열에 넣으면 시스템이 스레드를 배분해주는 구조입니다.
큐는 두 가지 유형이 있습니다.
Serial Queue(직렬 큐)는 작업을 순서대로 하나씩 실행합니다. 이전 작업이 완료될 때까지 다음 작업이 시작되지 않으므로 실행 순서가 보장됩니다.
let serialQueue = DispatchQueue(label: "com.example.serial")
serialQueue.async { print("작업 1") }
serialQueue.async { print("작업 2") } // 반드시 작업 1 이후 실행
Concurrent Queue(동시 큐)는 여러 작업을 동시에 실행합니다. 작업 간 완료 순서는 보장되지 않습니다.
let concurrentQueue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
concurrentQueue.async { print("작업 A") }
concurrentQueue.async { print("작업 B") } // 완료 순서 보장 없음
여기서 중요한 점은, 큐와 스레드는 1:1 관계가 아니라는 것입니다. GCD는 내부적으로 스레드 풀을 관리하고, 큐에 작업이 쌓이면 풀에서 적절한 스레드를 배분합니다. Serial Queue라도 매번 같은 스레드에서 실행된다는 보장이 없으며, 두 개의 Serial Queue가 같은 스레드를 공유하기도 합니다.
GCD를 사용할 때 주의해야 할 문제 중 하나는 Thread Explosion입니다. 여러 큐에 블로킹 작업(동기 I/O, sleep, semaphore.wait() 등)을 대량으로 dispatch하면, GCD는 블로킹된 스레드를 대신하기 위해 새 스레드를 계속 생성합니다. 한 번에 수십 개의 스레드가 생성되면 메모리와 컨텍스트 스위칭 비용이 급증합니다.
Task는 Swift Concurrency에서 도입된 개념으로, async/await 코드를 실행하는 논리적 작업 단위입니다. 스레드나 큐와는 다른 레이어에서 작동하며, GCD와의 핵심 차이는 스레드를 블로킹하지 않는다는 점입니다.
await 키워드를 만나면 Task는 suspend됩니다. 이 순간, Task는 실행 중이던 스레드를 런타임에 반납합니다. 반납된 스레드는 즉시 다른 Task를 처리하는 데 사용됩니다. 대기 중이던 작업(네트워크 응답, 파일 읽기 등)이 완료되면, 런타임은 Task를 resume 상태로 만들고 가용 스레드에 다시 스케줄링합니다.
func fetchUserData(id: String) async throws -> Data {
print("① 시작: \(Thread.current)")
// await 지점에서 Task suspend — 스레드를 반납하고 대기
let (data, _) = try await URLSession.shared.data(from: URL(string: "https://api.example.com/user/\(id)")!)
// 응답 후 resume — 시작과 다른 스레드일 수 있음
print("② 재개: \(Thread.current)")
return data
}
실행하면 ①과 ② 출력에서 Thread.current가 다른 경우가 있습니다. WWDC21 "Swift concurrency: Behind the scenes" 세션은 이 동작에 대해 명확하게 설명합니다.
Swift makes no guarantee that the thread which executed the code before the await is the same thread which will pick up the continuation.
— WWDC21 Session 10254
GCD의 DispatchQueue.global().async는 작업이 내부에서 블로킹 I/O를 수행해도 스레드를 붙잡고 대기합니다. 반면 async/await는 await 지점에서 스레드를 반납하므로, 대기 시간 동안 그 스레드가 다른 Task를 실행할 수 있습니다.
WWDC21 "Meet async/await in Swift" 세션은 이 차이를 이렇게 표현합니다.
When an async function suspends, it gives control of the thread to the system, asking the system to schedule the work.
— WWDC21 Session 10132
GCD의 Thread Explosion 문제를 Swift Concurrency는 Cooperative Thread Pool로 해결합니다.
Swift 런타임은 기본적으로 CPU 코어 수에 비례하는 고정된 스레드 풀을 유지합니다.
The new thread pool will only spawn as many threads as there are CPU cores, thereby making sure not to overcommit the system.
— WWDC21 Session 10254
스레드를 코어 수만큼만 유지할 수 있는 이유는 Task의 suspend/resume 덕분입니다. GCD처럼 스레드가 블로킹되지 않으므로, 각 스레드는 한 Task가 await로 대기하는 동안 즉시 다른 Task를 처리합니다. 스레드가 쉬지 않고 일하기 때문에, 추가 스레드를 생성할 필요가 없습니다.
이를 통해 컨텍스트 스위칭 비용이 줄어들고, 512KB 단위의 스택 메모리 오버헤드도 최소화됩니다. await가 있으면 오히려 비효율적인 것 아니냐는 의문이 생길 수 있는데, 실제로는 await 덕분에 더 효율적으로 동작하는 구조입니다.
| 개념 | 추상화 레벨 | 역할 | 스레드 관계 |
|---|---|---|---|
| Thread | 시스템 | CPU가 코드를 실행하는 물리적 단위 | - |
| DispatchQueue | GCD | 작업을 받아 스레드 풀에 배분하는 대기열 | 내부적으로 스레드 풀 사용, 1:1 아님 |
| Task | Swift Concurrency | async/await 코드를 실행하는 논리적 단위 | suspend/resume으로 스레드를 공유, 비블로킹 |
세 개념을 한 문장으로 정리하면, 스레드는 실행자, DispatchQueue는 작업 분배기, Task는 중단·재개 가능한 작업 레코드입니다.
Swift Concurrency로 전환할 때 DispatchQueue.global().async 패턴을 Task { ... }로 단순 교체하는 것만으로는 충분하지 않습니다. Task 내부에 블로킹 코드(동기 I/O, Thread.sleep(), semaphore.wait() 등)가 남아 있으면 Cooperative Thread Pool의 스레드를 그대로 블로킹하게 됩니다. 블로킹이 불가피한 경우에는 withCheckedContinuation이나 DispatchQueue를 통해 별도 스레드로 격리하는 것이 올바른 접근입니다.
글에 대한 피드백이나 더 좋은 방법이 있다면 댓글로 공유해주세요.