본문 바로가기
Swift

[Swift] Actor, @MainActor, Task, Sendable의 모든것

by Jimmy_iOS 2024. 3. 5.

Actor

기본적으로 액터의 코드는 백그라운드 스레드에서 실행됩니다.

Swift의 구조적 동시성에서 스레드는 낮은 수준에서 존재하며, 코드는 어떤 스레드에나 할당될 수 있습니다.

메인 스레드와 백그라운드 스레드의 구별은 중요하며, 이를 위해 '액터'라는 개념이 사용됩니다.

액터를 사용하면 코드가 어떤 스레드에서 실행되어야 하는지를 명시할 수 있으며, 이를 통해 복잡한 동시성 문제를 효과적으로 처리하고 코드를 깔끔하게 유지할 수 있습니다.

Swift에서 Actor는 백그라운드 스레드에서 코드를 실행하는 데 도움이 되며, 멀티스레딩 코드를 안전하게 실행하는 데 중요한 역할을 합니다. 멀티스레딩 코드에서 가장 큰 문제점은 공유 상태의 문제입니다.

속성이 하나 이상의 스레드에서 변경될 수 있다면, 코드는 일관성 없는 상태에 빠질 위험이 있습니다. Actor는 이러한 가능성을 방지하기 위해 속성을 격리합니다.

Actor Isolation

Actor의 mutable(가변)한 상태를, Actor를 고립시킴으로써 보호

actor-isolated는 기본적으로 self를 통해서만 접근이 가능하다

  • Actor 인스턴스가 가변 속성(var)을 가지고 있다면, 해당 인스턴스만이 그 속성을 변경할 수 있습니다. 즉, self만이 self.myMutableProperty를 변경할 수 있으며, other.myMutableProperty를 변경하는 것은 불가능합니다.
  • Actor 외부의 코드는 Actor의 가변 속성(var)을 가져올 수 있지만, 이 접근은 비동기적입니다. 즉, 코드는 반드시 await를 사용해야 합니다.
  • Actor 외부의 코드는 Actor의 메소드를 호출할 수 있지만, 이 접근 역시 비동기적입니다. 즉, 코드는 반드시 await를 사용해야 합니다. 이는 Actor가 메소드를 사용하여 자신의 가변 속성을 변경할 수 있기 때문입니다.

코드 예시

actor MyActor {
    let id = UUID().uuidString // 랜덤 유니크 식별자
    var myProperty: String
    init(_ myProperty: String) {
        self.myProperty = myProperty
    }
    func changeMyProperty(to newValue: String) {
        self.myProperty = newValue
    }
}
func test() async {
    let act = MyActor("howdy")
    let id = act.id // let 직접 접근 가능
    let prop = await act.myProperty // var에 점근은 가능하지만 외부에서 접근하기 떄문에 await 필요
    await act.changeMyProperty(to: "hello") // 접근 가능하지만 외부에서 메서드에 접근 시 반드시 await 필요
    await act.myProperty = "hello" // 컴파일 에러: 불가능
}

let

Actor의 상수(let) 속성은 Actor 외부에서 직접 접근 가능합니다. 상수 값 유형의 속성은 변하지 않기 때문에, 어떤 코드에서든 어떤 스레드에서든 안전하게 접근할 수 있습니다.

var

Actor의 가변(var) 속성은 Actor 외부에서 await를 사용하여 가져올 수 있습니다. 이는 실제 접근이 Actor 자신의 스레드에서 비동기적으로 이루어지도록 보장합니다. (기술적인 이유로, 다른 모듈에서 접근하는 경우 Actor의 상수 속성도 마찬가지입니다.)

Actor의 가변 속성은 Actor 외부에서는 await를 사용하더라도 변경할 수 없습니다.

외부

Actor의 메소드는 Actor 외부에서 호출할 수 있지만, await를 사용해야 합니다. 이는 호출이 Actor 자신의 스레드에서 비동기적으로 이루어지도록 보장하기 위함입니다.

