Swift Actors: 동시성 프로그래밍의 새로운 패러다임

Ios_Roy·2025년 9월 22일

WWDC

목록 보기
4/13
post-thumbnail

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 프로퍼티에 접근하면서 예측할 수 없는 결과를 만들어낼 수 있습니다.

기존 해결책들의 한계

Value Semantics의 부분적 해결

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 출력
}

위 코드는 데이터 레이스는 없지만, 우리가 원하는 동작(공유된 카운터)을 수행하지 못합니다.

기존 동기화 방법들의 문제점

지금까지는 다음과 같은 동기화 메커니즘들을 사용해왔습니다:

  • Locks (NSLock, pthread_mutex 등)
  • Atomic Operations
  • Serial Dispatch Queues
  • Operation Queues

하지만 이러한 방법들은 모두 개발자의 세심한 주의를 요구합니다. 실수로 동기화를 빼먹거나 잘못 사용하면 데이터 레이스가 발생할 수 있습니다.

Swift Actors: 근본적인 해결책

Actor의 핵심 개념

Actor는 Swift 5.5에서 도입된 새로운 동시성 모델입니다. Actor는 다음과 같은 특징을 가집니다:

  1. 자체 상태 격리: Actor는 내부 상태를 외부로부터 격리합니다
  2. 동기화된 접근: Actor 내부 상태에 대한 접근은 항상 동기화됩니다
  3. 언어 수준 보장: 컴파일러가 안전성을 보장합니다
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 Isolation의 작동 원리

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는 재진입(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]
    }
}

Protocol Conformance와 Actor

Static Methods와 Nonisolated

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 외부에서 실행됨을 의미하며, 따라서 가변 상태에 접근할 수 없습니다.

Sendable: 안전한 데이터 전송

Sendable 프로토콜의 중요성

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 클로저

// Sendable 클로저는 가변 지역 변수를 캡처할 수 없음
var counter = 0

Task.detached {  // @Sendable 클로저
    counter += 1  // 컴파일 에러!
}

Main Actor: UI 스레드의 안전한 관리

기존 방식의 문제점

UI 작업은 반드시 메인 스레드에서 실행되어야 합니다:

// 기존 방식
func updateUI() {
    DispatchQueue.main.async {
        // UI 업데이트 코드
        booksView.checkedOutBooks = booksOnLoan
    }
}

Main Actor를 사용한 개선

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

// 사용시
await checkedOut(booksOnLoan)  // Swift가 메인 스레드 실행을 보장

Main Actor 타입

@MainActor
class MyViewController: UIViewController {
    func onPress() {
        // 암시적으로 @MainActor
    }
    
    nonisolated func fetchData() async {
        // 메인 액터에서 제외
    }
}

실전 활용 가이드

1. Actor 설계 원칙

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
    }
}

2. Sendable 타입 활용

// ✅ 올바른 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
    }
}

3. Actor 간 통신

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 오버헤드 최소화

  1. 배치 작업: 여러 개의 작은 작업을 하나로 묶어서 처리
  2. 동기 코드 우선: Actor 내부에서는 가능한 한 동기 코드 사용
  3. 적절한 분할: 너무 큰 Actor는 병목이 될 수 있음
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)
        }
    }
}

마이그레이션 가이드

기존 코드에서 Actor로 전환

// 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
    }
}

단계적 도입 전략

  1. 새로운 컴포넌트부터 Actor 적용
  2. Sendable 프로토콜부터 도입
  3. 기존 동기화 코드 점진적 대체
  4. Main Actor로 UI 코드 안전화

결론

Swift Actors는 동시성 프로그래밍의 패러다임을 바꾸는 혁신적인 기능입니다. 기존의 수동적인 동기화 방식에서 벗어나 언어 수준에서 안전성을 보장하는 새로운 접근 방식을 제공합니다.

핵심 장점

  1. 컴파일 타임 안전성: 데이터 레이스를 컴파일 시점에 방지
  2. 자연스러운 문법: 기존 Swift 문법과 일관성 있는 설계
  3. 성능 최적화: 런타임 오버헤드 최소화
  4. 점진적 도입: 기존 코드와의 호환성
profile
iOS 개발자 공부하는 Roy

0개의 댓글