Swift

Swift Concurrency - Swift Actors로 Mutable State 보호하기 (4)

빨간체리반지 2026. 2. 24. 11:25

Reference

WWDC21: Protect mutable state with Swift actors | Apple

동시성 프로그래밍의 어려움

Data Race란?

2개 이상의 스레드에서 동시에 같은 데이터에 접근할 때, 최소 하나 이상의 쓰기(Write) 작업이 발생하면 생기는 동시성 문제.

 

Data Race는 타이밍에 따라 결과가 달라지는 특성이 있어 디버깅이 매우 어렵다.

코드로 예를 들면 아래와 같고, 상황에 따라 각 print문에서 1/2 혹은 1/1 혹은 2/2 가 프린트 될 수 있다.

class Counter {
	var value = 0
	
	func increment() -> Int {
		value = value + 1 // read & write
		return value
	}
}

let counter = Counter()

Task.detached {
	print(counter.increment()) // 🖨️
}

Task.detached {
	print(counter.increment()) // 🖨️
}

 

Value Semantics를 통한 Data Race 예방

Value Semantics란?

Swift에서 사용하는 메모리 효율을 높이기 위한 방식이다.

이는 객체에 대한 참조 사용을 줄이고 값 중심으로 데이터를 다룸으로써, 불필요한 메모리 사용을 최소화하려는 프로그래밍 패턴을 의미한다.

 

Data Race는 '공유되는 가변 상태' 때문에 발생한다.

따라서 Value Semantics를 이용하면, data race를 방지할 수 있다.

1. Value Type 사용

이를 피하는 첫 번째 방법은 struct와 같은 값 타입(Value type)을 사용하는 것이다.

값 타입은 변형이 지역적(Local)으로 일어나며, 불변(let) 프로퍼티는 완벽히 안전하다.

 

위에서 봤던 class Counter 코드 예시를 struct Counter로 변경해보면 아래와 같다.

struct Counter {
	var value = 0
	
	mutating func increment() -> Int {
		value = value + 1 // read & write
		return value
	}
}

var counter = Counter()

Task.detached {
	var counter = counter // (new copy)
	print(counter.increment()) // 🖨️
}

Task.detached {
	var counter = counter // (new copy)
	print(counter.increment()) // 🖨️
}


항상 1/1 이 프린트 되며, data race를 방지할 수 있다.
🤔 하지만 우리가 원하는 건 'value' 프로퍼티의 값을 공유하면서 data race를 방지하는 것이다.

(프로퍼티 값을 공유하면서 data race를 방지하는 방법은 Actor을 설명하면서 이어하겠다)

 

2. 컬렉션의 COW 매커니즘 사용

Swift의 배열(Array), 딕셔너리(Dictionary)와 같은 컬렉션 타입은 COW(Copy-on-Write) 메커니즘을 사용하므로,

데이터를 할당할 때가 아니라 실제 변경(write)이 일어날 때만 복사되어 안전하게 사용할 수 있다.

 

아래 코드를 예로 들면,

var array1 = [1, 2]
var array2 = array1 // array1 과 array2 는 같은 버퍼를 공유 (copy X)

array1.append(3) // (new copy)
array2.append(4) // (original)

print(array1) // [1, 2, 3]
print(array2) // [1, 2, 4]

 

var array2 = array1이 호출될 때에는

array1 배열이 array2 배열로 copy되는 것이 아닌, array1 과 array2 는 같은 버퍼를 공유하게 된다.

따라서 참조 카운트(reference count)값은 2이 된다.

 

하지만 array1.append(3)이 호출될 때에는

값이 변경되므로 기존 버퍼와 동일한 값을 갖도록 copy되고, copy된 버퍼에 '3'이 append된다.

 

array2.append(4)이 호출될 때에는 copy가 필요하지 않으므로, 기존 버퍼를 그대로 사용하게 된다.

 

Shared Mutable State와 기존 동기화 방식의 한계

항상 값 타입만 사용할 수는 없으며, 때로는 여러 스레드에서 동시 작업에서 데이터를 공유하고 수정할 수 있는 상태일 필요가 있다.

이를 위해 기존에는 Atomics, Locks, Serial dispatch queues 등의 동기화 도구를 사용했었다.

하지만 이러한 도구들은 완벽하게 이해하고 매번 정확하게 사용해야만 Data Race를 막을 수 있다는 치명적인 단점이 있었다.


Actor의 등장과 특징

이러한 문제를 해결하기 위해 Swift에 기본 내장된 동기화 메커니즘이 바로 Actor이다.

 

주요 특징:

  • 독립적인 상태(Actor Isolation): Actor는 고유한 state를 가지며, 이 state는 외부로부터 철저히 격리된다.
  • 상호 배제(Mutual Exclusion): Actor 내의 state에는 한 번에 오직 하나의 작업만 접근할 수 있다.
  • 참조 타입(Reference Type): class처럼 참조 타입이며, 프로퍼티, 함수, 상속 모두 가능하다.
actor Counter { // actor 로만 변경하면, data race 가 보장된다.
	var value = 0
	
	func increment() -> Int {
		value = value + 1
		return value
	}
}

