@MainActor 제대로 사용하기 - Swift Concurrency

민규·2024년 12월 20일
0
post-thumbnail

Swift Concurrency 의 @MainActor 에 대해 알아보자!

안녕하세요!

이번 글에서는 Swift 를 사용해 UI 개발을 할 때 정말 자주 사용되는 Swift Concurrency 문법인 @MainActor 에 대해 알아보고, 어떻게 하면 제대로 사용할 수 있는지 같이 살펴보도록 하겠습니다!

MainActor

1. MainActor 란?

MainActor 는 SE-0316: Global Actors 에서 도입된 Global Actor 의 한 종류이며, 특정 코드가 메인 스레드에서 실행되도록 보장해주는 역할을 합니다.

@globalActor final public actor MainActor : GlobalActor { }

(MainActor 구현부)

여기서 중요하게 봐야할 부분은 바로 "특정 코드가 메인 스레드에서 실행되는 것을 보장한다" 입니다.

2. 메인 스레드란?

iOS 앱은 앱이 실행될 때 기본적으로 하나의 메인 스레드에서 시작됩니다. 이때 메인 스레드는 UI 업데이트와 사용자 이벤트 처리를 담당합니다.

여러분들이 Xcode 디버거에서 자주 보셨던 Thread 1로 표시되는 스레드가 바로 메인 스레드입니다! 메인 스레드에서 실행 중인 코드나 작업은 Xcode 디버그 세션의 Thread 1에서 확인할 수 있습니다.

(예를 들어, 앱의 UI 가 업데이트 되거나 버튼을 누르는 등의 이벤트를 처리하는 동안 디버거에서 Thread 1에 해당 작업이 표시됩니다.)

3. 만약 다른 스레드에서 UI 를 업데이트 한다면?

만약 메인 스레드가 아닌 다른 스레드에서 UI 를 업데이트 하게 되면 어떤 문제가 발생할까요? 바로 런타임 경고가 발생합니다. (보라보라한 경고)

Text(viewModel.labelText)
	.onAppear {
    	viewModel.updateLabelText()
    }

class ViewModel: ObservableObject {
    @Published var labelText: String = ""
    
    func updateLabelText() {
        Task {
        	let data = await fetchData()
            labelText = data.text
        }
    }
}

(일부 코드 생략)

SwiftUI 를 통해 대표적인 예시를 하나 들어보자면, 주로 개발을 하며 어떠한 비동기적인 작업을 처리하기 위해서는 Task 블럭 내부에서 비동기적인 작업을 진행하고 결과를 @Published 변수에 업데이트합니다.

하지만 Task 블럭의 내부에서 실행되는 코드는 Swift 런타임에 의해 관리되며, 작업의 성격에 따라 적절한 스레드에서 실행됩니다. 즉, 메인 스레드가 아닌 다른 스레드에서 실행될 수도 있다는 것입니다.

해당 코드를 사용한 앱을 실행하게 되면 다른 스레드에서 labelText 의 업데이트가 진행되기 때문에 아래와 같이 런타임 경고가 뜨게 됩니다.

Publishing changes from background threads is not allowed; make sure to publish values from the main thread.

(실제로 위와 같은 보라색 경고가 뜨면 화면에선 큰 문제가 발생하지 않는 것처럼 보이지만, Data Race 가 일어나거나 UI 업데이트가 멈추는 경우도 발생할 수 있고, 크러쉬가 나기도 합니다.)

@MainActor

1. @MainActor 를 사용하는 방법

간단히 클래스, 구조체, 변수, 함수 등에 @MainActor 를 명시적으로 적용하기만 하면, 해당 코드가 항상 메인 스레드에서 실행되게 됩니다.

@MainActor var url: URL?

@MainActor func updateLabelText() { }

주로 ObservableObject 혹은 @Observable 을 사용하는 클래스는 UI 업데이트와 직접적인 연관성이 크기 때문에, @MainActor 를 명시하는 것이 좋습니다.

@MainActor
class ViewModel: ObservableObject { }
// 혹은
@Observable @MainActor
class ViewModel { }

단순히 클래스에 @MainActor 를 명시하는 것만으로도 해당 클래스의 모든 메서드나 프로퍼티들이 메인 스레드에서의 실행을 보장하게 되기 때문이죠.

여기까지 보면 CGD 보다 훨씬 직관적이고 쉽다..! 라는 생각이 듭니다.
하지만 방심하면 큰일나게 됩니다. 언제나 예외는 있거든요ㅎㅎㅎ

2. @MainActor 를 쓰면 안되는 경우?

예시 코드였던 updateLabelText() 에 @MainActor 를 적용해보겠습니다.

@MainActor func updateLabelText() {
	Task {
    	let data = await fetchData()
    	labelText = data.text
    }
}

실제로 호출해보면 정상적으로 메인 스레드에서 실행되게 됩니다!

하지만... 만약 여기서 호출한 fetchData() 가 네트워크 요청, 파일 읽기, 또는 CPU 집약적인 작업이라면, 앱의 성능에 심각한 영향을 미칠 수도 있습니다.

(메인 스레드가 이 작업을 처리하느라 다른 UI 작업을 처리하지 못하게 되기 때문이죠.)

