23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
안녕하세요. 에이닷 iOS App을 개발하고 있는 김혜지입니다.
현재 저희 팀은 SwiftUI로 에이닷 iOS app을 개발하고 있습니다.
SwiftUI는 아직 그 역사가 짧다 보니 예상치 못한 문제들이나 어떻게 개발하는 것이 맞는지에 대한 고민이 잦은데요🥲
이번 포스팅 역시 팀원분들과 이야기하다 나온 주제에서 영감을 얻었습니다.
SwiftUI로 개발하다 보면 아래와 같이 View에 helper property로 View 를 만드는 경우가 있습니다.
struct TestView: View {
var body: some View {
VStack {
textView
}
.task {
// 뭔가 한다...
}
}
private var textView: some View {
Text("Hello World!")
.task {
// 뭔가 한다...
}
}
}body 와 textView 에서 Task 를 생성했을 때의 차이가 있다는 것을 알게되어 여러분께 한 번 말씀드려보고자 이 글을 쓰게 되었습니다.
SwiftUI로 개발하고 계시거나 Swift Concurrency를 사용하시는 개발자 분들이라면 관심을 가지실 주제일 것 같습니다
이 글은 SwiftUI와 Actor isolation을 포함한 Swift Concurrency를 이해하고 계시면 더욱 읽기 좋습니다.
View.task modifier는 Main Thread 입니까?SwiftUI로 개발하면서 task modifier 참 많이 사용하는데요. 혹시 이 task modifier는 어느 쓰레드에서 실행이 되는지 아시나요?
아래 코드의 주석 // 1과 // 2 모두 main thread 에서 실행되는 것이 보장될까요?
struct TestView: View {
var body: some View {
VStack {
textView
}
.task {
// 1
}
}
private var textView: some View {
Text("Hello World!")
.task {
// 2
}
}
}빠르게 정답을 확인해보겠습니다.
struct TestView: View {
var body: some View {
VStack {
textView
}
.task {
// 1
test()
}
}
private var textView: some View {
Text("Hello World!")
.task {
// 2
test()
}
}
@MainActor private func test() {}
}새로 추가한 test method는 @MainActor 로 annotate 되어 main thread에 대한 isolation이 보장된 곳에서만 호출이 가능합니다.
test 를 await 키워드 없이 호출할 수 있는 task가 main actor isolation, 즉 main thread에서 실행이 보장된 곳이겠죠.
정리해보자면,
1번 task : main thread 에서의 실행이 보장됨
2번 task: main thread 에서의 실행이 보장되지 않음
이겠네요.
사실 1번 task 의 main thread 실행은 당연한 것입니다. 이유는 다음과 같습니다.
View.body는 @MainActor 임.
View.task 의 action 파라미터는 @_inheritActorContext 로 annotate 되어 있음.
하나씩 자세히 알아보겠습니다.
1. View.body는 @MainActor 임
View.body의 정의는 다음과 같습니다.
public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor var body: Self.Body { get }
}View.body 는 @MainActor 라고 명시적으로 annotate 되어있네요... 참 쉽죠?
2. View.task 의 action 파라미터는 @_inheritActorContext 로 annotate 되어 있음
@inlinable public func task(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some Viewtask modifier의 정의만으로는 action 파라미터에 대한 비밀을 알 수가 없군요... 그래서 SwiftUI module interface를 한 번 열어보았습니다.
extension SwiftUI.View {
#if compiler(>=5.3) && $AsyncAwait && $Sendable && $InheritActorContext
@inlinable public func task(priority: _Concurrency.TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Swift.Void) -> some SwiftUI.View {
modifier(_TaskModifier(priority: priority, action: action))
}
#endif
// 생략...
}@_inheritActorContext로 annotate 되어 있네요. @_inheritActorContext annotation은 sendable async 클로져들이 actor context를 inherit 하게 만들어줍니다.
하지만 2번 task 도 때로는 main thread에서 도는 것이 보장될 수도 있습니다.
final class SomeObservableObject: ObservableObject {}
struct TestView: View {
@StateObject private var someObservableObj: SomeObservableObject = .init()
var body: some View {
VStack {
textView
}
.task {
// 1
test()
}
}
private var textView: some View {
Text("Hello World!")
.task {
// 2
test()
}
}
@MainActor private func test() {}
}위 코드를 빌드해보면 2번 task 에서도 async 키워드 없이도 test 메소드를 호출할 수 있는 것을 확인할 수 있습니다. 이게 왜 되지...?
이게 왜 되는지 결론부터 말하자면 global actor inference에 의해 @StateObject를 갖고 있는 우리의 TestView 가 MainActor가 되었기 때문인데요...
Global Actor Inference는 무엇일까요?
이 기묘한 상황을 이해하기 위해 Swift Evolution의 문서를 바탕으로 Global Actor Inference에 대해 간단하게 이야기 해보겠습니다.
문서에서 말하길,
Declarations that are not explicitly annotated with either a global actor or nonisolated can infer global actor isolation from several different places:
라고 합니다. 내가 global actor로 명시하지 않은 곳들에서도 global actor isolation이 추론(infer)될 수 있다고 하네요. 조금 더 읽어보겠습니다.
A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper:
actor-qualified wrappedValue 를 가진 property wrapper를 가진 struct나 class는 그 property wrapper로부터 actor isolation을 infer 하게 되는군요.
그럼 여기서 세울 수 있는 가설은 @StateObject 의 wrappedValue 의 actor isolation을 TestView가 infer 했다 가 되겠네요.
이 가설이 맞는지 @StateObject property wrapper의 declaration을 한 번 확인해보겠습니다.
@frozen @propertyWrapper public struct StateObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
// 생략...
@MainActor public var wrappedValue: ObjectType { get }
}선명하게 @MainActor 로 annotate 되어 있네요.
정리해보자면,
@StateObject 의 wrappedValue 는 main actor isolation임.
TestView 는 @StateObject property wrapper의 actor isolation을 infer함.
TestView struct도 main actor isolation이 됨.
이 되겠네요.
저도 모르는 사이에 제 View가 main actor가 되어버리고 있었다니...😱 의도하지 않은 동작이라 당황스러운데요.
이 포스팅을 보면 당황스러운건 저 뿐만이 아닌 것 같습니다.
작성자는 main thread에서의 동작이 보장되는 custom property wrapper인 @OnMainThread 를 생성했는데, actor inference 때문에 예상치 못한 에러가 생겼다고 합니다.
class MyContainer {
let contained = Contained() // error: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}
class Contained {
@OnMainThread var int = 1
}actor inference로 인해 actor isolation이 원래 의도했던 scope 이상으로 퍼지게 되면서 Swift Concurrency를 지키기가 어렵게 된거죠.
Global Actor Inference가 오히려 actor isolation의 적용 범위를 모호하게 하여 Swift Concurrency model을 이해하기 어렵게 만드는 것에 대한 의견이 생기면서
Property Wrapper로 인해 생기는 actor isolation을 제거하자는 proposal이 올라왔습니다.
해당 proposal은 현재 Swift 5.9에 반영되었는데, 간략하게 정리하면 다음과 같습니다.
Swift 6 language mode에서 컴파일할 때 property wrapper에 의한 actor isolation inference를 제거함.
Swift 5 language mode에서 컴파일 할 때는 -enable-upcoming-feature DisableOutwardActorInference 컴파일러 플래그를 적용해서 해당 기능을 적용할 수 있음.
현재 저는 Swift 5를 사용하고 있는데, 한 번 위의 컴파일러 플래그를 사용해서 property wrapper에 의한 actor isolation inference를 제거해보겠습니다.
컴파일러 플래그를 사용해서 이후에 나올 feature를 enable 시키는 방법은 다음과 같습니다
(xcconfig configuration file이나 Swift package에 upcoming feature 플래그를 적용하는 법은 이 문서를 참고하시면 될 것 같습니다).
Xcode project editor에서 컴파일러 플래그를 적용하고 싶은 프로젝트나 타겟을 선택합니다.
Build Settings 탭을 선택합니다.
Scope을 All로 선택합니다.
"swift flags"로 검색하여 Other Swift Flags build setting을 찾습니다.
더블클릭 하여 -enable-upcoming-feature 플래그를 적용합니다.
DisableOutwardActorInference 컴파일러 플래그가 적용된 모습은 다음과 같습니다.
플래그도 적용했으니 이제 아래 코드를 다시 빌드해보겠습니다.
final class SomeObservableObject: ObservableObject {}
struct TestView: View {
@StateObject private var someObservableObj: SomeObservableObject = .init()
var body: some View {
VStack {
textView
}
.task {
// 1
test()
}
}
private var textView: some View {
Text("Hello World!")
.task {
// 2
test()
}
}
@MainActor private func test() {}
}global actor inference가 되던 이전과 다르게 textView 는 main actor isolation이 되지 않은 것을 확인할 수 있습니다.
개발을 진행하며 거의 대부분의 View 들이 @StateObject 를 갖고 있어 View 의 helper property나 function들이 제가 의도하지 않았지만 main actor isolation이 되고 있었는데요.
Swift 6을 사용하고 계시지 않다면 DisableOutwardActorInference 플래그를 사용하고,
View에 있는 helper property나 function들이 body 와 같은 semantic를 갖게 하고 싶으면 명시적으로 @MainActor 로 annotate 하는 것이 좋을 것 같습니다.
최대한 정확한 정보 전달을 위해 Swift와 Apple 공식 문서를 바탕으로 작성했지만, 혹시라도 잘못된 내용이 있다면 언제든 댓글로 알려주세요. 긴 글 읽어주셔서 감사합니다 🙇
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.