액터에 대해서 공부해보겠습니다.
액터란 데이터 레이싱을 해결할 수 있는 새로운 참조 타입입니다. 이제 참조타입은 무엇이 있나요 했을 때 액터를 함께 얘기해야겠습니다.
오늘도 WWDC에 대한 내용을 기반으로 설명해보려고 합니다.
class Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(counter.increment()) // data race
}
Task.detached {
print(counter.increment()) // data race
}
이런 코드가 전형적인 데이터 레이싱 상태입니다.
이것의 결과는 순차적으로 1, 2 혹은 2, 1의 결과를 생각하겠지만, 1, 1이 나올 수도 2, 2가 나올 수도 있습니다. 결과를 알 수 없죠. 혹은 크래시가 날 수도 있습니다.
이게 만일 계좌 이체 앱이라고 생각한다면... 더 끔찍하죠.
이 문제의 원인은 공유된 가변 상태(shared mutable state) 때문입니다.
value는 각 Task에서 공유하지만 그 값이 변경이 가능하죠. 이것이 이 문제의 원인입니다.
이전에는 이런 문제를 해결하기 위해서 어떻게 했을까요?
lock을 직접 구현했습니다. 값을 참조할 때 누군가 들어온다면 NSLock을 이용해서 막고, 다 사용하면 lock을 풀었죠. 그런데 lock이 풀린 것을 기다려야만 했고, 그 기다림을 감지해서 다시 원 동작을 하는 것도 쉽지 않았습니다.
그래서 나온 것이 actor입니다.
Actor는:
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(await counter.value) // await 필요
// counter.value += 1 // ERROR! Actor-isolated property 'value' cannot be mutated from a nonisolated context
}
위 에러에서 isolated라는 말이 나왔습니다.

