어제는 async/await을 이용해 비동기 코드를 깔끔하게 작성하는 법을 배웠습니다. 하지만 서로 의존성이 없는 여러 작업을 처리할 때에는 이전의 작업이 끝날 때 까지 기다려야한다는 단점이 있습니다. 이럴 때 필요한 것이 바로 Structured Concurrency입니다. 본격적으로 알아보기 전에, 동시성 작업의 기본 단위인 Task에 대해 먼저 알아보도록 하겠습니다.
Task: 동시성 작업의 기본 단위
Task는 비동기적으로 실행될 수 있는 작업의 단위를 나타냅니다. 동기적인 코드(예: SwiftUI 버튼의 action 클로저, UIKit의 viewDidLoad)속에서 비동기 코드를 실행하고 싶을 때 Task를 사용하여 비동기 코드를 실행시킬 수 있습니다. await 키워드를 사용하려면 반드시 Task와 같은 비동기 문맥(context) 안에 있어야하기 때문입니다.
// 동기적인 함수 buttonTapped()
func buttonTapped() {
// Task를 사용해 비동기 코드를 실행할 수 있는 환경을 시작합니다.
Task {
print("비동기 작업 시작!")
let data = try await fetchData()
print("데이터 로딩 완료: \(data)")
}
print("buttonTapped 함수는 Task의 작업을 기다리지 않고 바로 종료됩니다.")
}
Task는 이렇게 Task { ... } 클로저 형태로 생성하며, 생성되는 즉시 시스템이 알아서 적절한 스레드에서 실행을 시작합니다. Task는 독립적인 작업 단위이므로 작업을 취소하거나 우선순위를 설정하는 등 더 세밀한 제어도 가능합니다.
순차적으로 실행되는 await의 문제점
만약 Task 안에서 await을 순서대로 호출하면, 작업은 병렬이 아닌 순차적으로 실행됩니다.
Task {
print("다운로드 시작")
// 첫 번째 이미지 다운로드가 끝날 때까지 기다림
let image1 = try await fetchImage(from: url1)
print("이미지 1 다운로드 완료")
// 두 번째 이미지 다운로드가 끝날 때까지 기다림
let image2 = try await fetchImage(from: url2)
print("이미지 2 다운로드 완료")
// 세 번째 이미지 다운로드가 끝날 때까지 기다림
let image3 = try await fetchImage(from: url3)
print("이미지 3 다운로드 완료")
print("다운로드 완료!")
}
각 이미지를 다운로드하는 데 1초가 걸린다면 모든 작업을 완료하는데 3초가 걸리게 됩니다. 하지만 이 작업들은 서로 연관이 없으므로 동시에 진행하는 것이 훨씬 효율적입니다.
async let
만약 동시에 실행할 작업의 개수를 미리 알고 있다면 async let을 사용할 수 있습니다. async let은 변수를 선언할 때 await 없이 async 함수를 호출하여 작업을 즉시 시작시킵니다. 그리고 나중에 그 변수의 결과가 실제로 필요할 때 await을 사용해 값을 기다립니다.
Task {
print("다운로드 시작")
// 3개의 다운로드를 '동시에' 시작시킴 (자식 Task들이 생성됨)
async let image1 = fetchImage(from: url1)
async let image2 = fetchImage(from: url2)
async let image3 = fetchImage(from: url3
// 모든 결과가 준비될 때까지 여기서 기다림
let images = try await [image1, image2, image3]
print("다운로드 완료!")
}
위 코드는 3개의 fetchImage 함수를 동시에 실행시킵니다. 그리고 마지막 줄에서 await을 만나 세 작업이 모두 완료되기를 기다립니다. 결과적으로 총 소요 시간은 가장 오래 걸리는 작업 하나의 시간과 비슷해지기 때문에 순차적으로 실행하는 것 보다 작업이 빠르게 끝납니다.
TaskGroup
만약 실행할 작업의 개수를 미리 알 수 없다면 어떻게 해야할까요? 예를 들어 서버에서 이미지 URL 목록을 받아온 뒤, 그 개수만큼 이미지를 다운로드해야 하는 경우입니다. 이럴 때는 TaskGroup을 사용할 수 있습니다.
TaskGroup은 동적으로 생성되는 작업들을 그룹으로 묶어 관리할 수 있는 도구입니다.
func fetchAllImagesAndLog(from urls: [URL]) async throws {
var downloadedImages: [String] = []
try await withThrowingTaskGroup(of: String.self) { group in
// 그룹에 작업 추가
// addTask는 작업을 시작만 시키고 바로 다음으로 넘어갑니다.
for url in urls {
print("그룹에 '\(url.lastPathComponent)' 작업 추가")
group.addTask {
return try await fetchImage(from: url)
}
}
// 그룹의 결과 수신
// 그룹 내의 작업 중 가장 먼저 끝나는 순서대로 결과가 도착합니다. (작업을 추가한 순서와 다름)
for try await imageName in group {
print("결과 도착! -> \(imageName)")
downloadedImages.append(imageName)
}
}
print("최종 결과물: \(downloadedImages)")
}
- withThrowingTaskGroup: 에러를 던질 수 있는 작업들을 위한 그룹을 만듭니다.
- group.addTask: 그룹에 새로운 비동기 작업을 추가합니다. 추가된 작업은 즉시 실행됩니다.
- for try await image in group: 그룹 내의 작업이 완료되는 순서대로 결과를 하나씩 가져옵니다. 모든 작업이 끝날 때까지 기다립니다.
위의 예제처럼 TaskGroup을 사용하면 작업의 개수와 상관없이 병렬로 처리할 수 있습니다.
이제 여러 작업을 효율적으로 동시에 처리하는 방법까지 알아보았습니다. 하지만 작업이 병렬로 처리되면서 여러 작업이 하나의 데이터에 동시에 접근하는 문제가 발생할 수 있습니다. 예를 들어 여러 스레드가 동시에 은행 계좌 잔액을 수정하려고하는 문제가 있을 수 있습니다. 이렇게 여러 스레드가 동시에 하나의 데이터에 접근하는 문제를 데이터 레이스(Data Race)라고 합니다. Swift Councurrency에서는 이 문제를 해결하기 위해 Actor라는 것을 사용할 수 있습니다. 내일은 Actor에 대해서 알아보도록 하겠습니다.
'iOS' 카테고리의 다른 글
| 야구게임 프로젝트 리팩토링 과정 (3) | 2025.08.29 |
|---|---|
| Swift Concurrency(Actor, @MainActor) (0) | 2025.08.28 |
| Swift의 비동기 프로그래밍 (async/await) (1) | 2025.08.26 |
| Swift의 비동기 프로그래밍 (Operation) (3) | 2025.08.22 |
| 클로저에서 순환참조 없애기 트러블 슈팅 (3) | 2025.08.21 |