Swift Concurrency: Global Actor Inference (전역 액터 추론)

틀틀보·2025년 11월 8일

Swift Concurency

목록 보기
6/11

컴파일러가 코드의 문맥(context)을 분석하여 개발자가 명시적으로 @MainActor와 같은 전역 액터를 선언하지 않아도, 해당 코드가 특정 전역 액터에서 실행되어야 함을 자동으로 추론하고 적용하는 기능

왜 필요할까?

UI의 업데이트는 항상 메인 스레드에서 이루어져야 한다.

  • @MainActor 도입 이전에는 개발자가 잊지 않고 DispatchQueue.main.async 작성해줬어야 했음.

  • 이제는 자동으로 @MainActor를 써야함을 추론해서 적용해줌.

일일이 @MainActor 붙여주기 번거로움

  • 모든 ViewController 등 UI와 관련된 클래스, 메서드, 프로퍼티는 @MainActor에서 동작해야 함.

  • 일일이 @MainActor 키워드를 붙이지 않아도 되게 구현

추론되는 규칙

1. 상속

부모 클래스가 특정 전역 액터(예: @MainActor)로 격리되어 있다면, 그 클래스를 상속받는 자식 클래스와 그 멤버들도 자동으로 동일한 전역 액터로 추론

// UIKit의 정의 (간략화)
@MainActor
open class UIViewController { ... }

// @MainActor를 명시하지 않아도,
// MyViewController는 @MainActor로 추론
class MyViewController: UIViewController {

    var count = 0 // @MainActor에서만 접근 가능

    // 이 메서드도 @MainActor로 추론
    func updateLabel() {
        // UI 업데이트 (안전)
    }

    // @MainActor가 아닌 곳에서 호출하면 컴파일 에러 발생
    func fetchFromServer() async {
        let data = await someNetworkCall() // (global context)
        // self.count = 5 // ❌ ERROR: Main actor-isolated
    }
}
  • 해당 VC는 @MainActor 키워드로 추론되어 count 프로퍼티도 @MainActor의 보호를 받음.

  • 이때, 비동기 함수 사용 시에 @MainActor의 보호를 받는 프로퍼티, 메서드 사용시, @MainActor에서 동작해야 함을 명시해줘야 함.

  • await MainActor.run {} or Task {}를 사용하여 해결

  • Task는 문맥을 상속받아 비동기 함수의 호출로 스레드 제어권을 넘기고 다시 재개되면 호출된 actor에서 동작하도록 해줌.

2. 프로토콜 준수

프로토콜 자체가 전역 액터를 요구하도록 선언된 경우, 해당 프로토콜을 준수하는 타입은 자동으로 해당 전역 액터로 추론 (단, classactor 타입에 한정될 수 있음.)

@MainActor
protocol MainActorIsolatedViewModel {
    func didLoad()
}

// 이 클래스는 @MainActor를 명시하지 않아도
// 프로토콜 준수로 인해 @MainActor로 추론
class MyViewModel: MainActorIsolatedViewModel {
    // 이 메서드도 @MainActor로 추론됨
    func didLoad() {
        print("Loaded on Main Actor")
    }
}

3. 재정의 및 구현

부모 클래스의 메서드를 override하거나 프로토콜의 요구사항(메서드, 프로퍼티)을 구현할 때, 원본(부모/프로토콜)이 특정 전역 액터에 속해 있다면, 재정의/구현된 코드도 동일한 전역 액터로 추론

@MainActor
class BaseViewController {
    func viewWillAppear() { ... }
}

class DetailViewController: BaseViewController {
    // 부모의 viewWillAppear가 @MainActor이므로
    // 이 메서드도 @MainActor로 추론
    override func viewWillAppear() {
        super.viewWillAppear()
        // ...
    }
}

4. @Sendable 클로저

함수 타입이 @MainActor @Sendable () -> Void와 같이 전역 액터와 @Sendable로 표시되면, 이 클로저를 받는 쪽에서는 이 클로저가 메인 액터에서 실행됨을 알고 있으며, 클로저 내부의 코드도 해당 액터에서 실행되는 것으로 추론

