iOS 앱개발에서 UI는 메인쓰레드에서 그린다. 그런데 아주 오랜 시간이 걸리는 코드 예를들어 네트워크 호출과 같은 코드를 메인쓰레드에서 실행하게되면 UI를 그리는 메인쓰레드는 그 일을 담당하게되면서 UI 그리기를 중단한다. 이는 사용자에게 앱이 버벅거리거나 끊기는듯한 경험을 제공하게 된다.
이러한일을 방지하기위해 오랜시간이 걸리는 코드를 다른 쓰레드에서 실행시키고 결과만을 받아와 UI에 업데이트시키는 방식을 많이 사용한다. 이러한 방식을 우리는 동시성 프로그래밍이라 부른다.
Swift에서의 동시성 프로그래밍에는 2가지 방법이 있다.
기존에 사용하던 Grand Cetnral Dispatch (GCD)와 2021년 발표된 Swift Concurrency가 있다.
여기서 왜 애플은 기존에 사용하던 GCD를 대체하기위해 새로운 API를 만들었을까?
동시성 프로그래밍의 가독성과 사용성을 크게 개선하기 위해서이다. GCD는 강력하고 유연한 도구이지만, 비동기 코드가 복잡해질수록 중첩된 클로저와 콜백으로 인해 코드가 난잡해지기 쉽고, 디버깅과 유지보수가 어려워진다.
그리고 기존에 Swift 언어가 추구하던 안정성 높은 언어라는 철학에 맞게 타입안전성과 에러처리를 강화하고 더 복잡한 동시성 프로그래밍을 위해 커스텀하지 않고 기본적으로 제공하고자 새로운 API를 만든것이다.
GCD는 필요에 따라 동적으로 스레드를 생성하고 관리한다. 이를 Thread Pool 관리라고 하며, 이 방식을 통해 비동기적으로 작업이 큐에 추가되면 GCD는 Thread Pool에서 유휴 상태의 스레드를 할당하여 해당 작업을 실행한다. 새로운 비동기 작업이 생기면 다른 유휴 스레드를 사용하여 처리하며, 스레드가 부족해지면 필요에 따라 새 스레드를 생성하여 작업을 할당한다.
즉, GCD는 작업이 완료될 때까지 스레드가 그 작업을 수행하도록 하며, 시스템은 동시에 여러 스레드를 사용하여 병렬 처리를 수행하는 메커니즘이다.
반면 Concurrency는 비동기 작업의 흐름을 관리하면서 스레드를 효율적으로 반환할 수 있도록 설계된 방식이다. 비동기 작업중 await 키워드를 통해 작업의 결과를 기다리게 되는데, 이때 스레드풀로 반환해 다른 작업에 사용될수 있게 한다.
즉, await 구문을 만나면 해당 스레드는 풀로 반환되어 다른 작업에 재사용될 수 있으며, 이를 통해 Swift Concurrency는 GCD에 비해 스레드를 효율적으로 재활용하고, 불필요한 스레드 생성을 줄여 리소스를 최적화하는 메커니즘을 제공한다.
GCD
하나의 스레드에 작업이 할당되면 그 작업이 완료되기 전까진 그 작업만 담당.
Concurrency
하나의 스레드에 작업이 할당된 상태에서 await 키워드를 만나면 작업의 결과를 기다리는동안 다른 작업에 스레드를 사용할수 있게 함.
// 메인 스레드 코드 시작
DispatchQueue.global().async {
print("작업 시작") // 몇번 스레드일지 모르지만 예를들어 3번 스레드가 작업 시작
let data = try Data(contentsOf: url) // 10초 걸린다고 가정
print("작업 완료") // 작업 완료후 이어가는거 여전히 3번 스레드
}
// 메인 스레드 코드 시작
Task { // 몇번 스레드일지 모르지만 예를들어 3번 스레드가 작업 시작
let data = await fetchData() // await을 만나서 작업이 중단(suspend)되면 3번 스레드를 반납
print("완료") // 재개될 때는 같거나 다른 스레드에서 계속 실행될 수 있다
}
기존 GCD에서의 중첩된 클로져 코드는 코드를 이해하기 어렵게 만든다.
DispatchQueue.global().async {
// 작업 1
DispatchQueue.main.async {
// UI 업데이트
DispatchQueue.global().async {
// 작업 2
DispatchQueue.main.async {
// 최종 UI 업데이트
}
}
}
}
이러한 문제를 Concurrency에서는 해결했다.
Task {
await doWork1()
await MainActor.run { updateUI() }
await doWork2()
await MainActor.run { finalUpdateUI() }
}
GCD 에서의 에러처리는 Completion 핸들러를 통해 전달했는데 이 역시
클로져를 사용해야하기때문에 가독성이 좋지 않았다.
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
DispatchQueue.global().async {
do {
let data = try performNetworkRequest()
completion(.success(data))
} catch {
completion(.failure(error))
}
}
}
Concurrency에서는 직접 에러를 throw 할수 있어 직관적이게 에러처리를 할 수 있다.
func fetchData() async throws -> Data {
return try await performNetworkRequest()
}
do {
let data = try await fetchData()
// 데이터 처리
} catch {
// 에러 처리
}
GCD에서의 작업 취소는 기본적으로 제공되지않아, 코드를 커스텀해야 작업취소를 구현 할 수있다.
하지만 Concurrency에서는 이러한 작업취소를 기본 제공한다.
let task = Task {
do {
let data = try await fetchData()
// 데이터 처리
} catch is CancellationError {
// 취소 처리
} catch {
// 기타 에러 처리
}
}
task.cancel()
GCD는 쓰레드 안전한 코드를 작성하기 위해 직접 수동으로 메커니즘을 구현해야 한다.
class Counter {
private var count = 0
private let queue = DispatchQueue(label: "com.example.counter")
func increment() {
queue.async {
self.count += 1
}
}
func getCount() -> Int {
return queue.sync { self.count }
}
}
하지만 이러한 코드는 Data Race를 컴파일 타임에 감지할수 없다.
Data Race
두 개 이상의 스레드가 동시에 동일한 메모리 위치에 접근하여, 하나 이상의 스레드가 그 데이터를 수정하려고 할 때 발생하는 문제.
Concurrency에서는 Sendable, Actor라는걸 이용해 컴파일 타임에서부터 쓰레드 안전한 코드를 작성할 수 있다.
actor BankAccount {
private var balance = 0
func deposit(_ amount: Int) {
balance += amount
}
func getBalance() -> Int {
return balance
}
}
작업양도란 실행중인 작업이 자발적으로 실행권을 포기하고 다른 대기중인 작업에 실행권을 넘기는것을 의미한다.
예를들어 10분동안 작업해야되는 일을 진행중이다 1분에 한번씩 다른작업을 실행할 기회를 주고싶을때
GCD의경우 제어하기가 어렵다.
Concurrency에서는 yield() 메서드를 통해 손쉽게 작업을 양도할 수 있다.
func processLargeDataSet(_ data: [Int]) async {
for (index, item) in data.enumerated() {
// 데이터 처리
process(item)
if index % 100 == 0 {
// 100개마다 한번씩 작업을 양도한다.
await Task.yield()
}
}
}
여러 동시작업이 수행중일때 이러한 작업들이 모두 끝났을때 어떠한 작업을 하고싶다면 이런 처리를
GCD에서는 조금 복잡하게 수행할수 있었다.
let group = DispatchGroup()
group.enter()
task1 {
group.leave()
}
group.enter()
task2 {
group.leave()
}
group.notify(queue: .main) {
print("모든 작업 완료")
}
Concurrency에서는 async let과 taskGroup을 이용해 더 쉽고 안전하게 동시성 제어를 할수 있다.
// async let
Task {
async let result1 = task1()
async let result2 = task2()
let (value1, value2) = await (result1, result2)
print("모든 작업 완료")
}
// TaskGroup
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { await task1() }
group.addTask { await task2() }
}
print("모든 작업 완료")