iOS
DispatchQueue.main.async에 대해 파헤쳐보자
최지석(Laon) / iOS개발팀여기어때
2024년 9월 24일
원문에서 보기 ↗
Copyright 2024. 여기어때컴퍼니 All Right Reserved. Graphic by 김제린(Riny).
안녕하세요, 여기어때컴퍼니 iOS 개발팀 라온입니다.
Swift로 iOS 앱 개발을 해보셨다면, 한 번쯤은 DispatchQueue를 사용해보신 적이 있으실 겁니다. 보통 백그라운드 스레드에서 무거운 작업이나 네트워크 요청을 처리한 뒤, DispatchQueue.main.async를 사용하여 메인 스레드에서 UI를 업데이트 하곤 하죠.
URLSession.shared.dataTask(with: url) { data, response, error in
// ...
if let data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.imageView.image = image
}
}
// ...
}.resume()
대부분 DispatchQueue.main.async 코드를 "메인 스레드에서 실행해줘"라는 의미로만 간단하게 이해하고 넘어가는 경우가 많습니다. 저 역시도 처음에는 그러했었습니다. 정확한 동작 원리를 모른 채 사용하다 보니, DispatchQueue에 전달한 작업이 언제 수행되는지, 그리고 첫 번째로 전달한 작업이 항상 먼저 수행되는지 등 여러 궁금증이 생겨났습니다. 저는 이러한 궁금증을 해결하기 위해 DispatchQueue에 많은 자료들을 찾아보며 공부해보았습니다.
이번 블로그 글을 통해서는 제가 GCD(Grand Central Dispatch)와 DispatchQueue를 공부하면서 알게 된 내용들을 최대한 쉽게 설명해드리고자 합니다.
1. GCD
먼저, GCD의 기본적인 개념부터 차근차근 알아보겠습니다. GCD 는 macOS, iOS, watchOS, tvOS와 같은 애플 운영체제에서 멀티코어 프로세서를 활용해 동시성 코드 실행을 지원하는 프레임워크입니다. GCD는 시스템 자원을 효율적으로 사용하면서 작업(Task)들을 처리하기 위해 DispatchQueue를 활용합니다. 여기서 말하는 작업은 하나의 코드 블록(Closure)이나 DispatchWorkItem을 의미합니다.
GCD는 내부적으로 스레드풀을 관리하며, 필요에 따라 새로운 스레드를 생성하거나 기존 스레드를 재사용하여 작업을 효율적으로 처리합니다. 이 덕분에 프로그래머는 직접 스레드를 관리하지 않고도 백그라운드 스레드에서 비동기 작업을 처리하거나, 메인 스레드에서 UI 작업을 안전하게 수행할 수 있습니다.
2. DispatchQueue
DispatchQueue는 일종의 작업 큐로, 대기열에 추가된 작업들을 GCD가 적절한 스레드에 할당하여 처리할 수 있게 합니다. DispatchQueue는 큐의 속성에 따라 Serial Queue와 Concurrent Queue로 구분할 수 있습니다.
1) Serial Queue
Serial Queue는 큐에 추가된 작업을 한 번에 하나씩 순차적으로 처리 합니다. 이 경우, 이전 작업이 완료된 후에야 다음 작업을 시작할 수 있습니다. 예를 들어, 3개의 작업을 Serial Queue인 메인 큐(Main Queue)에 추가하면, 이 작업들은 메인 스레드에서 순차적으로 실행됩니다.
for i in 1...3 {
DispatchQueue.main.async {
print("Serial-Queue Task [\(i)] - Thread: \(Thread.current)")
}
}

