WWDC 2021에서 소개된 Swift Actors가 어떻게 데이터 레이스를 방지하고 안전한 동시성 프로그래밍을 가능하게 하는지 알아봅시다.
현대 앱 개발에서 동시성(Concurrency)은 피할 수 없는 요소입니다. 사용자 인터페이스의 반응성을 유지하면서 백그라운드에서 데이터를 처리하고, 네트워크 요청을 수행하며, 복잡한 계산을 처리해야 합니다. 하지만 동시성 프로그래밍은 항상 데이터 레이스(Data Race)라는 까다로운 문제를 동반합니다.
class Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(counter.increment()) // 데이터 레이스!
}
Task.detached {
print(counter.increment()) // 데이터 레이스!
}
위 코드는 언뜻 보기에는 무해해 보이지만, 실제로는 심각한 데이터 레이스 문제를 안고 있습니다. 두 태스크가 동시에 같은 value 프로퍼티에 접근하면서 예측할 수 없는 결과를 만들어낼 수 있습니다.
Swift는 처음부터 Value Semantics를 강조해왔습니다. 값 타입을 사용하면 각 인스턴스가 독립적인 복사본을 가지므로 데이터 레이스를 원천적으로 방지할 수 있습니다.
var array1 = [1, 2]
var array2 = array1
array1.append(3)
array2.append(4)
print(array1) // [1, 2, 3]
print(array2) // [1, 2, 4]
하지만 Value Semantics만으로는 모든 문제를 해결할 수 없습니다. 때로는 공유된 가변 상태(Shared Mutable State)가 필요한 경우가 있기 때문입니다.
struct Counter {
var value = 0
mutating func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
var localCounter = counter
print(localCounter.increment()) // 항상 1 출력
}
Task.detached {
var localCounter = counter
print(localCounter.increment()) // 항상 1 출력
}
위 코드는 데이터 레이스는 없지만, 우리가 원하는 동작(공유된 카운터)을 수행하지 못합니다.
지금까지는 다음과 같은 동기화 메커니즘들을 사용해왔습니다:
하지만 이러한 방법들은 모두 개발자의 세심한 주의를 요구합니다. 실수로 동기화를 빼먹거나 잘못 사용하면 데이터 레이스가 발생할 수 있습니다.
Actor는 Swift 5.5에서 도입된 새로운 동시성 모델입니다. Actor는 다음과 같은 특징을 가집니다:
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(await counter.increment()) // 안전한 접근
}
Task.detached {
print(await counter.increment()) // 안전한 접근
}
Actor의 핵심은 Actor Isolation입니다. Actor 외부에서 Actor에 접근할 때는 반드시 비동기적으로 접근해야 하며, 이때 await 키워드를 사용합니다.
extension Counter {
func resetSlowly(to newValue: Int) {
value = 0 // 동기적 접근 (Actor 내부)
for _ in 0..<newValue {
increment() // 동기적 호출 (Actor 내부)
}
assert(value == newValue)
}
}
Actor 내부에서는 동기적으로 상태에 접근할 수 있지만, 외부에서는 반드시 await를 사용해야 합니다.
Actor는 재진입(Reentrancy)을 허용합니다. 이는 데드락을 방지하고 진행을 보장하지만, 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)
// 여기서 문제! cache가 변경되었을 수 있음
cache[url] = image
return image
}
}
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)
// await 이후 가정을 다시 확인
cache[url] = cache[url, default: image]
return cache[url]
}
}
Actor도 다른 타입처럼 프로토콜을 구현할 수 있습니다:
actor LibraryAccount {
let idNumber: Int
var booksOnLoan: [Book] = []
}
extension LibraryAccount: Equatable {
static func ==(lhs: LibraryAccount, rhs: LibraryAccount) -> Bool {
lhs.idNumber == rhs.idNumber
}
}
extension LibraryAccount: Hashable {
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(idNumber)
}
}
nonisolated 키워드는 해당 메서드가 Actor 외부에서 실행됨을 의미하며, 따라서 가변 상태에 접근할 수 없습니다.
Actor 간에 안전하게 전송할 수 있는 타입을 Sendable이라고 합니다:
struct Book: Sendable {
var title: String
var authors: [Author] // Author도 Sendable이어야 함
}
// 조건부 Sendable 적합성
struct Pair<T, U> {
var first: T
var second: U
}
extension Pair: Sendable where T: Sendable, U: Sendable {}
// Sendable 클로저는 가변 지역 변수를 캡처할 수 없음
var counter = 0
Task.detached { // @Sendable 클로저
counter += 1 // 컴파일 에러!
}
UI 작업은 반드시 메인 스레드에서 실행되어야 합니다:
// 기존 방식
func updateUI() {
DispatchQueue.main.async {
// UI 업데이트 코드
booksView.checkedOutBooks = booksOnLoan
}
}
@MainActor
func checkedOut(_ booksOnLoan: [Book]) {
booksView.checkedOutBooks = booksOnLoan
}
// 사용시
await checkedOut(booksOnLoan) // Swift가 메인 스레드 실행을 보장
@MainActor
class MyViewController: UIViewController {
func onPress() {
// 암시적으로 @MainActor
}
nonisolated func fetchData() async {
// 메인 액터에서 제외
}
}
actor DatabaseManager {
private var connections: [DatabaseConnection] = []
private let maxConnections = 10
// 동기 작업은 한 번에 완료되도록 설계
func addConnection(_ connection: DatabaseConnection) {
guard connections.count < maxConnections else { return }
connections.append(connection)
}
// 비동기 작업 후 상태 확인
func executeQuery(_ query: String) async throws -> [Row] {
guard let connection = connections.first else {
throw DatabaseError.noConnection
}
let result = try await connection.execute(query)
// await 후 상태 재확인
if connections.isEmpty {
throw DatabaseError.connectionLost
}
return result
}
}
// ✅ 올바른 Sendable 구조체
struct UserData: Sendable {
let id: UUID
let name: String
let email: String
}
// ✅ Sendable 클래스 (불변 데이터)
final class ImmutableConfig: Sendable {
let apiURL: URL
let timeout: TimeInterval
init(apiURL: URL, timeout: TimeInterval) {
self.apiURL = apiURL
self.timeout = timeout
}
}
actor UserManager {
private var users: [UUID: User] = [:]
func addUser(_ user: User) {
users[user.id] = user
}
func getUser(id: UUID) -> User? {
return users[id]
}
}
actor NotificationManager {
private let userManager: UserManager
init(userManager: UserManager) {
self.userManager = userManager
}
func sendNotification(to userID: UUID, message: String) async {
guard let user = await userManager.getUser(id: userID) else {
return
}
// 알림 전송 로직
await sendPushNotification(to: user, message: message)
}
}
actor DataProcessor {
private var queue: [DataItem] = []
// ❌ 비효율적: 개별 아이템 처리
func processItem(_ item: DataItem) async {
// 처리 로직
}
// ✅ 효율적: 배치 처리
func processBatch(_ items: [DataItem]) async {
queue.append(contentsOf: items)
while !queue.isEmpty {
let batch = Array(queue.prefix(100))
queue.removeFirst(min(100, queue.count))
await processBatchInternal(batch)
}
}
}
// Before: 기존 클래스 + 락
class ThreadSafeCounter {
private var _value = 0
private let lock = NSLock()
var value: Int {
lock.withLock { _value }
}
func increment() -> Int {
lock.withLock {
_value += 1
return _value
}
}
}
// After: Actor 사용
actor Counter {
private var _value = 0
var value: Int { _value }
func increment() -> Int {
_value += 1
return _value
}
}
Swift Actors는 동시성 프로그래밍의 패러다임을 바꾸는 혁신적인 기능입니다. 기존의 수동적인 동기화 방식에서 벗어나 언어 수준에서 안전성을 보장하는 새로운 접근 방식을 제공합니다.