이 글은 누구를 위한 것인가
- Swift 6로 마이그레이션 중 컴파일 에러 홍수를 만난 개발자
- async/await는 쓰고 있는데 actor가 언제 필요한지 모르는 팀
- 데이터 레이스 없는 안전한 동시성 코드를 작성하고 싶은 엔지니어
들어가며
Swift 6는 동시성 안전성을 컴파일 타임에 강제한다. 기존 Swift 5.x 코드를 Swift 6 모드로 빌드하면 수십 개의 "Sendable" 관련 에러를 만나게 된다. 이것은 버그가 아니라 설계된 변화다.
Swift 6의 엄격한 동시성 검사는 런타임에 발생하던 데이터 레이스를 컴파일 타임에 잡아낸다. 처음에는 고통스럽지만, 이해하고 나면 훨씬 신뢰성 있는 코드를 작성할 수 있다.
이 글은 bluefoxdev.kr의 Swift 동시성 진화 가이드 를 참고하고, Swift 6 실전 적용 관점에서 확장하여 작성했습니다.
1. Swift 동시성 모델 핵심 개념
[Swift 동시성 레이어]
Task/async-await: 비동기 작업 단위
↓
Actor: 데이터 보호 경계 (격리 도메인)
↓
Executor: 실제 스레드 관리 (Swift 런타임)
핵심 규칙:
- Actor 내부 상태는 한 번에 하나의 Task만 접근 가능
- Actor 외부에서 내부 상태에 접근하려면 await 필요
- Sendable: 동시성 경계를 넘어 안전하게 전달 가능한 타입
2. Actor 기본 사용법
// ❌ Swift 5: 데이터 레이스 가능
class UserCache {
var users: [String: User] = [:] // 여러 스레드에서 동시 접근 위험
func save(_ user: User) {
users[user.id] = user // 레이스 컨디션!
}
}
// ✅ Swift 6: Actor로 안전하게
actor UserCache {
private var users: [String: User] = [:]
func save(_ user: User) {
users[user.id] = user // Actor 내부, 안전
}
func get(id: String) -> User? {
users[id]
}
}
// 사용:
let cache = UserCache()
// Actor 외부에서는 반드시 await
let user = await cache.get(id: "123")
await cache.save(newUser)
2.1 nonisolated - Actor 격리 제외
actor DataStore {
var items: [Item] = []
let storeID: String // 변하지 않는 값은 격리 불필요
// nonisolated: await 없이 호출 가능
nonisolated func description() -> String {
"DataStore(\(storeID))" // storeID는 let이라 안전
}
// nonisolated computed property
nonisolated var id: String { storeID }
}
// nonisolated 함수는 동기로 호출 가능
let store = DataStore(storeID: "main")
print(store.description()) // await 불필요
3. @MainActor - UI 업데이트 보장
// ❌ Swift 5: 메인 스레드 보장 없음
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func fetchItems() async {
let data = await networkService.fetch()
items = data // 어느 스레드? 불확실!
}
}
// ✅ Swift 6: @MainActor로 명시
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func fetchItems() async {
// 이 함수는 항상 메인 스레드에서 실행
let data = await networkService.fetch() // fetch는 백그라운드
items = data // 메인 스레드, 안전
}
}
// SwiftUI View는 암묵적으로 @MainActor
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
List(viewModel.items) { item in
Text(item.name)
}
.task {
await viewModel.fetchItems()
}
}
}
3.1 @MainActor와 백그라운드 작업 조합
@MainActor
class ImageViewModel: ObservableObject {
@Published var processedImage: UIImage?
@Published var isProcessing = false
func processImage(_ raw: UIImage) async {
isProcessing = true // 메인 스레드
// 무거운 처리는 백그라운드로
let processed = await Task.detached(priority: .userInitiated) {
ImageProcessor.process(raw) // 백그라운드 스레드
}.value
// 결과는 다시 메인으로 (이미 @MainActor라서 자동)
processedImage = processed
isProcessing = false
}
}
4. Sendable 프로토콜
동시성 경계를 넘어 안전하게 전달할 수 있는 타입.
// Sendable 조건:
// 1. 값 타입 (struct, enum) - 복사되므로 안전
// 2. 불변 클래스 (let만 있고 Sendable 프로퍼티만)
// 3. Actor (내부적으로 격리)
// ✅ 자동으로 Sendable (struct)
struct UserProfile: Sendable {
let id: String
let name: String
// let만 있고 모든 프로퍼티가 Sendable
}
// ✅ 명시적 Sendable (클래스)
final class ImmutableConfig: Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}
// ❌ Sendable 불가 (변경 가능한 클래스)
class MutableState {
var count = 0 // var → Sendable 불가
}
4.1 @unchecked Sendable - 탈출구 (주의 사용)
// 직접 동기화를 보장할 때 사용 (주의 필요)
class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var cache: [String: Data] = [:]
func set(_ data: Data, for key: String) {
lock.withLock { cache[key] = data }
}
func get(for key: String) -> Data? {
lock.withLock { cache[key] }
}
}
5. Swift 6 마이그레이션 전략
5.1 단계별 마이그레이션
// Package.swift - 점진적 Swift 6 모드 적용
// 타겟별로 개별 활성화 가능
.target(
name: "CoreModule",
swiftSettings: [
.swiftLanguageVersion(.v6) // 이 타겟만 Swift 6
]
),
.target(
name: "LegacyModule",
// Swift 6 미적용 (기존 코드 유지)
)
5.2 자주 만나는 에러와 해결책
// 에러 1: "Sending 'self' risks causing data races"
// 원인: Sendable이 아닌 self를 Task에 캡처
// ❌
class Controller {
func load() {
Task {
let data = await fetch()
self.update(data) // 에러: self가 Sendable 아님
}
}
}
// ✅ @MainActor로 해결
@MainActor
class Controller {
func load() {
Task { @MainActor in
let data = await fetch()
self.update(data) // OK: MainActor에서 실행
}
}
}
// 에러 2: "Non-sendable type 'UIImage' passed in implicitly async context"
// 원인: UIKit 타입은 Sendable 아님
// ❌
func processAsync(image: UIImage) async -> UIImage {
return await Task.detached {
process(image) // UIImage는 Sendable 아님!
}.value
}
// ✅ 데이터로 변환 후 전달
func processAsync(imageData: Data) async -> Data {
return await Task.detached {
let image = UIImage(data: imageData)!
let processed = process(image)
return processed.pngData()!
}.value
}
6. 실전 패턴: Repository + ViewModel
// Repository: actor로 데이터 안전하게 관리
actor UserRepository {
private var cache: [String: User] = [:]
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func fetchUser(id: String) async throws -> User {
if let cached = cache[id] {
return cached
}
let user = try await apiClient.getUser(id: id)
cache[id] = user
return user
}
}
// ViewModel: @MainActor로 UI 상태 관리
@MainActor
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var error: Error?
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func loadUser(id: String) async {
isLoading = true
error = nil
do {
user = try await repository.fetchUser(id: id)
} catch {
self.error = error
}
isLoading = false
}
}
마무리
Swift 6 동시성은 처음에는 제약처럼 느껴지지만, 실제로는 런타임 크래시와 데이터 레이스를 컴파일 타임에 잡아준다.
핵심 규칙 3가지:
- 공유 가변 상태 → Actor로 격리
- UI 업데이트 → @MainActor로 보장
- 동시성 경계를 넘는 타입 → Sendable 준수
마이그레이션은 타겟 하나씩 점진적으로. 전체를 한번에 바꾸려 하지 마라.