하지만, Serial Queue는 모든 작업이 한 번에 하나씩 처리된다는 것만 보장할 뿐, 모든 작업이 동일한 스레드에서 실행된다는 것을 보장하지는 않습니다. (큐에 추가된 모든 작업이 하나의 스레드에서만 실행되는 걸 보장하는 Serial Queue는 오직 메인 큐 하나 밖에 없습니다.)
아래 코드에서는 Serial Queue의 속성을 가진 커스텀 큐를 생성하여 두 개의 작업을 해당 큐에 추가했지만, 두 작업이 서로 다른 스레드에서 처리되는 걸 확인하실 수 있습니다.
let serialQueue = DispatchQueue(label: "(Serial) Custom Queue")
// Serial Queue에 작업을 async 방식으로 추가 (1)
serialQueue.async {
print("[SerialQueue] First Async Task - Thread: \(Thread.current)")
}
DispatchQueue.main.async {
// Serial Queue에 작업을 async 방식으로 추가 (2)
serialQueue.async {
print("[SerialQueue] Second Async Task - Thread: \(Thread.current)")
}
}

Serial Queue가 어떤 스레드를 사용하여 작업을 처리할 것인지는 시스템 환경에 따라 GCD가 결정합니다.
2) Concurrent Queue
Concurrent Queue는 큐에 추가된 작업들을 여러 스레드에서 동시에 처리 합니다. (시스템 환경에 따라 달라질 수 있지만, 대부분 여러 스레드에서 Concurrent하게 작업을 처리합니다.) 따라서, 이전 작업이 완료되지 않아도 다음 작업이 시작될 수 있습니다. 예를 들어, 아래와 같이 3개의 작업을 Concurrent Queue인 글로벌 큐(Global Queue)에 추가하면, 해당 작업들은 여러 스레드에 할당되어 동시에 처리됩니다.
for i in 1...3 {
DispatchQueue.global().async {
print("Concurrent-Queue Task [\(i)] - Thread: \(Thread.current)")
}
}

DispatchQueue는 Main Queue, Global Queue, 그리고 Custom Queue의 세 가지 유형으로 나뉩니다.
(1) Main Queue
시스템에서 제공하는 전역 큐로, Serial Queue 의 속성을 가집니다. 메인 큐에 전달된 작업은 반드시 메인 스레드에서 처리됩니다. UI 업데이트와 같은 작업들은 메인 큐에 추가하여 메인 스레드에서 안전하게 수행할 수 있습니다.
// 1. Main Queue
DispatchQueue.main.async { }

(2) Global Queue
시스템에서 제공하는 전역 큐로, Concurrent Queue 의 속성을 가집니다. 글로벌 큐는 QoS(Quality of Service)에 따라 여러 종류로 나뉘며, 각 큐는 백그라운드 스레드에 작업을 할당해 처리합니다.
- QoS는 작업의 우선순위를 결정하며, GCD는 QoS가 높은 작업에 더 많은 리소스를 할당해 먼저 처리될 수 있도록 합니다.
// 2. Global Queue
DispatchQueue.global().async { } // .default
DispatchQueue.global(qos: .background).async { }

QoS는 우선순위에 따라 아래의 6가지로 구분됩니다.

(3) Custom(Private) Queue
DispatchQueue(label:)과 같이 작성하여 사용자가 직접 생성하는 큐 입니다. 기본적으로 Serial Queue 의 속성을 가지지만, attributes 파라미터를 통해 Concurrent Queue의 속성을 갖도록 만들 수도 있습니다. 글로벌 큐와 마찬가지로, 커스텀 큐에도 QoS를 설정할 수 있습니다.
// 3. Custom Queue (Serial / Concurrent)
DispatchQueue(label: "Custom Queue (Serial)").async { }
DispatchQueue(label: "Custom Queue (Concurrent)", qos: .background, attributes: .concurrent).async { }

(그림에서는 큐에 추가된 모든 작업이 하나의 스레드에서 처리되는 것처럼 묘사되었지만, 실제로는 다른 스레드에서 처리될 수도 있습니다.)

