본문 바로가기
iOS/Swift

[Swift] Async Await와 MainActor의 관계에 대한 고찰

by 빡구동동 2023. 4. 26.

[Swift] Async Await와 MainActor의 관계에 대한 고찰


Swift에서 Concurrency 환경을 만들어야할 때 다양한 사용법이 있다.

특히 비동기로 데이터를 처리한 후 UI를 업데이트할 땐 MainThread에서 처리해야 한다.

여기서 MainActor를 사용함으로써 명시적으로 Main Thread로 컨텍스트 스위칭할 수 있다.

 

명시적으로 Main Thread에서 돌아가게 하는 방법은 여러 방법이 있는데 각각의 차이를 알아보자.

본격적으로 들어가기에 앞서, 환경부터 만들어보자.

 

환경

UILabel에 실제 API 호출을 통해 가져온 데이터를 바꾸도록 만들자.

우선 간단한 레이아웃 구조와 viewModel 인스턴스를 생성하자.

class ViewController: UIViewController {
    let viewModel = WeatherViewModel()
    
    private let label: UILabel = {
        let label = UILabel()
        label.textColor = .black
        return label
    }()
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        // Layout
        view.addSubview(label)
        label.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
        // #1
        Task {
            await viewModel.load()
        }
        // #2
        binding()
    }
}

 

 

#1과 같이 ViewModel 인스턴스로 API 데이터를 호출하는 퍼블릭 함수를 만들자.

// ViewModel.swift
// labelData type: ((String) -> ())?
public func load() async {
    do {
        let data = try await loadData()
        labelData?(data.name)
        print("returning")
    } catch {
        print("error occured")
    }
}

 

 

#2와 같이 서버에서 가져온 값을 UILabel에 바인딩하는 함수를 만들자.

// ViewController.swift
private func binding() {
    viewModel.labelData = { [weak self] name in
        self?.label.text = name
    }
}

자 여기까지 하고 실행을 해보면 어떻게 될까?

 

XCode는 우리에게 UI 업데이트는 메인스레드에서 실행해야 한다고 보라색 메세지를 던져준다 ㅋㅋ

 


해결 방법 

1. ViewModel의 load() 함수에 @MainActor 어노테이션 붙이기

// ViewModel.swift
@MainActor
public func load() async {
    do {
        print("#1 mainthread? = \(Thread.isMainThread)")
        let data = try await loadData() // #3 API 비동기 호출
        print("#2 mainthread? = \(Thread.isMainThread)")
        labelData?(data.name)
        print("returning")
    } catch {
        print("error occured")
    }
}

 

실제로 UI 업데이트와 관련이 있는 프로퍼티를 다루는 함수 앞에 @MainActor 어노테이션을 붙여보자.

 

이렇게 하면 날씨 데이터를 비동기로 호출하는 loadData() 함수에서 반환된 값을 얻고나면, Swift는 labelData 클로저에 할당하기 전에 필요한 경우 Main Thread로 컨텍스트 스위칭을 진행(hop)한다.

 

결과

 

2. ViewModel Class 자체에 @MainActor 어노테이션 붙이기

@MainActor
class WeatherViewModel {
... 중략 ...
     public func load() async { ... }
}

 

 

위 처럼 클래스 자체에 어노테이션을 붙여버리면 모든 함수 호출과 프로퍼티 레퍼런스는 Main Actor에서 진행된다고 선언하는 것과 같다.

그렇다면 그냥 클래스에 어노테이션 넣고 쓰면 되지 않냐고 생각할 법 하다.

 

이렇게 되면 클래스 내부의 거의 모든 것들이 @MainActor 어노테이션이 달릴 것이고, 이는 우리 코드의 사이드 이펙트를 초래할 수도 있다. 

 

예를 들면 다른 클래스나 메서드에서 WeatherViewModel에 접근해서 인스턴스 변수나 메서드를 사용하려고 한다면 Main Actor 때문에 고립 상태의 컨텍스트가 되어버린다. 

 

즉, Main Actor 자체가 'Thread - Safe' 하기 때문이며, 이는 '경쟁 상태'를 막을 수 있다는 말이니 접근하는 쪽에선 고립 상태가 되어 버린다.

 

그냥 이렇게 쓰지 말자...

 

3. 프로퍼티를 업데이트 하는 함수를 만들어서 @MainActor 붙이기

// ViewModel.swift
public func load() async {
    do {
        print("#1 mainthread? = \(Thread.isMainThread)")
        let data = try await loadData()
        print("#2 mainthread? = \(Thread.isMainThread)")
        await bind(data)
        print("returning")
    } catch {
        print("error occured")
    }
}


@MainActor
private func bind(_ data: MainWeatherResponseModel) {
    print("#4 mainthread? = \(Thread.isMainThread)") // true
    labelData?(data.name)
}

UI와 관련된 프로퍼티를 업데이트할 때 @MainActor를 가진 함수로 빼는 방법이다.

기존의 load 함수에서 @MainActor가 없어진 것을 볼 수 있다.

 

이 코드는 두가지 문제점이 있다.

 

