| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- Protocol
- map
- HIG
- tableView
- Human interface guide
- Clean Code
- UICollectionView
- 애니메이션
- Refactoring
- combine
- uiscrollview
- swift documentation
- claude code
- swiftUI
- 리펙토링
- 클로드코드
- 스위프트
- RxCocoa
- 클린 코드
- SWIFT
- Observable
- UITextView
- 리팩토링
- Xcode
- collectionview
- MVVM
- ios
- uitableview
- clean architecture
- rxswift
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS] Swift6 개념 (actor, Sendable, @MainActor, @unchecked Sendable) 본문
[iOS] Swift6 개념 (actor, Sendable, @MainActor, @unchecked Sendable)
jake-kim 2026. 9. 23. 00:39지난 글 복습
class Counter {
var n = 0
func bump() { n += 1 }
}
let c = Counter()
Task { c.bump() }
Task { c.bump() }
- n += 1은 읽기 → 더하기 → 쓰기 3단계 → 두 스레드가 겹치면 한쪽 작업이 사라짐 (데이터 레이스)
- 문제는 재현이 안 됨: 99번 잘 돌다가 사용자 기기에서만 터짐. Swift 5는 이걸 막아주지 않았음
- Swift 6는 이런 코드를 컴파일 에러로 만들어버림
핵심 아이디어 = 격리 구역(isolation domain)
- 코드는 항상 어느 구역엔가 속함: actor 인스턴스마다 구역 하나, @MainActor 구역, 또는 아무 데도 안 속한 nonisolated
- 같은 구역 안 = 안전 (동시 접근 없음)
- 구역 경계를 넘어갈 때만 위험 → 넘어가는 값이 안전한지 검사하는 게 Swift 6의 본질
- 이걸 위한 도구 3가지: actor / @MainActor (코드가 어느 구역에 있는지) + Sendable (값이 구역을 넘어도 되는지)
① actor — 구역을 만든다
actor Counter {
private var n = 0
func bump() { n += 1 }
func value() -> Int { n }
}
class → actor 한 글자 바꿨을 뿐인데, 이제 이 인스턴스는 자기만의 구역
구역 안의 상태(n)는 오직 구역 안 코드만 만질 수 있습니다. 바깥에서 접근하려면 줄을 서야 하고, 그래서 await이 붙음
let c = Counter()
await c.bump() // ✅ 줄 서서 들어감
print(await c.value()) // ✅
print(c.n) // ❌ 컴파일 에러: 구역 밖에서 상태 직접 접근
await은 여기서 "느리다"는 뜻이 아니라 "구역에 들어가려고 기다린다" 는 표시
반대로 actor 내부에서는 이미 구역 안이니 await 없이 씀
actor Counter {
private var n = 0
func bump() { n += 1 }
func bumpTwice() {
bump() // await 없음 — 같은 구역
bump()
}
}
직관: actor = 상태를 감싼 방 + 자동으로 붙는 자물쇠. 한 번에 한 명만 들어감
② @MainActor — 가장 많이 쓰게 될 구역
- UI는 반드시 메인 스레드에서 건드려야 함. 이걸 타입으로 표현한 게 @MainActor
@MainActor
final class ViewModel {
var title = "" // 메인 구역에 있는 상태
func setTitle(_ s: String) { title = s }
}
이제 백그라운드에서 실수로 건드리면 컴파일 에러가 발생하며 안정성 확보
func loadInBackground(vm: ViewModel) async {
let data = await fetch()
vm.title = data.name // ❌ 에러: 메인 구역 밖
await MainActor.run {
vm.title = data.name // ✅ 구역 안으로 들어가서 수정
}
}
예전에 DispatchQueue.main.async를 빼먹어서 생기던 버그가 컴파일 타임에 잡힘. Swift 6에서 가장 실질적인 이득이 이곳.
final class Service {
@MainActor var lastError: String? // 이 프로퍼티만 메인 구역
func work() { ... } // 나머지는 자유
}
③ Sendable — 구역을 넘어가도 되는 값
- 구역이 나뉘었으니, 이제 값이 구역을 넘을 때 검사가 필요
actor Database {
func save(_ user: User) { ... } // 바깥 → Database 구역으로 User가 넘어감
}
이때 User가 넘어가도 되는 타입인가? 그 판정 기준이 Sendable 프로토콜
// 안전한 경우 — 자동으로 Sendable
struct User { // Sendable 안 써도 자동 인정
let id: Int
let name: String
}
struct는 값 타입이라 넘길 때 복사됨. 양쪽이 서로 다른 사본을 가지니 애초에 공유가 없음. 내부 멤버가 전부 Sendable이면 컴파일러가 알아서 Sendable로 취급.
Int, String, Bool, enum(연관값이 Sendable이면), Sendable 원소의 Array/Dictionary — 전부 해당
// 위험한 경우 — 거부됨
class Session { // 참조 타입 + 가변 상태
var token: String = ""
}
actor Database {
func save(_ s: Session) { ... } // ❌ Session is not Sendable
}
클래스는 참조가 복사됨. 사본이 아니라 같은 객체를 양쪽이 가리키게 되므로 컴파일러 발생
- 해결 방법
방법 A — 불변으로 만든다
final class Session: Sendable {
let token: String // 전부 let → 쓰기가 없으니 레이스도 없음
init(token: String) { self.token = token }
}
final + 모든 프로퍼티가 let + 그 타입들도 Sendable이면 컴파일러가 검증해 줌
방법 B — actor로 바꾼다
actor Session { var token = "" } // actor는 언제나 Sendable
방법 C — 직접 락을 걸고 컴파일러에게 약속한다
final class Session: @unchecked Sendable {
private let lock = NSLock()
private var _token = ""
var token: String {
lock.withLock { _token }
}
}
@unchecked는 "검사를 끄겠다, 안전은 내가 책임진다" 는 선언. 기존 코드 마이그레이션에서 유혹이 크지만, 락 하나 빠뜨리면 Swift 6를 쓰는 의미가 사라짐. A나 B방법이 좋은 것
클로저에서의 컴파일 에러
- 클로저도 value type이며 아래와 같은 상황이 많이 발생함
var total = 0
Task {
total += 1 // ❌ 클로저가 바깥 변수를 캡처해서 다른 구역으로 감
}
Task { }의 내용물은 별도 구역에서 실행되는데, total을 캡처하면 두 구역이 같은 변수를 공유하게 됨. 컴파일 에러 발생
- await를 붙여서 해결이 가능함
let result = await Task { 1 + 1 }.value
요약
- actor / @MainActor 로 상태를 구역 안에 가두기 → 구역 안에서는 동시 접근이 없음
- 구역 경계를 넘는 값은 Sendable 검사를 받음 → 값 타입·불변 타입은 Sendable없어도 그냥 통과됨 (공유 메모리가 아니고 복사해서 쓰기 때문)
- 막히면 @unchecked로 덮지 말고 상태를 어느 구역에 둘지 다시 설계
┌─────────────────────┐ ┌─────────────────────┐
│ @MainActor 구역 │ │ actor Database 구역 │
│ │ │ │
│ vm.title │ User │ var rows │
│ (UI 상태) │ ──────> │ (DB 상태) │
│ │ ↑ │ │
└─────────────────────┘ │ └─────────────────────┘
│
여기서 컴파일러가 묻는다:
"User는 Sendable인가?"
struct → ✅ 통과
가변 class → ❌ 에러