3. sync와 async
DispatchQueue에 작업을 전달할 때는 sync와 async 두 가지 방식 중 하나를 선택할 수 있습니다. 이 두 방식의 차이는 DispatchQueue에 작업을 전달한 이후, 작업을 전달한 스레드가 어떻게 동작하는지에 있습니다.
1) sync 방식
DispatchQueue에 작업을 sync 방식으로 전달하는 경우, 해당 작업이 완료될 때까지 작업을 전달한 스레드는 코드 실행을 멈추고(Block) 기다립니다. 이는 해당 작업이 끝나기 전까지 해당 작업을 전달하는 코드 이후의 라인들이 실행되지 않는다는 의미입니다. DispatchQueue에 전달한 작업이 완료되어 반환되면, 스레드는 Block 상태에서 빠져나와 이후의 라인들을 계속해서 실행합니다.
2) async 방식
DispatchQueue에 작업을 async 방식으로 전달하는 경우, 작업을 전달한 스레드는 멈추지 않고 코드 실행을 계속 이어나갑니다. 이는 스레드가 작업을 큐에 전달한 다음, 해당 작업이 완료되기를 기다리지 않고, 즉시 다음 라인들을 실행한다는 의미입니다. async 방식은 스레드의 실행 흐름을 차단하지 않으며, 전달한 작업이 처리되는 동안 해당 스레드는 계속해서 다른 작업을 수행할 수 있습니다.
아래의 예제 코드를 통해 sync와 async 방식이 구체적으로 어떻게 동작하는지 살펴보겠습니다.
func start() {
print("Current Thread: \(Thread.current)")
let queue = DispatchQueue(label: "Custom Queue (Serial)")
for i in 1...3 {
queue.async {
print("Async Task [\(i)] - Thread: \(Thread.current)")
}
}
print("Continue execution after dispatching async tasks.")
queue.sync {
print("Sync Task - Thread: \(Thread.current)")
}
print("Continue execution after dispatching sync tasks.")
}
우선, 메인 스레드에서 start() 메서드가 호출된다고 가정해 보겠습니다. 해당 메서드가 메인 스레드에서 호출되기 때문에, 현재 스레드를 출력해보면 메인 스레드가 출력됩니다.

이후, Serial Queue의 속성을 갖는 커스텀 큐를 생성하고, 메인 스레드에서 해당 큐에 세 개의 작업을 async 방식으로 전달합니다. DispatchQueue를 생성하면, GCD 내부적으로 스레드 풀에서 큐에 추가된 작업들을 수행할 스레드(예: Thread 6)를 하나 선택하여 작업들을 처리합니다.

작업들을 async 방식으로 큐에 전달했기 때문에, 메인 스레드는 코드 실행을 멈추지 않고 계속해서 다음 라인을 실행합니다.

(메인 스레드에서 이후의 코드를 실행할 때, 커스텀 큐에 추가된 작업들이 Thread 6에서 동시에 처리될 수도 있습니다.)
다음으로, 메인 스레드에서 커스텀 큐에 하나의 작업을 sync 방식으로 전달합니다. 작업을 sync 방식으로 큐에 전달했기 때문에, 해당 작업을 전달한 메인 스레드는 해당 작업이 수행되어 끝날 때까지 코드 실행을 멈추고(Block) 기다립니다.

(실제 어플리케이션에서는 메인 스레드를 Block하는 것을 지양해야 합니다.)
GCD에 의해 선택된 Thread 6는 DispatchQueue에 추가된 작업들을 순차적으로 처리합니다. 아직 DispatchQueue에 sync로 추가된 작업이 처리되지 않았기 때문에, 메인 스레드는 기존 코드 진행을 멈추고 기다리고 있는 상태입니다.

큐에 sync로 추가된 작업이 처리될 때, 해당 작업은 Thread 6가 아니라, Caller 스레드인 메인 스레드에서 실행됩니다.
sync방식으로 전달한 작업이 항상 Caller 스레드에서 처리되는 것은 아닙니다. 예를 들어, 메인 큐에sync방식으로 추가된 작업의 경우, Caller 스레드에서 처리되지 않고 메인 스레드에서 처리됩니다.

해당 작업이 완료되어 반환되면, 메인 스레드가 Block 상태에서 빠져나와 기존에 진행하던 코드 실행을 계속 이어나갑니다.
- 큐에 추가된 작업을 처리하기 위해 선택된
Thread 6는 GCD가 적절한 시점에 해제(Release)시켜 줍니다.