첫번째는 적어야 할 코드의 양이 많아지며, 생성된 SIL 코드의 양도 많아진다.(SIL은 Swift의 컴파일러가 소스 코드를 변환한 것)

코드의 양이 많아진다는 뜻은 단순히 작성해야할 코드가 많아지기도 하지만, 중복된 MainActor의 셋업이 발생한다. 

또한 기존의 load 함수는 'non-isolated-context'에서 'isolated-context(고립 상태)'로 넘어간다. 

즉, 멀티 스레드 환경에서 안정성을 확보하는데는 도움이 되겠지만, 고립 상태에서의 작업은 느릴 수 있으며 작업을 분할해서 동시에 실행하는 것이 어려워질 수도 있다.

 

두번째는 bind 함수의 await 후 어떤 스레드인지 모른다는 점이다.

다시 말해 'Main Thread' 라는 보장이 없다. 그래서 컴파일러에게 확실하게 진행 시점을 다시 명시해줘야 한다.

 

4. MainActor.run 클로저로 실행하기

public func load() async {
    do {
        print("#1 mainthread? = \(Thread.isMainThread)")
        let data = try await loadData() // #3
        print("#2 mainthread? = \(Thread.isMainThread)")
        
        await MainActor.run {
            print("#4 mainthread? = \(Thread.isMainThread)")
            labelData?(data.name)
        }
        
        print("returning")
    } catch {
        print("error occured")
    }
}

 

이 방식은 꽤 깔끔하게 보인다.

하지만 컴파일러의 뒤편으로 들어가보면 생성되는 SIL 코드는 약 15%나 증가한다고 한다!

 

생성한 MainActor.run 의 핸들러는 새로운 클로저를 생성하고 할당하는데 필요한 코드를 만들어야 한다. 이 클로저들은 현재 컨텍스트에서 값을 캡처해야 하기 때문에 생성되는 코드의 양이 더 많아지게 된다.

 

5. Task / @MainActor 사용하기

 

public func load() async {
    do {
        print("#1 mainthread? = \(Thread.isMainThread)")
        let data = try await loadData()
        print("#2 mainthread? = \(Thread.isMainThread)")
        Task { @MainActor in
            print("updating")
            labelData?(data.name)
        }
        print("returning")
    } catch {
        print("error occured")
    }
}

이 방식도 4번과 비슷하게 생성된 SIL 코드의 양을 증가시킨다.

또한 Task Closure를 생성하는 것도 동일하다.

 

하지만 4번 방식보다 더 큰 이슈는 labelData를 업데이트 하기 위해 새로운 'Task'를 생성한다는 점이다.

생성된 Task는 비동기적으로 작동하거나, await 상태의 load 함수에 의존하게 되어 다른 작업이 끝날 때 까지 기다릴 수도 있다!!

또한 load 함수는 Task { @MainActor in } 에서 작업된 내용이 업데이트 되기전에 return 되어버릴 수도 있다.

 

실제로 로그를 찍어보면 

load 함수가 먼저 리턴되고 load 함수 내부의 Task가 실행된다.

이렇게 하면 테스트 코드를 작성할 때 약간의 딜레이가 발생하기 때문에 고려해야 할 사항이 늘게된다.

 

6. DispatchQueue.main 사용하기

public func load() async {
    do {
        print("#1 mainthread? = \(Thread.isMainThread)")
        let data = try await loadData()
        print("#2 mainthread? = \(Thread.isMainThread)")
        
        DispatchQueue.main.async { [weak self] in
            print("updating in dispatchqueue.main.async")
            self?.labelData?(data.name)
        }
        
        print("returning load()")
    } catch {
        print("error occured")
    }
}

 

이 방식은 앞서 말한 4,5 방법의 문제점을 그대로 재현한다.

생성된 SIL 코드의 사이즈가 커지며, 확실하게 비동기로 작동한다. 

따라서 테스트 환경에서도 앞서 말한 5번의 방식과 동일한 어려움을 겪게된다.

 


그래서 뭐 쓰라고?

맨 처음에 말한 async load 함수에 @MainActor를 붙이게 되면 컴파일러가 main thread에서 작업이 필요한 경우 

자동으로 hop 즉, 컨텍스트 스위칭을 시켜준다. 

그래서 1번 방식을 사용하자!!

 


정리

1. 비동기로 처리된 데이터를 가지고 Main Thread로 실행하는데는 여러가지 방식이 존재한다.

2. MainActor를 어디서 사용하느냐에 따라 컴파일러에 의해 생성된 코드의 양이 달라진다.

3. 비동기 함수 내부에서 비동기 Task를 생성하면 결과가 함수에 의존적이거나, 비동기 함수가 먼저 리턴되어 버릴 수도 있다.

4. Swift 컴파일러는 똑똑하다. 하지만, 우리가 시키는대로 해준다. 예를 들면 클로저 생성이나 비동기 Task 생성과 같이.

'iOS > Swift' 카테고리의 다른 글

한장으로 보는 UIViewController & UIView의 LifeCycle  (0) 2023.04.27
[Swift] 다양한 문자열 처리 방법  (0) 2023.04.05

댓글