Swift 동시성이란?
Swift 5.5에서 도입된 구조화된 동시성(Structured Concurrency)은 async/await 키워드로 비동기 코드를 순차적으로 작성하고, Actor로 데이터 경쟁을 컴파일 타임에 방지합니다. 콜백 지옥이나 completion handler의 복잡성을 해결하는 현대적인 비동기 프로그래밍 모델입니다.
이 글에서는 async/await의 기본부터 Task, TaskGroup, Actor까지 핵심 개념을 정리합니다.
async/await 기본
async 키워드는 비동기 함수를 선언하고, await 키워드는 비동기 함수의 결과를 대기합니다.
import Foundation
// 비동기 함수 선언
func fetchUser(id: Int) async -> String {
// 비동기 대기 — 스레드를 차단하지 않음
try? await Task.sleep(for: .seconds(1))
return "사용자_\(id)"
}
func fetchOrders(for user: String) async -> [String] {
try? await Task.sleep(for: .milliseconds(800))
return ["주문A", "주문B", "주문C"]
}
// 에러를 던질 수 있는 비동기 함수
enum NetworkError: Error {
case invalidURL
case serverError(code: Int)
case timeout
}
func fetchData(from urlString: String) async throws -> Data {
guard let url = URL(string: urlString) else {
throw NetworkError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.serverError(code: 500)
}
return data
}
// 메인 진입점
@main
struct App {
static func main() async {
// 순차 실행
let user = await fetchUser(id: 1)
print("사용자: \(user)")
// 실행 결과: 사용자: 사용자_1
let orders = await fetchOrders(for: user)
print("주문: \(orders)")
// 실행 결과: 주문: ["주문A", "주문B", "주문C"]
// 병렬 실행 — async let
let startTime = CFAbsoluteTimeGetCurrent()
async let user1 = fetchUser(id: 1)
async let user2 = fetchUser(id: 2)
async let user3 = fetchUser(id: 3)
// 모든 결과를 동시에 대기
let users = await [user1, user2, user3]
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
print("사용자들: \(users)")
print("소요 시간: \(String(format: "%.1f", elapsed))초 (병렬이므로 약 1초)")
// 실행 결과:
// 사용자들: ["사용자_1", "사용자_2", "사용자_3"]
// 소요 시간: 1.0초 (병렬이므로 약 1초)
}
}
async let으로 여러 비동기 작업을 동시에 시작하면, 순차 실행 대비 실행 시간이 크게 줄어듭니다.
Task와 TaskGroup
Task는 비동기 작업의 단위이고, TaskGroup은 동적인 수의 작업을 관리합니다.
import Foundation
struct UserProfile {
let name: String
let score: Int
}
func fetchProfile(id: Int) async -> UserProfile {
try? await Task.sleep(for: .milliseconds(500))
return UserProfile(name: "사용자_\(id)", score: Int.random(in: 60...100))
}
@main
struct App {
static func main() async {
// Task — 독립적인 비동기 작업
let task = Task {
await fetchProfile(id: 1)
}
let profile = await task.value
print("\(profile.name): \(profile.score)점")
// 실행 결과: 사용자_1: 87점 (랜덤 값)
// Task 취소
let longTask = Task {
for i in 1...10 {
// 취소 확인 — 협조적 취소
guard !Task.isCancelled else {
print("작업 취소됨 (i=\(i))")
return
}
try? await Task.sleep(for: .milliseconds(200))
print("진행: \(i)/10")
}
}
try? await Task.sleep(for: .milliseconds(500))
longTask.cancel() // 취소 요청
await longTask.value
// 실행 결과:
// 진행: 1/10
// 진행: 2/10
// 작업 취소됨 (i=3)
// TaskGroup — 동적 수의 병렬 작업
let userIds = [1, 2, 3, 4, 5]
let profiles = await withTaskGroup(
of: UserProfile.self,
returning: [UserProfile].self
) { group in
// 모든 ID에 대해 병렬로 프로필 조회
for id in userIds {
group.addTask {
await fetchProfile(id: id)
}
}
// 결과 수집
var results: [UserProfile] = []
for await profile in group {
results.append(profile)
}
return results
}
print("\n전체 프로필:")
for p in profiles.sorted(by: { $0.score > $1.score }) {
print(" \(p.name): \(p.score)점")
}
// 실행 결과 (랜덤 값, 점수 내림차순):
// 전체 프로필:
// 사용자_3: 95점
// 사용자_1: 88점
// 사용자_5: 82점
// 사용자_2: 76점
// 사용자_4: 71점
}
}
TaskGroup은 동적인 수의 작업을 병렬로 실행하고 결과를 모을 때 유용합니다. async let은 정적인 수의 작업에 적합합니다.
Actor — 데이터 경쟁 방지
Actor는 자신의 상태에 대한 접근을 직렬화하여 데이터 경쟁을 컴파일 타임에 방지합니다. 클래스와 유사하지만, 외부에서 프로퍼티나 메서드에 접근할 때 반드시 await를 사용해야 합니다.
import Foundation
// Actor — 동시 접근으로부터 상태 보호
actor BankAccount {
let owner: String
private(set) var balance: Int
init(owner: String, balance: Int) {
self.owner = owner
self.balance = balance
}
func deposit(_ amount: Int) {
balance += amount
print("\(owner): +\(amount)원 (잔액: \(balance)원)")
}
func withdraw(_ amount: Int) -> Bool {
guard balance >= amount else {
print("\(owner): 잔액 부족 (잔액: \(balance)원, 요청: \(amount)원)")
return false
}
balance -= amount
print("\(owner): -\(amount)원 (잔액: \(balance)원)")
return true
}
// nonisolated — actor 격리 없이 접근 가능 (불변 데이터만)
nonisolated var accountInfo: String {
return "계좌 소유자: \(owner)"
}
}
// @MainActor — 메인 스레드에서 실행 보장
@MainActor
class ViewModel {
var statusMessage = ""
func updateStatus(_ message: String) {
statusMessage = message
print("[UI 업데이트] \(message)")
}
}
@main
struct App {
static func main() async {
let account = BankAccount(owner: "홍길동", balance: 100000)
// Actor의 메서드는 await 필요
await account.deposit(50000)
// 실행 결과: 홍길동: +50000원 (잔액: 150000원)
let success = await account.withdraw(30000)
print("출금 성공: \(success)")
// 실행 결과:
// 홍길동: -30000원 (잔액: 120000원)
// 출금 성공: true
// 동시에 여러 작업이 접근해도 안전
await withTaskGroup(of: Void.self) { group in
for i in 1...5 {
group.addTask {
await account.deposit(i * 1000)
}
}
}
let finalBalance = await account.balance
print("최종 잔액: \(finalBalance)원")
// 실행 결과: 최종 잔액: 135000원 (120000 + 1000+2000+3000+4000+5000)
// nonisolated 프로퍼티는 await 불필요
print(account.accountInfo)
// 실행 결과: 계좌 소유자: 홍길동
}
}
Actor는 뮤텍스나 잠금 없이도 스레드 안전한 코드를 작성할 수 있게 합니다. 컴파일러가 격리 규칙을 강제하므로, 데이터 경쟁이 발생할 수 있는 코드는 컴파일 시점에 오류로 잡힙니다.
실전 팁
- async let vs TaskGroup: 작업 수가 컴파일 타임에 정해지면
async let, 동적이면TaskGroup을 사용합니다. - Task 취소는 협조적:
Task.isCancelled를 확인하거나try Task.checkCancellation()을 호출하여 취소에 협조합니다. - Actor는 참조 타입: Actor는 클래스처럼 참조로 전달됩니다. 값 타입이 필요하면 구조체를 사용합니다.
- @MainActor로 UI 보호: UI 관련 코드는
@MainActor로 표시하여 메인 스레드에서 실행되도록 보장합니다. - Sendable 프로토콜: Actor 경계를 넘어가는 데이터는
Sendable을 채택해야 합니다. 구조체와 열거형은 자동으로Sendable입니다. - 에러 처리:
async throws함수는do-catch블록에서try await로 호출합니다.