let counter = Counter()

Task.detached {
	print(await counter.increment()) // 👈 await
}

Task.detached {
	print(await counter.increment()) // 👈 await
}

 

위 처럼 코드를 actor 변경하면, 상황에 따라 1/2 혹은 2/1이 프린트 되며,

아래와 같은 Actor의 특성으로 data race 방지가 되는 것이다.

  • Actor 외부에 있는 코드가 Actor 내부 state에 접근하려면 반드시 await 키워드를 사용하여 비동기적으로 상호작용해야 한다.
  • 만약 Actor가 이미 다른 작업을 처리하느라 바쁘다면, 새로 요청된 코드는 일시 중단(Suspend) 되고 해당 스레드(CPU)는 다른 작업을 수행한다.
  • 이후 Actor가 free되면, suspend 되었던 코드를 다시 재개(Resume) 하여 작업을 마저 수행한다.

 

Actor Reentrancy(재진입성) 주의사항

⚠️ 단, Actor 내에서 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) // reentrancy 발생
        
        cache[url] = image
        return image
    }
}

 

위 코드 예시에서 동일한 URL로 image(from:) 함수가 동시에 2번 호출되었고, 두 호출 모두 await에서 suspend된 상태라고 가정하자.

그렇게 되면 각각의 호출이 resume되는 시점에 cache가 중복 업데이트되거나 덮어씌워지는 버그가 발생할 수 있다.
data race는 없지만 캐싱기능을 제대로 수행하지 못하게 된다.

 

해결 방법:

  1. await 이후에, 중단되었던 시간 동안 상태가 변경되지 않았는지 (예: 캐시에 이미 값이 있는지) 다시 확인해야 하거나
  2. await 호출 전에 작업이 현재 진행 중(Pending / In Progress)임을 Dictionary 등에 저장해두어, 중복 실행을 차단해야 한다.

 

Actor와 nonisolated 함수

Swift에서는 Actor 내부에 있지만 실제로는 Actor 외부에 있는 것처럼 동작하도록 nonisolated 키워드를 제공한다.

nonisolated가 적용된 함수는 Actor 외부에서 await 없이 호출할 수 있다!

 

단, Actor isolation에서 벗어났으므로

Actor의 mutable state(= var로 선언된 프로퍼티)에는 접근할 수 없고, 오직 불변 상태(let)에만 접근 가능하다.

 

nonisolated 함수는 Actor에서 Equatable, Hashable 등의 프로토콜을 채택하고자 할 때, 동기적(Synchronous)으로 호출되어야 하는 delegate 함수들을 구현할 때 자주 사용되며, 코드 예시는 아래와 같다.

actor LibraryAccount {
    let idNumber: Int
    var booksOnLoan: [Book] = []
}

extension LibraryAccount: Hashable {
    nonisolated func hash(into hasher: inout Hasher) { // 👈 nonisolated 키워드 사용
        hasher.combine(idNumber)
    }
}

 

Actor와 Closure

Actor 내에서 동기적으로 호출되는 클로저(예: reduce)는 Actor-isolated하므로 await가 필요 없다.

하지만 Task.detached 내부의 클로저처럼 동시적으로 실행되는 클로저는 더 이상 Actor-isolated하지 않으므로,
Actor 메서드를 호출할 때 await 키워드가 반드시 필요하다.

 

extension LibraryAccount {
    func readSome(_ book: Book) -> Int { ... }

    func read() -> Int {
        booksOnLoan.reduce(0) { book in // Actor-isolated O
            readSome(book) // await 필요하지 않음
        }
    }

    func readLater() {
        Task.detached { // Actor-isolated X
            await self.read() // await 필요
        }
    }
}

그렇다면... 아래 코드 예시와 같이 다른 Actor 사이에서 Reference Type 데이터를 주고 받으면 어떻게 될까?

actor LibraryAccount {
    let idNumber: Int
    var booksOnLoan: [Book] = []
    func selectRandomBook() -> Book? { … }
}

class Book {
    var title: String
    var authors: [Author]
}

// -------

// actor 외부에서 구현된 함수
func visit(_ account: LibraryAccount) async {
    guard var book = await account.selectRandomBook() else {
        return // 🚫 data race 발생 위험!
    }
    book.title = "\(book.title)!!!"
}

 

selectRandomBook()가 actor 경계를 넘어 Book 인스턴스를 외부로 전달하고 있고,
Book은 mutable한 참조 타입(class) 이라서 data race 위험이 있다.

 

더 자세히 설명하면 아래 코드가 호출 될 때,

LibraryAccount는 actor 이고, visit 함수는 actor 외부에서 호출된다.

게다가 await는 actor의 격리 영역(isolation)을 벗어난다는 신호이다.

guard var book = await account.selectRandomBook() else { ... }

 

따라서 아래와 같이 동시에 다른 Task에서 접근하면?

// actor 내부
account.booksOnLoan[0].title = "Swift"

// actor 외부
book.title = "Swift!!!"


data race가 발생할 수 있는 것이다.

