[Swift] Actor와 isolated에 대해 알아보자
최근 간단한 비디오 데모 앱을 개발하며 컴파일 타임 Thread Safety를 직접 경험하기 위해 Swift 6를 도입했습니다.
이전까지는 Swift가 스레드 간 전환을 자동으로 처리해 주었기 때문에, 세밀하게 동시성 문제를 신경 쓰지 않아도 되는 경우가 많았습니다.
하지만 Swift 6에서는 Thread Safety를 강화하면서 스레드 관련 경고를 설정할 수 있고, 이를 Strict 모드로 두면 다수의 경고와 오류를 마주하게 됩니다.
이 과정에서 자연스럽게 actor, @MainActor, isolated, nonisolated 같은 새로운 키워드를 접하게 됩니다.
이번 글에서는 Swift 동시성 모델의 핵심인 Actor 시스템을 중심으로, 이 키워드들이 어떻게 스레드 안전성을 보장하는지 살펴보겠습니다.
🦺 Actor - Thread safety type
정의
Actor는 Class와 마찬가지로 참조 타입이지만, 내부 동작 방식이 다릅니다.
Actor는 race condition을 방지하며, 한 시점에 하나의 스레드만 내부 상태에 접근하도록 보장합니다.
아래 예시를 보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
// ❌ Class: 데이터 레이스 가능
class BankAccount {
var balance: Int = 0
func deposit(_ amount: Int) {
balance += amount // 🚨 여러 스레드에서 동시에 접근하면 문제 발생
}
}
let account = BankAccount()
Task { account.deposit(100) }
Task { account.deposit(100) }
위 예제에서 deposit()을 여러 스레드가 동시에 호출하면 balance 프로퍼티에 race condition이 발생합니다.
하지만 이 문제를 Actor로 해결할 수 있습니다. 어떻게 가능한걸까요?
작동 원리 1 - Isolation
Actor를 사용하면 내부 프로퍼티는 자신의 고유한 격리 영역(isolation) 안에 존재하게 됩니다.
아까 Class를 Actor로 바꿔보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
// ✅ Actor: 데이터 레이스 방지
actor SafeBankAccount {
var balance: Int = 0
func deposit(_ amount: Int) {
balance += amount // ✅ 한 번에 하나의 작업만 접근 가능
}
}
let account = SafeBankAccount()
Task { await account.deposit(100) }
Task { await account.deposit(100) }
이제 내부 프로퍼티는 SafeBankAccount의 격리 영역에 존재하게 됩니다.
이러한 격리 영역을 만듦으로써 Actor는 Thread safety를 위한 기반을 얻습니다.
작동 원리 2 - Serial Executor
Actor는 한 시점에 오직 하나의 Task만 내부 상태에 접근할 수 있는데요.
이는 Actor가 내부적으로 serial executor를 가지고 있기 때문입니다.
serial executor는 Actor 내부의 코드를 순차적으로 처리하도록 만듭니다.
그렇기에 한 시점에 오직 하나의 Task만 들어오고, 격리 영역에 대한 접근 또한 동시에 발생하지 않는 것이죠.
위 예제 코드를 보면 이제 외부에서 actor의 상태를 바꾸기 위한 deposit() 함수를 사용하기 위해 await를 사용하는 모습을 볼 수 있는데요.
이 또한 외부에서 접근 시 이를 직렬적으로 처리해주기 위함입니다.
결과적으로
Actor는 다음과 같은 방식으로 Thread Safety를 달성합니다.
- 객체 상태를 내부적으로 격리하고
- isolation을 다룰때는 직렬적으로 접근하여
- race condition을 방지하고
- Thread Safety를 달성한다
🌍 @MainActor와 Global Actor
@MainActor란?
방금 이해한 Actor가 백그라운드 스레드를 위한 것이었다면
@MainActor는 메인 스레드를 위한 Actor라고 볼 수 있습니다.
일반적으로 UI 요소는 항상 메인 스레드에서만 동작해야 합니다.
그래서 이를 보장해주기 위해 UIKit, SwiftUI의 대부분의 화면들은 @MainActor를 채택해주고 있습니다.
하지만 추가적으로 메인 스레드에서 동작하도록 보장하게 만들고 싶다면 직접 @MainActor 키워드를 붙여주면 되는데요.
아래의 예시를 보겠습니다.
1
2
3
4
5
6
7
8
@MainActor
class ContentViewModel: ObservableObject {
@Published var displayText: String = ""
func updateText() {
displayText = "Updated" // ✅ 메인 스레드에서만 실행됨
}
}
만약 @MainActor 어노테이션을 따로 붙여주지 않았다면 displayText의 값이 변하는 시점의 스레드가 어디인지 보장받을 수 없습니다.
이를 위해 여러 방법이 있겠지만, 예시와 같이 해당 ViewModel에 @MainActor 어노테이션을 붙여준다면 내부의 동작들은 모두 메인 스레드에서 실행한다는 보장을 해줄 수 있는것이죠.
Global Actor란?
Global Actor는 전역적으로 공유되는 단일 Actor입니다. @MainActor 또한 Global Actor입니다.
일반 Actor가 독립된 isolation 영역을 가지는 반면, Global Actor는 앱 전체에서 하나의 isolation 영역을 공유합니다.
1
2
3
4
5
6
7
@MainActor
class ViewModel1 { var data = "" }
@MainActor
class ViewModel2 { var data = "" }
// ViewModel1과 ViewModel2는 같은 MainActor 격리 영역을 공유
🫥 isolated와 nonisolated
isolated — 격리 영역 내부 (기본 상태)
Actor 내부의 모든 프로퍼티와 메서드는 기본적으로 isolated 상태입니다.
즉, Actor의 격리 영역 안에서만 접근할 수 있으며 외부에서는 await를 통해서만 접근 가능합니다.
1
2
3
4
5
6
7
8
9
10
actor Counter {
var count = 0 // isolated
func increment() { // isolated
count += 1
}
}
let counter = Counter()
await counter.increment() // ⚠️ await 필요
nonisolated — 격리 영역 외부
actor 인스턴스의 모든 코드들이 serial executor에 의해 직렬적인 수행을 필요로 하지 않을 수 있습니다.
이럴 때는 nonisolated 키워드를 붙이면 해당 메서드는 Actor의 격리 영역 밖에서 실행됩니다.
아래 예시와 같은 상황에서 유용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
actor Calculator {
var history: [Int] = [] // isolated
func addToHistory(_ value: Int) {
history.append(value)
}
nonisolated func calculate(_ a: Int, _ b: Int) -> Int {
return a + b // ✅ 상태 접근 없음, await 불필요
}
}
예시처럼 calculate(_:, _:) 함수는 전혀 Actor 내부 상태를 변경하는데 관심이 없습니다.
이렇게 Actor의 상태에 접근하지 않거나, 순수 계산 로직일 때 유용합니다.
차이 정리
| 구분 | isolated (기본) | nonisolated |
|---|---|---|
| Actor 상태 접근 | ✅ 가능 | ❌ 불가능 |
| 외부 호출 시 await | ⚠️ 필요 | ✅ 불필요 |
| 실행 순서 보장 | 순차적 | 병렬 가능 |
| 사용 예 | 상태 변경, 내부 로직 | 순수 계산, 불변 데이터 |
🏁 마무리
Swift 동시성 핵심
| 키워드 | 설명 | 사용 시점 |
|---|---|---|
actor | 스레드 안전한 참조 타입 | 공유 상태를 안전하게 관리할 때 |
@MainActor | 메인 스레드 전용 Global Actor | UI 업데이트 로직 |
isolated | Actor의 격리 영역 내부 | 상태 변경 메서드 |
nonisolated | Actor 격리 영역 외부 | 순수 계산, 불변 데이터 |
정리해보면
Actor는 Class가 가질 수 있는 race condition에 대한 문제를 해결해주기 핵심 도구입니다.
내부 상태를 격리하고, 직렬적으로 코드를 수행하여
최종적으로는 Thread safety한 코드를 만드는 것이죠.
또한 상황에 따라 isolated와 nonisolated를 조합한다면 더 유연한 동시 제어가 가능해집니다.
이제는 자신있게 actor와 @MainActor를 사용할 수 있고, 상황에 따라 제어자를 두어 적절한 concurrency를 사용할 수 있을것 같습니다.
🔗 레퍼런스
해당 자료를 찾아보며 도움이 됐던 링크