액터 내부의 메서드는 한 번에 하나씩만 실행되도록 Swift가 보장하기 때문에 내부에서는 상태에 직접 접근해도 데이터 레이싱이 발생할 위험이 없습니다. 그래서 await 키워드 없이도 value를 자유롭게 변경할 수 있죠.
외부에서 접근할 경우에는 read의 경우에도 await 키워드가 필요합니다. 즉 await는 액터의 격리 경계를 넘어 접근할 때 필요하다고 생각하면 됩니다.
await 키워드는 컴파일러에게 이 동작은 일시정지할 수 있음을 알려줍니다. 비동기가 필요하다는 것을 정적으로(statically) 알 수 있습니다.
await는 작업이 일시정지되고 다른 작업으로 CPU를 양보할 수 있죠. 이것이 바로 액터가 여러 태스크의 접근을 직렬화하면서도 전체 프로그램의 효율성을 유지할 수 있는 비결입니다.
await 키워드 때문에 태스크가 잠시 멈췄다가 다시 실행될 때, 그동안 세상(프로그램의 다른 부분)은 계속 변했을 수 있습니다.
await라고 하는 것은 일시중지되고 다른 곳에 CPU를 양보한다는 것은 세상이 변할 수 있다는 뜻입니다. 세상이 변한다는 것은 내가 접근하려고 하는 가변 공유 자원에 대해서 가변될 수 있다는 것이죠.
이것이 액터의 재진입성 때문에 생길 수 있는 문제입니다.
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
// Potential bug: `cache` may have changed.
cache[url] = image
return image
}
}
해당 코드는 잘 짜여있는 코드처럼 보입니다. 그런데 자세히 살펴볼까요?
let downloader = ImageDownloader()
let url = URL(string: "https://어떤다운로드url")!
Task.detached { // 태스크1
Image(await downloader.image(from: url))
}
Task.detached { // 태스크2
Image(await downloader.image(from: url))
}
동일한 이미지일까요?
이것이 바로 await 지점에서 발생할 수 있는 '무효화된 가정(invalidated assumption)' 문제입니다.
이 문제는 await 후에 가정을 다시 확인하거나, 중복 다운로드 자체를 방지하여 해결할 수 있습니다.
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
// Replace the image only if it is still missing from the cache.
cache[url] = cache[url, default: image]
return cache[url]
}
}
더 나은 해결책은 다운로드 중인 작업을 추적하여 중복 다운로드를 방지하는 것입니다.
actor ImageDownloader {
private enum CacheEntry {
case inProgress(Task<Image, Error>)
case ready(Image)
}
private var cache: [URL: CacheEntry] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
switch cached {
case .ready(let image):
return image
case .inProgress(let task):
return try await task.value
}
}
let task = Task {
try await downloadImage(from: url)
}
cache[url] = .inProgress(task)
do {
let image = try await task.value
cache[url] = .ready(image)
return image
} catch {
cache[url] = nil
throw error
}
}
}
두 번째 방법이 더 좋은 이유는 await를 하지 않고 먼저 cached에 진행중임을 넣기 때문에 중복 다운로드를 막게 됩니다.
액터 격리는 액터 타입의 근본적인 동작입니다. 액터 격리는 프로토콜, 클로저, 클래스 등 다른 언어 기능과 상호작용합니다.
그 다음에는 nonisolated 키워드에 대해서 한번 알아봅시다.
nonisolated 키워드는 격리되지 않은 상태이기 때문에 접근할 때 await가 필요하지 않습니다. 그런데 actor에서 모든 상태를 보호하는 게 좋지 않을까요?
예를 들어, let name: String처럼 상수 프로퍼티를 가진 액터가 있다고 가정해 봅시다. 이 name은 절대로 변경되지 않는데, 굳이 await를 통해 접근해야 할까요?
액터는 프로토콜을 준수할 수 있습니다. Equatable과 같은 프로토콜의 정적 메서드는 액터와 격리되어 있으므로 액터의 불변(immutable) 상태에만 접근해야 합니다.
actor LibraryAccount {
let idNumber: Int
var booksOnLoan: [Book] = []
}
extension LibraryAccount: Equatable {
static func ==(lhs: LibraryAccount, rhs: LibraryAccount) -> Bool {
lhs.idNumber == rhs.idNumber
}
}
Equatable 프로토콜의 == 메서드에 nonisolated가 필요 없는 이유는 == 메서드가 static이기 때문입니다. static 메서드는 특정 인스턴스에 속하지 않으므로, 액터의 내부 상태에 접근할 수 없습니다. 따라서 액터의 격리 경계 밖에 있는 것처럼 취급되므로, 별도로 nonisolated를 붙이지 않아도 되는 거죠.
Hashable 프로토콜의 hash(into:) 메서드에 nonisolated를 붙여야 하는 이유는, 이 메서드가 액터의 가변 상태(booksOnLoan)에 접근하지 않고, idNumber와 같은 안전한 불변(immutable) 상태에만 접근하기 때문입니다. 또한 이 메서드가 동기적으로(synchronously) 작동해야 한다는 요구사항도 맞습니다.
actor LibraryAccount {
let idNumber: Int
var booksOnLoan: [Book] = []
}
extension LibraryAccount: Hashable {
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(idNumber)
}
}
클로저도 액터 isolated되거나 nonisolated 할 수 있습니다. 액터 내부에서 생성된 클로저는 액터-격리 상태를 상속받습니다.
extension LibraryAccount {
func readSome(_ book: Book) -> Int { ... }
func read() -> Int {
booksOnLoan.reduce(0) { book in
readSome(book)
}
}
}
즉 read() 메서드 내부 클로저에서는 readSome()을 호출함에도 await 키워드가 필요하지 않습니다.
반면, Task.detached와 같이 액터 외부에서 병행(concurrently)으로 실행되는 클로저는 비격리입니다. 따라서 액터의 메서드를 호출할 때 await가 필요합니다.
extension LibraryAccount {
func readSome(_ book: Book) -> Int { ... }
func read() -> Int { ... }
func readLater() {
Task.detached {
await read()
}
}
}
readLater() 함수는 Task.detached 내부에서 read()를 호출합니다. 이 Task.detached 클로저는 액터 격리 밖에 있는 코드입니다. 즉, 액터의 보호를 받지 않는 '바깥 세상'에 있죠. '바깥 세상'에서 액터의 '보호된 방' 안으로 들어가려면 반드시 await 키워드를 사용해야 합니다.
이처럼 동시성 환경에서 안전하게 값을 공유할 수 있는 타입을 Swift에서 Sendable 프로토콜로 정의합니다.
Sendable 타입은 값들이 서로 다른 액터 사이에서 안전하게 공유될 수 있는 타입입니다. 값 타입은 복사본을 만들어 전달하므로 Sendable입니다. 액터 타입도 내부 상태에 대한 동기화를 제공하므로 Sendable입니다.
actor LibraryAccount {
let idNumber: Int
var booksOnLoan: [Book] = []
func selectRandomBook() -> Book? { ... }
}
struct Book {
var title: String
var authors: [Author]
}
func visit(_ account: LibraryAccount) async {
guard var book = await account.selectRandomBook() else {
return
}
book.title = "\(book.title)!!!" // OK: modifying a local copy
}
Book이 struct와 같은 값 타입일 때, selectRandomBook()은 Book의 복사본을 반환하므로 안전합니다.
그러나 Book이 클래스일 경우, selectRandomBook()은 액터의 가변 상태에 대한 참조(reference)를 반환하게 되어 데이터 레이스 가능성이 생깁니다.
actor LibraryAccount {
let idNumber: Int
var booksOnLoan: [Book] = []
func selectRandomBook() -> Book? { ... }
}
class Book {
var title: String
var authors: [Author]
}
func visit(_ account: LibraryAccount) async {
guard var book = await account.selectRandomBook() else {
return
}
book.title = "\(book.title)!!!" // Not OK: potential data race
}
Swift는 Sendable 프로토콜을 통해 타입의 동시성 안전성을 검사합니다.
struct Book: Sendable {
var title: String
var authors: [Author]
}
제네릭 타입의 경우, 조건부 적합(conditional conformance)을 사용하여 Sendable을 전파할 수 있습니다. 예를 들어, Pair 타입은 양쪽 인자가 모두 Sendable일 때만 Sendable이 됩니다.
struct Pair<T, U> {
var first: T
var second: U
}
extension Pair: Sendable where T: Sendable, U: Sendable {
}
동시성의 경계라는 말도 Sendable에서 나옵니다. 동시성의 경계란 스레드 간 데이터를 주고받는 것을 의미합니다.
actor Container { ... }
let container = Container(...)
Task {
// 동시성 경계
await container.updateProgress(myData)
}
Main Actor라는 특별한 actor가 있습니다. 이는 전역(global) 액터로 메인스레드에서만 동작하도록 하는 액터입니다.
DispatchQueue.main.async { }로 하면 메인스레드에서 동일하게 동작하지 않냐고요? 맞습니다. 하지만 다른 점이 있습니다. 어떤 동작을 main 스레드에서 하는 것을 컴파일단계에서 강제할 수 있기 때문에 안정성이 올라갑니다.
기존에는 DispatchQueue.main.async를 사용하여 메인 스레드에서 코드를 실행해야 했습니다.
func checkedOut(_ booksOnLoan: [Book]) {
booksView.checkedOutBooks = booksOnLoan
}
// Dispatching to the main queue is your responsibility.
DispatchQueue.main.async {
checkedOut(booksOnLoan)
}
Swift 동시성에서는 @MainActor 속성을 사용하여 선언이 반드시 메인 액터에서 실행되어야 한다고 명시할 수 있습니다.
@MainActor func checkedOut(_ booksOnLoan: [Book]) {
booksView.checkedOutBooks = booksOnLoan
}
// Swift ensures that this code is always run on the main thread.
await checkedOut(booksOnLoan)
메서드에 붙힐 수도 있고 혹은 객체 전체에 붙힐 수도 있습니다.
타입 전체에 @MainActor를 붙여 해당 타입의 모든 멤버를 메인 액터에 속하게 할 수도 있습니다. 개별 메서드는 nonisolated 키워드로 제외할 수 있습니다.
주의할 점은 객체 전체에 한다면 모든 멤버를 메인 액터에 속하게 하는 것임으로 오래걸리는 메서드가 존재한다면 이는 격리를 해제시켜줘야합니다.
@MainActor class MyViewController: UIViewController {
func onPress(...) { ... } // implicitly @MainActor
nonisolated func fetchLatestAndDisplay() async { ... }
}
주로 MVVM 패턴에서는 다음과 같은 경우에 분리해주는 케이스가 많을 것입니다. 그리고 main 스레드로 돌아오는 일이요.
@MainActor class MyViewModel: ObservableObject {
@Published var data: String = ""
func onButtonTapped() async {
// UI와 관련된 작업은 메인 스레드에서
print("UI 작업 시작")
// 오래 걸리는 작업은 nonisolated 메서드에서 호출
let fetchedData = await fetchData()
// 결과로 UI 업데이트
self.data = fetchedData
print("UI 작업 완료")
}
nonisolated func fetchData() async -> String {
// 이 메서드는 메인 액터와 분리되어 백그라운드에서 실행
print("데이터 요청 시작 (백그라운드)")
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3초 대기
print("데이터 요청 완료 (백그라운드)")
return "새로운 데이터"
}
}
또는 Task.detached를 사용하는 것입니다.
@MainActor class MyViewModel: ObservableObject {
@Published var data: String = ""
func onButtonTapped() {
Task.detached {
// 이 블록은 백그라운드에서 실행
print("데이터 요청 시작 (백그라운드)")
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3초 대기
print("데이터 요청 완료 (백그라운드)")
// UI 업데이트를 위해 메인 액터로 돌아옴
let fetchedData = "새로운 데이터"
// await를 통해 @MainActor인 self에 접근
await MainActor.run {
self.data = fetchedData
}
}
}
}