Swift 6의 Strict Concurrency, async/await, Actor, Sendable, 데이터 레이스 방지와 마이그레이션 패턴.
한 줄 요약: Swift 6는 컴파일 타임에 데이터 레이스를 완전히 차단하는 Strict Concurrency를 도입했다. async/await, Actor, Sendable 프로토콜을 제대로 이해하면 기존 코드의 컴파일 에러를 해결하고 안전한 동시성 코드를 작성할 수 있다.
이 글이 필요한 사람
Swift 6로 마이그레이션 시 발생하는 동시성 컴파일 에러를 해결하려는 iOS/macOS 개발자
async/await를 사용하지만 Actor와 Sendable의 차이가 아직 모호한 경우
데이터 레이스가 발생하는 원인을 코드 레벨에서 이해하고 싶은 경우
MainActor와 커스텀 Actor의 올바른 사용법을 실전 패턴으로 익히려는 경우
Swift 6 Strict Concurrency — 무엇이 바뀌었나
Swift 5.x에서 async/await는 선택적이었다. Swift 6에서는 Strict Concurrency Checking이 기본값으로 활성화되어, 데이터 레이스가 발생할 수 있는 코드는 컴파일 에러로 차단된다.
핵심 변화는 세 가지다:
Sendable 강제 검사: 태스크 경계를 넘어 전달되는 타입은 반드시 Sendable을 준수해야 한다.
Actor Isolation 강화: Actor의 가변 상태에 대한 접근은 반드시 await로만 가능하다.
@MainActor 추론 감소: Swift 5.x에서는 암묵적으로 MainActor가 추론되던 경우가 있었지만, Swift 6는 명시적 어노테이션을 요구한다.
Swift 6 마이그레이션은 Xcode에서 SWIFT_STRICT_CONCURRENCY = complete를 설정하거나 Package.swift에서 swiftLanguageVersions: [.v6]로 활성화할 수 있다.
Swift 6 동시성 완벽 가이드 — async/await부터 Actor까지 — 언어별 성능 벤치마크 (출처: 공식 문서 및 벤치마크 데이터 기반)
async/await 기초와 Task 관리
async/await는 콜백과 CompletionHandler 기반 비동기 코드를 선형 흐름으로 작성할 수 있게 해준다. 기본 개념을 코드로 먼저 확인하자.
async/await 기본 패턴과 에러 처리
import Foundation
// async 함수 정의
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(User.self, from: data)
}
// 병렬 실행 — async let
func loadDashboard() async throws -> Dashboard {
// 두 요청이 동시에 실행됨
async let user = fetchUser(id: "1")
async let posts = fetchPosts(userId: "1")
// 두 결과가 모두 완료될 때까지 대기
return Dashboard(user: try await user, posts: try await posts)
}
// Task 생성
func onButtonTap() {
Task {
do {
let dashboard = try await loadDashboard()
await updateUI(with: dashboard)
} catch {
print("Error: \(error)")
}
}
}
// Task.detached — 현재 Actor 컨텍스트와 무관하게 실행
func runInBackground() {
Task.detached(priority: .background) {
let result = await heavyComputation()
// MainActor 필요 시 명시적 전환
await MainActor.run {
self.label.text = "\(result)"
}
}
}
Task vs Task.detached:Task { }는 현재 Actor 컨텍스트를 상속한다. UI 코드에서 Task를 만들면 자동으로 MainActor에서 실행된다. Task.detached는 컨텍스트를 상속하지 않으므로 백그라운드 작업에 적합하다.
Actor — 공유 가변 상태를 안전하게 관리하는 방법
Actor는 내부 상태에 대한 접근을 직렬화하는 참조 타입이다. 같은 Actor에 여러 태스크가 동시에 접근해도 내부 상태는 한 번에 하나의 태스크만 수정할 수 있도록 보장된다.
Swift 6 동시성 완벽 가이드 — async/await부터 Actor까지 — 문법 비교 차트 (출처: 공식 문서 및 벤치마크 데이터 기반)
Actor 정의와 사용 — 카운터 예시
// Actor 정의
actor Counter {
private var value = 0
func increment() {
value += 1
}
func decrement() {
value -= 1
}
var current: Int {
value // Actor 내부에서는 동기적 접근 가능
}
}
// Actor 외부에서는 await 필요
let counter = Counter()
Task {
await counter.increment()
await counter.increment()
let value = await counter.current
print(value) // 2
}
// 여러 태스크가 동시에 접근해도 안전
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask {
await counter.increment()
}
}
}
// counter.current == 1002 (데이터 레이스 없음)
실전 패턴 — 네트워크 캐시 Actor
actor ImageCache {
private var cache: [URL: Data] = [:]
private var inProgress: [URL: Task<Data, Error>] = [:]
func image(for url: URL) async throws -> Data {
// 캐시 히트
if let cached = cache[url] {
return cached
}
// 중복 요청 방지 — 진행 중인 태스크 재사용
if let existingTask = inProgress[url] {
return try await existingTask.value
}
// 새 요청 시작
let task = Task<Data, Error> {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
inProgress[url] = task
do {
let data = try await task.value
cache[url] = data
inProgress.removeValue(forKey: url)
return data
} catch {
inProgress.removeValue(forKey: url)
throw error
}
}
}
Sendable — 태스크 경계를 넘는 타입의 안전 규칙
Sendable은 여러 태스크 또는 Actor 간에 안전하게 공유할 수 있는 타입을 나타내는 프로토콜이다. Swift 6에서는 태스크 경계를 넘는 값은 반드시 Sendable이어야 한다.
자동으로 Sendable인 타입
값 타입 (struct, enum): 모든 프로퍼티가 Sendable이면 자동 채택
기본 타입: Int, String, Bool, Double 등
actor: 자체 격리로 Sendable 자동 채택
Sendable이 안 되는 패턴
가변 프로퍼티가 있는 class
non-Sendable 타입을 저장하는 타입
클로저 (명시적 @Sendable 어노테이션 없이)
Sendable 준수 패턴 — struct, class, @unchecked
// struct는 모든 프로퍼티가 Sendable이면 자동 Sendable
struct UserProfile: Sendable {
let id: String
let name: String
let score: Int
}
// class는 명시적 선언 + final + 불변 or 직렬화 보장 필요
final class ImmutableConfig: Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}
// @unchecked Sendable — 직접 동기화를 보장하는 경우
// (Lock 등을 사용하는 레거시 코드 마이그레이션 시)
final class ThreadSafeCache: @unchecked Sendable {
private var storage: [String: Any] = [:]
private let lock = NSLock()
func set(_ value: Any, for key: String) {
lock.withLock { storage[key] = value }
}
func get(for key: String) -> Any? {
lock.withLock { storage[key] }
}
}
// @Sendable 클로저
func runAsync(_ work: @Sendable @escaping () async -> Void) {
Task { await work() }
}
MainActor — UI 업데이트 안전 패턴
@MainActor는 메인 스레드에서만 실행되어야 하는 코드를 표시하는 글로벌 액터다. UIKit/SwiftUI에서 UI 업데이트는 반드시 메인 스레드에서 이루어져야 하므로 이를 컴파일 타임에 보장한다.
Swift 6 동시성 완벽 가이드 — async/await부터 Actor까지 — 생태계 구성 다이어그램 (출처: 공식 문서 및 벤치마크 데이터 기반)
@MainActor 활용 — ViewModel 패턴
import SwiftUI
// 전체 클래스를 MainActor로 격리
@MainActor
final class UserViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let service = UserService()
func loadUsers() {
isLoading = true
errorMessage = nil
Task {
do {
// service.fetchAll()은 백그라운드에서 실행
let result = try await service.fetchAll()
// 이미 @MainActor 컨텍스트이므로 바로 업데이트
users = result
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
}
// 특정 메서드만 MainActor로 지정
class DataProcessor {
// 백그라운드 작업
func process(data: [Int]) async -> [Int] {
data.map { $0 * 2 }
}
// UI 업데이트만 MainActor
@MainActor
func updateLabel(_ text: String, on label: UILabel) {
label.text = text
}
}
Swift 6 마이그레이션 — 자주 발생하는 에러와 해결
Swift 6로 마이그레이션할 때 가장 자주 보이는 컴파일 에러 케이스와 해결 방법을 정리했다.
케이스 1: "Sending value of non-Sendable type across actor boundary"
원인: Non-Sendable 타입을 태스크 또는 Actor 경계를 넘어 전달하려 할 때 발생.
해결: 타입을 struct로 변경하거나, final class + Sendable 채택, 또는 Actor 내부에서만 사용하도록 리팩토링.
케이스 2: "Call to main actor-isolated can only be made from main actor"