//    이 클래스는 백그라운드 스레드에서 동작한다고 가정
class NetworkService {
    // - @Sendable:  이 클로저는 스레드 경계(여기서는 NetworkService -> MainActor)를
    //               안전하게 넘길 수 있음을 의미
    func loadData(completion: @MainActor @Sendable (String) -> Void) {
        
        Task.detached {
            print("NetworkService: 백그라운드 스레드에서 데이터 로딩 중...")
            await Task.sleep(nanoseconds: 1_000_000_000) 
            let loadedData = "서버에서 받은 데이터"
            
            // 'completion'은 @MainActor로 약속되어 있으므로,
            // 'await'을 사용해야만 호출 가능
            await completion(loadedData)
        }
    }
}

//@MainActor
class MyViewController: UIViewController {
    
    let networkService = NetworkService()
    let myLabel = UILabel()
    
    func buttonTapped() {
        print("ViewController: 버튼 탭됨 (메인 스레드)")
        networkService.loadData { data in
            
       
            
            // 컴파일러는 'loadData' 함수의 (A) 정의를 보고,
            // "이 클로저는 @MainActor에서 실행될 것
            // 이라고 추론
            
            print("ViewController: 콜백 실행됨 (메인 스레드로 추론됨)")
            
            // (F) 따라서 UI 업데이트가 안전하게 허용됨
            self.myLabel.text = data // ✅ OK
            self.view.backgroundColor = .lightGray // ✅ OK
        }
    }
}

이 글을 작성하게 된 이유

ViewModel, Model, DiffableDataSource

문제가 되었던 상황

  1. Model을 정의

  2. 해당 모델을 ViewModel 측에서 해당 Model 타입을 가지는 배열을 정의

  3. 또 해당 Model을 가지고 ListView를 그리고 업데이트하는 DiffableDataSource에 정의

  4. Main actor-isolated conformance of 'HealthRecord' to 'Hashable' cannot satisfy conformance requirement for a 'Sendable' type parameter 'SectionIdentifierType' 다음과 같은 에러 메시지 등장

문제의 원인

  1. ViewModel의 @MainActor 추론: ObservableObject 프로토콜을 준수하는 ViewModel이 @MainActor로 추론됨.

  2. Model로의 전파: @MainActorViewModelModel 타입의 배열을 @Published 등으로 소유

  3. Hashable 격리 추론: 컴파일러는 ViewModel이 이 Model을 사용한다는 것을 보고, ModelHashable 프로토콜 구현(내부적인 hash(into:), == 메서드) 역시 @MainActor에서만 사용되어야 한다고 추론

  4. DiffableDataSource의 Sendable 요구: 반면에 DiffableDataSource의 스냅샷은 백그라운드 스레드에서 비동기적으로 데이터를 처리할 수 있어야 함. 따라서 DataSourceModel은 반드시 Sendable

  5. 충돌 발생

    • 컴파일러는 ModelHashable 구현을 @MainActor에 격리
    • DiffableDataSourceModelSendable이길 바람.
    • 두 요구사항 충돌로 에러 발생

해결
nonisolated 키워드 사용

  • 컴파일러의 추론을 깨뜨리고 명시적으로 이 구조체는 특정 액터에 종속되지 않음을 명시해서 해결

  • 다른 방법으로는 새로운 구조체로 변환해서 구현도 가능했었다.

nonisolated struct MyModel {
    let id: UUID
    var name: String
    // ... 기타 프로퍼티
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func == (lhs: MyModel, rhs: MyModel) -> Bool {
        lhs.id == rhs.id
    }
}

참고
https://github.com/apple/swift-evolution/blob/main/proposals/0316-global-actors.md

https://forums.swift.org/t/se-0316-global-actors/48905

https://github.com/apple/swift-evolution/blob/main/proposals/0304-structured-concurrency.md

https://github.com/apple/swift-evolution/blob/main/proposals/0313-actor-isolation-control.md

profile
안녕하세요! iOS 개발자입니다!

0개의 댓글