메서드 내의 모든 코드가 실행되었으므로, start() 메서드는 반환됩니다.
4. 추가 예제
아직까지 감이 잘 잡히지 않는 분들을 위해 추가로 몇 가지 예제 코드들을 더 준비해 보았습니다.
그럼 예제 코드를 함께 살펴보겠습니다.
예제 1 )
print("Start - Thread: \(Thread.current)")
DispatchQueue.global().async {
for i in 1...5 {
print("Async Task 1 (progress : \(i)/5) - Thread: \(Thread.current)")
}
}
DispatchQueue.global().async {
for j in 1...5 {
print("Async Task 2 (progress : \(j)/5) - Thread: \(Thread.current)")
}
}
print("End - Thread: \(Thread.current) ")

- 먼저, 첫 번째 라인의
print문을 통해 현재 스레드를 출력합니다. (현재 스레드는Thread 6라고 가정합니다.) - 그 다음, Concurrent Queue의 속성을 가진 글로벌 큐에
async방식으로 작업을 하나 추가합니다. - 작업이 추가된 즉시 반환되며, 이후의 코드가 이어서 실행됩니다. (GCD는 스레드 풀에서 적절한 스레드(예:
Thread 4)를 선택하여 해당 작업을 처리합니다.) - 다시 글로벌 큐에
async방식으로 또 다른 작업을 추가하고, 마찬가지로 즉시 반환되어 다음 코드가 이어서 실행됩니다. (GCD가 다시 적절한 스레드(예:Thread 5)를 하나 선택하여 해당 작업을 처리합니다.) - 마지막으로,
print문을 통해 현재 스레드(Thread 6)를 출력합니다. (상황에 따라 글로벌 큐에서 백그라운드 스레드에 할당된 작업들이 먼저 처리되어, 마지막print문이 나중에 출력될 수도 있습니다.) - 글로벌 큐에 추가된 작업들은 백그라운드 스레드에 할당되어 동시에 처리됩니다.
예제 2 )
위의 예제에서 두 번째로 글로벌 큐에 작업을 전달할 때, async가 아닌 sync 방식으로 작업을 전달하도록 바꿔보겠습니다.
print("Start - Thread: \(Thread.current)")
DispatchQueue.global().async {
for i in 1...5 {
print("Async Task 1 (progress : \(i)/5) - Thread: \(Thread.current)")
}
}
DispatchQueue.global().sync {
for j in 1...5 {
print("Sync Task 2 (progress : \(j)/5) - Thread: \(Thread.current)")
}
}
print("End - Thread: \(Thread.current) ")

- 먼저, 첫 번째
print문을 통해 현재 스레드를 출력합니다. (현재 스레드는Thread 3라고 가정합니다.) - 그 다음, Concurrent Queue의 속성을 가진 글로벌 큐에
async방식으로 작업을 하나 추가합니다. - 작업이 추가된 즉시 반환되며, 이후의 코드가 이어서 실행됩니다. (GCD는 스레드 풀에서 적절한 스레드(예:
Thread 6)를 선택하여 해당 작업을 처리합니다.) - 이번엔 글로벌 큐에
sync방식으로 또 다른 작업을 전달합니다. 이 경우, 작업을 전달한Thread 3는 코드 실행을 멈추고(Block), 해당 작업이 큐에서 처리될 때까지 기다리게 됩니다. - 큐에 추가된
sync작업이 처리되어야 할 때, 컨트롤이 Caller 스레드인Thread 3로 넘어오게 되고,sync로 전달된 작업은Thread 3에서 수행됩니다. - 해당 작업이 끝나면,
Thread 3는 Block 상태에서 빠져나오게 됩니다. - 마지막으로,
print문을 통해 현재 스레드(Thread 3)를 출력합니다. (Thread 6에서 처리되는 작업은 먼저 끝날 수도, 나중에 끝날 수도 있습니다.)
예제 3 )
다음은 비교적 간단해 보이는 코드입니다.
let serialQueue = DispatchQueue(label: "(Serial) Custom Queue")
print("Start - Thread: \(Thread.current)")
serialQueue.async {
print("Async Task - Thread: \(Thread.current)")
serialQueue.sync {
print("Sync Task - Thread: \(Thread.current)")
}
}
Thread.sleep(forTimeInterval: 1)
- 먼저 Serial Queue의 속성을 갖는 커스텀 큐를 생성한 다음, 현재 스레드를 출력합니다. (현재 스레드는
Thread 3라고 가정합니다.) - 그 다음,
DispatchQueue에async방식으로 작업을 하나 추가합니다. - 작업이 추가된 즉시 반환되며, 이후
Thread.sleep(forTimeInterval: 1)코드를 실행하여Thread 3의 코드 실행이 일시 중단됩니다. (GCD는 스레드 풀에서 적절한 스레드(예:Thread 7)를 선택하여 해당 작업을 처리합니다.) - 그 사이에 큐에 추가된 작업은 GCD에 의해 선택된 스레드(예:
Thread 7)에 의해 실행됩니다. - 클로저의 첫 번째 라인에서
Async Task - ~문자열을 출력합니다. - 그 다음,
DispatchQueue에sync로 작업을 전달합니다.
흠.. 그런데 이 시점에 런타임 에러가 발생하네요.

