저번에 GCD와 async/await에 대해서 다룬 적이 있는데 그땐 개념만 설명했어서 이번에는 async/await를 사용하면서 어떤 부분들이 좋아졌는지 알아볼게요
GCD는 다음과 같이 사용한다했었죠?
func fetchData(completion: (String) -> Void) {
DispatchQueue.global().async {
let result = "data"
completion(result)
}
}
작업이 끝나면 “나 이거 끝났어!” 하고 알려줘야 하기 때문에 completion(콜백)을 넘겨줘야해요. 그런데 작업이 순서대로 필요할 때 다음과 같이 되겠죠?
fetch { data in
parse(data) { parsed in
save(parsed) {
DispatchQueue.main.async {
updateUI()
}
}
}
}
이처럼 GCD는 콜백지옥이 발생하기도 해요. 하지만 async/await를 사용하면서 그 부분이 해결되죠
let data = await fetch()
let parsed = await parse(data)
await save(parsed)
updateUI()
gcd에서는 에러 처리를 다음과 같이 할 수 있긴 해요.
fetch { result in
switch result {
case .success(let data):
...
case .failure(let error):
...
}
}
하지만 내부에 함수가 많아지면 어떻게 될까요?
fetch { result in
switch result {
case .success(let data):
parse(data) { result in
switch result {
case .success(let parsed):
save(parsed) { result in
switch result {
case .success:
print("done")
case .failure(let error):
print(error)
}
}
case .failure(let error):
print(error)
}
}
case .failure(let error):
print(error)
}
}
이처럼 각 함수마다 에러 처리를 해줘야해요. 반면에 async/await를 사용하면 다음처럼 에러 처리는 한 번만 하면 되서 편해졌죠
do {
let data = try await fetch()
let parsed = try await parse(data)
try await save(parsed)
print("done")
} catch {
print(error)
}
gcd에서 병렬 작업을 하려면 다음과 같이 사용해야 해요
func fetchAll(urls: [URL], completion: @escaping ([Data]) -> Void) {
var results: [Data] = []
let lock = NSLock()
let group = DispatchGroup()
for url in urls {
group.enter()
DispatchQueue.global().async {
let data = fetch(url)
lock.lock()
results.append(data)
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
completion(results)
}
}
gcd를 사용하면 여러 비동기 작업을 진행할 때 언제 작업이 완료됐는지 알 수 없기 때문에 DispatchGroup을 사용합니다. enter()로 작업 시작을 알리고 leave()로 끝났음을 알리죠. 또한 여러 스레드가 동시에 같은 배열에 append를 호출하면 데이터가 유실되거나 크래시가 나기 때문에 NSLock을 사용하여 공유 자원을 보호하고 notify를 사용해 모든 작업이 끝난 뒤 실행할 코드를 등록하죠
이처럼 gcd로 병렬 작업을 사용하려면 해야할 작업이 많죠? 반면에 async/await를 사용하면 다음과 같이 간단하게 작성할 수 있어요
// 개수 고정일 때
async let a = fetch(urlA)
async let b = fetch(urlB)
let (dataA, dataB) = try await (a, b)
// 개수 동적일 때
let results = try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls { group.addTask { try await fetch(url) } }
return try await group.reduce(into: []) { $0.append($1) }
}
gcd는 취소 구조가 없기 때문에 매 단계 수동으로 체크를 해야해요. 만약 작업이 많아진다면 체크하는 코드 또한 계속 작성해야겠죠?
var isCancelled = false
DispatchQueue.global().async {
guard !isCancelled else { return }
step1()
guard !isCancelled else { return } // 매 단계 수동 체크
step2()
}
반면 async/await에서는 Task.cancel()을 호출하기만 하면 취소가 전파되어 이를 확인한 작업들이 멈추게 되죠
// async/await — Task.cancel() 한 줄, 자동 전파
let task = Task {
for url in urls {
try Task.checkCancellation() // 취소 시 CancellationError throw
try await fetch(url) // URLSession도 자동 중단
}
}
task.cancel() // 내부 작업 전체에 자동 전파
GCD에서 비동기 작업이 네트워크 응답이나 파일 읽기처럼 결과를 기다려야 할 때, 해당 스레드는 결과가 올 때까지 아무것도 못 하고 대기하는 블로킹에 걸려요. 문제는 GCD가 블로킹된 스레드를 기다리지 않고 다른 작업을 처리하기 위해 새 스레드를 생성한다는 점인데 동시 작업이 많아질수록 스레드가 계속 쌓이고, 스레드 하나당 메모리(스택)를 점유하기 때문에 메모리 사용량이 늘어나요. 또한 스레드 간 전환(컨텍스트 스위칭) 비용도 함께 증가하게 되는거죠
DispatchQueue.global().async {
let data = try? Data(contentsOf: url) // 이 스레드는 완료될 때까지 블로킹
// 기다리는 동안 이 스레드는 다른 작업을 못 함
DispatchQueue.main.async {
self.update(data)
}
}
반면 async/await는 await 키워드를 만나면 현재 작업의 실행 상태를 힙 메모리에 저장하고 스레드를 반환하고 반환된 스레드는 다른 작업을 처리하러 가요. 결과가 준비되면 풀에 있는 스레드 중 하나가 저장해뒀던 상태를 불러와서 이어서 실행하기 때문에 스레드를 새로 만들 필요 없이 적은 수의 스레드로 많은 작업을 처리할 수 있죠.
Task {
// 스레드A가 여기서부터 실행
let user = try await fetchUser()
// await 만남 → 스레드A는 풀로 반환
// fetchUser 내부의 네트워크 I/O는 커널이 처리
// 결과 도착 → 풀에서 스레드B(또는 A)가 이어서 실행
let posts = try await fetchPosts(userId: user.id)
// 또 await 만남 → 스레드B 반환, 커널이 처리
// 결과 도착 → 풀에서 스레드 하나가 이어서 실행
return Profile(user, posts)
}
// Task 밖의 코드는 반환된 스레드(A) 또는 다른 스레드들이 처리할 수 있음
doSomething()
async/await는 await를 만나면 스레드가 블로킹 걸리지 않는다 했죠? 이건 async/await가 코루틴이기 때문이에요. 코루틴에 대해 알아보기 전에 루틴에 대해 간단하게 정리할게요
우선 루틴에 대해 알아볼게요. 루틴은 한 번 시작되면 종료될 때까지 멈추지 않고 실행되는 일반적인 함수를 의미하고 호출 → 실행 → 반환 이 일직선으로 진행되죠
func add(a: Int, b: Int) -> Int {
return a + b // 끝까지 실행 후 한 번만 반환
}
호출자 ──call──▶ add()
◀─return─ (완료 후 반환)
코루틴은 실행을 일시 중단(suspend)했다가 나중에 재개(resume)할 수 있는 가벼운 비동기 작업 단위로 다음과 같이 진행됩니다
호출자 ──call──▶ 코루틴
◀─yield── (중간에 일시정지 + 제어권 반환)
──resume▶ (다시 이어서 실행)
◀─yield── (또 일시정지)
──resume▶
◀─return─ (최종 완료)
핵심 차이로는 중간에 멈췄다가 나중에 이어서 실행할 수 있다는 점이에요
// 코루틴 — await에서 멈출 수 있음
func coroutineFunc() async -> String {
let a = "hello"
let b = await fetchFromServer() // ← 여기서 멈춤
return a + b // 나중에 여기서 재개
}
코루틴은 협력(Cooperative)과 루틴(Routine)의 합성어인데 이런 이름이 붙은 이유는 호출자와 코루틴이 실행을 서로 주고받기 때문이라네요. 어떻게 진행 되는지도 알아야 겠죠? 다음 코드를 예제로 볼게요
func loadProfile() async -> Profile {
let user = await fetchUser() // 중단점 1
let image = await fetchImage() // 중단점 2
return Profile(user, image)
}
Task { // await를 사용하는 클로저이기 때문에 코루틴 생성
await loadProfile() // 코루틴 시작
}
스레드가 코루틴 실행 중 첫 번째 중단점인 let user = await fetchUser()에 도달하면 코루틴은 다음과 같은 일을 해요
힙(Heap) 메모리:
┌─────────────────────────────┐
│ loadProfile 코루틴 상태 │
│ - 다음 실행 위치: 중단점 1 │
│ - user: (아직 없음) │
│ - image: (아직 없음) │
└─────────────────────────────┘스레드 입장에서는 다음과 같이 진행이 되죠
loadProfile 코루틴 실행
↓
await fetchUser() → 코루틴 중단
↓
스레드 반환 → 다른 작업 실행
await fetchUser() 함수가 완료되면 런타임은 코루틴을 큐에 등록하고 가용 스레드가 코루틴을 재개하죠. 이때 위에 힙에 저장한 상태를 가지고 복원한 후 중단점 1 이후부터 실행되죠
전체 흐름을 보면 다음과 같아요
Task 생성
│
▼
loadProfile 코루틴 시작 (스레드 점유)
│
▼
await fetchUser() → 중단 → 스레드 반환
│ │
│ [스레드: 다른 코루틴 처리]
│
▼ (fetchUser 완료)
재개 → user 값 받음 (스레드 재점유)
│
▼
await fetchImage() → 중단 → 스레드 반환
│ │
│ [스레드: 다른 코루틴 처리]
│
▼ (fetchImage 완료)
재개 → image 값 받음
│
▼
Profile(user, image) 반환 → 코루틴 완료
GCD에서 백그라운드 작업 후 UI를 업데이트하려면 직접 DispatchQueue.main.async를 호출해서 Main Thread로 전환해야 해요. 이 전환을 hop이라고 부르는데, 말 그대로 다른 스레드에서 Main Thread로 "건너뛴다"는 의미에요. 문제는 이걸 빠뜨려도 컴파일 에러가 나지 않고 런타임에서 크래시가 난다는 점이에요
DispatchQueue.global().async {
let data = heavyWork() // 백그라운드 스레드에서 실행
DispatchQueue.main.async { // Main Thread로 hop
self.label.text = data // UI 업데이트
}
// 이 hop을 빠뜨리면 백그라운드에서 UI 접근 → 런타임 크래시
}
async/await에서는 @MainActor를 붙이면 해당 함수나 클래스의 코드는 반드시 Main Thread에서 실행된다고 컴파일러가 보장하기 때문에 개발자가 직접 hop 코드를 작성할 필요가 없어져요. @MainActor를 사용해도 내부적으로는 여전히 스레드 전환이 일어나는데 그 hop을 런타임이 자동으로 처리해주기 때문이죠.
@MainActor
func updateUI() async {
let data = await heavyWork() // 백그라운드에서 실행 후 결과 반환
label.text = data // @MainActor가 Main Thread 보장
// DispatchQueue.main.async 없어도 됨
}