따라서 Swift에는 이러한 상황을 컴파일 단계에서 차단하고자 Sendable 이라는 프로토콜이 존재한다.

 

Sendable

Sendable은 동시성(Concurrency) 환경에서 서로 다른 액터(Actor) 간에 값을 안전하게 공유할 수 있는 타입을 의미한다.

동시성 코드나 액터 간에 데이터를 주고받을 때는 기본적으로 Sendable 타입을 사용해야 하며, 이를 통해 컴파일러 단계에서 데이터 경합(Data race)을 방지할 수 있다.

 

단, 아래와 같은 조건이 만족하는 경우에만 Sendable 프로토콜을 만족할 수 있다.

  • 값 타입 (Value Types): 구조체(struct)와 같은 값 타입은 복사할 때마다 독립적인 값을 가지므로 기본적으로 안전하여 Sendable이 될 수 있습니다. 단, 구조체 내부의 모든 저장 프로퍼티가 Sendable 타입이어야만 구조체 전체가 Sendable로 인정됩니다.
  • 액터 (Actor Types): 액터는 내부적으로 가변 상태에 대한 접근을 자체적으로 동기화(상호 배제)하므로 그 자체로 Sendable입니다.
  • 제네릭 및 컬렉션 타입: 배열(Array)과 같은 제네릭 타입은 내부에 담긴 요소(Generic arguments)가 Sendable일 때만 조건부로 Sendable이 됩니다.

클래스는 참조 타입(Reference Type)이며 가변 상태를 가지기 때문에 대부분의 클래스는 Sendable이 아니다.

만약 클래스를 여러 액터 간에 공유하면, 각 액터가 동일한 가변 상태에 동시 접근하여 데이터 경합이 발생할 위험이 있다.

 

단, 클래스가 Sendable이 될 수 있는 예외적인 경우는 아래와 같다.

  • 클래스와 그 하위 클래스가 오직 불변 데이터(Immutable data)(= let 프로퍼티)만 가지고 있거나
  • 락(Lock) 등을 사용하여 내부적으로 동기화 처리를 완벽하게 해둔 경우

 

Sendable 사용 예시:

struct Pair<T, U> {
	var first: T
	var second: U
}

// 이렇게 확장해서 사용할 수도 있다!
extension Pair: Sendable where T: Sendable, U: Sendable {
}

 

@Sendable 클로저

함수나 클로저 역시 다른 액터로 전달될 수 있으므로 Sendable 개념이 적용되며, 이 경우 @Sendable 어트리뷰트를 사용해야한다.

 

@Sendable 함수 예시:

let work: @Sendable () -> Void = { // Sendable 프로토콜을 채택한다는 뜻
    print("Safe closure")
}

 

 

단, data race를 막기 위해 @Sendable 클로저는 다음의 엄격한 제약 조건을 따른다.

  1. 클로저 외부의 가변 지역 변수(Mutable local variable)를 캡처할 수 없다.
  2. 클로저가 캡처하는 모든 값은 반드시 Sendable 타입이어야 한다.
  3. 동기적으로 동작하는 Sendable 클로저는 특정 액터에 격리(Actor-isolated)될 수 없다.
// 1
var count = 0
let task: @Sendable () -> Void = {
    count += 1  // ❌ mutable capture
}

// 2
let number: Int = 42
let task: @Sendable () -> Void = {
    print(number) // ✅ immutable capture
}

// 3
static func detached(operation: @Sendable () async -> Success) -> Task<Success, Never>

actor Counter {
    var value = 0
    
    func increment() {
        Task.detached {
            self.value += 1   // ❌ await 을 통해서 접근해야 한다.
        }
    }
}

Main Actor (@MainActor)

Main Actor는 메인 스레드(Main thread)를 추상화한 특수한 Actor이다.

UI 업데이트와 같이 반드시 메인 스레드에서 수행되어야 하는 작업들을 안전하게 처리하기 위해 사용한다.

 

클래스나 뷰 컨트롤러, 특정 함수에 @MainActor를 붙이면 해당 코드가 항상 메인 스레드에서 실행된다.

@MainActor
func checkedOut(_ booksOnLoan: [Book]) {
    booksView.checkedOutBooks = booksOnLoan // UI Updates
}

// 다른 actor 와 마찬가지로 actor 외부에서 접근시 await 필요한다
await checkedOut(booksOnLoan)

 

 

일반 Actor와 마찬가지로, 메인 액터 내부에서도 메인 스레드 실행이 필요 없는 무거운 작업 등은 nonisolated로 표기하여 격리에서 제외할 수 있다.

@MainActor 
class MyViewController: UIViewController {
    func onPress(…) { … } // implicitly @MainActor

    nonisolated func fetchLatestAndDisplay() async { … }
}

유익했다면 댓글/공감 남겨주세요~~ 작성자에게 큰 힘이 됩니다 ☺️

 

(이전 글) 👉 Swift Concurrency - Task cancellation과 hierarchy 심화 (3)

(다음 글) 👉 Swift Concurrency - Concurrency와 Thread (5)