위와 같은 에러가 발생한 이유는 무엇일까요? Serial Queue의 속성을 생각해보면 어렵지 않게 이해하실 수 있을 겁니다.

앞서 말씀드린 것처럼, Serial Queue는 한 번에 하나의 작업만 순차적으로 처리할 수 있습니다. 따라서, 이전 작업이 완료되고 반환되기 전까지, 다음 작업은 실행되지 않습니다. 그렇다면 Serial Queue에서 작업을 수행 중인 스레드가 동일한 큐에 sync 방식으로 작업을 전달하면 어떻게 될까요?
위의 그림에서 Async Task1 작업을 수행 중인 Thread A가 동일한 Serial Queue에 sync 방식으로 작업을 전달한다고 가정해 봅시다. sync 방식으로 작업을 전달했기 때문에, Thread A는 해당 작업이 완료되어 반환될 때까지 코드 실행을 멈추고 Block 상태가 됩니다.

Thread A가 Block 상태가 되었기 때문에, Thread A가 처리하던 Async Task1도 더 이상 진행되지 않게 됩니다. 이전 작업이 끝나야 다음 작업을 시작할 수 있는 Serial Queue의 특성상, Async Task1이 끝나지 않았기 때문에 Sync Task도 시작될 수가 없겠네요. Async Task1은 Sync Task가 끝나길 기다리고, Sync Task도 마찬가지로 Async Task1(그리고 이전에 추가된 다른 작업들)이 끝나기를 기다리고 있는 상황이기 때문에, '데드락(DeadLock)'이 발생하게 됩니다.
이와 같이 Serial Queue의 작업을 수행하고 있는 스레드에서 동일한 Serial Queue에 sync 방식으로 작업을 전달하면 데드락이 발생하게 됩니다. 메인 스레드에서 메인 큐에 sync 방식으로 작업을 전달했을 때(DispatchQueue.main.sync), 데드락이 생길 수 있는 이유도 메인 큐가 Serial Queue의 속성을 갖기 때문입니다.
지금까지 GCD가 무엇인지, DispatchQueue의 속성 및 종류, 그리고 작업을 sync와 async 방식으로 전달하는 방법에 대해 알아보았습니다.
이제 DispatchQueue.main.async 코드의 의미도 이해하실 수 있을 겁니다.
“메인 큐에 작업을 async 방식으로 전달하고, 현재 스레드는 다음 코드를 계속 실행하라”
글이 생각보다 길어졌네요. 예제도 더 추가하고 싶고 QoS, DispatchWorkItem, DispatchGroup, DispatchSemaphore 등 더 소개하고 싶은 내용이 아직 남아있지만, 이번 글은 여기서 마무리하려고 합니다. 여러분께서 관심을 보여주신다면, 다음 글에서 더 심화된 주제로 찾아뵙겠습니다.
긴 글 읽어주셔서 감사합니다.