그런 경우 메인 스레드에서 실행되어야 하는 작업과 아닌 작업을 분리시켜야 합니다.

2-1. nonisolated

만약 @MainActor class 내부에 있는 특정 프로퍼티나 메서드를 메인 스레드에서 실행시키고 싶지 않다면 nonisolated 를 명시하면 해당 프로퍼티나 메서드는 MainActor 의 영향을 받지 않고 실행하게 됩니다.

// @MainActor 가 명시된 코드블럭 내부에 있는 메서드입니다.
nonisolated func updateLabelText() {
	Task {
    	let data = await fetchData()
    	labelText = data.text
    }
}

(여기서 왜 하필 키워드가 isolated(분리, 격리) 가 아닌 nonisolated 인가요? 라는 의문이 들었습니다. 전 오히려 MainActor 의 영역에서 분리하는 느낌이였기 때문에 isolated 가 더 어올린다고 생각했었어요.)

찾아보니 사실 "Actor 에 포함된 모든 것들은 이미 격리된 상태이다" 라는 것이 Swift 의 기본 동작이였다고 합니다. 그래서 nonisolated 는 "격리된 상태에서 벗어난" 특별한 예외 케이스를 명시하는 것이라고 합니다.
(SE: 0313-actor-isolation-control)

하지만 단순히 해당 메서드 전체를 MainActor 에서 부터 격리 해버린다면 fetchData() 뿐만 아니라 UI 업데이트 코드까지 백그라운드 스레드에서 동작해버리게 됩니다.

2-2. MainActor.run()

extension MainActor {
	/// Execute the given body closure on the main actor.
	public static func run<T>(resultType: T.Type = T.self, body: @MainActor @Sendable () throws -> T) async rethrows -> T where T : Sendable
}

(구현부)

단순히 일부 코드만 메인 스레드에서 실행시키기 위해서는 MainActor.run() 을 호출하면 됩니다.

이 경우 해당 코드가 어떤 스레드에서 실행되고 있었든 MainActor.run() 의 body 에 있는 코드는 무조건 메인 스레드에서 실행되게 됩니다.

func updateLabelText() async {
	// 다른 스레드에서 fetchData() 호출 후 결과 대기
    let data = await fetchData()
    
	// 메인 스레드에서 UI 업데이트
	await MainActor.run {
		labelText = data.text
	}
}

혹은 MainActor.run() 이 리턴값을 가지게 할 수도 있습니다.

func printName() async {
    let name = await MainActor.run {
    	return "mingyu lee"
	}
    print(name)
}

여기서 비슷한 역할을 하는 GCD 의 DispatchQueue.main.async() 가 떠오르게 됩니다. DispatchQueue.main.async() 와 MainActor.run() 는 작동 방식에서의 차이가 있는데, 여기서 MainActor.run() 의 정점이 나타나게 됩니다.

먼저 DispatchQueue.main.async() 를 통해 메인 스레드에 작업을 요청하면, 메인 스레드의 작업 큐에 올라가게 됩니다. 그 후 다음 Run Loop 가 오면 실행되게 됩니다. (그래서 어떠한 상황이든 작업 지연이 발생하게 됩니다.)

반면, MainActor.run() 의 코드가 이미 메인 스레드에서 실행 중이라면 작업을 바로 실행하게 됩니다. (메인 스레드에서 실행 중이 아니여도 즉시 메인 스레드로 전환하여 실행합니다)

이는 아까 찾아본 Actor 가 액터 격리(isolation) 를 기반으로 동작하기 때문에 가능한 최적화라고 합니다.

2-2. Task + MainActor.run()

만약 결과를 기다리지(await) 않고 MainActor 에서 실행되게 하기 위해서는 간단히 Task 를 따로 만들어 주면 됩니다.

func printName() {
	Task {
    	await MainActor.run {
        	print("mingyu")
        }
    }
    print("lee") // print("mingyu") 를 기다리지 않고 출력됩니다. 
}
// lee
// mingyu

혹은 Task 의 Closure 에 { @MainActor in } 을 사용해서 표현할 수도 있습니다.

func printName() {
	Task { @MainActor in
        print("mingyu")
    }
    print("lee") 
}
// lee
// mingyu

(해당 방법은 MainActor 에서 실행 중이더라도 작업이 Swift 런타임에 의해 관리되며, 실행 시점이 약간 지연될 수 있다는 점에서 MainActor.run() 과 차이가 있습니다.)

하지만 해당 방법은 동기식 컨텍스트에서 자주 쓰이게 됩니다. 이 말은 await 를 명시하지 않고도 MainActor.run() 을 호출할 수 있다는 것이기 때문에 저도 개발하면서 대부분 해당 방법을 사용합니다!

마무리

학교 다닐 때, Swift Concurrency 에 대한 강연을 열어서 발표했을 정도로 개인적으로 Swift 에서 가장 좋아하는 기술 스택인 Swift Concurrency 에 대한 글을 적을 수 있어서 좋았습니다ㅎㅎ

긴 글 읽어주셔서 감사합니다!

profile
iOS & macOS & visionOS 개발하는 사람

0개의 댓글