
WWDC21에 소개된 Actor 라는 개념이 있습니다.

변경 가능한 상태를 보호하기 위해 Swift Actor를 도입했다고 합니다.
변경 가능한 상태를 왜 보호해야할까?
iOS는 멀티스레드 환경이므로 변경 가능한 상태에 대해
두 스레드가 동시에 값을 쓰고자 할 경우
Race Condition(경쟁 상태)가 발생할 수 있기 때문입니다.
경쟁 상태는 하나의 값에 대해
예제를 통해 알아볼게요.
class Cart {
var itemCount = 0
}
let cart = Cart()
// 상품 추가
DispatchQueue.global().async {
cart.itemCount += 1
}
// 상품 삭제
DispatchQueue.global().async {
cart.itemCount -= 1
}
Thread.sleep(forTimeInterval: 0.1)
print(cart.itemCount) // 0? 1? -1? (예측 불가)
실행할 때마다 결과가 달라집니다. 왜 그럴까요?
Race Condition (경쟁 상태)
iOS 앱은 멀티스레드 환경에서 동작합니다.
위 코드에서는 두 스레드가 동시에 itemCount를 수정하려고 하는데,
이때 누가 먼저 쓰느냐에 따라 결과가 달라집니다.
시나리오 1: 추가 → 삭제
itemCount: 0 → 1 → 0 ✅
시나리오 2: 삭제 → 추가
itemCount: 0 → -1 → 0 ⚠️
시나리오 3: 동시 실행
itemCount: 예측 불가 ❌
이것이 mutable한 참조 타입의 상태를 보호해야 하는 이유입니다.
기존 해결 방법들
그렇다면 이 문제를 어떻게 해결할까요?
전통적으로 두 가지 방법이 있었습니다.
1. NSLock 2. DispatchQueue
NSLock
Race Condition은 두 개의 스레드가 하나의 값에 동시에 쓰려고 하는 경우, 개발자의 의도와 달리 예측할 수 없는 값이 써질 수 있는 문제였죠.
NSLock을 사용하면 Lock을 점유한 스레드만 mutable state에 접근할 수 있게 강제할 수 있습니다.
class Cart {
private var itemCount = 0
private let lock = NSLock()
func addItem() {
lock.lock()
itemCount += 1
lock.unlock()
}
func removeItem() {
lock.lock()
itemCount -= 1
lock.unlock()
}
}
let cart = Cart()
DispatchQueue.global().async {
cart.addItem()
}
DispatchQueue.global().async {
cart.removeItem()
}
```
**동작 방식:**
```
Thread 1: lock.lock() → 획득 성공 → itemCount += 1 → unlock
Thread 2: lock.lock() → 대기... → (Thread 1 unlock 후) 획득 → itemCount -= 1 → unlock
Lock을 획득하지 못한 Thread는 대기하므로, 한 번에 하나의 Thread만 접근할 수 있습니다.
NSLock 문제점
하지만 NSLock은 치명적인 단점이 있습니다.
func withdraw(_ amount: Int) -> Bool {
lock.lock()
guard itemCount >= amount else {
return false // ❌ unlock을 깜빡함!
}
itemCount -= amount
lock.unlock()
return true
}
unlock()을 호출하지 않으면 영원히 잠긴 상태가 되어 데드락(Deadlock)이 발생합니다.
func withdraw(_ amount: Int) -> Bool {
lock.lock()
defer { lock.unlock() } // 함수 종료 시 자동 호출
guard itemCount >= amount else {
return false
}
itemCount -= amount
return true
}
defer를 사용해서 unlock을 빠트리지 않게 작성할 수 있지만
여전히 unlock을 빠트리지 않게 정신을 차려야하는건 개발자의 몫이 됩니다.
DispatchQueue
작업들을 큐에 줄을 세워서 동시 접근을 막는 방법입니다.
DispatchQueue를 사용하면 작업을 순차적으로 실행할 수 있습니다.
Serial Queue는 작업을 하나씩 순서대로 처리하므로, 동시 접근 문제를 자연스럽게 해결할 수 있습니다.
class Cart {
private var itemCount = 0
private let queue = DispatchQueue(label: "cart.queue")
func addItem() {
queue.async {
self.itemCount += 1
}
}
func removeItem() {
queue.async {
self.itemCount -= 1
}
}
}
let cart = Cart()
DispatchQueue.global().async {
cart.addItem()
}
DispatchQueue.global().async {
cart.removeItem()
}
```
**동작 방식:**
```
Queue: [addItem] [removeItem]
↓
실행: addItem 완료 → removeItem 실행
모든 작업이 같은 Queue에 들어가므로 순차적으로 실행되며, unlock을 깜빡할 걱정도 없습니다.
DispatchQueue 문제점
DispatchQueue는 NSLock보다 안전하지만, 여전히 문제가 있습니다.
class Cart {
private var itemCount = 0
private let queue = DispatchQueue(label: "cart.queue")
func addItem() {
queue.async {
self.itemCount += 1
}
}
// ❌ 실수로 queue를 사용하지 않음
func getCount() -> Int {
return itemCount // Race Condition 발생 가능!
}
}
개발자가 수동으로 queue를 사용해야 하므로, 실수로 빼먹으면 보호되지 않습니다.
컴파일러는 이를 감지하지 못합니다.
비교 정리
| 방법 | 장점 | 단점 |
| NSLock | • 빠름 • 명시적 |
• unlock 깜빡하면 데드락 • defer 써도 실수 가능 |
| DispatchQueue | • unlock 걱정 없음 • 사용 간편 |
• queue 사용 안 하면 보호 안 됨 • 컴파일러가 확인 못 함 |
공통 문제:
- 둘 다 개발자가 수동으로 관리
- 컴파일러가 실수를 잡아주지 못함
이런 한계를 해결하기 위해 Actor가 등장했습니다.
Actor의 등장
Actor는 기존 방법들의 문제를 어떻게 해결할까요?
개발자가 신경쓰지 않도록 내 코드가 문제를 일으킬 거라는 걸 누군가 알려주면 좋겠죠.
그래서 Swift 컴파일러가 이를 알려줍니다.
Actor를 사용하면 mutable state 값을 보호해줍니다. 여러 스레드가 접근하고자 할 때 내부적으로 큐를 만들어 순차적으로 대기시키죠.
lock, unlock, DispatchQueue도 필요 없습니다.
actor 키워드를 통해 참조 타입을 만들고, await와 함께 호출하면 되죠.
Actor 사용법
actor Cart {
private var itemCount = 0
func addItem() {
itemCount += 1
}
func removeItem() {
itemCount -= 1
}
func getCount() -> Int {
return itemCount
}
}
class 랑 매우 비슷하게 생겼죠.
class를 actor로 바꾸기만 하면 data race가 일어나지 않도록 mutable state를 보호할 수 있습니다 (너무 쉽다.ᐟ)
actor는 class처럼 참조타입이며, protocol 채택 가능하지만, 상속은 안됩니다
Actor는 class와 비슷하지만 몇 가지 차이가 있습니다:
- ✅ 참조 타입 (class처럼)
- ✅ Protocol 채택 가능
- ❌ 상속 불가능
actor Cache: Cacheable { // ✅ Protocol OK
// ...
}
actor SubCache: Cache { // ❌ 상속 불가
// Error: Actors cannot inherit from other actors
}
Actor 특징
1. 컴파일 타임 검증 ⭐️⭐️⭐️
let cart = Cart()
// ❌ 컴파일 에러!
cart.addItem()
// Error: Expression is 'async' but is not marked with 'await'
// ✅ 이렇게 해야 함
await cart.addItem()
await 없이는 아예 컴파일이 안 됩니다.
NSLock이나 DispatchQueue처럼 "까먹으면 어쩌지?"라는 걱정이 필요 없습니다.
Actor의 가장 큰 장점이라고 생각합니다.
2. Data Isolation (데이터 격리)
actor Cart {
private var itemCount = 0
func addItem() {
itemCount += 1 // ✅ self의 프로퍼티는 자유롭게 접근
}
func transferItem(to other: Cart) {
other.itemCount += 1 // ❌ 컴파일 에러!
// Error: Actor-isolated property 'itemCount' can only be referenced on 'self'
}
}
Actor 내부의 프로퍼티는 self에서만 직접 접근 가능합니다.
다른 Actor 인스턴스의 프로퍼티에 접근하려면 반드시 메서드를 통해 접근하고 await를 사용해야 합니다:
func transferItem(to other: Cart) async {
let count = await other.getCount() // ✅ 메서드를 통해 접근
// ...
}
3. 한 번에 하나씩 실행
let cart = Cart()
// 100개의 작업을 동시에 실행해도
for _ in 0..<100 {
Task {
await cart.addItem()
}
}
// Actor 내부에서는 한 번에 하나씩만 실행됨
Actor는 내부적으로 Mailbox(우체통)를 가지고 있어서, 들어온 요청들을 순차적으로 처리합니다.
Mailbox: [요청1] [요청2] [요청3] ...
↓
실행: 요청1 완료 → 요청2 실행 → 요청3 실행
DispatchQueue처럼 순차 실행되지만, 컴파일러가 강제하므로 실수할 여지가 없습니다.
비교 정리
| 항목 | NSLock | DispatchQueue | Actor |
|---|---|---|---|
| 보호 방식 | 수동 lock/unlock | 수동 queue 사용 | 자동 격리 |
| 컴파일 검증 | ❌ | ❌ | ✅ |
| 실수 가능성 | 높음 (unlock 깜빡) | 중간 (queue 깜빡) | 낮음 (강제됨) |
| 코드 복잡도 | 높음 | 중간 | 낮음 |
Actor 사용 시 주의사항
순서 보장이 필요한 경우
Actor는 우선순위 기반으로 작업을 처리하므로, 들어온 순서대로 실행된다는 보장이 없습니다.
DispatchQueue와의 차이:
- DispatchQueue: 엄격한 선입선출 (FIFO)
- Actor: 우선순위 고려 (중요한 작업 먼저 처리)
actor Logger {
func log(_ message: String) {
print(message)
}
}
let logger = Logger()
// ❌ 1, 2, 3 순서 보장 안 됨
Task { await logger.log("1") }
Task { await logger.log("2") }
Task { await logger.log("3") }
순서가 중요하다면 같은 Task 안에서 순차적으로 호출하세요:
// ✅ 1 → 2 → 3 순서 보장
Task {
await logger.log("1")
await logger.log("2")
await logger.log("3")
}
동기 함수에서는 사용 불가
Actor 메서드는 비동기이므로, 동기 함수에서는 호출할 수 없습니다.
actor Counter {
var value = 0
func increment() {
value += 1
}
}
func syncFunction() {
let counter = Counter()
await counter.increment() // ❌ 컴파일 에러!
// Error: 'await' in a function that does not support concurrency
}
해결 방법:
// 1. 함수를 async로 변경
func asyncFunction() async {
let counter = Counter()
await counter.increment() // ✅
}
// 2. Task 사용
func syncFunction() {
let counter = Counter()
Task {
await counter.increment() // ✅
}
}
정리
Actor는 멀티스레드 환경에서 mutable state를 안전하게 보호하는 강력한 도구입니다.
장점:
- 컴파일러가 안전성을 강제
- 실수로 보호를 빼먹을 수 없음
- 코드가 간결하고 명확함
주의사항:
- 순서가 중요하면 같은 Task에서 순차 호출
- 동기 함수에서는 직접 호출 불가
기존의 NSLock이나 DispatchQueue보다 안전하고 사용하기 쉬운 Actor를 활용해보세요!
참고
Protect mutable state with Swift actors - WWDC21 - Videos - Apple Developer
Data races occur when two separate threads concurrently access the same mutable state. They are trivial to construct, but are notoriously...
developer.apple.com
swift-evolution/proposals/0306-actors.md at main · swiftlang/swift-evolution
This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution
github.com
'Tech > iOS' 카테고리의 다른 글
| [iOS] UIPanGestureRecognizer 알아보기 (드로어 구조 만들기) (2) | 2021.03.07 |
|---|---|
| [iOS] 아이폰에서 URL, 이메일 열기 (0) | 2021.02.28 |
| [iOS] Date 구조체를 이용해 캘린더 만들기 - 1 (extension 이용하기) (0) | 2021.02.14 |
| [iOS] NotificationCenter 사용법 (0) | 2021.02.07 |