self

changeMyProperty(to:) 를 사용해서 myProperty를 변경해줘야 하는 이유는 let id 에는 직접 접근이 가능하지만 var myProperty는 직접 접근이 불가능하고 self.myProperty를 통해서만 접근이 가능하기 때문이다.

@MainActor

Swift의 동시성 모델에서는 개발자들이 액터를 생성하면, 일반적으로 해당 코드가 백그라운드 스레드에서 실행되도록 지정합니다. 그러나 뒷 배경에서는 메인 스레드에서 코드를 실행하는 메인 액터라는 보이지 않는 전역 액터가 있습니다. 코드를 메인 스레드에서 실행하려면 메인 액터에 그 코드를 격리해야 합니다.

이를 위해 주로 사용하는 것이 @MainActor 어트리뷰트입니다. @MainActor 어트리뷰트를 다음과 같이 첨부할 수 있습니다:

  • 속성에: 속성이 메인 스레드에서만 액세스되어야 함을 명시합니다. 인스턴스 속성이나 정적/클래스 속성일 수 있습니다.
  • 메서드에: 메서드가 메인 스레드에서만 호출되어야 함을 명시합니다. 인스턴스 메서드나 정적/클래스 메서드일 수 있습니다. 클래스에서 오버라이드하는 경우, 오버라이드된 메서드가 @MainActor로 지정되어 있으면 이 메서드도 그 지정을 상속합니다.
  • 전체 유형에: 클래스, 구조체, 열거형 전체의 모든 속성과 메서드가 메인 스레드에서만 액세스되어야 함을 명시합니다. (해당 유형이 액터인 경우는 불가능합니다. 전체 사용자 정의 액터에 @MainActor를 표시할 수 없습니다.) 서브클래스가 명시적으로 @MainActor로 표시되면, 그것은 @MainActor로 표시된 슈퍼클래스에서 파생되어야 하거나, 그 선언된 슈퍼클래스는 NSObject여야 합니다.
  • 전역 (최상위) 변수나 함수에.

예를 들어, 다음과 같이 액터를 변경할 수 있습니다:

actor MyActor {
    let id = UUID().uuidString // random unique identifier
    @MainActor var myProperty: String
    init(_ myProperty: String) {
        self.myProperty = myProperty
    }
    @MainActor func changeMyProperty(to newValue: String) {
        self.myProperty = newValue
    }
}

이 코드에서 속성 myProperty를 메인 액터에 격리했습니다. 이 변경에 대한 실질적인 이유는 없으며, 문법을 설명하기 위한 것입니다. 그 결과는 다음과 같습니다:

  • 메서드 changeMyProperty(to:)도 메인 액터에 격리해야 합니다. 그렇지 않으면 코드가 컴파일되지 않습니다. 왜냐하면 changeMyProperty(to:)는 메인 액터에 격리된 myProperty를 변경하기 때문입니다.
  • 메인 스레드에서 실행되는 다른 코드는 이제 myProperty를 직접 가져올 수 있습니다. 즉, await를 사용하지 않아도 됩니다. 또한 changeMyProperty(to:)를 직접 호출할 수 있습니다. 하지만 이제 myProperty를 직접 변경할 수 있으므로 필요하지는 않습니다.

메인 스레드에서 실행된다는것은 멀티스레딩 환경에서 data race가 일어나지 않는다는 말이기 때문에 await을 사용하지 않고 직접 접근이 가능하다

인터페이스와 관련된 빌트인 코코아 클래스는 인터페이스 코드가 메인 스레드에서 실행되어야 하므로 @MainActor 어트리뷰트가 이미 붙어 있습니다. 예를 들어, ViewController의 viewDidLoad 메서드는 메인 스레드에서 실행됩니다. 왜냐하면 ViewController는 @MainActor 클래스인 UIViewController의 서브클래스이기 때문입니다.

MainActor.run은 무엇인가?

우선 Task의 암시적인 컨텍스트 스위칭에 대해서 알아야합니다.

Swift 코드는 개발자가 그 사실을 인식하지 못하는 상태에서도 메인 스레드와 백그라운드 스레드 사이를 전환할 수 있습니다. 대부분의 경우, 이것이 문제가 되지는 않습니다.

예를 들어, fetchManyURLs의 구현을 생각해보십시오. 이전에 addTask를 사용했던 부분을 떠올려 보세요:

for url in urls { // 메인 스레드에서 실행
    group.addTask {
        // 백그라운드 스레드에서 실행
        return (url, try await self.download(url: url))
    }
}

제 테스트 결과, addTask 호출에서 제공하는 operation: 함수는 fetchManyURLs가 메인 스레드에서 호출되더라도 백그라운드 스레드에서 실행됩니다. 하지만 그 사실을 코드에서 인지하기는 어렵고, 이 경우에 그것을 인식할 필요는 없습니다.

또 다른 사례로, Task 초기화자를 호출하면, 작업은 주변 컨텍스트의 특성을 상속받습니다. 따라서, 뷰 컨트롤러의 viewDidLoad 메소드에서 Task 초기화자의 operation: 코드는 메인 스레드에서 실행됩니다:

class ViewController: UIViewController { // @MainActor로 표시
    override func viewDidLoad() {
        super.viewDidLoad() // 메인 스레드에서 실행
        Task {
            // 메인 스레드에서 실행
            let result = try? await self.fetchManyURLs()
            // ... }
        }
    }
}

하지만 다시 한번, 코드에서는 어떤 스레드에서 실행되는지에 대한 명시가 없으며, 이 경우에도 그것을 인식할 필요는 없습니다.

그런데 특정 메소드가 백그라운드 스레드에서 실행되기를 원한다고 가정해봅시다. 예를 들어 fetchManyURLs 메소드가 그럴 수 있습니다. 이 메소드는 백그라운드 스레드에서 잘 실행되므로, 메인 스레드 시간을 사용하여 실행할 필요는 없습니다. 이럴 때 actor를 사용하면 됩니다! fetchManyURLs가 백그라운드 스레드에서 실행되게 하려면, 이를 actor에 격리시키면 됩니다.

이를 위해 MyDownloader라는 actor를 선언하고, download(url:)fetchManyURLs 메소드를 이 actor로 이동시키겠습니다. 그런 다음 이 actor의 인스턴스를 보유하는 속성을 선언하고, 그 인스턴스에서 fetchManyURLs를 호출하겠습니다:

class ViewController: UIViewController {
    actor MyDownloader {
        func download(url: URL) async throws -> Data {
            // ... 이전과 동일 ...
        }
        func fetchManyURLs() async throws -> [URL:Data] {
            // ... 이전과 동일 ...
        }
    }
    let downloader = MyDownloader()
    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            // 메인 스레드에서 실행
            // 하지만 fetchManyURLs는 백그라운드 스레드에서 실행
            let result = try? await self.downloader.fetchManyURLs()
            // ...
        }
    }
}

fetchManyURLs 코드는 self.downloader가 actor이기 때문에 백그라운드 스레드에서 실행됩니다. 이는 매우 좋은 아키텍처로, 스레드 컨텍스트를 대상 객체와 연관시킵니다. MyDownloader 객체 자체는 메소드가 백그라운드 스레드에서 실행될 것임을 강제합니다. 그러나 여전히 코드에서는 어떤 스레드에서 실행되는지에 대해 언급하지 않습니다.

이처럼 Swift에서는 암시적 컨텍스트 스위칭을 통해 코드의 복잡성을 줄이고 가독성을 높이는 동시에, 필요한 스레드 관리를 효과적으로 수행할 수 있습니다.

반대로 Task의 명시적인 컨텍스트 스위칭 이해하기

백그라운드 스레드에서 작업하기

Swift에서는 코드가 실행되는 스레드의 종류를 명시적으로 지정할 필요가 있을 때가 있습니다. 이런 경우에는 어떻게 해야 할까요? 먼저, 코드가 메인 스레드에서 실행되고 있고, 다른 스레드에서 작업을 실행하고 싶을 때를 가정해봅시다.

일반적인 Task 초기화자를 호출하면, 주변 환경의 특성을 상속받는 작업을 얻게 됩니다. 예를 들어, 뷰 컨트롤러의 viewDidLoad 메소드에서 Task 초기화자의 operation: 함수는 메인 스레드에서 실행됩니다. 이를 방지하려면, 대신 Task.detached 정적 메소드를 호출할 수 있습니다.

Task.detached는 팩토리 메소드로, Task 초기화자와 마찬가지로 결과는 Task 객체입니다. 그리고 Task 초기화자와 마찬가지로 operation: 함수 매개변수를 받아 함수를 작업으로 실행합니다. 차이점은 Task.detached가 결과 Task 객체와 주변 컨텍스트 간의 관계를 끊어서 operation: 함수를 자체 백그라운드 스레드에서 실행한다는 것입니다.

class ViewController: UIViewController { // @MainActor로 표시
    override func viewDidLoad() {
        super.viewDidLoad() // 메인 스레드에서 실행
        Task.detached {
            // 백그라운드 스레드에서 실행
            let result = try? await self.fetchManyURLs()
            // ...
        }
    }
}

메인 스레드에서 작업하기

그렇다면 반대 상황은 어떨까요? 코드가 백그라운드 스레드에서 실행되고 있고, 특정 코드 블록이 메인 스레드에서 실행되도록 강제하고 싶을 때에는 어떻게 해야 할까요? 이런 상황은 iOS 프로그래밍에서 자주 발생합니다. 왜냐하면 인터페이스 코드와 상호 작용하는 경우가 많으며, 이 코드는 메인 액터에 격리되어 있어 반드시 메인 스레드에서 호출해야 하기 때문입니다.

예를 들어, 이미지 데이터를 다운로드하고 인터페이스에 그 이미지를 표시하려는 경우를 생각해봅시다. 이미지를 표시하기 위해 UIImageView를 사용할 가능성이 크고, 이것이 뷰 컨트롤러인 경우, UIImageView를 참조하는 속성이 있을 것입니다.

Task.detached를 호출하여 백그라운드 스레드에서 실행하고, 그 operation: 함수에서 download(url:) 메소드를 호출한다고 가정해봅시다. 그런 다음 결과 Data 객체를 UIImage 객체로 변환하고, 이를 UIImageViewimage 속성에 할당합니다:

class ViewController: UIViewController {
    var imageView : UIImageView?
    actor MyDownloader {
        // ... }
        let downloader = MyDownloader()
        override func viewDidLoad() {
            super.viewDidLoad()
            Task.detached {
                // 메인 스레드에서 실행
                let url = URL(string: "https://www.apeth.com/pep/manny.jpg")!
                if let data = try? await self.downloader.download(url: url) {
                    if let image = UIImage(data: data) {
                        self.imageView?.image = image // 🚫 컴파일 에러
                    }
								}
            }
				}
    }

그러나 이 코드는 컴파일되지 않습니다. UIImageView는 인터페이스 클래스이며, 메인 액터에 격리되어 있습니다. 따라서 백그라운드 스레드에서 그 image 속성을 변경할 수 없고, 컴파일러는 "Property image isolated to global actor MainActor can not be mutated from a non-isolated context."라는 에러를 발생시킵니다.

이 경우, 우리는 이미지 뷰의 image 속성을 변경하기 위해 메인 스레드로 잠시 이동하려고 합니다. 이를 위해 특별한 MainActor.run 정적 메소드를 호출할 수 있습니다:

await MainActor.run { self.imageView?.image = image }

하지만 주의해야 할 점은, 메인 스레드로의 명시적인 컨텍스트 전환은 비용이 들 수 있습니다. 메인 스레드에서 여러 작업을 수행해야 하고 MainActor.run을 호출해야 하는 경우, 불필요한 컨텍스트 전환이 발생하지 않도록 이러한 작업을 단일 MainActor.run 호출로 모아 놓도록 노력해야 합니다.

Sendable

주어진 타입의 값이 concurrent code에서 안전하게 사용될 수 있음을 나타냅니다

→ 동시에 사용하기에 안전한 타입 == Sendable

→ Actor간에 값을 공유할 수 있는 타입 == Sendable.

Sendable을 왜 사용하느냐?

Data Race가 발생하는 원인은 Data가 공유 가변 상태이기 떄문입니다.

하지만 Value타입일 경우 값의 복사가 일어나고 참조가 발생하지 않기 때문에 Data Race가 발생하지 않습니다.

즉 주어진 타입의 값이 안전하게 동시성 코드에서 사용해야하고 안전한지 아닌지를 컴파일러가 알게하게 위해서 Sendable을 사용해서 체킹하기 위함이 목적입니다.

그렇다면 아래와 같은 Sendable을 채택하는 구조체에 참조타입입 Class를 할당하면 컴파일러가 에러를 발생시킵니다.

struct BankAccount: Sendable {
    let accountNumber: Int = 0
    let balance: Double = 0.0

    let justClass: JustClass // 🚫 Stored property 'justClass' of 'Sendable'-conforming struct 'BankAccount' has non-sendable type 'JustClass
}

class JustClass {}

Sendable을 만족하는 Class를 만들기 위해서는 다음과 같은 조건을 만족해야 합니다.

  1. Class는 final 이어야 합니다.
  1. 가변 저장 변수가 있으면 안됩니다. 즉 let 프로퍼티만 존재해야 합니다.
final class BankAccount: Sendable {
    let accountNumber: Int = 0
    // var balance: Double = 0.0 // 🚫 Non-final class 'BankAccount' cannot conform to `Sendable`
		let balance: Double = 0.0
}

하 지 만! 이렇게 사용하게 되면 var를 사용할 수 없기 때문에

그냥 actor를 만들면 됩니다.

actor BankAccount {
    let accountNumber: Int = 0
    var balance: Double = 0.0
}

그러면 마지막으로 @unchecked Sendable은 무엇인가?

Swift에서의 동시성 처리를 위해 Sendable 프로토콜이 도입되었습니다. Sendable 프로토콜은 다중 스레드 환경에서 안전하게 데이터를 전달할 수 있음을 보장합니다. 그런데 이 Sendable에는 @unchecked라는 특별한 어노테이션이 있는데, 이것은 무엇인지와 어떻게 사용해야 하는지 알아보겠습니다.

일반적으로, 클래스가 Sendable 프로토콜을 채택하면, 해당 클래스는 Sendable 프로토콜의 규칙을 충족해야 합니다. 그렇지 않으면 컴파일러가 에러를 발생시킵니다. 이것은 다중 스레드 환경에서 데이터 일관성과 안전성을 보장하기 위한 중요한 체크입니다.

그러나 특정 경우에는 이런 체크를 우회해야 할 때가 있습니다. 이럴 때 사용하는 것이 @unchecked Sendable입니다. 이 어노테이션을 채택하면 컴파일러는 Sendable 프로토콜의 체크를 건너뛰게 됩니다.

하지만 이것은 주의가 필요합니다. @unchecked Sendable을 채택하면 개발자가 직접 스레드 안전성을 보장해야 합니다. 즉, 커스텀 큐를 만들어서 스레드 관리를 직접 해야 한다는 의미입니다.

따라서 @unchecked Sendable을 사용할 때는 반드시 스레드 안전성을 고려해야합니다. 이것이 잘못 사용되면 데이터 불일치나 동시성 문제가 발생할 수 있으니 주의해야 합니다.


Uploaded by N2T